mirror of
https://github.com/xszyou/Fay.git
synced 2026-08-28 23:00:40 +08:00
refactor: 大小模型逻辑重构 + 单模型模式 + 多处优化
核心改动(大小模型重构): - 删死代码约 750 行:LangGraph 工作流、本地知识库、ltp_mode、nlp_cemotion、__check_and_clear_chroma_db、QAService Persona/command 分支 - 命名修复:planner -> judge(闲聊判断器) - 删除 80 字兜底核实和 kb_search 硬编码优先 - 新增单模型模式:未配置 big_model_engine 时同步执行工具循环 - 重写 docs/Prompt设计文档.md 其他改动: - 配置中心 URL 更换为 124.220.24.49:5500 - recorder.py 录音非 16kHz 时重采样到 16kHz - fay_booter.py 远程音频连接健壮性修复(recv 空字节/异常退出/socket 关闭/心跳批量清理) - mcp_service.py 修复(_serialize_mcp_result 递归序列化、add_mcp_server 用 new_id、werkzeug 多线程替代 gevent) - remote_audio_key0.py 按键通话自动开关唤醒词 - 打包默认配置加 big_model_*/embedding_* 字段,system.conf.bak 统一 asr_mode - 新增 packaging/ 打包脚本、fay.spec、samples/、requirements-legacy.txt Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,8 @@ MANIFEST
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
!fay.spec
|
||||
packaging/release/memory/
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
|
||||
+15
-15
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"attribute": {
|
||||
"additional": "\u53d1\u5446",
|
||||
"age": "\u6210\u5e74",
|
||||
"birth": "Github",
|
||||
"constellation": "\u6c34\u74f6\u5ea7",
|
||||
"contact": "qq467665317",
|
||||
"gender": "\u7537",
|
||||
"goal": "\u5de5\u4f5c\u534f\u52a9",
|
||||
"hobby": "\u53d1\u5446",
|
||||
"job": "\u52a9\u7406",
|
||||
"name": "Fay",
|
||||
"position": "\u966a\u4f34",
|
||||
"additional": "",
|
||||
"age": "",
|
||||
"birth": "",
|
||||
"constellation": "",
|
||||
"contact": "",
|
||||
"gender": "",
|
||||
"goal": "assistant",
|
||||
"hobby": "",
|
||||
"job": "assistant",
|
||||
"name": "Feifei",
|
||||
"position": "companion",
|
||||
"voice": "abin",
|
||||
"zodiac": "\u86c7"
|
||||
"zodiac": ""
|
||||
},
|
||||
"interact": {
|
||||
"QnA": "qa.csv",
|
||||
@@ -24,7 +24,7 @@
|
||||
"indifferent": 10,
|
||||
"join": 10
|
||||
},
|
||||
"playSound": true,
|
||||
"playSound": false,
|
||||
"visualization": false
|
||||
},
|
||||
"items": [],
|
||||
@@ -36,7 +36,7 @@
|
||||
"automatic_player_status": false,
|
||||
"automatic_player_url": "http://127.0.0.1:6000",
|
||||
"liveRoom": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"url": ""
|
||||
},
|
||||
"record": {
|
||||
@@ -47,4 +47,4 @@
|
||||
"wake_word_enabled": false,
|
||||
"wake_word_type": "front"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,8 +76,6 @@ from utils import config_util as cfg
|
||||
from core import content_db
|
||||
|
||||
|
||||
from ai_module import nlp_cemotion
|
||||
|
||||
|
||||
from core import stream_manager
|
||||
|
||||
|
||||
+2
-28
@@ -10,27 +10,9 @@ import time
|
||||
from utils import util
|
||||
|
||||
class QAService:
|
||||
|
||||
def __init__(self):
|
||||
# 人设提问关键字
|
||||
self.attribute_keyword = [
|
||||
[['你叫什么名字', '你的名字是什么'], 'name'],
|
||||
[['你是男的还是女的', '你是男生还是女生', '你的性别是什么', '你是男生吗', '你是女生吗', '你是男的吗', '你是女的吗', '你是男孩子吗', '你是女孩子吗', ], 'gender', ],
|
||||
[['你今年多大了', '你多大了', '你今年多少岁', '你几岁了', '你今年几岁了', '你今年几岁了', '你什么时候出生', '你的生日是什么', '你的年龄'], 'age', ],
|
||||
[['你的家乡在哪', '你的家乡是什么', '你家在哪', '你住在哪', '你出生在哪', '你的出生地在哪', '你的出生地是什么', ], 'birth', ],
|
||||
[['你的生肖是什么', '你属什么', ], 'zodiac', ],
|
||||
[['你是什么座', '你是什么星座', '你的星座是什么', ], 'constellation', ],
|
||||
[['你是做什么的', '你的职业是什么', '你是干什么的', '你的职位是什么', '你的工作是什么', '你是做什么工作的'], 'job', ],
|
||||
[['你的爱好是什么', '你有爱好吗', '你喜欢什么', '你喜欢做什么'], 'hobby'],
|
||||
[['联系方式', '联系你们', '怎么联系客服', '有没有客服'], 'contact']
|
||||
]
|
||||
|
||||
self.command_keyword = [
|
||||
[['关闭', '再见', '你走吧'], 'stop'],
|
||||
[['静音', '闭嘴', '我想静静'], 'mute'],
|
||||
[['取消静音', '你在哪呢', '你可以说话了'], 'unmute'],
|
||||
[['换个性别', '换个声音'], 'changeVoice']
|
||||
]
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def question(self, query_type, text):
|
||||
if query_type == 'qa':
|
||||
@@ -39,14 +21,6 @@ class QAService:
|
||||
if action:
|
||||
MyThread(target=self.__run, args=[action]).start()
|
||||
return answer, 'qa'
|
||||
|
||||
elif query_type == 'Persona':
|
||||
answer_dict = self.attribute_keyword
|
||||
answer, action = self.__get_keyword(answer_dict, text, query_type)
|
||||
return answer, 'Persona'
|
||||
elif query_type == 'command':
|
||||
answer, action = self.__get_keyword(self.command_keyword, text, query_type)
|
||||
return answer, 'command'
|
||||
return None, None
|
||||
|
||||
def __run(self, action):
|
||||
|
||||
+26
-7
@@ -308,11 +308,15 @@ class Recorder:
|
||||
time.sleep(0.01)
|
||||
for i in range(len(self.__history_data) - 1): #当前data在下面会做发送,这里是发送激活前的音频数据,以免漏掉信息
|
||||
buf = self.__history_data[i]
|
||||
audio_data_list.append(self.__process_audio_data(buf, self.channels))
|
||||
#重采样:采集设备非 16kHz 时,统一重采样到 16kHz 再送 ASR
|
||||
mono_data = self.__process_audio_data(buf, self.channels)
|
||||
if self.sample_rate != 16000:
|
||||
mono_data = self._resample_audio(mono_data, self.sample_rate, 16000)
|
||||
audio_data_list.append(mono_data)
|
||||
if self.ASRMode == "ali":
|
||||
self.__aLiNls.send(self.__process_audio_data(buf, self.channels).tobytes())
|
||||
self.__aLiNls.send(mono_data.tobytes())
|
||||
else:
|
||||
concatenated_audio.extend(self.__process_audio_data(buf, self.channels).tobytes())
|
||||
concatenated_audio.extend(mono_data.tobytes())
|
||||
self.__history_data.clear()
|
||||
else:#结束拾音
|
||||
last_mute_time = time.time()
|
||||
@@ -330,11 +334,15 @@ class Recorder:
|
||||
|
||||
#拾音中
|
||||
if isSpeaking:
|
||||
audio_data_list.append(self.__process_audio_data(data, self.channels))
|
||||
#重采样:采集设备非 16kHz 时,统一重采样到 16kHz 再送 ASR
|
||||
mono_data = self.__process_audio_data(data, self.channels)
|
||||
if self.sample_rate != 16000:
|
||||
mono_data = self._resample_audio(mono_data, self.sample_rate, 16000)
|
||||
audio_data_list.append(mono_data)
|
||||
if self.ASRMode == "ali":
|
||||
self.__aLiNls.send(self.__process_audio_data(data, self.channels).tobytes())
|
||||
self.__aLiNls.send(mono_data.tobytes())
|
||||
else:
|
||||
concatenated_audio.extend(self.__process_audio_data(data, self.channels).tobytes())
|
||||
concatenated_audio.extend(mono_data.tobytes())
|
||||
except Exception as e:
|
||||
util.printInfo(1, self.username, "录音失败: " + str(e))
|
||||
|
||||
@@ -390,7 +398,18 @@ class Recorder:
|
||||
# 将累积的音频数据块连接起来
|
||||
data = np.concatenate(audio_data_list)
|
||||
return data
|
||||
|
||||
|
||||
#重采样:将单声道 int16 音频从 orig_rate 重采样到 target_rate(默认 16kHz,ASR 要求)
|
||||
def _resample_audio(self, data: np.ndarray, orig_rate: int, target_rate: int = 16000) -> np.ndarray:
|
||||
"""将 numpy int16 数组从 orig_rate 重采样到 target_rate"""
|
||||
if orig_rate == target_rate:
|
||||
return data
|
||||
import scipy.signal # 惰性导入:仅非 16kHz 设备需要,避免 scipy 成为硬依赖
|
||||
# 转为 float32 进行重采样(避免溢出),再四舍五入回 int16
|
||||
float_data = data.astype(np.float32)
|
||||
resampled = scipy.signal.resample_poly(float_data, target_rate, orig_rate)
|
||||
return np.round(resampled).astype(np.int16)
|
||||
|
||||
#转变为单声道np.int16
|
||||
def __process_audio_data(self, data, channels):
|
||||
data = bytearray(data)
|
||||
|
||||
+293
-405
@@ -1,405 +1,293 @@
|
||||
# Fay Prompt 设计文档
|
||||
|
||||
> **提示**:使用 AI IDE 对 Fay 进行 Prompt 改造时,可以让 AI 参考 `docs/Prompt设计文档.md`
|
||||
|
||||
本文档详细说明 Fay 数字人系统中使用的各类 Prompt 设计及其工作流程。
|
||||
|
||||
## 1. Prompt 类型与功能对比
|
||||
|
||||
Fay 系统采用 **双阶段 Prompt 架构**,通过规划器和最终输出两个阶段协同工作:
|
||||
|
||||
| Prompt 类型 | 功能定位 | 输出格式 | 执行方式 | 使用场景 |
|
||||
| ----------------- | -------------------- | --------- | ------ | -------------- |
|
||||
| **系统 Prompt** | 定义 AI 人设和行为规范 | 自然语言 | - | 贯穿整个对话流程 |
|
||||
| **规划器 Prompt** | 决策下一步行动(调用工具或直接回复) | 严格 JSON | 同步 | 需要工具调用或复杂决策时 |
|
||||
| **最终输出 Prompt** | 生成自然语言回复 | 口语化文本 | 同步 | 生成最终用户可见的回复 |
|
||||
| **记忆重要性 Prompt** | 评估对话内容的记忆价值 | 0-10 数值 | 异步 | 记忆存储时评估重要性 |
|
||||
| **用户画像 Prompt** | 从对话中提取用户特征 | 结构化文本 | 异步 | 更新用户画像信息 |
|
||||
| **记忆反思 Prompt** | 对历史记忆进行归纳总结 | 高层次洞察 | 异步 | 定期反思生成抽象记忆 |
|
||||
|
||||
### 1.1 规划器Prompt及最终输出Prompt功能对比详解
|
||||
|
||||
| 特性 | 规划器 Prompt | 最终输出 Prompt |
|
||||
| -------------------- | ---------------------------- | --------------------- |
|
||||
| **System Message** | `你负责规划下一步行动,请严格输出合法 JSON。` | 直接输出|
|
||||
| **包含可用工具列表** | 是 | 否 |
|
||||
| **输出要求** | JSON 格式 | 自然口语 |
|
||||
| **支持流式输出** | 是(finish+message 模式) | 是 |
|
||||
| **预启动工具结果** | 包含 | 包含 |
|
||||
| **关联记忆** | 包含 | 包含 |
|
||||
| **最近对话** | 包含 | 包含 |
|
||||
|
||||
### 1.2 执行效率对比
|
||||
|
||||
| 场景 | 执行路径 | LLM 调用次数 | 适用情况 |
|
||||
| ------------ | --------------------------------- | ---------- | ------------ |
|
||||
| **简单问答** | 规划器 → finish+message | 1 次 | 无需工具,直接回复 |
|
||||
| **单次工具调用** | 规划器 → 工具 → 规划器 → finish+message | 2 次 | 需要一次工具辅助 |
|
||||
| **N次工具调用** | 规划器 → (工具 → 规划器) × N → finish | N+1 次 | 复杂任务需多次工具 |
|
||||
| **预启动优化** | 预启动 → 规划器 → finish+message | 1 次 | 预启动已获取所需信息 |
|
||||
|
||||
> **说明**:每次工具执行后都需要回到规划器决策下一步行动,因此 N 次工具调用需要 N+1 次 LLM 调用。当规划器返回 `finish+message` 时,直接使用 message 内容,无需额外调用最终输出 LLM。
|
||||
|
||||
#### 效率优化机制
|
||||
|
||||
1. **finish+message 快速路径**
|
||||
|
||||
- 规划器直接在 JSON 中返回 `{"action": "finish", "message": "..."}`
|
||||
- 跳过最终输出阶段,节省一次 LLM 调用
|
||||
- 无论是否使用工具,只要规划器返回 message 就直接使用
|
||||
|
||||
2. **预启动工具预取**
|
||||
|
||||
- 常用信息(时间、天气等)在用户提问前自动获取
|
||||
- 规划器可直接使用预取结果,减少工具调用次数
|
||||
- 适合高频查询场景
|
||||
|
||||
3. **流式输出**
|
||||
|
||||
- 规划器的 message 支持流式输出
|
||||
- 用户无需等待完整生成即可看到内容
|
||||
- 提升感知响应速度
|
||||
|
||||
#### 典型场景 LLM 调用次数
|
||||
|
||||
| 场景 | 调用次数 | 说明 |
|
||||
| --------------- | -------- | ------------------------------------ |
|
||||
| "你好" | 1 次 | 规划器直接 finish+message |
|
||||
| "现在几点" (有预启动) | 1 次 | 预启动已获取时间,规划器直接回复 |
|
||||
| "帮我查天气" | 2 次 | 规划器(1) → 工具 → 规划器(2) → finish |
|
||||
| "设置闹钟" | 2 次 | 规划器(1) → 工具 → 规划器(2) → finish |
|
||||
| "查天气并设置提醒" | 3 次 | 规划器(1) → 工具 → 规划器(2) → 工具 → 规划器(3) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Prompt 示例
|
||||
|
||||
### 2.1 系统 Prompt 示例
|
||||
|
||||
系统 Prompt 由用户在配置文件中定义(GUI可以管理),用于设定 AI 的人设:
|
||||
|
||||
```
|
||||
**系统设定**
|
||||
你是一个名叫Fay的数字人助手。你性格温柔、善解人意,说话简洁明了。
|
||||
你会用亲切的语气与用户交流,称呼用户为"主人"。
|
||||
---
|
||||
```
|
||||
|
||||
### 2.2 规划器 Prompt 示例
|
||||
|
||||
~~~
|
||||
**提问内容**
|
||||
主人:
|
||||
```
|
||||
今天天气怎么样?
|
||||
```
|
||||
---
|
||||
**系统设定**
|
||||
你是一个名叫Fay的数字人助手...
|
||||
---
|
||||
|
||||
**额外观察**
|
||||
(无补充)
|
||||
---
|
||||
|
||||
**关联记忆**
|
||||
(无相关记忆)
|
||||
---
|
||||
|
||||
**预启动工具结果**
|
||||
【query_weather】(city=北京)
|
||||
```
|
||||
北京今日晴,气温 -2°C 至 8°C,空气质量良好
|
||||
```
|
||||
---
|
||||
|
||||
**可用工具**
|
||||
- query_weather: 查询天气信息
|
||||
参数: city (string) - 城市名称
|
||||
- add_schedule: 添加日程
|
||||
参数: title (string), content (string), schedule_time (string)
|
||||
---
|
||||
|
||||
**历史工具执行**
|
||||
(无)
|
||||
---
|
||||
|
||||
**最近对话**
|
||||
主人:
|
||||
```
|
||||
你好
|
||||
```
|
||||
Fay:
|
||||
```
|
||||
主人,你好呀!有什么我可以帮你的吗?
|
||||
```
|
||||
---
|
||||
|
||||
请返回 JSON,格式如下:
|
||||
- 若需要调用工具:
|
||||
{"action": "tool", "tool": "工具名", "args": {...}}
|
||||
- 若直接回复(需附带回复内容):
|
||||
{"action": "finish", "message": "你的回复内容"}
|
||||
~~~
|
||||
|
||||
**规划器输出示例:**
|
||||
|
||||
```json
|
||||
{"action": "finish", "message": "主人,北京今天是晴天,气温在零下2度到8度之间,空气质量良好,适合出门活动哦!"}
|
||||
```
|
||||
|
||||
### 2.3 最终输出 Prompt 示例
|
||||
|
||||
~~~
|
||||
**提问内容**
|
||||
主人:
|
||||
```
|
||||
帮我设置一个明天早上9点的闹钟
|
||||
```
|
||||
---
|
||||
**系统设定**
|
||||
你是一个名叫Fay的数字人助手...
|
||||
---
|
||||
|
||||
**关联记忆**
|
||||
(无相关记忆)
|
||||
---
|
||||
|
||||
**其他观察**
|
||||
(无补充)
|
||||
---
|
||||
|
||||
**工具执行摘要**
|
||||
add_schedule(title=早起闹钟, schedule_time=09:00) → 日程添加成功,ID: 15
|
||||
---
|
||||
|
||||
**最近对话**
|
||||
主人:
|
||||
```
|
||||
帮我设置一个明天早上9点的闹钟
|
||||
```
|
||||
---
|
||||
~~~
|
||||
|
||||
**最终输出示例:**
|
||||
|
||||
```
|
||||
好的主人,我已经帮你设置好明天早上9点的闹钟了,到时候会提醒你的!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 工作流程
|
||||
|
||||
### 3.1 整体架构
|
||||
|
||||
```
|
||||
用户输入
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 预处理阶段 │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ 加载系统 │ │ 检索关联 │ │ 执行预启动 │ │
|
||||
│ │ Prompt │ │ 记忆 │ │ 工具 │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 规划器阶段 (Planner) │
|
||||
│ │
|
||||
│ 输入: 规划器 Prompt (含工具列表、对话历史、记忆等) │
|
||||
│ 输出: JSON 决策 │
|
||||
│ ├─ {"action": "tool", ...} → 执行工具 → 循环 │
|
||||
│ └─ {"action": "finish", "message": "..."} │
|
||||
│ ↓ │
|
||||
│ (可选) 流式输出 message │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 最终输出阶段 (Final Output) │
|
||||
│ │
|
||||
│ 输入: 最终输出 Prompt (含工具执行摘要、规划器建议等) │
|
||||
│ 输出: 自然语言回复 (流式) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
用户看到回复
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 异步后处理阶段 (Background) │
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ 记忆重要性评估 │ │ 用户画像更新 │ │
|
||||
│ │ (记忆重要性 │ │ (用户画像 │ │
|
||||
│ │ Prompt) │ │ Prompt) │ │
|
||||
│ └─────────────────┘ └─────────────────┘ │
|
||||
│ ↓ ↓ │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ 记忆存储与索引 │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ 定期反思 (记忆反思 Prompt) │ │
|
||||
│ │ 归纳总结历史记忆,生成高层次洞察 │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 详细流程说明
|
||||
|
||||
#### 阶段一:预处理
|
||||
|
||||
1. **加载系统 Prompt**:从配置获取 AI 人设定义
|
||||
2. **检索关联记忆**:基于用户输入从记忆库中检索相关内容
|
||||
3. **执行预启动工具**:自动执行配置的预启动 MCP 工具,获取上下文信息
|
||||
|
||||
#### 阶段二:规划器决策
|
||||
|
||||
```python
|
||||
# 规划器循环
|
||||
while True:
|
||||
# 构建规划器 Prompt
|
||||
messages = _build_planner_messages(state)
|
||||
|
||||
# 调用 LLM 获取决策
|
||||
result = call_llm(messages) # 返回 JSON
|
||||
|
||||
if result["action"] == "tool":
|
||||
# 执行工具
|
||||
tool_result = execute_tool(result["tool"], result["args"])
|
||||
# 将结果加入历史,继续循环
|
||||
state["tool_results"].append(tool_result)
|
||||
|
||||
elif result["action"] == "finish":
|
||||
# 如果有 message,可直接流式输出
|
||||
if result.get("message"):
|
||||
stream_output(result["message"])
|
||||
break
|
||||
```
|
||||
|
||||
#### 阶段三:最终输出
|
||||
|
||||
```python
|
||||
# 构建最终输出 Prompt
|
||||
messages = _build_final_messages(state)
|
||||
|
||||
# 流式生成自然语言回复
|
||||
for chunk in stream_llm(messages):
|
||||
yield chunk
|
||||
```
|
||||
|
||||
#### 阶段四:异步后处理(后台执行)
|
||||
|
||||
对话完成后,系统在后台异步执行以下任务,不影响用户体验:
|
||||
|
||||
```python
|
||||
# 1. 记忆重要性评估
|
||||
importance = evaluate_importance(conversation) # 返回 0-10
|
||||
|
||||
# 2. 用户画像更新
|
||||
profile_updates = extract_user_profile(conversation)
|
||||
update_user_profile(user_id, profile_updates)
|
||||
|
||||
# 3. 记忆存储
|
||||
if importance >= threshold:
|
||||
store_memory(conversation, importance)
|
||||
|
||||
# 4. 定期反思(由调度器触发)
|
||||
if should_reflect():
|
||||
insights = reflect_on_memories(recent_memories)
|
||||
store_reflection(insights)
|
||||
```
|
||||
|
||||
**异步 Prompt 说明:**
|
||||
|
||||
| Prompt 类型 | 触发时机 | 输入 | 输出 |
|
||||
| ------------------ | ---------- | ------------- | ------------ |
|
||||
| **记忆重要性 Prompt** | 每轮对话结束后 | 对话内容 | 0-10 重要性分数 |
|
||||
| **用户画像 Prompt** | 每轮对话结束后 | 对话内容 + 现有画像 | 画像更新字段 |
|
||||
| **记忆反思 Prompt** | 定期触发 | 近期记忆列表 | 高层次洞察总结 |
|
||||
|
||||
### 3.3 Prompt 各部分说明
|
||||
|
||||
| 部分 | 说明 | 来源 |
|
||||
| ------------- | ---------------- | ----------- |
|
||||
| **提问内容** | 当前用户输入 | 用户消息 |
|
||||
| **系统设定** | AI 人设定义 | 配置文件(GUI可管理) |
|
||||
| **关联记忆** | 与问题相关的历史记忆 | 记忆检索模块 |
|
||||
| **预启动工具结果** | 自动执行的工具返回 | MCP 预启动工具 |
|
||||
| **可用工具** | 当前可调用的工具列表 | MCP 工具注册 |
|
||||
| **历史工具执行** | 本轮对话中已执行的工具及结果 | 规划器循环累积 |
|
||||
| **最近对话** | 近期对话历史 | 对话缓存 |
|
||||
|
||||
### 3.4 特殊标签处理
|
||||
|
||||
系统使用特殊 XML 标签来标记和管理某些内容:
|
||||
|
||||
#### `<prestart>` 标签
|
||||
|
||||
| 属性 | 说明 | 在 Prompt 中的表现 |
|
||||
| ---- | ---- | ------------------ |
|
||||
| 无属性 | 临时预启动结果 | 仅在当前轮对话的 Prompt 中出现,下一轮自动移除 |
|
||||
| `keep="true"` | 持久预启动结果 | 保留在对话历史中,后续轮次的 **最近对话** 模块中持续可见 |
|
||||
|
||||
**使用场景**:
|
||||
- 预启动工具配置 `include_history: false`(默认)时,结果用 `<prestart>` 包裹
|
||||
- 预启动工具配置 `include_history: true` 时,结果用 `<prestart keep="true">` 包裹
|
||||
|
||||
#### `<think>` 标签
|
||||
|
||||
| 场景 | 说明 | 在 Prompt 中的表现 |
|
||||
| ---- | ---- | ------------------ |
|
||||
| 工具调用过程 | 包裹规划器的工具调用决策过程 | 从对话历史中移除,不进入后续 Prompt |
|
||||
| 模型思考内容 | 兼容带思考标签的模型(如 DeepSeek R1) | 解析 JSON 前自动移除 |
|
||||
|
||||
**处理逻辑**:
|
||||
- `<think>` 标签内容在构建 **最近对话** 时会被移除
|
||||
- 用户看到的流式输出中可能包含 `<think>` 标签(显示思考过程)
|
||||
- 但这些内容不会进入后续对话的 Prompt
|
||||
|
||||
### 3.5 预启动工具结果格式
|
||||
|
||||
预启动工具的结果采用特定格式展示:
|
||||
|
||||
~~~
|
||||
**预启动工具结果**
|
||||
【工具名】(参数...)
|
||||
```
|
||||
工具返回的结果内容
|
||||
```
|
||||
---
|
||||
~~~
|
||||
|
||||
如果工具配置了 `include_history: true`,其结果会以 `<prestart keep="true">` 标签保存在对话历史中,在后续对话中持续可见。
|
||||
|
||||
### 3.6 流式输出机制
|
||||
|
||||
规划器支持 `finish + message` 模式的流式输出:
|
||||
|
||||
1. 当规划器返回 `{"action": "finish", "message": "..."}` 时
|
||||
2. 系统会流式输出 `message` 内容
|
||||
3. 同时跳过最终输出阶段(避免重复生成)
|
||||
|
||||
这样可以实现更快的响应速度,用户无需等待两次 LLM 调用。
|
||||
|
||||
---
|
||||
|
||||
## 4. 最佳实践
|
||||
|
||||
### 4.1 系统 Prompt 编写建议
|
||||
|
||||
- 明确定义 AI 的名称、性格特点
|
||||
- 设定说话风格和语气
|
||||
- 指定对用户的称呼方式
|
||||
- 避免过长,控制在 200 字以内
|
||||
|
||||
### 4.2 工具设计建议
|
||||
|
||||
- 工具描述要清晰,让 LLM 能准确判断何时使用
|
||||
- 参数说明要完整,包含类型和示例
|
||||
- 返回结果要简洁,便于 LLM 理解和转述
|
||||
|
||||
### 4.3 预启动工具使用建议
|
||||
|
||||
- 用于获取实时上下文(如时间、天气、用户状态)
|
||||
- 配置 `include_history: true`(保存到记忆) 可让结果在多轮对话中持续可见
|
||||
- 避免预启动工具执行时间过长,影响响应速度
|
||||
# Fay Prompt 设计文档
|
||||
|
||||
> **提示**:使用 AI IDE 对 Fay 进行 Prompt 改造时,可以让 AI 参考 `docs/Prompt设计文档.md`
|
||||
|
||||
本文档说明 Fay 数字人系统中各类 Prompt 的设计与大小模型协作机制。
|
||||
|
||||
## 1. 架构总览
|
||||
|
||||
Fay 采用 **闲聊判断器 + 工具循环 + 最终回复** 的三段式架构。根据是否配置大模型,自动在双模型模式和单模型模式之间切换:
|
||||
|
||||
| 模式 | 触发条件 | 闲聊判断器 | 工具循环 | 最终回复 |
|
||||
|---|---|---|---|---|
|
||||
| **双模型模式** | `system.conf` 配置了 `big_model_engine` | 小模型(流式) | 大模型(后台线程) | 小模型(流式) |
|
||||
| **单模型模式** | `big_model_engine` 留空 | 小模型(流式) | 小模型(同步) | 小模型(流式) |
|
||||
|
||||
> 所有面向用户的文本始终由小模型流式输出。大模型只在双模型模式的工具循环中工作,从不直接面向用户。
|
||||
|
||||
### 1.1 Prompt 类型与功能
|
||||
|
||||
| Prompt 类型 | 功能定位 | 输出格式 | 执行模型 | 触发时机 |
|
||||
|---|---|---|---|---|
|
||||
| **系统 Prompt** | 定义 AI 人设和行为规范 | 自然语言 | - | 贯穿整个对话流程 |
|
||||
| **闲聊判断器 Prompt** | 判断是闲聊(finish)还是需要工具(tool) | 严格 JSON | 小模型 | 每次 `question()` 调用 |
|
||||
| **工具循环 Prompt** | 决策下一步:调哪个工具 / 是否完成 | 严格 JSON | 大模型(双)/ 小模型(单) | 工具循环的每一步 |
|
||||
| **最终回复 Prompt** | 基于工具结果生成自然语言回复 | 口语化文本 | 小模型 | 工具循环完成后 |
|
||||
| **意图分类 Prompt** | 后台任务运行中判断用户新消息意图 | 标签字符串 | 小模型 | 后台任务运行中用户又发消息 |
|
||||
| **记忆重要性 Prompt** | 评估对话内容的记忆价值 | 0-10 数值 | 小模型 | 每轮对话后异步 |
|
||||
| **用户画像 Prompt** | 从对话中提取用户特征 | 结构化文本 | 小模型 | 每天 11:30 定时 |
|
||||
| **记忆反思 Prompt** | 对历史记忆进行归纳总结 | 高层次洞察 | 小模型 | 每天 23:00 定时 |
|
||||
|
||||
### 1.2 调用次数对比
|
||||
|
||||
| 场景 | 双模型模式 | 单模型模式 |
|
||||
|---|---|---|
|
||||
| 闲聊"你好" | 小模型 × 1 | 小模型 × 1 |
|
||||
| 工具请求"查课程 A" | 小模型 × 2(判断 + 最终回复)+ 大模型 × N(工具循环) | 小模型 × (2 + N)(判断 + 工具循环 + 最终回复) |
|
||||
| 后台任务运行中"进度?" | 小模型 × 1(意图分类) | 同左 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 工作流程
|
||||
|
||||
### 2.1 整体流程
|
||||
|
||||
```
|
||||
用户输入(文字/语音)
|
||||
↓
|
||||
预处理:加载系统 Prompt、检索关联记忆、执行预启动工具、加载对话历史、加载 MCP 工具
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 检查 ExecutionManager 状态 │
|
||||
│ ├─ 情况1:有已完成的后台结果 -> 小模型基于结果生成最终回复 │
|
||||
│ ├─ 情况2:有 RUNNING 后台任务 -> 小模型做意图分类 │
|
||||
│ └─ 情况3:无后台任务 -> 走正常流程 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓ (情况3)
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 闲聊判断器(小模型,流式 JSON) │
|
||||
│ ├─ {"action": "finish", "message": "..."} → 流式输出 │
|
||||
│ └─ {"action": "tool", "keyword": "..."} → 提交工具执行 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓ (action == "tool")
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 工具循环(双模型:大模型后台 / 单模型:小模型同步) │
|
||||
│ while tool_results < max_steps: │
|
||||
│ 1. 执行工具 → spec.executor(args) │
|
||||
│ 2. 决策下一步 → LLM.invoke(next_step_messages) │
|
||||
│ 3. 解析 JSON:tool → 继续 / finish → 跳出 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 最终回复(小模型,流式) │
|
||||
│ 基于工具结果生成自然语言回复,写入原始会话流 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
异步后处理:记忆重要性评估、对话记忆存储
|
||||
↓
|
||||
定时任务:用户画像分析(11:30)、每日反思(23:00)
|
||||
```
|
||||
|
||||
### 2.2 关键文件:行号
|
||||
|
||||
| 功能 | 位置 |
|
||||
|---|---|
|
||||
| 配置加载 | `utils/config_util.py:543-563` |
|
||||
| 单模型模式判断 | `llm/nlp_cognitive_stream.py` `_is_single_model_mode()` |
|
||||
| 模型实例工厂 | `llm/execution_manager.py:172-205` `_get_llm_instance(role, streaming)` |
|
||||
| 工具循环入口 | `llm/execution_manager.py:415` `_big_model_execute(state, llm_role)` |
|
||||
| `question()` 主入口 | `llm/nlp_cognitive_stream.py` `question()` |
|
||||
| 闲聊判断器 | `llm/nlp_cognitive_stream.py` `_call_judge_llm()` |
|
||||
| 闲聊判断器 Prompt 构建 | `llm/nlp_cognitive_stream.py` `_build_judge_messages()` |
|
||||
| 工具循环 Prompt 构建 | `llm/execution_manager.py:226-330` `_build_execution_next_step_messages()` |
|
||||
| 最终回复 Prompt 构建 | `llm/nlp_cognitive_stream.py` `_build_final_messages()` |
|
||||
| 后台完成自动回复 | `llm/nlp_cognitive_stream.py` `_auto_reply_after_execution()` |
|
||||
| 单模型同步分支 | `llm/nlp_cognitive_stream.py` `_submit_tool_execution()` 内 |
|
||||
|
||||
---
|
||||
|
||||
## 3. Prompt 示例
|
||||
|
||||
### 3.1 系统 Prompt 示例
|
||||
|
||||
系统 Prompt 由用户在配置文件中定义(GUI 可以管理),用于设定 AI 的人设:
|
||||
|
||||
```
|
||||
**系统设定**
|
||||
你是一个名叫 Feifei 的数字人助手。你性格温柔、善解人意,说话简洁明了。
|
||||
你会用亲切的语气与用户交流。
|
||||
---
|
||||
```
|
||||
|
||||
### 3.2 闲聊判断器 Prompt
|
||||
|
||||
**System Message 核心**(`_build_judge_messages`):
|
||||
|
||||
```
|
||||
你是一个闲聊判断器。判断用户的话是不是闲聊,请严格输出合法 JSON,不要输出其他内容。
|
||||
|
||||
输出格式只有两种:
|
||||
1. 是闲聊: {"action": "finish", "message": "你的回复内容"}
|
||||
2. 不是闲聊: {"action": "tool", "keyword": "提取的搜索关键词"}
|
||||
|
||||
什么是闲聊(输出 finish):
|
||||
- 打招呼:你好、hi、早上好
|
||||
- 情绪表达:我好开心、今天好累
|
||||
- 感谢道别:谢谢、再见、拜拜
|
||||
- 简单确认:好的、收到、明白了
|
||||
|
||||
什么不是闲聊(输出 tool):
|
||||
- 问任何具体事物/概念:XX是什么、你知道XX吗
|
||||
- 要求查询/获取/阅读内容
|
||||
- 提到任何产品名、项目名、专有名词
|
||||
- 任何你需要查资料才能准确回答的问题
|
||||
|
||||
不确定时 -> 输出 tool
|
||||
```
|
||||
|
||||
**附加内容**:系统 Prompt + 关联记忆 + 可用工具名 + 知识库主题 + 预启动工具结果 + 历史 + 对话历史
|
||||
|
||||
**输出示例**:
|
||||
|
||||
```json
|
||||
{"action": "finish", "message": "你好呀!有什么我可以帮你的吗?"}
|
||||
```
|
||||
|
||||
```json
|
||||
{"action": "tool", "keyword": "课程A"}
|
||||
```
|
||||
|
||||
### 3.3 工具循环 Prompt
|
||||
|
||||
**System Message 核心**(`_build_execution_next_step_messages`):
|
||||
|
||||
```
|
||||
你是一个任务执行器,负责逐步调用工具完成用户请求。请严格输出合法 JSON。
|
||||
|
||||
输出格式只有两种:
|
||||
1. 还需要调用工具: {"action": "tool", "tool": "工具名", "args": {...}}
|
||||
2. 任务已完成: {"action": "finish", "message": "简短总结执行结果"}
|
||||
|
||||
判断规则:
|
||||
- 对照用户的原始请求,检查是否所有要求都已满足
|
||||
- 【严格遵守】调用前先看【已完成的工具调用】列表,凡是已经成功执行过的"同名+同参数"调用,绝对不要再调一次
|
||||
|
||||
搜索策略:
|
||||
- 如果 kb_search 的结果不够相关,换关键词重试
|
||||
- 可以用 kb_list_sources 查看所有课程列表
|
||||
```
|
||||
|
||||
**附加内容**:人设 + 关联记忆 + 观察 + 预启动工具结果 + 已完成工具摘要 + 对话历史
|
||||
|
||||
**输出示例**:
|
||||
|
||||
```json
|
||||
{"action": "tool", "tool": "kb_search", "args": {"query": "课程A"}}
|
||||
```
|
||||
|
||||
```json
|
||||
{"action": "finish", "message": "已找到课程A的内容:..."}
|
||||
```
|
||||
|
||||
### 3.4 最终回复 Prompt
|
||||
|
||||
**System Message 核心**(`_auto_reply_after_execution` 内的精简 prompt):
|
||||
|
||||
```
|
||||
你是一个友好的助手。你已经告诉用户"我来帮你查一下,稍等…",现在工具执行完毕。
|
||||
请基于以下工具执行结果回答用户,不要再重复"我来查一下"之类的过渡语,直接给出答案。
|
||||
|
||||
**用户消息**: {user_request}
|
||||
**实际执行的操作**: {tool_call_details}
|
||||
**工具执行结果**: {tool_context}
|
||||
|
||||
请基于工具结果直接回答,用日常口语,不要说'工具调用'等技术术语。回复简洁,3-5句话即可。
|
||||
```
|
||||
|
||||
**输出示例**:
|
||||
|
||||
```
|
||||
课程A的内容是……(基于工具结果的口语化总结)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 单模型模式 vs 双模型模式
|
||||
|
||||
### 4.1 单模型模式(`big_model_engine` 留空)
|
||||
|
||||
```
|
||||
用户输入
|
||||
↓
|
||||
闲聊判断器(小模型,流式)→ finish 直接回复
|
||||
↓ (tool)
|
||||
同步工具循环(小模型,最多 30 步)
|
||||
↓
|
||||
最终回复(小模型,流式)
|
||||
```
|
||||
|
||||
**特点**:
|
||||
- 不开后台线程,工具循环在 `question()` 调用栈内同步执行
|
||||
- 用户会等待工具循环完成才看到最终回复
|
||||
- 不走 `ExecutionManager` 状态机,无 `on_complete` 回调
|
||||
- 适合只有一个模型实例的部署(如本地小模型)
|
||||
|
||||
### 4.2 双模型模式(配置了 `big_model_engine`)
|
||||
|
||||
```
|
||||
用户输入
|
||||
↓
|
||||
闲聊判断器(小模型,流式)→ finish 直接回复
|
||||
↓ (tool)
|
||||
exec_mgr.submit() → 后台线程启动
|
||||
↓
|
||||
question() 立即返回 ""(前端通过 stream 接收异步回复)
|
||||
↓ (后台线程)
|
||||
工具循环(大模型,最多 30 步)
|
||||
↓
|
||||
on_complete 回调 → _auto_reply_after_execution
|
||||
↓
|
||||
最终回复(小模型,流式,写入原始会话流)
|
||||
```
|
||||
|
||||
**特点**:
|
||||
- 工具循环在后台线程执行,不阻塞 `question()`
|
||||
- 大模型负责工具决策,小模型负责最终回复
|
||||
- 每用户最多 1 个 RUNNING 任务(`ExecutionManager.submit` 拒绝重复)
|
||||
- 后台任务运行中用户又发消息 → 意图分类(5 类:update_task / query_progress / cancel_task / new_task / normal_chat)
|
||||
|
||||
---
|
||||
|
||||
## 5. 特殊标签处理
|
||||
|
||||
### 5.1 `<prestart>` 标签
|
||||
|
||||
| 属性 | 说明 | 在 Prompt 中的表现 |
|
||||
|---|---|---|
|
||||
| 无属性 | 临时预启动结果 | 仅在当前轮对话的 Prompt 中出现,下一轮自动移除 |
|
||||
| `keep="true"` | 持久预启动结果 | 保留在对话历史中,后续轮次的最近对话模块中持续可见 |
|
||||
|
||||
### 5.2 `<think>` 标签
|
||||
|
||||
| 场景 | 说明 | 处理逻辑 |
|
||||
|---|---|---|
|
||||
| 工具执行日志 | 包裹工具调用过程(工具名、参数、结果摘要、耗时) | 由 `_auto_reply_after_execution` 生成,写入流让用户看到执行过程 |
|
||||
| 模型思考内容 | 兼容带思考标签的模型(如 DeepSeek R1) | 解析 JSON 前自动移除 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 最佳实践
|
||||
|
||||
### 6.1 系统 Prompt 编写建议
|
||||
|
||||
- 明确定义 AI 的名称、性格特点
|
||||
- 设定说话风格和语气
|
||||
- 指定对用户的称呼方式
|
||||
- 避免过长,控制在 200 字以内
|
||||
|
||||
### 6.2 工具设计建议
|
||||
|
||||
- 工具描述要清晰,让 LLM 能准确判断何时使用
|
||||
- 参数说明要完整,包含类型和示例
|
||||
- 返回结果要简洁,便于 LLM 理解和转述
|
||||
|
||||
### 6.3 预启动工具使用建议
|
||||
|
||||
- 用于获取实时上下文(如时间、天气、用户状态)
|
||||
- 配置 `include_history: true` 可让结果在多轮对话中持续可见
|
||||
- 避免预启动工具执行时间过长,影响响应速度
|
||||
|
||||
### 6.4 单模型 vs 双模型选择
|
||||
|
||||
- **单模型模式**:本地部署小模型、低成本场景、不需要复杂工具循环
|
||||
- **双模型模式**:云服务部署、需要强工具调用能力(如 MiniMax-M2.7、gpt-4o)、希望工具循环不阻塞用户
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Fay 与 Harness Agent 的记忆机制对比
|
||||
|
||||
> 写作时间:2026-04-27。"Harness agent" 指 Anthropic Claude Code 这一类"为开发者准备的通用代理脚手架",下文以 Claude Code 为代表。**Fay 是为数字人场景设计的开源 agent 框架——它的记忆机制不是给开发者用的工程辅助工具,而是支撑一个长期陪伴主体的认知底座。**
|
||||
|
||||
**核心结论先放上:**
|
||||
- Harness agent 把"开发者协作记忆"做透了,Fay 学了它的元数据组织、来源归因、规则通道、自描述这套"记忆治理"经验;
|
||||
- 但**数字人场景的诉求与 IDE 场景根本不同**——长期陪伴、多用户隔离、跨会话语义检索、自动反思、自动画像、多模态状态、跨领域外部协作;
|
||||
- 这些诉求 harness agent 并不打算解决,**Fay 才是数字人场景下记忆机制的正确形态**。
|
||||
|
||||
---
|
||||
|
||||
## 一、Fay 从 harness agent 学到了什么
|
||||
|
||||
Fay 的记忆底座来自斯坦福 Generative Agents 的 memory_stream:三种节点 + 三因子打分 + 反思机制。但 Claude Code 这一年的演进里把"长期记忆怎么对外协作"这件事打磨得很到位,Fay 这次记忆改造里就直接搬了几条它的设计经验:
|
||||
|
||||
### 1.1 用前缀命名空间组织 tag,而不是新增枚举字段
|
||||
|
||||
Claude Code 的 feedback 记忆文件里,每条记忆带 YAML frontmatter——`name / description / type: feedback / originSessionId`。它没有为"feedback 是不是规则、是不是偏好、属于哪个项目"再单独建字段,而是用"key:value"风格把维度收敛在元数据里。
|
||||
|
||||
Fay 这次没有给 ConceptNode 加 `is_rule / is_persistent / domain` 一堆布尔字段,而是加了一个 `tags: list[str]`,用 `kind:rule / persistent:true / domain:quant` 这种命名空间式的前缀承载所有维度。新增维度不改 schema,反思继承 tag 也是按前缀过滤——这套思路直接对标了 harness 的做法。
|
||||
|
||||
### 1.2 来源归因(source attribution)
|
||||
|
||||
Claude Code 的每条 feedback 都记 `originSessionId`,让"这条规则是哪次对话留下的"可追溯。Fay 这次在 tag 里固定预留了 `source:` 命名空间——`source:claude_code / source:cursor / source:fay_self / source:fay_reflection / source:user`。同一条记忆在反思、检索、调试时都能立刻看出来源,不会出现"这条规则是谁写的"这种盲区。
|
||||
|
||||
### 1.3 persistent 标志与"长期规则"通道
|
||||
|
||||
Claude Code 的 MEMORY.md 是 auto-loaded 的——每次会话开始自动注入。这种"无条件加载"的语义让"长期约束"和"具体事件"在物理上分开。
|
||||
|
||||
Fay 借鉴了这个区分:tag 里专门有 `persistent:true`,并给 service 层加了 `get_active_rules()` 函数,专门返回 `kind:rule + persistent:true` 的节点。外部 agent 在每次开新任务前调一下,就拿到一份等效于 MEMORY.md 的"必须遵守的清单"。
|
||||
|
||||
### 1.4 schema 自描述工具
|
||||
|
||||
Claude Code 的 memory 系统有一个隐含但关键的体验:约定是公开的、可发现的。第三方工具看到一个 feedback 文件就知道它的字段是什么含义。
|
||||
|
||||
Fay 把这条做成了显式接口——`memory_get_schema()` MCP 工具会返回 `kind` 枚举与 tag 命名空间约定。任何外部 agent(Claude Code、Cursor、自定义工具)在不熟悉参数时调用一次,就能拿到"该填什么、有什么约束"的完整说明。这是 Fay 不让外部 agent 乱写 tag 的主要保险。
|
||||
|
||||
### 1.5 append-only + 周期性 consolidation
|
||||
|
||||
Claude Code 的实践:会话过程中一直 append(消息日志、TodoWrite、feedback 文件),到容量上限时由 compaction 把历史压缩成 summary 注入下一段。
|
||||
|
||||
Fay 这次确认了同样的节奏:写入路径只 append 不修改(4 条写入路径都走 `append_prepared_node`),每天 23:00 由 `perform_daily_reflection` 把零散事件 consolidate 成 reflection 节点。reflection 节点继承业务 tag 但覆盖打 `kind:insight + source:fay_reflection`,相当于一次结构化的 compaction。
|
||||
|
||||
### 1.6 index/detail 分层
|
||||
|
||||
Claude Code 的 MEMORY.md 是个 index,里面是指向 `feedback_xxx.md` 等详细文件的链接,主索引短,详情分文件。
|
||||
|
||||
Fay 在用户档案上对应同样的模式:`T_Member.user_portrait` 是简短画像(1000 字以内的 LLM 总结),而 `memory_stream` 里 nodes.json 是完整明细。检索时先看 portrait 拿"这个人是谁",再用语义检索拿具体事件。
|
||||
|
||||
---
|
||||
|
||||
## 二、数字人场景下,Fay 是更合适的形态
|
||||
|
||||
**这一节是本文的重点。** Harness agent 的目标是辅助开发者写代码——它面对的是一个键盘前的工程师、一个仓库、一段会话;记忆机制只要让"上下文不丢"就够了。
|
||||
|
||||
Fay 的目标完全不同:承载一个**会陪伴用户多年、会跨多种场景工作、会随时间成长**的数字人主体。它面对的是一个老人、一个家庭、一个学生、一支量化团队——记忆要做的不是"帮 agent 别忘事",而是**让数字人成为一个有人格、有关系、有时间纵深的存在**。
|
||||
|
||||
下面这张对比表,**第三列是关键**——它直接说明每一项设计差异背后,数字人场景为什么必须选 Fay 这条路:
|
||||
|
||||
| 维度 | Harness Agent(如 Claude Code) | Fay | 数字人场景为什么需要 Fay 的设计 |
|
||||
|---|---|---|---|
|
||||
| **存储介质** | 主要是文件(CLAUDE.md / MEMORY.md / feedback md) | sqlite(用户档案)+ JSON(节点流)+ embedding(向量) | 数字人要支持多用户、按用户隔离、跨会话语义检索,纯文件不够 |
|
||||
| **节点类型** | 单一文件粒度,无类型语义 | observation / conversation / reflection 三种 | 对话、观察、反思在数字人交互里需要分别召回(比如检索时按"近期发生过什么"vs"我们聊过什么"分段) |
|
||||
| **检索方式** | 全量文件加载到 prompt(命中即注入) | 三因子打分(recency + relevance + importance)+ tag 过滤 | 数字人的对话会话短、节点数量多,必须按相关度+时间打分挑出 top-N,否则 prompt 直接撑爆 |
|
||||
| **重要度评估** | 无,由人/agent 自己取舍内容 | LLM 在写入时自动给 0–100 重要度分数 | 数字人写入是高频自动行为(每句话/每次观察都写),必须有自动评分才能在检索时分层 |
|
||||
| **时间维度** | 文件 mtime(粗粒度) | 每个节点带 `datetime` + `created` time_step + `last_retrieved`,retrieve 用时间衰减打分 | 陪伴型数字人需要回答"上周我们聊过什么"、"昨天血压是多少",时间是一等公民 |
|
||||
| **反思机制** | 无内建反思(用户主动整理 MEMORY.md) | 每天 23:00 LLM 自动反思,把零散事件提炼成 insight 节点,自动继承业务 tag | 数字人作为长期陪伴对象,需要主动从昨日发现规律("用户连续 3 天血压偏高"),不能等用户来整理 |
|
||||
| **画像生成** | 无 | 每天 22:35 LLM 重写 `T_Member.user_portrait`,含"与 Fay 的关系"维度(亲密度、情感基调、共同经历) | 数字人需要稳定人格 + 关系记忆,让"昨天对你说过的话"塑造"今天的语气" |
|
||||
| **多用户隔离** | 单一用户视角 | `memory.isolate_by_user=true` 时按用户名分目录,T_Member 表按 username 索引 | 数字人产品经常一对多(养老院、家教平台、客服),必须支持每个用户独立记忆 |
|
||||
| **写入并发模型** | 串行(agent 顺序写文件) | 锁外算 importance/embedding,锁内 append;落盘异步 | 对话延迟敏感,不能因为 LLM 评分卡住就让下一句话排队(曾经因此挂死过 5 小时) |
|
||||
| **多模态状态** | 纯文本 | 与 voice / avatar / live2d / 设备状态联动(scratch 角色卡 + observation 节点) | 数字人是有"身体"的——表情、语音、设备事件都是记忆材料 |
|
||||
| **实时对话注入** | 一次性加载,不每轮变化 | 每轮对话 retrieve 30 条候选 → 按 type 分三段拼进 system prompt | 数字人对话每一轮上下文都不同,需要动态更新"该想起来什么" |
|
||||
| **外部 agent 协作写入** | 主要是被 harness 写,没有标准化外部写入接口 | core.memory_service + 7 个 MCP 工具,外部 agent(Claude Code/Cursor)可以反向回写 | 数字人是被多个工具/服务共同塑造的——量化策略写交易事件、监测脚本写血压、家教 agent 写学员进度 |
|
||||
| **schema 自描述** | 约定式(看示例就懂) | `memory_get_schema()` MCP 工具显式返回 kind 枚举与命名空间 | 数字人对接的外部 agent 异质化高,必须能让外部"运行时发现规约" |
|
||||
| **场景普适性** | 紧贴 IDE / coding 场景 | 量化交易、居家养老、教育辅导、生活助理、智能家居……同一套机制 | 数字人的产品形态本身就是跨领域的,记忆机制必须场景无关 |
|
||||
|
||||
### 2.1 一个具象场景:harness agent 撑不起,Fay 撑得起
|
||||
|
||||
想象一个真实数字人产品——居家养老陪护机器人 "小慧",部署在一个家庭里 365 天。
|
||||
|
||||
| 时间 | 发生的事 | 用 harness agent 的记忆能不能扛? | Fay 怎么扛? |
|
||||
|---|---|---|---|
|
||||
| Day 1 上午 | 老人第一次和小慧聊天,告诉她"我不喜欢被叫'老人家',叫我老张" | ❌ 没有 user 概念,下次会话不会记得 | conversation 节点 + 偏好节点 `kind:preference, persistent:true`,写入 `T_Member.user_portrait` |
|
||||
| Day 1 下午 | 血压计自动上传:158/95 | ❌ 不是文本对话,无写入接口 | MCP 工具 `memory_remember(kind=event, source=elderly_monitor, domain:homecare)` 直接写入 |
|
||||
| Day 1 晚 23:00 | 系统主动反思 | ❌ 不会主动反思 | `perform_daily_reflection` 跑一次,提炼"老张今天血压偏高"insight |
|
||||
| Day 7 早晨 | 老张说"最近怎么有点累" | ❌ 没有时间维度的检索 | retrieve 带 recency 权重 + tag 过滤,捞到这周 3 条血压偏高的 event 与 1 条反思 insight |
|
||||
| Day 30 | 家属在 app 设规则"每天早上必须测血压" | ❌ 没有外部 agent 写入通道 | 家属 agent 通过 MCP 调 `memory_remember(kind=rule, persistent=true)`,小慧每天自动 `get_active_rules()` 读到 |
|
||||
| Day 90 | 小慧和老张已经熟络 | ❌ 没有人格演化 | 22:35 画像分析持续重写 `user_portrait`,含"与 Fay 的关系(亲密度、称呼偏好、共同经历)"维度 |
|
||||
|
||||
这是 harness agent 与 Fay 的根本分野——**前者活在一次会话里,后者活在一段关系里。**
|
||||
|
||||
### 2.2 一句话定位
|
||||
|
||||
> **Harness agent 的记忆,是为了让 agent 不忘事;Fay 的记忆,是为了让数字人成为一个"人"。**
|
||||
|
||||
---
|
||||
|
||||
## 三、两个仓库
|
||||
|
||||
| 项目 | 仓库地址 | 简介 |
|
||||
|---|---|---|
|
||||
| **Fay** | https://gitee.com/xszyou/fay | 开源数字人 agent 框架。本文记忆机制的全部代码都在这个仓库里。 |
|
||||
| **Claude Code(harness agent)** | https://github.com/anthropics/claude-code | Anthropic 官方为开发者打造的通用代理脚手架,本文的"harness agent"参照对象。 |
|
||||
|
||||
> 想动手验证:克隆 Fay,启动 `faymcp/mcp_server.py`,用 Claude Code 通过 SSE 连上端口 8765,就能体会"外部 harness agent 通过 MCP 把记忆回写到 Fay"这条新通道。
|
||||
>
|
||||
> **两个仓库各司其职**:Claude Code 让开发者更高效地写代码,Fay 让你的数字人活得更长、更真。
|
||||
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
# Fay 记忆机制总览(2026-04-27)
|
||||
|
||||
> 本文档描述 Fay 当前默认的认知记忆机制。涉及代码:
|
||||
> `llm/nlp_cognitive_stream.py` + `genagents/modules/memory_stream.py`
|
||||
> + `core/memory_service.py` + `core/member_db.py` + `faymcp/mcp_server.py`。
|
||||
>
|
||||
> 旧版精简说明见 [`memory_module.md`](./memory_module.md),本文为增补与扩展。
|
||||
|
||||
---
|
||||
|
||||
## 一、三层存储
|
||||
|
||||
| 存储 | 路径 | 内容 | 隔离方式 |
|
||||
|---|---|---|---|
|
||||
| **memory_stream**(核心) | `memory/[<username>/]memory_stream/nodes.json` + `embeddings.json` | 所有事件、对话、反思节点 | `config.memory.isolate_by_user=true` 时按 username 分目录,否则全局共享 |
|
||||
| **scratch**(人物属性) | 内存里的 `agent.scratch` dict,启动时从 `config.json` 的 `attribute` 段加载 | 姓名、年龄、性别、性格、目标等 Fay 自身的"角色卡" | 不持久化,每次启动从 config 重新装填 |
|
||||
| **T_Member**(用户档案) | `memory/user_profiles.db` 的 sqlite 表 | `username`、`extra_info`(人工补充)、`user_portrait`(每天 22:35 自动生成的画像) | 一行一个用户 |
|
||||
|
||||
ChromaDB 仅用于本地知识库(doc 检索),不参与记忆流。
|
||||
|
||||
---
|
||||
|
||||
## 二、节点的数据结构
|
||||
|
||||
每个 `ConceptNode` 包含:
|
||||
|
||||
```
|
||||
node_id, node_type, content, importance, datetime,
|
||||
created (time_step), last_retrieved, pointer_id,
|
||||
tags ← 业务标签列表
|
||||
```
|
||||
|
||||
`node_type` 只有三种:`observation` / `conversation` / `reflection`。
|
||||
|
||||
`tags` 走命名空间约定:
|
||||
|
||||
```
|
||||
kind:<decision|event|fact|rule|error|insight|preference|observation>
|
||||
source:<fay_self|claude_code|cursor|user|fay_reflection|...>
|
||||
persistent:true ← 长期保留标志,rule 必带
|
||||
domain:<quant|homecare|education|life_assistant|home_automation|companion|...>
|
||||
strategy:<策略名> symbol:<标的> session:<会话ID> schedule:<表达式> date:<YYYY-MM-DD>
|
||||
```
|
||||
|
||||
### 2.1 节点完整事例
|
||||
|
||||
#### 事例 A:普通对话(conversation,无 tag)
|
||||
|
||||
```json
|
||||
{
|
||||
"node_id": 142,
|
||||
"node_type": "conversation",
|
||||
"content": "主人:今天股市怎么样?\nFay:A股震荡走低,沪指跌 0.8%。",
|
||||
"importance": 4,
|
||||
"datetime": "2026/04/27 10:23:15",
|
||||
"created": 142,
|
||||
"last_retrieved": 142,
|
||||
"pointer_id": null,
|
||||
"tags": []
|
||||
}
|
||||
```
|
||||
|
||||
> 当前对话路径暂未打 tag,反思阶段无法从中继承业务标签。
|
||||
|
||||
#### 事例 B:外部 agent 写入的事件(event)
|
||||
|
||||
```json
|
||||
{
|
||||
"node_id": 156,
|
||||
"node_type": "observation",
|
||||
"content": "上午 10:30 AAPL 突破日内高点 187.4,breakout 策略买入信号触发",
|
||||
"importance": 7,
|
||||
"datetime": "2026/04/27 10:30:42",
|
||||
"created": 156,
|
||||
"last_retrieved": 156,
|
||||
"pointer_id": null,
|
||||
"tags": [
|
||||
"domain:quant",
|
||||
"kind:event",
|
||||
"session:2026-04-27-am",
|
||||
"source:claude_code",
|
||||
"strategy:breakout",
|
||||
"symbol:AAPL"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 事例 C:长期规则(rule,必带 persistent:true)
|
||||
|
||||
```json
|
||||
{
|
||||
"node_id": 88,
|
||||
"node_type": "observation",
|
||||
"content": "每小时整点检查所有持仓策略是否有未触发的止损单",
|
||||
"importance": 9,
|
||||
"datetime": "2026/04/26 09:00:00",
|
||||
"created": 88,
|
||||
"last_retrieved": 130,
|
||||
"pointer_id": null,
|
||||
"tags": [
|
||||
"domain:quant",
|
||||
"kind:rule",
|
||||
"persistent:true",
|
||||
"schedule:hourly",
|
||||
"source:user"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 事例 D:用户偏好(preference)
|
||||
|
||||
```json
|
||||
{
|
||||
"node_id": 31,
|
||||
"node_type": "observation",
|
||||
"content": "用户希望晚上 22:00 之后不要主动发起对话",
|
||||
"importance": 8,
|
||||
"datetime": "2026/04/15 22:13:05",
|
||||
"created": 31,
|
||||
"last_retrieved": 105,
|
||||
"pointer_id": null,
|
||||
"tags": [
|
||||
"domain:life_assistant",
|
||||
"kind:preference",
|
||||
"persistent:true",
|
||||
"source:user"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 事例 E:失败事件(error)
|
||||
|
||||
```json
|
||||
{
|
||||
"node_id": 173,
|
||||
"node_type": "observation",
|
||||
"content": "买入信号在 AAPL 涨停封板后仍触发,导致 0 成交但占用了风控额度",
|
||||
"importance": 8,
|
||||
"datetime": "2026/04/27 14:02:11",
|
||||
"created": 173,
|
||||
"last_retrieved": 173,
|
||||
"pointer_id": null,
|
||||
"tags": [
|
||||
"domain:quant",
|
||||
"kind:error",
|
||||
"session:2026-04-27-pm",
|
||||
"source:claude_code",
|
||||
"strategy:breakout",
|
||||
"symbol:AAPL"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 事例 F:居家养老观察(event,跨场景示例)
|
||||
|
||||
```json
|
||||
{
|
||||
"node_id": 412,
|
||||
"node_type": "observation",
|
||||
"content": "老人晨起血压 158/95,比上周平均高 12 个点",
|
||||
"importance": 7,
|
||||
"datetime": "2026/04/27 07:15:00",
|
||||
"created": 412,
|
||||
"last_retrieved": 412,
|
||||
"pointer_id": null,
|
||||
"tags": [
|
||||
"domain:homecare",
|
||||
"kind:event",
|
||||
"source:elderly_monitor",
|
||||
"date:2026-04-27"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 事例 G:Fay 自动生成的反思(reflection,继承+追加 tag)
|
||||
|
||||
```json
|
||||
{
|
||||
"node_id": 201,
|
||||
"node_type": "reflection",
|
||||
"content": "用户在 AAPL 上的 breakout 策略本周 3 次假突破亏损,可能需要加 ATR 滤波或限制涨停后入场",
|
||||
"importance": 8,
|
||||
"datetime": "2026/04/27 23:00:14",
|
||||
"created": 201,
|
||||
"last_retrieved": 201,
|
||||
"pointer_id": [156, 162, 173],
|
||||
"tags": [
|
||||
"domain:quant",
|
||||
"kind:insight",
|
||||
"source:fay_reflection",
|
||||
"strategy:breakout",
|
||||
"symbol:AAPL"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> 反思节点的 tag 由 `MemoryStream.reflect()` 自动从 `pointer_id` 指向的源节点继承(去掉 `session:`、`date:`、`schedule:` 前缀),再覆盖为 `kind:insight` + `source:fay_reflection`。
|
||||
|
||||
---
|
||||
|
||||
## 三、写入路径(共 4 条)
|
||||
|
||||
| 触发 | 函数 | 节点类型 | 是否打 tag |
|
||||
|---|---|---|---|
|
||||
| 用户每说一句话 | `remember_conversation_thread` | conversation | ❌ 暂未打 |
|
||||
| 主动观察(API/前端调) | `record_observation` → `remember_observation_thread` | observation | ❌ 暂未打 |
|
||||
| 外部 agent / Fay 自身 | `core.memory_service.remember()` | observation(默认) | ✅ 自动 normalize |
|
||||
| 每晚 23:00 反思 | `perform_daily_reflection` → `MemoryStream.reflect()` | reflection | ✅ 继承源节点 tag + 自动加 `kind:insight`、`source:fay_reflection` |
|
||||
|
||||
写入的统一管线:
|
||||
1. **锁外** 算 importance(LLM 评分 0–10)和 embedding(API 向量)
|
||||
2. **持锁** `agent_lock`,把节点 append 到 `memory_stream.seq_nodes`,并写 `embeddings[content]`
|
||||
3. 内部 4 条只更新内存;只有 `core.memory_service.remember` 会立即落盘
|
||||
4. 每天 00:00 `save_agent_memory` 全量 dump 一次 nodes.json + embeddings.json
|
||||
|
||||
---
|
||||
|
||||
## 四、检索路径
|
||||
|
||||
唯一入口:`MemoryStream.retrieve(focal_points, time_step, ...)`。
|
||||
|
||||
打分公式:
|
||||
```
|
||||
score = recency_w · 衰减(last_retrieved)
|
||||
+ relevance_w · cosine(query_embedding, node_embedding)
|
||||
+ importance_w · normalized(importance)
|
||||
```
|
||||
|
||||
默认权重 `[0, 1, 0.5]`(纯相关度+重要度),但**对话流**用的是 `[0.8, 0.5, 0.5]`(加重时间权重)。
|
||||
|
||||
新增 tag 过滤:`filter_tags_all`(AND)/ `filter_tags_any`(OR)。
|
||||
|
||||
**对话时拼提示词的过程**(见 `nlp_cognitive_stream.py` 2329 行附近):
|
||||
1. 用当前用户输入做 query
|
||||
2. 一次 retrieve 拉 30 条候选
|
||||
3. 按 `node_type` 分成三段:观察记忆 / 对话记忆 / 反思记忆,每段最多 10 条
|
||||
4. 拼成 markdown,塞进系统 prompt 的 `memory_context`
|
||||
|
||||
---
|
||||
|
||||
## 五、定时任务(`init_memory_scheduler`)
|
||||
|
||||
| 时间 | 任务 | 作用 |
|
||||
|---|---|---|
|
||||
| 00:00 | `save_agent_memory` | 把内存里的 nodes/embeddings/scratch 全量落盘 |
|
||||
| 11:30(注释说正式应改 22:35) | `perform_user_portrait_analysis` | LLM 读最近对话,更新 `T_Member.user_portrait`(含"与 Fay 的关系"维度) |
|
||||
| 23:00 | `perform_daily_reflection` | 抽取热门主题做反思,生成 reflection 节点 |
|
||||
|
||||
启动时还会:
|
||||
- `precheck_embedding_dimensions` — 修复维度不一致的旧 embedding
|
||||
- `create_agent` 默认 username 创建主 agent
|
||||
|
||||
---
|
||||
|
||||
## 六、对外接口
|
||||
|
||||
| 调用方 | 接口 | 用途 |
|
||||
|---|---|---|
|
||||
| Fay 内部对话流 | 直接调 `agent.memory_stream` + `remember_*_thread` | 高频路径,绕过 service 层 |
|
||||
| Flask `/api/observation` 等 | `record_observation` | 外部 HTTP 写入观察 |
|
||||
| **MCP** | `faymcp/mcp_server.py`(SSE,端口 8765) | 暴露 7 个 `memory_*` 工具,进程内直调 `core.memory_service` |
|
||||
| `core.memory_service` | 7 个函数:`remember / search / get_recent / get_active_rules / get_reflections / get_user_profile / get_schema` | 唯一权威 API,统一 tag 规范 + 立即落盘 |
|
||||
|
||||
MCP 的 7 个工具:
|
||||
- `memory_remember` — 写入(含 kind 枚举 + persistent + extra_tags)
|
||||
- `memory_search` — 语义检索 + tag 过滤
|
||||
- `memory_get_recent` — 时间倒序最近 N 条
|
||||
- `memory_get_active_rules` — 所有 `kind:rule + persistent:true`
|
||||
- `memory_get_reflections` — 最近反思
|
||||
- `memory_get_user_profile` — portrait + extra_info
|
||||
- `memory_get_schema` — kind 枚举与 tag 命名空间,外部 agent 拿来对齐参数
|
||||
|
||||
---
|
||||
|
||||
## 七、典型流程示例
|
||||
|
||||
### 7.1 内部场景(Fay 主进程内)
|
||||
|
||||
#### 例 1:普通用户聊天
|
||||
```
|
||||
用户:"今天天气真不错"
|
||||
└─→ Fay 主流程: question() 拿到内容
|
||||
├─→ 锁外算 importance/embedding
|
||||
├─→ remember_conversation_thread 异步写一条 conversation 节点(content="主人:...\nFay:...")
|
||||
└─→ 当前提示词组装时调 memory_stream.retrieve(content)
|
||||
├─→ 取回 30 条候选,分三段塞进 system prompt 的 memory_context
|
||||
└─→ LLM 据此生成回复
|
||||
```
|
||||
当前 conversation 节点不带 tag,反思阶段无法继承。
|
||||
|
||||
#### 例 2:用户主动表达偏好(理想路径)
|
||||
```
|
||||
用户:"以后晚上 10 点之后别叫我"
|
||||
└─→ Fay 听到后做出回应(写 conversation 节点)
|
||||
└─→ Fay 应该 同时调 core.memory_service.remember(
|
||||
content="用户希望晚上 22:00 之后不要主动发起对话",
|
||||
kind="preference",
|
||||
persistent=True,
|
||||
source="fay_self",
|
||||
extra_tags=["domain:life_assistant"]
|
||||
)
|
||||
└─→ 写入一条 observation 节点(事例 D)
|
||||
```
|
||||
> 当前 Fay 还没自动做这件事,需要后续在对话流里加"指令识别 → 调 service" 的钩子。
|
||||
|
||||
#### 例 3:每天 22:35 用户画像分析
|
||||
```
|
||||
schedule.run_pending() 触发 perform_user_portrait_analysis()
|
||||
└─→ 读 T_Member.user_portrait(旧画像)
|
||||
└─→ 抽取最近 N 条 conversation 节点
|
||||
└─→ 调 LLM,按 6 个维度(含"与 Fay 的关系")生成新画像,1000 字以内
|
||||
└─→ 写回 T_Member.user_portrait
|
||||
```
|
||||
|
||||
#### 例 4:每晚 23:00 反思
|
||||
```
|
||||
schedule.run_pending() 触发 perform_daily_reflection()
|
||||
└─→ 选若干"主题锚点"(anchor)
|
||||
└─→ 对每个 anchor 调 memory_stream.reflect(anchor)
|
||||
├─→ 内部 retrieve 出 120 条相关节点
|
||||
├─→ LLM 提炼为 5 条 reflection 文本
|
||||
├─→ 算 importance + embedding
|
||||
└─→ 调 _add_node 写入,tags 自动从源节点继承+覆盖
|
||||
结果如事例 G
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7.2 外部场景(外部 agent 通过 MCP 调用)
|
||||
|
||||
> 外部 agent 通过 SSE 连接 `http://<fay_host>:8765/sse`,按 MCP 协议调用 `memory_*` 工具。
|
||||
|
||||
#### 例 5:Claude Code 开新策略任务前先拉规则 & 写 session 开始
|
||||
```
|
||||
[Claude Code 启动新会话]
|
||||
1) call memory_get_active_rules(username="trader_zhang")
|
||||
← 返回 [事例 C, 例 8 的 homecare 规则等]
|
||||
→ Claude Code 把规则塞进自己的 system prompt
|
||||
|
||||
2) call memory_remember(
|
||||
content="开始 2026-04-27 上午盘量化执行任务",
|
||||
kind="event",
|
||||
source="claude_code",
|
||||
extra_tags=["domain:quant", "session:2026-04-27-am"],
|
||||
username="trader_zhang"
|
||||
)
|
||||
← {"ok": true, "node_id": 155, ...}
|
||||
```
|
||||
|
||||
#### 例 6:执行过程中实时回写事件
|
||||
```
|
||||
[策略触发 → Claude Code 调 broker API → 成交]
|
||||
3) call memory_remember(
|
||||
content="上午 10:30 AAPL 突破日内高点 187.4,breakout 策略买入信号触发",
|
||||
kind="event",
|
||||
source="claude_code",
|
||||
extra_tags=["domain:quant", "strategy:breakout", "symbol:AAPL", "session:2026-04-27-am"],
|
||||
username="trader_zhang"
|
||||
)
|
||||
→ 节点 156 落地(事例 B)
|
||||
|
||||
[发现策略 bug]
|
||||
4) call memory_remember(
|
||||
content="买入信号在 AAPL 涨停封板后仍触发,导致 0 成交但占用了风控额度",
|
||||
kind="error",
|
||||
source="claude_code",
|
||||
extra_tags=["domain:quant", "strategy:breakout", "symbol:AAPL", "session:2026-04-27-pm"],
|
||||
username="trader_zhang"
|
||||
)
|
||||
→ 节点 173 落地(事例 E)
|
||||
```
|
||||
|
||||
#### 例 7:第二天另一个会话回顾
|
||||
```
|
||||
[次日 Claude Code 新会话开启]
|
||||
1) call memory_search(
|
||||
query="breakout 策略最近问题",
|
||||
filter_tags_all=["domain:quant", "strategy:breakout"],
|
||||
n=10,
|
||||
username="trader_zhang"
|
||||
)
|
||||
← 返回:
|
||||
- 节点 173 (kind:error 假突破)
|
||||
- 节点 201 (kind:insight 反思——"3 次假突破,建议加 ATR 滤波")
|
||||
- 节点 156 (kind:event 当时的买入)
|
||||
|
||||
2) Claude Code 据此调整策略实现,再写一条:
|
||||
call memory_remember(
|
||||
content="已在 breakout 策略加 ATR(14)>1.5 滤波,规避涨停后假突破",
|
||||
kind="decision",
|
||||
source="claude_code",
|
||||
extra_tags=["domain:quant", "strategy:breakout"],
|
||||
username="trader_zhang"
|
||||
)
|
||||
```
|
||||
|
||||
#### 例 8:居家养老监测脚本(非交易场景)
|
||||
```
|
||||
[ESP32 血压计 → 中转脚本 → MCP]
|
||||
call memory_remember(
|
||||
content="老人晨起血压 158/95,比上周平均高 12 个点",
|
||||
kind="event",
|
||||
source="elderly_monitor",
|
||||
extra_tags=["domain:homecare", "date:2026-04-27"],
|
||||
username="grandpa_li"
|
||||
)
|
||||
→ 节点 412 落地(事例 F)
|
||||
|
||||
[家属在 app 设规则]
|
||||
call memory_remember(
|
||||
content="爷爷每天早晨必须测血压,9 点前没数据要报警",
|
||||
kind="rule",
|
||||
persistent=True,
|
||||
source="family_app",
|
||||
extra_tags=["domain:homecare", "schedule:daily"],
|
||||
username="grandpa_li"
|
||||
)
|
||||
→ 一条 persistent rule 入库
|
||||
|
||||
[当晚反思]
|
||||
Fay 23:00 reflect → 检索 grandpa_li 节点
|
||||
→ 生成 insight: "老人本周血压偏高 3 次,建议家属安排复查" (kind:insight, domain:homecare)
|
||||
```
|
||||
|
||||
#### 例 9:Cursor 修 bug 后回写
|
||||
```
|
||||
[Cursor 修了一个内存泄漏]
|
||||
call memory_remember(
|
||||
content="发现 stream_manager 在 ws 异常断开时未释放 buffer,已改为 try/finally 关闭",
|
||||
kind="fact",
|
||||
source="cursor",
|
||||
extra_tags=["domain:engineering", "module:stream_manager", "session:fix-memleak-1"]
|
||||
)
|
||||
|
||||
[同时记录决策]
|
||||
call memory_remember(
|
||||
content="Fay 项目里所有 ws 路径都要在 finally 里 close buffer",
|
||||
kind="rule",
|
||||
persistent=True,
|
||||
source="cursor",
|
||||
extra_tags=["domain:engineering"]
|
||||
)
|
||||
```
|
||||
|
||||
#### 例 10:教育辅导 agent
|
||||
```
|
||||
[家教 agent 完成一节课]
|
||||
call memory_remember(
|
||||
content="小明今天独立完成 5 道一元二次方程,全部正确",
|
||||
kind="fact",
|
||||
source="tutor_bot",
|
||||
extra_tags=["domain:education", "subject:math", "topic:quadratic_eq"],
|
||||
username="xiaoming"
|
||||
)
|
||||
|
||||
[下次开课前]
|
||||
call memory_search(
|
||||
query="小明数学进度",
|
||||
filter_tags_all=["domain:education", "subject:math"],
|
||||
username="xiaoming"
|
||||
)
|
||||
→ 拉到上一次的"已掌握一元二次方程" → 这次直接进二次函数
|
||||
```
|
||||
|
||||
#### 例 11:跨 agent 协作 — Claude Code 写规则、Fay 自身遵守
|
||||
```
|
||||
1) Claude Code 调:
|
||||
memory_remember(
|
||||
content="每小时整点检查所有持仓策略是否有未触发的止损单",
|
||||
kind="rule",
|
||||
persistent=True,
|
||||
source="user",
|
||||
extra_tags=["domain:quant", "schedule:hourly"]
|
||||
)
|
||||
|
||||
2) Fay 主进程的某个内置 agent(如调度器)
|
||||
每小时启动时调:
|
||||
core.memory_service.get_active_rules(username)
|
||||
← 返回所有 persistent rules,包含上面这条
|
||||
→ 调度器据此触发巡检流程
|
||||
```
|
||||
> 这是"MCP 工具同时向 Fay 自身暴露"的核心价值:写入与读取走同一条 service 层,外部 agent 写、内部 agent 读,无 sync 问题。
|
||||
|
||||
---
|
||||
|
||||
## 八、还没解决的事
|
||||
|
||||
1. **对话/观察线程没打 tag**:反思继承不到东西,需在 `remember_conversation_thread` / `remember_observation_thread` 里加默认 tag(如 `source:fay_conversation`、`source:fay_observation`)
|
||||
2. **冷热分层缺失**:节点数大了之后 retrieve 全扫,没有按 importance + 年龄做分层或聚类收敛
|
||||
3. **上下文压缩缺失**:retrieve 回来的节点直接全文进 prompt,没有 summary 压缩层
|
||||
4. **端到端联调未跑**:MCP 工具刚加,需要:启动 Fay → Claude Code 连 SSE → 调 `memory_get_schema` / `memory_remember` / `memory_search`,确认节点正确落盘
|
||||
|
||||
---
|
||||
|
||||
## 九、文件位置速查
|
||||
|
||||
| 用途 | 文件 |
|
||||
|---|---|
|
||||
| 节点结构 + memory_stream 主体 | `genagents/modules/memory_stream.py` |
|
||||
| 对话流 + 写线程 + 定时任务 | `llm/nlp_cognitive_stream.py` |
|
||||
| 唯一权威 service | `core/memory_service.py` |
|
||||
| 用户档案 sqlite | `core/member_db.py` |
|
||||
| MCP SSE 服务(含记忆工具) | `faymcp/mcp_server.py` |
|
||||
| 记忆数据 | `memory/[<username>/]memory_stream/nodes.json` + `embeddings.json` |
|
||||
| 用户档案 DB | `memory/user_profiles.db` |
|
||||
@@ -0,0 +1,17 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
set "URL=http://192.168.1.26:5000/transparent-pass"
|
||||
set "BODY={\"user\":\"User\",\"text\":\"fay \u7684\u5de5\u4f5c\u5df2\u7ecf\u5b8c\u6210\"}"
|
||||
|
||||
curl.exe -s -X POST "%URL%" ^
|
||||
-H "Content-Type: application/json" ^
|
||||
--data-raw "%BODY%" >nul
|
||||
|
||||
if errorlevel 1 (
|
||||
echo [done.bat] Request failed.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [done.bat] Notification sent.
|
||||
exit /b 0
|
||||
@@ -0,0 +1,189 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
block_cipher = None
|
||||
|
||||
|
||||
def _detect_project_root():
|
||||
spec_path = None
|
||||
for item in reversed(sys.argv):
|
||||
if isinstance(item, str) and item.lower().endswith(".spec"):
|
||||
spec_path = item
|
||||
break
|
||||
if spec_path is None:
|
||||
return os.getcwd()
|
||||
if os.path.isfile(spec_path):
|
||||
return os.path.dirname(spec_path)
|
||||
return spec_path
|
||||
|
||||
|
||||
PROJECT_ROOT = os.path.abspath(_detect_project_root())
|
||||
IS_WINDOWS = sys.platform.startswith("win")
|
||||
|
||||
|
||||
def _project_path(*parts):
|
||||
return os.path.join(PROJECT_ROOT, *parts)
|
||||
|
||||
|
||||
def _first_existing(*relative_paths):
|
||||
for rel_path in relative_paths:
|
||||
abs_path = _project_path(*rel_path.split("/"))
|
||||
if os.path.exists(abs_path):
|
||||
return abs_path
|
||||
return None
|
||||
|
||||
|
||||
def _add_data_entry(data_items, source_path, target_dir):
|
||||
if source_path and os.path.exists(source_path):
|
||||
data_items.append((source_path, target_dir))
|
||||
|
||||
|
||||
def _add_package_dlls(data_items, package_name, target_dir):
|
||||
try:
|
||||
package = __import__(package_name, fromlist=["__file__"])
|
||||
package_dir = Path(package.__file__).resolve().parent
|
||||
except Exception:
|
||||
return
|
||||
|
||||
for dll_path in sorted(package_dir.glob("*.dll")):
|
||||
_add_data_entry(data_items, str(dll_path), target_dir)
|
||||
|
||||
|
||||
datas = []
|
||||
|
||||
for rel_path, target_dir in [
|
||||
("qa.csv", "."),
|
||||
("verifier.json", "."),
|
||||
("favicon.ico", "."),
|
||||
("icon.png", "."),
|
||||
("packaging/release/memory/fay.db", "memory"),
|
||||
("packaging/release/memory/user_profiles.db", "memory"),
|
||||
("新知识库", "新知识库"),
|
||||
("gui/templates", "gui/templates"),
|
||||
("gui/static", "gui/static"),
|
||||
("gui/robot", "gui/robot"),
|
||||
("faymcp/templates", "faymcp/templates"),
|
||||
("faymcp/static", "faymcp/static"),
|
||||
("faymcp/robot", "faymcp/robot"),
|
||||
("packaging/release/faymcp/data", "faymcp/data"),
|
||||
("test/mcp_stdio_example.py", "test"),
|
||||
("mcp_servers/elderly_mcp", "mcp_servers/elderly_mcp"),
|
||||
("mcp_servers/elderly_mcp.zip", "mcp_servers"),
|
||||
("mcp_servers/fay_broadcast", "mcp_servers/fay_broadcast"),
|
||||
("mcp_servers/logseq", "mcp_servers/logseq"),
|
||||
("mcp_servers/mcp-todo-server", "mcp_servers/mcp-todo-server"),
|
||||
("mcp_servers/schedule_manager", "mcp_servers/schedule_manager"),
|
||||
("mcp_servers/window_capture", "mcp_servers/window_capture"),
|
||||
("mcp_servers/yueshen_rag", "mcp_servers/yueshen_rag"),
|
||||
("mcp_servers/fay_player_knowledge", "mcp_servers/fay_player_knowledge"),
|
||||
("fay_player_knowledge", "fay_player_knowledge"),
|
||||
("genagents/templates", "genagents/templates"),
|
||||
("genagents/instruction.json", "genagents"),
|
||||
("simulation_engine/prompt_template", "simulation_engine/prompt_template"),
|
||||
]:
|
||||
_add_data_entry(datas, _project_path(*rel_path.split("/")), target_dir)
|
||||
|
||||
if IS_WINDOWS:
|
||||
_add_package_dlls(datas, "azure.cognitiveservices.speech", os.path.join("azure", "cognitiveservices", "speech"))
|
||||
_add_data_entry(datas, _project_path("test", "ovr_lipsync", "test_olipsync.py"), os.path.join("test", "ovr_lipsync"))
|
||||
_add_data_entry(datas, _project_path("test", "ovr_lipsync", "ffmpeg"), os.path.join("test", "ovr_lipsync", "ffmpeg"))
|
||||
_add_data_entry(
|
||||
datas,
|
||||
_project_path("test", "ovr_lipsync", "ovr_lipsync_exe", "ProcessWAV.exe"),
|
||||
os.path.join("test", "ovr_lipsync", "ovr_lipsync_exe"),
|
||||
)
|
||||
_add_data_entry(
|
||||
datas,
|
||||
_project_path("test", "ovr_lipsync", "ovr_lipsync_exe", "OVRLipSync.dll"),
|
||||
os.path.join("test", "ovr_lipsync", "ovr_lipsync_exe"),
|
||||
)
|
||||
|
||||
pathex = [PROJECT_ROOT]
|
||||
if IS_WINDOWS:
|
||||
pathex.append(_project_path("test", "ovr_lipsync"))
|
||||
|
||||
hiddenimports = [
|
||||
"test_olipsync",
|
||||
"flask",
|
||||
"flask_cors",
|
||||
"requests",
|
||||
"requests.adapters",
|
||||
"numpy",
|
||||
"pyaudio",
|
||||
"websockets",
|
||||
"websocket",
|
||||
"websocket._core",
|
||||
"websocket._socket",
|
||||
"ws4py",
|
||||
"azure.cognitiveservices.speech",
|
||||
"aliyunsdkcore",
|
||||
"aliyunsdkcore.client",
|
||||
"aliyunsdkcore.request",
|
||||
"openpyxl",
|
||||
"flask_httpauth",
|
||||
"psutil",
|
||||
"langchain",
|
||||
"langchain_openai",
|
||||
"langgraph",
|
||||
"bs4",
|
||||
"bs4.element",
|
||||
"schedule",
|
||||
"mcp",
|
||||
"cv2",
|
||||
"pygame",
|
||||
"scipy",
|
||||
"pydub",
|
||||
"gevent",
|
||||
"gevent.pywsgi",
|
||||
"edge_tts",
|
||||
"sentence_transformers",
|
||||
"transformers",
|
||||
"faymcp.mcp_service",
|
||||
"genagents.genagents_flask",
|
||||
]
|
||||
|
||||
a = Analysis(
|
||||
["main.py"],
|
||||
pathex=pathex,
|
||||
binaries=[],
|
||||
datas=datas,
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[_project_path("packaging", "hooks", "rthook_block_tornado.py")],
|
||||
excludes=["tornado"],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name="fay",
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=False,
|
||||
console=True,
|
||||
icon=_project_path("favicon.ico") if os.path.exists(_project_path("favicon.ico")) else None,
|
||||
contents_directory=".",
|
||||
)
|
||||
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=False,
|
||||
upx_exclude=[],
|
||||
name="fay",
|
||||
)
|
||||
+39
-12
@@ -145,10 +145,16 @@ class DeviceInputListener(Recorder):
|
||||
addr = None
|
||||
while self.__running:
|
||||
try:
|
||||
|
||||
data = b""
|
||||
while self.deviceConnector:
|
||||
while self.deviceConnector and self.__running:
|
||||
data = self.deviceConnector.recv(2048)
|
||||
if not data:
|
||||
# 对端已优雅关闭连接(FIN),recv 返回空字节但不抛异常。
|
||||
# 不判断这一点会导致线程在死连接上以极高频率空转,
|
||||
# 且 stop() 永远无法让本线程退出(见下方 finally 与 stop())。
|
||||
util.log(1, f"远程音频设备 {self.username} 连接已关闭,停止监听线程")
|
||||
self.__running = False
|
||||
break
|
||||
if b"<username>" in data:
|
||||
data_str = data.decode("utf-8")
|
||||
match = re.search(r"<username>(.*?)</username>", data_str)
|
||||
@@ -167,11 +173,21 @@ class DeviceInputListener(Recorder):
|
||||
self.streamCache.write(data)
|
||||
time.sleep(0.005)
|
||||
self.streamCache.clear()
|
||||
|
||||
|
||||
except Exception as err:
|
||||
pass
|
||||
# 连接被重置/网络异常等:视为连接已死,退出线程而不是无限重试
|
||||
util.log(1, f"远程音频设备 {self.username} 监听异常,停止监听线程: {err}")
|
||||
self.__running = False
|
||||
time.sleep(1)
|
||||
|
||||
# 线程即将退出:确保 socket 一定被关闭,避免 fd 泄漏
|
||||
try:
|
||||
if self.deviceConnector:
|
||||
self.deviceConnector.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.deviceConnector = None
|
||||
|
||||
def on_speaking(self, text):
|
||||
global feiFei
|
||||
if len(text) > 1:
|
||||
@@ -189,6 +205,15 @@ class DeviceInputListener(Recorder):
|
||||
def stop(self):
|
||||
super().stop()
|
||||
self.__running = False
|
||||
# 主动关闭 socket:run() 的监听线程可能正阻塞在 recv() 上等待数据,
|
||||
# 若连接已经是"僵尸连接"(对端未发 FIN/RST,例如 NAT 静默丢弃映射),
|
||||
# 仅置 __running=False 不会让线程退出。强制关闭 socket 会让 recv()
|
||||
# 立刻抛出异常,使线程能在下一轮循环及时感知并退出。
|
||||
try:
|
||||
if self.deviceConnector:
|
||||
self.deviceConnector.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def is_remote(self):
|
||||
return True
|
||||
@@ -197,20 +222,22 @@ class DeviceInputListener(Recorder):
|
||||
def device_socket_keep_alive():
|
||||
global DeviceInputListenerDict
|
||||
while __running:
|
||||
delkey = None
|
||||
for key, value in DeviceInputListenerDict.items():
|
||||
# 先收集本轮所有死连接的 key,循环内部不直接 pop(会在遍历 dict 时报错),
|
||||
# 遍历结束后统一清理。原实现发现一个死连接就 break,多个设备同时断线时
|
||||
# 每 10 秒只能清理 1 个,清理速度远跟不上产生速度,是残留累积的重要原因。
|
||||
dead_keys = []
|
||||
for key, value in list(DeviceInputListenerDict.items()):
|
||||
try:
|
||||
value.deviceConnector.send(b'\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8')#发送心跳包
|
||||
if wsa_server.get_web_instance().is_connected(value.username):
|
||||
wsa_server.get_web_instance().add_cmd({"remote_audio_connect": True, "Username" : value.username})
|
||||
wsa_server.get_web_instance().add_cmd({"remote_audio_connect": True, "Username" : value.username})
|
||||
except Exception as serr:
|
||||
util.printInfo(1, value.username, "远程音频输入输出设备已经断开:{}".format(key))
|
||||
value.stop()
|
||||
delkey = key
|
||||
break
|
||||
if delkey:
|
||||
value = DeviceInputListenerDict.pop(delkey)
|
||||
if wsa_server.get_web_instance().is_connected(value.username):
|
||||
dead_keys.append(key)
|
||||
for key in dead_keys:
|
||||
value = DeviceInputListenerDict.pop(key, None)
|
||||
if value and wsa_server.get_web_instance().is_connected(value.username):
|
||||
wsa_server.get_web_instance().add_cmd({"remote_audio_connect": False, "Username" : value.username})
|
||||
time.sleep(10)
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1,16 +1 @@
|
||||
{
|
||||
"4": {
|
||||
"query_yueshen": {
|
||||
"params": {
|
||||
"embedding_api_key": "",
|
||||
"embedding_base_url": "",
|
||||
"embedding_model": "",
|
||||
"query": "{{question}}",
|
||||
"top_k": 5,
|
||||
"where": {}
|
||||
},
|
||||
"include_history": true,
|
||||
"allow_function_call": false
|
||||
}
|
||||
}
|
||||
}
|
||||
{}
|
||||
@@ -80,7 +80,7 @@
|
||||
"id": 6,
|
||||
"name": "课程知识库",
|
||||
"ip": "",
|
||||
"connection_time": "2026-04-17 16:28:31",
|
||||
"connection_time": "2026-04-27 16:01:49",
|
||||
"key": "",
|
||||
"transport": "stdio",
|
||||
"command": "python",
|
||||
|
||||
@@ -1,48 +1 @@
|
||||
{
|
||||
"5": {
|
||||
"now": true,
|
||||
"add": false,
|
||||
"upper": false,
|
||||
"echo": false,
|
||||
"ping": false,
|
||||
"ingest_yueshen": false,
|
||||
"yueshen_stats": false
|
||||
},
|
||||
"1": {
|
||||
"add": true,
|
||||
"upper": false,
|
||||
"echo": false,
|
||||
"ping": false,
|
||||
"now": true,
|
||||
"display_media": true
|
||||
},
|
||||
"2": {
|
||||
"('meta', None)": true,
|
||||
"('nextCursor', None)": true,
|
||||
"browser_snapshot": false
|
||||
},
|
||||
"3": {
|
||||
"get_file_info": false,
|
||||
"list_allowed_directories": true,
|
||||
"edit_file": false,
|
||||
"list_directory": false,
|
||||
"directory_tree": false,
|
||||
"create_directory": false,
|
||||
"read_text_file": false,
|
||||
"read_multiple_files": false,
|
||||
"read_media_file": false,
|
||||
"read_file": false,
|
||||
"move_file": false,
|
||||
"list_directory_with_sizes": false,
|
||||
"search_files": false,
|
||||
"write_file": false
|
||||
},
|
||||
"4": {
|
||||
"ingest_yueshen": false,
|
||||
"yueshen_stats": false,
|
||||
"query_yueshen": false
|
||||
},
|
||||
"6": {
|
||||
"list_windows": true
|
||||
}
|
||||
}
|
||||
{}
|
||||
+32
-24
@@ -371,6 +371,20 @@ def disconnect_all_mcp_servers():
|
||||
|
||||
util.log(1, f'成功断开 {disconnected_count} 个MCP服务连接,资源已清理')
|
||||
|
||||
# 递归序列化 MCP 工具返回值(CallToolResult 等 pydantic 对象 -> 可 jsonify 的结构)
|
||||
def _serialize_mcp_result(obj):
|
||||
if obj is None:
|
||||
return None
|
||||
if isinstance(obj, (str, int, float, bool)):
|
||||
return obj
|
||||
if isinstance(obj, dict):
|
||||
return {k: _serialize_mcp_result(v) for k, v in obj.items()}
|
||||
if isinstance(obj, (list, tuple)):
|
||||
return [_serialize_mcp_result(item) for item in obj]
|
||||
if hasattr(obj, '__dict__'):
|
||||
return {k: _serialize_mcp_result(v) for k, v in vars(obj).items()}
|
||||
return str(obj)
|
||||
|
||||
# 调用MCP服务器工具
|
||||
def call_mcp_tool(server_id, method, params=None, skip_enabled_check=False):
|
||||
"""
|
||||
@@ -480,7 +494,7 @@ def add_mcp_server():
|
||||
tool_dict = {
|
||||
'name': tool_name,
|
||||
'description': str(getattr(tool, 'description', '')),
|
||||
'enabled': get_tool_state(server_id, tool_name)
|
||||
'enabled': get_tool_state(new_id, tool_name)
|
||||
}
|
||||
|
||||
# 处理 inputSchema
|
||||
@@ -499,7 +513,7 @@ def add_mcp_server():
|
||||
'name': tool_name,
|
||||
'description': str(tool.get('description', '')),
|
||||
'inputSchema': tool.get('inputSchema', {}),
|
||||
'enabled': get_tool_state(server_id, tool_name)
|
||||
'enabled': get_tool_state(new_id, tool_name)
|
||||
})
|
||||
else:
|
||||
# 其他情况,尝试转换为字符串
|
||||
@@ -507,7 +521,7 @@ def add_mcp_server():
|
||||
tools_list.append({
|
||||
'name': tool_name,
|
||||
'description': '',
|
||||
'enabled': get_tool_state(server_id, tool_name)
|
||||
'enabled': get_tool_state(new_id, tool_name)
|
||||
})
|
||||
except Exception as e:
|
||||
util.log(1, f"工具列表序列化失败: {e}")
|
||||
@@ -517,7 +531,7 @@ def add_mcp_server():
|
||||
tool_name = str(tool)
|
||||
tools_list.append({
|
||||
'name': tool_name,
|
||||
'enabled': get_tool_state(server_id, tool_name)
|
||||
'enabled': get_tool_state(new_id, tool_name)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
@@ -988,16 +1002,9 @@ def call_mcp_tool_direct(tool_name):
|
||||
|
||||
if success:
|
||||
try:
|
||||
if hasattr(result, '__dict__'):
|
||||
result_dict = dict(vars(result))
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"result": result_dict,
|
||||
"server": server['name']
|
||||
})
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"result": result,
|
||||
"result": _serialize_mcp_result(result),
|
||||
"server": server['name']
|
||||
})
|
||||
except Exception as e:
|
||||
@@ -1652,18 +1659,19 @@ def start_connection_check():
|
||||
|
||||
# 主程序入口
|
||||
def run():
|
||||
# 禁止服务器日志输出的类
|
||||
class NullLogHandler:
|
||||
def write(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
# 使用gevent的pywsgi服务器,并禁用日志输出
|
||||
from gevent import pywsgi
|
||||
server = pywsgi.WSGIServer(
|
||||
('0.0.0.0', 5010),
|
||||
app,
|
||||
log=NullLogHandler()
|
||||
)
|
||||
# 使用 werkzeug 的多线程 WSGI 服务器,而不是 gevent pywsgi。
|
||||
# 原因:MCP 工具调用最终会走到 McpClient.call_tool -> future.result(),
|
||||
# 这是一个阻塞等待(客户端的 asyncio 事件循环跑在各自独立的后台线程里)。
|
||||
# 若用 gevent 单 hub 且未做 monkey.patch_all,这个阻塞会卡住整个 hub,
|
||||
# 导致 5010 上所有请求被串行化、慢工具执行期间无法并发。
|
||||
# 而 monkey.patch_all 又会把 MCP 客户端的 asyncio 事件循环线程打成 greenlet,
|
||||
# 直接破坏 asyncio 运行。折中且正确的做法:每个请求用独立 OS 线程处理,
|
||||
# 阻塞只影响该请求自身,且与后台 asyncio 线程完全兼容。
|
||||
import logging as _logging
|
||||
from werkzeug.serving import make_server
|
||||
# 静默 werkzeug 的每请求访问日志,保持与原 NullLogHandler 一致的安静输出
|
||||
_logging.getLogger('werkzeug').setLevel(_logging.ERROR)
|
||||
server = make_server('0.0.0.0', 5010, app, threaded=True)
|
||||
server.serve_forever()
|
||||
|
||||
# 启动时自动连接标记为 autostart 的服务器
|
||||
|
||||
@@ -412,15 +412,19 @@ def _extract_decision(text: str) -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
|
||||
|
||||
def _big_model_execute(state: ExecutionState):
|
||||
"""大模型后台线程入口:从小模型的第一轮规划结果开始执行工具循环。"""
|
||||
def _big_model_execute(state: ExecutionState, llm_role: str = "big"):
|
||||
"""工具循环执行入口:从首步规划开始执行工具调用循环。
|
||||
|
||||
llm_role="big" 走大模型实例(双模型模式,由后台线程调用)
|
||||
llm_role="small" 走小模型实例(单模型模式,由 question() 同步调用)
|
||||
"""
|
||||
from llm.nlp_cognitive_stream import (
|
||||
_remove_think_from_text,
|
||||
_strip_json_code_fence,
|
||||
)
|
||||
|
||||
util.log(1, f"[大模型执行] {state.username}: 后台线程启动,first_plan={state.first_plan}")
|
||||
big_llm = _get_llm_instance("big", streaming=False)
|
||||
util.log(1, f"[工具执行] {state.username}: 启动 (role={llm_role}), first_plan={state.first_plan}")
|
||||
big_llm = _get_llm_instance(llm_role, streaming=False)
|
||||
tool_registry = state.tool_registry
|
||||
max_steps = 30
|
||||
|
||||
|
||||
+2731
-3522
File diff suppressed because it is too large
Load Diff
@@ -1,63 +1,63 @@
|
||||
#入口文件main
|
||||
import os
|
||||
import sys
|
||||
import runpy
|
||||
import os
|
||||
import sys
|
||||
import runpy
|
||||
|
||||
def _resolve_runtime_dir():
|
||||
if hasattr(sys, "_MEIPASS"):
|
||||
return os.path.abspath(sys._MEIPASS)
|
||||
return os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
_RUNTIME_DIR = _resolve_runtime_dir()
|
||||
os.environ['PATH'] += os.pathsep + os.path.join(_RUNTIME_DIR, "test", "ovr_lipsync", "ffmpeg", "bin")
|
||||
|
||||
|
||||
def _resolve_runner_script_path(script_arg):
|
||||
candidate = str(script_arg or "").strip()
|
||||
if not candidate:
|
||||
return None
|
||||
if os.path.isabs(candidate):
|
||||
return candidate if os.path.exists(candidate) else None
|
||||
|
||||
current_dir = os.path.abspath(os.getcwd())
|
||||
current_candidate = os.path.abspath(os.path.join(current_dir, candidate))
|
||||
if os.path.exists(current_candidate):
|
||||
return current_candidate
|
||||
|
||||
runtime_candidate = os.path.abspath(os.path.join(_RUNTIME_DIR, candidate))
|
||||
if os.path.exists(runtime_candidate):
|
||||
return runtime_candidate
|
||||
return None
|
||||
|
||||
|
||||
def _maybe_run_mcp_stdio_runner(argv):
|
||||
if not argv or argv[0] != "--mcp-stdio-runner":
|
||||
return
|
||||
|
||||
runner_args = list(argv[1:])
|
||||
while runner_args and runner_args[0] in ("-u", "-B", "-E", "-s", "-S", "-O", "-OO"):
|
||||
runner_args.pop(0)
|
||||
|
||||
if not runner_args:
|
||||
print("Missing MCP runner target script.", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
script_path = _resolve_runner_script_path(runner_args[0])
|
||||
if not script_path:
|
||||
print(f"Cannot resolve MCP runner target: {runner_args[0]}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
script_path = os.path.abspath(script_path)
|
||||
script_dir = os.path.dirname(script_path)
|
||||
if script_dir and script_dir not in sys.path:
|
||||
sys.path.insert(0, script_dir)
|
||||
if _RUNTIME_DIR not in sys.path:
|
||||
sys.path.insert(0, _RUNTIME_DIR)
|
||||
|
||||
os.chdir(script_dir or os.getcwd())
|
||||
sys.argv = [script_path] + runner_args[1:]
|
||||
runpy.run_path(script_path, run_name="__main__")
|
||||
raise SystemExit(0)
|
||||
_RUNTIME_DIR = _resolve_runtime_dir()
|
||||
os.environ['PATH'] += os.pathsep + os.path.join(_RUNTIME_DIR, "test", "ovr_lipsync", "ffmpeg", "bin")
|
||||
|
||||
|
||||
def _resolve_runner_script_path(script_arg):
|
||||
candidate = str(script_arg or "").strip()
|
||||
if not candidate:
|
||||
return None
|
||||
if os.path.isabs(candidate):
|
||||
return candidate if os.path.exists(candidate) else None
|
||||
|
||||
current_dir = os.path.abspath(os.getcwd())
|
||||
current_candidate = os.path.abspath(os.path.join(current_dir, candidate))
|
||||
if os.path.exists(current_candidate):
|
||||
return current_candidate
|
||||
|
||||
runtime_candidate = os.path.abspath(os.path.join(_RUNTIME_DIR, candidate))
|
||||
if os.path.exists(runtime_candidate):
|
||||
return runtime_candidate
|
||||
return None
|
||||
|
||||
|
||||
def _maybe_run_mcp_stdio_runner(argv):
|
||||
if not argv or argv[0] != "--mcp-stdio-runner":
|
||||
return
|
||||
|
||||
runner_args = list(argv[1:])
|
||||
while runner_args and runner_args[0] in ("-u", "-B", "-E", "-s", "-S", "-O", "-OO"):
|
||||
runner_args.pop(0)
|
||||
|
||||
if not runner_args:
|
||||
print("Missing MCP runner target script.", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
script_path = _resolve_runner_script_path(runner_args[0])
|
||||
if not script_path:
|
||||
print(f"Cannot resolve MCP runner target: {runner_args[0]}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
script_path = os.path.abspath(script_path)
|
||||
script_dir = os.path.dirname(script_path)
|
||||
if script_dir and script_dir not in sys.path:
|
||||
sys.path.insert(0, script_dir)
|
||||
if _RUNTIME_DIR not in sys.path:
|
||||
sys.path.insert(0, _RUNTIME_DIR)
|
||||
|
||||
os.chdir(script_dir or os.getcwd())
|
||||
sys.argv = [script_path] + runner_args[1:]
|
||||
runpy.run_path(script_path, run_name="__main__")
|
||||
raise SystemExit(0)
|
||||
|
||||
def _extract_config_center_id(argv):
|
||||
for i, arg in enumerate(argv):
|
||||
@@ -72,10 +72,10 @@ def _preload_config_center(argv):
|
||||
if config_center_id:
|
||||
os.environ["FAY_CONFIG_CENTER_ID"] = config_center_id
|
||||
|
||||
_preload_config_center(sys.argv[1:])
|
||||
_maybe_run_mcp_stdio_runner(sys.argv[1:])
|
||||
|
||||
import time
|
||||
_preload_config_center(sys.argv[1:])
|
||||
_maybe_run_mcp_stdio_runner(sys.argv[1:])
|
||||
|
||||
import time
|
||||
import psutil
|
||||
import re
|
||||
import argparse
|
||||
@@ -201,10 +201,6 @@ def __create_memory():
|
||||
if not os.path.exists("./memory"):
|
||||
os.mkdir("./memory")
|
||||
|
||||
def __check_and_clear_chroma_db():
|
||||
"""检查并清除ChromaDB数据库(如果存在清除标记)- 已移除仿生记忆,保留函数避免调用处报错"""
|
||||
pass
|
||||
|
||||
def kill_process_by_port(port):
|
||||
for conn in psutil.net_connections(kind='inet'):
|
||||
if conn.laddr.port == port and conn.pid:
|
||||
@@ -274,7 +270,6 @@ def console_listener():
|
||||
if __name__ == '__main__':
|
||||
__clear_samples()
|
||||
__create_memory()
|
||||
__check_and_clear_chroma_db() # 在创建memory目录后立即检查清理
|
||||
__clear_logs()
|
||||
|
||||
#init_db
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
param(
|
||||
[string]$MainPython = "C:\Users\Lenovo\anaconda3\envs\fay312\python.exe",
|
||||
[string]$LegacyPython = "C:\Users\Lenovo\anaconda3\envs\fay38\python.exe",
|
||||
[string]$Iscc = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
Set-Location $projectRoot
|
||||
$releaseMemoryDir = Join-Path $projectRoot "packaging\release\memory"
|
||||
$normalIssPath = Join-Path $projectRoot "packaging\inno\fay.iss"
|
||||
$legacyIssPath = Join-Path $projectRoot "packaging\inno\fay-legacy.iss"
|
||||
|
||||
function Get-IssDefineValue {
|
||||
param(
|
||||
[string]$Path,
|
||||
[string]$DefineName
|
||||
)
|
||||
|
||||
$match = Select-String -Path $Path -Pattern "^\s*#define\s+$DefineName\s+`"(.+)`"\s*$" | Select-Object -First 1
|
||||
if (-not $match) {
|
||||
throw "Missing #define $DefineName in $Path"
|
||||
}
|
||||
return $match.Matches[0].Groups[1].Value
|
||||
}
|
||||
|
||||
function Get-IsccPath {
|
||||
param([string]$ExplicitPath)
|
||||
|
||||
if ($ExplicitPath -and (Test-Path $ExplicitPath)) {
|
||||
return $ExplicitPath
|
||||
}
|
||||
|
||||
$candidates = @(
|
||||
(Get-Command ISCC.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -ErrorAction SilentlyContinue),
|
||||
"C:\Users\Lenovo\AppData\Local\Programs\Inno Setup 6\ISCC.exe",
|
||||
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
|
||||
"C:\Program Files\Inno Setup 6\ISCC.exe"
|
||||
) | Where-Object { $_ -and (Test-Path $_) }
|
||||
|
||||
return $candidates | Select-Object -First 1
|
||||
}
|
||||
|
||||
function Assert-Tool {
|
||||
param(
|
||||
[string]$Path,
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
if (-not (Test-Path $Path)) {
|
||||
throw "$Name not found: $Path"
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Step {
|
||||
param([string[]]$Command)
|
||||
|
||||
$commandLine = ($Command | ForEach-Object {
|
||||
if ($_ -match "\s") { '"{0}"' -f $_ } else { $_ }
|
||||
}) -join " "
|
||||
Write-Host $commandLine
|
||||
& $Command[0] $Command[1..($Command.Length - 1)]
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Command failed with exit code ${LASTEXITCODE}: $commandLine"
|
||||
}
|
||||
}
|
||||
|
||||
Assert-Tool -Path $MainPython -Name "Main Python"
|
||||
Assert-Tool -Path $LegacyPython -Name "Legacy Python"
|
||||
|
||||
$isccPath = Get-IsccPath -ExplicitPath $Iscc
|
||||
if (-not $isccPath) {
|
||||
throw "ISCC.exe not found"
|
||||
}
|
||||
|
||||
Write-Host "Restoring memory directory to Git HEAD..."
|
||||
Invoke-Step -Command @("git", "restore", "--source=HEAD", "--worktree", "--staged", "--", "memory")
|
||||
Invoke-Step -Command @("git", "clean", "-fd", "--", "memory")
|
||||
|
||||
if (Test-Path $releaseMemoryDir) {
|
||||
Remove-Item -Recurse -Force $releaseMemoryDir
|
||||
}
|
||||
Write-Host "Exporting Git memory snapshot for release packaging..."
|
||||
Invoke-Step -Command @(
|
||||
"git",
|
||||
"checkout-index",
|
||||
"--force",
|
||||
"--prefix=packaging/release/",
|
||||
"--",
|
||||
"memory/fay.db",
|
||||
"memory/user_profiles.db"
|
||||
)
|
||||
|
||||
Write-Host "Syncing release MCP config from Git baseline..."
|
||||
Invoke-Step -Command @($MainPython, "packaging\\sync_release_mcp_config.py")
|
||||
|
||||
Write-Host "Building normal package..."
|
||||
Invoke-Step -Command @($MainPython, "-m", "PyInstaller", "--clean", "--noconfirm", "fay.spec")
|
||||
|
||||
Write-Host "Building legacy package..."
|
||||
Invoke-Step -Command @($LegacyPython, "-m", "PyInstaller", "--clean", "--noconfirm", "fay-legacy.spec")
|
||||
|
||||
Write-Host "Building installers..."
|
||||
Invoke-Step -Command @($isccPath, "packaging\inno\fay.iss")
|
||||
Invoke-Step -Command @($isccPath, "packaging\inno\fay-legacy.iss")
|
||||
|
||||
$normalVersion = Get-IssDefineValue -Path $normalIssPath -DefineName "MyAppVersion"
|
||||
$legacyVersion = Get-IssDefineValue -Path $legacyIssPath -DefineName "MyAppVersion"
|
||||
$normalInstaller = "dist\installer\FaySetup-$normalVersion.exe"
|
||||
$legacyInstaller = "dist\installer\FaySetup-$legacyVersion-legacy.exe"
|
||||
|
||||
Write-Host "Installer hashes:"
|
||||
Get-FileHash $normalInstaller -Algorithm SHA256 | Format-Table -AutoSize
|
||||
Get-FileHash $legacyInstaller -Algorithm SHA256 | Format-Table -AutoSize
|
||||
@@ -0,0 +1,344 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
DIST_DIR = PROJECT_ROOT / "dist"
|
||||
BUILD_DIR = PROJECT_ROOT / "build"
|
||||
MCP_DIST_DIR = DIST_DIR / "mcp_bin"
|
||||
MCP_WORK_DIR = BUILD_DIR / "mcp_bin"
|
||||
MCP_SPEC_DIR = BUILD_DIR / "mcp_specs"
|
||||
RELEASE_MEMORY_DIR = PROJECT_ROOT / "packaging" / "release" / "memory"
|
||||
|
||||
DEFAULT_MAIN_PYTHON = Path(r"C:\Users\Lenovo\anaconda3\envs\fay312\python.exe")
|
||||
DEFAULT_RAG_PYTHON = Path(r"C:\Users\Lenovo\anaconda3\envs\rag\python.exe")
|
||||
|
||||
|
||||
@dataclass
|
||||
class BuildTarget:
|
||||
name: str
|
||||
script: Path
|
||||
python_kind: str = "main"
|
||||
extra_args: List[str] = field(default_factory=list)
|
||||
rewrite_suffix: Optional[str] = None
|
||||
|
||||
|
||||
TARGETS: List[BuildTarget] = [
|
||||
BuildTarget(
|
||||
name="mcp_stdio_example",
|
||||
script=PROJECT_ROOT / "test" / "mcp_stdio_example.py",
|
||||
extra_args=["--collect-all", "mcp"],
|
||||
rewrite_suffix="test/mcp_stdio_example.py",
|
||||
),
|
||||
BuildTarget(
|
||||
name="schedule_manager_mcp",
|
||||
script=PROJECT_ROOT / "mcp_servers" / "schedule_manager" / "server.py",
|
||||
extra_args=["--collect-all", "mcp"],
|
||||
rewrite_suffix="mcp_servers/schedule_manager/server.py",
|
||||
),
|
||||
BuildTarget(
|
||||
name="schedule_manager_web",
|
||||
script=PROJECT_ROOT / "mcp_servers" / "schedule_manager" / "web_server.py",
|
||||
extra_args=[
|
||||
"--hidden-import",
|
||||
"flask",
|
||||
"--hidden-import",
|
||||
"flask_cors",
|
||||
"--hidden-import",
|
||||
"psutil",
|
||||
"--add-data",
|
||||
f"{PROJECT_ROOT / 'mcp_servers' / 'schedule_manager' / 'schedule_web.html'};.",
|
||||
],
|
||||
),
|
||||
BuildTarget(
|
||||
name="logseq_mcp",
|
||||
script=PROJECT_ROOT / "mcp_servers" / "logseq" / "server.py",
|
||||
extra_args=["--collect-all", "mcp"],
|
||||
rewrite_suffix="mcp_servers/logseq/server.py",
|
||||
),
|
||||
BuildTarget(
|
||||
name="window_capture_mcp",
|
||||
script=PROJECT_ROOT / "mcp_servers" / "window_capture" / "server.py",
|
||||
extra_args=["--collect-all", "mcp"],
|
||||
rewrite_suffix="mcp_servers/window_capture/server.py",
|
||||
),
|
||||
BuildTarget(
|
||||
name="todo_server_mcp",
|
||||
script=PROJECT_ROOT / "mcp_servers" / "mcp-todo-server" / "server.py",
|
||||
rewrite_suffix="mcp_servers/mcp-todo-server/server.py",
|
||||
),
|
||||
BuildTarget(
|
||||
name="elderly_mcp_server",
|
||||
script=PROJECT_ROOT / "mcp_servers" / "elderly_mcp" / "server.py",
|
||||
extra_args=["--collect-all", "mcp"],
|
||||
rewrite_suffix="mcp_servers/elderly_mcp/server.py",
|
||||
),
|
||||
BuildTarget(
|
||||
name="yueshen_rag_mcp",
|
||||
script=PROJECT_ROOT / "mcp_servers" / "yueshen_rag" / "server.py",
|
||||
python_kind="rag",
|
||||
extra_args=[
|
||||
"--hidden-import",
|
||||
"typing_extensions",
|
||||
"--collect-all",
|
||||
"mcp",
|
||||
"--collect-all",
|
||||
"chromadb",
|
||||
"--collect-all",
|
||||
"docx",
|
||||
"--collect-all",
|
||||
"pdfplumber",
|
||||
"--collect-all",
|
||||
"tornado",
|
||||
],
|
||||
rewrite_suffix="mcp_servers/yueshen_rag/server.py",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _pick_python(preferred: Path, fallback: str) -> str:
|
||||
if preferred.exists():
|
||||
return str(preferred)
|
||||
return fallback
|
||||
|
||||
|
||||
def _run(command: List[str]) -> None:
|
||||
print(" ".join(f'"{part}"' if " " in part else part for part in command))
|
||||
subprocess.run(command, cwd=str(PROJECT_ROOT), check=True)
|
||||
|
||||
|
||||
def _reset_dir(path: Path) -> None:
|
||||
if path.exists():
|
||||
shutil.rmtree(path)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _has_pyinstaller(python_exe: str) -> bool:
|
||||
probe = subprocess.run(
|
||||
[python_exe, "-c", "import PyInstaller; print('ok')"],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return probe.returncode == 0
|
||||
|
||||
|
||||
def _prepare_release_memory_snapshot() -> None:
|
||||
subprocess.run(
|
||||
["git", "restore", "--source=HEAD", "--worktree", "--staged", "--", "memory"],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "clean", "-fd", "--", "memory"],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
)
|
||||
if RELEASE_MEMORY_DIR.exists():
|
||||
shutil.rmtree(RELEASE_MEMORY_DIR)
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"checkout-index",
|
||||
"--force",
|
||||
"--prefix=packaging/release/",
|
||||
"--",
|
||||
"memory/fay.db",
|
||||
"memory/user_profiles.db",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def _sync_release_mcp_config(python_exe: str) -> None:
|
||||
subprocess.run(
|
||||
[python_exe, str(PROJECT_ROOT / "packaging" / "sync_release_mcp_config.py")],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_rel_path(path_value: Optional[str]) -> str:
|
||||
return str(path_value or "").replace("\\", "/").strip().lower()
|
||||
|
||||
|
||||
def _path_matches(path_value: Optional[str], expected_suffix: str) -> bool:
|
||||
normalized = _normalize_rel_path(path_value)
|
||||
suffix = _normalize_rel_path(expected_suffix)
|
||||
if not normalized or not suffix:
|
||||
return False
|
||||
return normalized == suffix or normalized.endswith("/" + suffix)
|
||||
|
||||
|
||||
def _build_target(target: BuildTarget, python_exe: str) -> Optional[str]:
|
||||
if not target.script.exists():
|
||||
print(f"[skip] missing script: {target.script}")
|
||||
return None
|
||||
|
||||
command = [
|
||||
python_exe,
|
||||
"-m",
|
||||
"PyInstaller",
|
||||
"--clean",
|
||||
"--noconfirm",
|
||||
"--onedir",
|
||||
"--distpath",
|
||||
str(MCP_DIST_DIR),
|
||||
"--workpath",
|
||||
str(MCP_WORK_DIR),
|
||||
"--specpath",
|
||||
str(MCP_SPEC_DIR),
|
||||
"--paths",
|
||||
str(PROJECT_ROOT),
|
||||
"--name",
|
||||
target.name,
|
||||
]
|
||||
command.extend(target.extra_args)
|
||||
command.append(str(target.script))
|
||||
_run(command)
|
||||
return f"mcp_bin\\{target.name}\\{target.name}.exe"
|
||||
|
||||
|
||||
def _rewrite_release_mcp_config(output_path: Path, built_targets: Dict[str, str]) -> None:
|
||||
source_path = PROJECT_ROOT / "packaging" / "release" / "faymcp" / "data" / "mcp_servers.json"
|
||||
servers = json.loads(source_path.read_text(encoding="utf-8"))
|
||||
|
||||
for server in servers:
|
||||
args = server.get("args") or []
|
||||
arg_paths = [arg for arg in args if isinstance(arg, str) and arg and not arg.startswith("-")]
|
||||
rewrite_to = None
|
||||
for suffix, exe_relpath in built_targets.items():
|
||||
if any(_path_matches(arg, suffix) for arg in arg_paths):
|
||||
rewrite_to = exe_relpath
|
||||
break
|
||||
|
||||
configured_cwd = server.get("cwd") or ""
|
||||
suffix_dir = str(Path(suffix).parent).replace("\\", "/")
|
||||
if suffix_dir and _path_matches(configured_cwd, suffix_dir):
|
||||
if not arg_paths or any(Path(arg).name.lower() == Path(suffix).name.lower() for arg in arg_paths):
|
||||
rewrite_to = exe_relpath
|
||||
break
|
||||
|
||||
if rewrite_to:
|
||||
server["command"] = rewrite_to
|
||||
server["args"] = []
|
||||
server["cwd"] = ""
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(json.dumps(servers, ensure_ascii=False, indent=4), encoding="utf-8")
|
||||
|
||||
|
||||
def _copy_packaged_mcp_into_dist() -> None:
|
||||
target_dir = DIST_DIR / "fay" / "mcp_bin"
|
||||
if target_dir.exists():
|
||||
shutil.rmtree(target_dir)
|
||||
shutil.copytree(MCP_DIST_DIR, target_dir)
|
||||
|
||||
|
||||
def _find_iscc(explicit: Optional[str]) -> Optional[str]:
|
||||
if explicit:
|
||||
return explicit
|
||||
|
||||
which_path = shutil.which("ISCC.exe")
|
||||
if which_path:
|
||||
return which_path
|
||||
|
||||
for candidate in [
|
||||
Path(r"C:\Users\Lenovo\AppData\Local\Programs\Inno Setup 6\ISCC.exe"),
|
||||
Path(r"C:\Program Files (x86)\Inno Setup 6\ISCC.exe"),
|
||||
Path(r"C:\Program Files\Inno Setup 6\ISCC.exe"),
|
||||
]:
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Build Fay Windows release with packaged MCP servers.")
|
||||
parser.add_argument("--main-python", default=_pick_python(DEFAULT_MAIN_PYTHON, sys.executable))
|
||||
parser.add_argument("--rag-python", default=str(DEFAULT_RAG_PYTHON) if DEFAULT_RAG_PYTHON.exists() else "")
|
||||
parser.add_argument("--skip-yueshen", action="store_true")
|
||||
parser.add_argument(
|
||||
"--targets",
|
||||
nargs="+",
|
||||
default=[],
|
||||
help="Only build the listed MCP targets, for example: --targets yueshen_rag_mcp",
|
||||
)
|
||||
parser.add_argument("--skip-main", action="store_true")
|
||||
parser.add_argument("--skip-installer", action="store_true")
|
||||
parser.add_argument("--iscc", default="")
|
||||
args = parser.parse_args()
|
||||
|
||||
requested_targets = {name.strip() for name in (args.targets or []) if str(name).strip()}
|
||||
unknown_targets = sorted(requested_targets - {target.name for target in TARGETS})
|
||||
if unknown_targets:
|
||||
raise ValueError(f"Unknown MCP targets: {', '.join(unknown_targets)}")
|
||||
|
||||
_reset_dir(MCP_DIST_DIR)
|
||||
_reset_dir(MCP_WORK_DIR)
|
||||
_reset_dir(MCP_SPEC_DIR)
|
||||
_prepare_release_memory_snapshot()
|
||||
_sync_release_mcp_config(args.main_python)
|
||||
|
||||
built_targets: Dict[str, str] = {}
|
||||
for target in TARGETS:
|
||||
if requested_targets and target.name not in requested_targets:
|
||||
print(f"[skip] {target.name} not in --targets selection")
|
||||
continue
|
||||
|
||||
if target.name == "yueshen_rag_mcp" and args.skip_yueshen:
|
||||
print("[skip] yueshen_rag_mcp disabled by flag")
|
||||
continue
|
||||
|
||||
python_exe = args.main_python
|
||||
if target.python_kind == "rag":
|
||||
if not args.rag_python:
|
||||
print("[skip] rag python not configured, leave yueshen_rag as external server")
|
||||
continue
|
||||
python_exe = args.rag_python
|
||||
|
||||
if not os.path.exists(python_exe):
|
||||
if target.python_kind == "rag":
|
||||
print(f"[skip] rag python not found: {python_exe}")
|
||||
continue
|
||||
raise FileNotFoundError(f"Main python not found: {python_exe}")
|
||||
|
||||
if target.python_kind == "rag" and not _has_pyinstaller(python_exe):
|
||||
print(f"[skip] PyInstaller is not available in rag python: {python_exe}")
|
||||
continue
|
||||
|
||||
exe_relpath = _build_target(target, python_exe)
|
||||
if exe_relpath and target.rewrite_suffix:
|
||||
built_targets[target.rewrite_suffix] = exe_relpath
|
||||
|
||||
if args.skip_main:
|
||||
return 0
|
||||
|
||||
_run([args.main_python, "-m", "PyInstaller", "--clean", "--noconfirm", "fay.spec"])
|
||||
_copy_packaged_mcp_into_dist()
|
||||
_rewrite_release_mcp_config(DIST_DIR / "fay" / "faymcp" / "data" / "mcp_servers.json", built_targets)
|
||||
|
||||
if args.skip_installer:
|
||||
return 0
|
||||
|
||||
iscc_path = _find_iscc(args.iscc or None)
|
||||
if not iscc_path:
|
||||
print("[warn] ISCC.exe not found, skip installer build")
|
||||
return 0
|
||||
|
||||
_run([iscc_path, str(PROJECT_ROOT / "packaging" / "inno" / "fay.iss")])
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"attribute": {
|
||||
"additional": "\u53d1\u5446",
|
||||
"age": "\u6210\u5e74",
|
||||
"birth": "Github",
|
||||
"constellation": "\u6c34\u74f6\u5ea7",
|
||||
"contact": "qq467665317",
|
||||
"gender": "\u5973",
|
||||
"goal": "\u5de5\u4f5c\u534f\u52a9",
|
||||
"hobby": "\u53d1\u5446",
|
||||
"job": "\u52a9\u7406",
|
||||
"name": "\u83f2\u83f2",
|
||||
"position": "\u966a\u4f34",
|
||||
"voice": "abin",
|
||||
"zodiac": "\u86c7"
|
||||
},
|
||||
"interact": {
|
||||
"QnA": "qa.csv",
|
||||
"maxInteractTime": 15,
|
||||
"perception": {
|
||||
"chat": 10,
|
||||
"follow": 10,
|
||||
"gift": 10,
|
||||
"indifferent": 10,
|
||||
"join": 10
|
||||
},
|
||||
"playSound": false,
|
||||
"visualization": false
|
||||
},
|
||||
"items": [],
|
||||
"memory": {
|
||||
"isolate_by_user": true,
|
||||
"use_bionic_memory": false
|
||||
},
|
||||
"source": {
|
||||
"automatic_player_status": false,
|
||||
"automatic_player_url": "http://127.0.0.1:6000",
|
||||
"liveRoom": {
|
||||
"enabled": true,
|
||||
"url": ""
|
||||
},
|
||||
"record": {
|
||||
"device": "",
|
||||
"enabled": false
|
||||
},
|
||||
"wake_word": "\u83f2\u83f2",
|
||||
"wake_word_enabled": false,
|
||||
"wake_word_type": "front"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
|
||||
[key]
|
||||
#funasr / ali / sensevoice
|
||||
#建议使用funasr,请依照asr/funasr/README>md的说明启动
|
||||
asr_mode = funasr
|
||||
#ASR二选一(需要运行fay/test/funasr服务)集成达摩院asr项目、感谢中科大脑算法工程师张聪聪提供集成代码
|
||||
local_asr_ip=127.0.0.1
|
||||
local_asr_port=10197
|
||||
|
||||
# ASR二选一(第1次运行建议用这个,免费3个月), 阿里云 实时语音识别 服务密钥(必须)https://ai.aliyun.com/nls/trans
|
||||
ali_nls_key_id=
|
||||
ali_nls_key_secret=
|
||||
ali_nls_app_key=
|
||||
|
||||
|
||||
|
||||
#tts类型(切换请重新选择所需要的声音)azure、ali、gptsovits、volcano、gptsovits_v3
|
||||
tts_module=ali
|
||||
|
||||
# 微软 文字转语音 服务密钥(非必须,使用可产生不同情绪的音频)https://azure.microsoft.com/zh-cn/services/cognitive-services/text-to-speech/
|
||||
ms_tts_key=
|
||||
ms_tts_region=
|
||||
|
||||
# 阿里云 文字转语音 服务密钥 https://ai.aliyun.com/nls/trans
|
||||
ali_tss_key_id=
|
||||
ali_tss_key_secret=
|
||||
ali_tss_app_key=
|
||||
|
||||
# Doubao-语音合成 服务密钥 https://www.volcengine.com/product/voice-tech
|
||||
volcano_tts_appid=
|
||||
volcano_tts_access_token=
|
||||
volcano_tts_cluster=volcano_tts
|
||||
#可为空,为空时读取选择的音色
|
||||
volcano_tts_voice_type=
|
||||
|
||||
#key
|
||||
gpt_api_key=
|
||||
#gpt base url 如:https://api.openai.com/v1、https://rwkv.ai-creator.net/chntuned/v1、https://api.fastgpt.in/api/v1、https://api.moonshot.cn/v1
|
||||
gpt_base_url=
|
||||
#gpt model engine 如:glm4、deepseek、qwen3-4b等
|
||||
gpt_model_engine=
|
||||
|
||||
#大模型(复杂推理/工具调用循环使用,未配置时降级为小模型,单模型模式同步执行工具循环)
|
||||
#大模型 model engine 如:MiniMax-M2.7、gpt-4o、deepseek-r1等
|
||||
big_model_engine=
|
||||
#大模型 base url(留空则复用小模型的 gpt_base_url)
|
||||
big_model_base_url=
|
||||
#大模型 api key(留空则复用小模型的 gpt_api_key)
|
||||
big_model_api_key=
|
||||
|
||||
# Embedding API 配置(复用 LLM 的 url 和 key)
|
||||
embedding_api_model=
|
||||
# Embedding 服务 base url(可单独配置;留空则复用小模型的 gpt_base_url,key 始终复用 gpt_api_key)
|
||||
embedding_base_url=
|
||||
#gpt(fastgpt)代理(可为空,填写例子:127.0.0.1:7890)
|
||||
proxy_config=
|
||||
|
||||
#启动模式common、web(服务器或docker请使用web方式,通过http://127.0.0.1:5000控制)
|
||||
start_mode=web
|
||||
|
||||
#服务器主动地址
|
||||
fay_url=http://127.0.0.1:5000
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Runtime hook: 阻止 tenacity 导入 tornado(Fay 不需要 tornado)。
|
||||
在 tenacity.__init__ 执行前,将 tornado 注册为不可导入,
|
||||
使 tenacity 走到 `except ImportError: tornado = None` 分支。
|
||||
"""
|
||||
import sys
|
||||
sys.modules["tornado"] = None
|
||||
@@ -0,0 +1,419 @@
|
||||
; *** Inno Setup version 6.5.0+ Chinese Simplified messages ***
|
||||
;
|
||||
; To download user-contributed translations of this file, go to:
|
||||
; https://jrsoftware.org/files/istrans/
|
||||
;
|
||||
; Note: When translating this text, do not add periods (.) to the end of
|
||||
; messages that didn't have them already, because on those messages Inno
|
||||
; Setup adds the periods automatically (appending a period would result in
|
||||
; two periods being displayed).
|
||||
;
|
||||
; Maintainer: Zhenghan Yang (Kira)
|
||||
; Email: 847320916@QQ.com
|
||||
; Github: https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
|
||||
; Encoding: UTF-8
|
||||
; Translation based on network resource
|
||||
;
|
||||
|
||||
[LangOptions]
|
||||
; The following three entries are very important. Be sure to read and
|
||||
; understand the '[LangOptions] section' topic in the help file.
|
||||
LanguageName=简体中文
|
||||
; If Language Name display incorrect, uncomment next line
|
||||
; LanguageName=<7B80><4F53><4E2D><6587>
|
||||
; About LanguageID, to reference link:
|
||||
; https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/a9eac961-e77d-41a6-90a5-ce1a8b0cdb9c
|
||||
LanguageID=$0804
|
||||
; LanguageCodePage should always be set if possible, even if this file is Unicode
|
||||
; For English it's set to zero anyway because English only uses ASCII characters
|
||||
LanguageCodePage=936
|
||||
; If the language you are translating to requires special font faces or
|
||||
; sizes, uncomment any of the following entries and change them accordingly.
|
||||
;DialogFontName=
|
||||
;DialogFontSize=9
|
||||
;DialogFontBaseScaleWidth=7
|
||||
;DialogFontBaseScaleHeight=15
|
||||
;WelcomeFontName=Segoe UI
|
||||
;WelcomeFontSize=14
|
||||
|
||||
[Messages]
|
||||
|
||||
; *** Application titles
|
||||
SetupAppTitle=安装
|
||||
SetupWindowTitle=安装 - %1
|
||||
UninstallAppTitle=卸载
|
||||
UninstallAppFullTitle=%1 卸载
|
||||
|
||||
; *** Misc. common
|
||||
InformationTitle=信息
|
||||
ConfirmTitle=确认
|
||||
ErrorTitle=错误
|
||||
|
||||
; *** SetupLdr messages
|
||||
SetupLdrStartupMessage=现在将安装 %1。您想要继续吗?
|
||||
LdrCannotCreateTemp=无法创建临时文件。安装程序已中止
|
||||
LdrCannotExecTemp=无法执行临时目录中的文件。安装程序已中止
|
||||
HelpTextNote=
|
||||
|
||||
; *** Startup error messages
|
||||
LastErrorMessage=%1。%n%n错误 %2: %3
|
||||
SetupFileMissing=安装目录中缺少文件 %1。请修正这个问题或者获取程序的新副本。
|
||||
SetupFileCorrupt=安装文件已损坏。请获取程序的新副本。
|
||||
SetupFileCorruptOrWrongVer=安装文件已损坏,或是与这个安装程序的版本不兼容。请修正这个问题或获取新的程序副本。
|
||||
InvalidParameter=无效的命令行参数:%n%n%1
|
||||
SetupAlreadyRunning=安装程序正在运行。
|
||||
WindowsVersionNotSupported=此程序不支持当前计算机运行的 Windows 版本。
|
||||
WindowsServicePackRequired=此程序需要 %1 服务包 %2 或更高版本。
|
||||
NotOnThisPlatform=此程序不能在 %1 上运行。
|
||||
OnlyOnThisPlatform=此程序只能在 %1 上运行。
|
||||
OnlyOnTheseArchitectures=此程序只能安装到为下列处理器架构设计的 Windows 版本中:%n%n%1
|
||||
WinVersionTooLowError=此程序需要 %1 版本 %2 或更高。
|
||||
WinVersionTooHighError=此程序不能安装于 %1 版本 %2 或更高。
|
||||
AdminPrivilegesRequired=在安装此程序时您必须以管理员身份登录。
|
||||
PowerUserPrivilegesRequired=在安装此程序时您必须以管理员身份或有权限的用户组身份登录。
|
||||
SetupAppRunningError=安装程序发现 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
|
||||
UninstallAppRunningError=卸载程序发现 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
|
||||
|
||||
; *** Startup questions
|
||||
PrivilegesRequiredOverrideTitle=选择安装程序模式
|
||||
PrivilegesRequiredOverrideInstruction=选择安装模式
|
||||
PrivilegesRequiredOverrideText1=%1 可以为所有用户安装(需要管理员权限),或仅为您安装。
|
||||
PrivilegesRequiredOverrideText2=%1 可以仅为您安装,或为所有用户安装(需要管理员权限)。
|
||||
PrivilegesRequiredOverrideAllUsers=为所有用户安装(&A)
|
||||
PrivilegesRequiredOverrideAllUsersRecommended=为所有用户安装(&A)(建议选项)
|
||||
PrivilegesRequiredOverrideCurrentUser=仅为我安装(&M)
|
||||
PrivilegesRequiredOverrideCurrentUserRecommended=仅为我安装(&M)(建议选项)
|
||||
|
||||
; *** Misc. errors
|
||||
ErrorCreatingDir=安装程序无法创建目录“%1”
|
||||
ErrorTooManyFilesInDir=无法在目录“%1”中创建文件,因为里面包含太多文件
|
||||
|
||||
; *** Setup common messages
|
||||
ExitSetupTitle=退出安装程序
|
||||
ExitSetupMessage=安装程序尚未完成。如果现在退出,将不会安装该程序。%n%n您之后可以再次运行安装程序完成安装。%n%n现在退出安装程序吗?
|
||||
AboutSetupMenuItem=关于安装程序(&A)...
|
||||
AboutSetupTitle=关于安装程序
|
||||
AboutSetupMessage=%1 版本 %2%n%3%n%n%1 主页:%n%4
|
||||
AboutSetupNote=
|
||||
TranslatorNote=简体中文翻译由 Kira(847320916@qq.com)维护。项目地址:https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
|
||||
|
||||
; *** Buttons
|
||||
ButtonBack=< 上一步(&B)
|
||||
ButtonNext=下一步(&N) >
|
||||
ButtonInstall=安装(&I)
|
||||
ButtonOK=确定
|
||||
ButtonCancel=取消
|
||||
ButtonYes=是(&Y)
|
||||
ButtonYesToAll=全是(&A)
|
||||
ButtonNo=否(&N)
|
||||
ButtonNoToAll=全否(&O)
|
||||
ButtonFinish=完成(&F)
|
||||
ButtonBrowse=浏览(&B)...
|
||||
ButtonWizardBrowse=浏览(&R)...
|
||||
ButtonNewFolder=新建文件夹(&M)
|
||||
|
||||
; *** "Select Language" dialog messages
|
||||
SelectLanguageTitle=选择安装语言
|
||||
SelectLanguageLabel=选择安装时使用的语言。
|
||||
|
||||
; *** Common wizard text
|
||||
ClickNext=点击“下一步”继续,或点击“取消”退出安装程序。
|
||||
BeveledLabel=
|
||||
BrowseDialogTitle=浏览文件夹
|
||||
BrowseDialogLabel=在下面的列表中选择一个文件夹,然后点击“确定”。
|
||||
NewFolderName=新建文件夹
|
||||
|
||||
; *** "Welcome" wizard page
|
||||
WelcomeLabel1=欢迎使用 [name] 安装向导
|
||||
WelcomeLabel2=即将在您的计算机上安装 [name/ver]。%n%n建议您在继续安装前关闭所有其他应用程序。
|
||||
|
||||
; *** "Password" wizard page
|
||||
WizardPassword=密码
|
||||
PasswordLabel1=此安装程序需要密码验证。
|
||||
PasswordLabel3=请输入密码,然后点击“下一步”继续。密码区分大小写。
|
||||
PasswordEditLabel=密码(&P):
|
||||
IncorrectPassword=您输入的密码不正确,请重新输入。
|
||||
|
||||
; *** "License Agreement" wizard page
|
||||
WizardLicense=许可协议
|
||||
LicenseLabel=请在继续安装前阅读以下重要信息。
|
||||
LicenseLabel3=请仔细阅读下列许可协议。在继续安装前您必须同意这些协议条款。
|
||||
LicenseAccepted=我同意此协议(&A)
|
||||
LicenseNotAccepted=我不同意此协议(&D)
|
||||
|
||||
; *** "Information" wizard pages
|
||||
WizardInfoBefore=信息
|
||||
InfoBeforeLabel=请在继续安装前阅读以下重要信息。
|
||||
InfoBeforeClickLabel=准备好继续安装后,点击“下一步”。
|
||||
WizardInfoAfter=信息
|
||||
InfoAfterLabel=请在继续安装前阅读以下重要信息。
|
||||
InfoAfterClickLabel=准备好继续安装后,点击“下一步”。
|
||||
|
||||
; *** "User Information" wizard page
|
||||
WizardUserInfo=用户信息
|
||||
UserInfoDesc=请输入您的信息。
|
||||
UserInfoName=用户名(&U):
|
||||
UserInfoOrg=组织(&O):
|
||||
UserInfoSerial=序列号(&S):
|
||||
UserInfoNameRequired=请输入用户名。
|
||||
|
||||
; *** "Select Destination Location" wizard page
|
||||
WizardSelectDir=选择目标位置
|
||||
SelectDirDesc=您想将 [name] 安装在哪里?
|
||||
SelectDirLabel3=安装程序将安装 [name] 到下面的文件夹中。
|
||||
SelectDirBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
|
||||
DiskSpaceGBLabel=至少需要有 [gb] GB 的可用磁盘空间。
|
||||
DiskSpaceMBLabel=至少需要有 [mb] MB 的可用磁盘空间。
|
||||
CannotInstallToNetworkDrive=安装程序无法安装到一个网络驱动器。
|
||||
CannotInstallToUNCPath=安装程序无法安装到一个 UNC 路径。
|
||||
InvalidPath=您必须输入一个带驱动器卷标的完整路径,例如:%n%nC:\APP%n%n或UNC路径:%n%n\\server\share
|
||||
InvalidDrive=您选定的驱动器或 UNC 共享不存在或不能访问。请选择其他位置。
|
||||
DiskSpaceWarningTitle=磁盘空间不足
|
||||
DiskSpaceWarning=安装程序至少需要 %1 KB 的可用空间才能安装,但选定驱动器只有 %2 KB 的可用空间。%n%n您一定要继续吗?
|
||||
DirNameTooLong=文件夹名称或路径太长。
|
||||
InvalidDirName=文件夹名称无效。
|
||||
BadDirName32=文件夹名称不能包含下列任何字符:%n%n%1
|
||||
DirExistsTitle=文件夹已存在
|
||||
DirExists=文件夹:%n%n%1%n%n已经存在。您一定要安装到这个文件夹中吗?
|
||||
DirDoesntExistTitle=文件夹不存在
|
||||
DirDoesntExist=文件夹:%n%n%1%n%n不存在。您想要创建此文件夹吗?
|
||||
|
||||
; *** "Select Components" wizard page
|
||||
WizardSelectComponents=选择组件
|
||||
SelectComponentsDesc=您想安装哪些程序组件?
|
||||
SelectComponentsLabel2=选中您想安装的组件;取消您不想安装的组件。然后点击“下一步”继续。
|
||||
FullInstallation=完全安装
|
||||
; if possible don't translate 'Compact' as 'Minimal' (I mean 'Minimal' in your language)
|
||||
CompactInstallation=简洁安装
|
||||
CustomInstallation=自定义安装
|
||||
NoUninstallWarningTitle=组件已存在
|
||||
NoUninstallWarning=安装程序检测到下列组件已安装在您的计算机中:%n%n%1%n%n取消选中这些组件不会卸载它们。%n%n确定要继续吗?
|
||||
ComponentSize1=%1 KB
|
||||
ComponentSize2=%1 MB
|
||||
ComponentsDiskSpaceGBLabel=当前选择的组件需要至少 [gb] GB 的磁盘空间。
|
||||
ComponentsDiskSpaceMBLabel=当前选择的组件需要至少 [mb] MB 的磁盘空间。
|
||||
|
||||
; *** "Select Additional Tasks" wizard page
|
||||
WizardSelectTasks=选择附加任务
|
||||
SelectTasksDesc=您想要安装程序执行哪些附加任务?
|
||||
SelectTasksLabel2=选择您想要安装程序在安装 [name] 时执行的附加任务,然后点击“下一步”。
|
||||
|
||||
; *** "Select Start Menu Folder" wizard page
|
||||
WizardSelectProgramGroup=选择开始菜单文件夹
|
||||
SelectStartMenuFolderDesc=安装程序应该在哪里放置程序的快捷方式?
|
||||
SelectStartMenuFolderLabel3=安装程序将在下列“开始”菜单文件夹中创建程序的快捷方式。
|
||||
SelectStartMenuFolderBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
|
||||
MustEnterGroupName=您必须输入一个文件夹名。
|
||||
GroupNameTooLong=文件夹名或路径太长。
|
||||
InvalidGroupName=无效的文件夹名字。
|
||||
BadGroupName=文件夹名不能包含下列任何字符:%n%n%1
|
||||
NoProgramGroupCheck2=不创建开始菜单文件夹(&D)
|
||||
|
||||
; *** "Ready to Install" wizard page
|
||||
WizardReady=准备安装
|
||||
ReadyLabel1=安装程序准备就绪,现在可以开始安装 [name] 到您的计算机。
|
||||
ReadyLabel2a=点击“安装”继续此安装程序。如果您想重新考虑或修改任何设置,点击“上一步”。
|
||||
ReadyLabel2b=点击“安装”继续此安装程序。
|
||||
ReadyMemoUserInfo=用户信息:
|
||||
ReadyMemoDir=目标位置:
|
||||
ReadyMemoType=安装类型:
|
||||
ReadyMemoComponents=已选择组件:
|
||||
ReadyMemoGroup=开始菜单文件夹:
|
||||
ReadyMemoTasks=附加任务:
|
||||
|
||||
; *** TDownloadWizardPage wizard page and DownloadTemporaryFile
|
||||
DownloadingLabel2=正在下载文件...
|
||||
ButtonStopDownload=停止下载(&S)
|
||||
StopDownload=您确定要停止下载吗?
|
||||
ErrorDownloadAborted=下载已中止
|
||||
ErrorDownloadFailed=下载失败:%1 %2
|
||||
ErrorDownloadSizeFailed=获取大小失败:%1 %2
|
||||
ErrorProgress=无效的进度:%1 / %2
|
||||
ErrorFileSize=文件大小错误:预期 %1,实际 %2
|
||||
|
||||
; *** TExtractionWizardPage wizard page and ExtractArchive
|
||||
ExtractingLabel=正在提取文件...
|
||||
ButtonStopExtraction=停止提取(&S)
|
||||
StopExtraction=您确定要停止提取吗?
|
||||
ErrorExtractionAborted=提取已中止
|
||||
ErrorExtractionFailed=提取失败:%1
|
||||
|
||||
; *** Archive extraction failure details
|
||||
ArchiveIncorrectPassword=密码不正确
|
||||
ArchiveIsCorrupted=压缩包已损坏
|
||||
ArchiveUnsupportedFormat=不支持的压缩包格式
|
||||
|
||||
; *** "Preparing to Install" wizard page
|
||||
WizardPreparing=正在准备安装
|
||||
PreparingDesc=安装程序正在准备安装 [name] 到您的计算机。
|
||||
PreviousInstallNotCompleted=先前的程序安装或卸载未完成,需要您重启计算机。%n%n在重启计算机后,再次运行安装程序以完成 [name] 的安装。
|
||||
CannotContinue=安装程序不能继续。请点击“取消”退出。
|
||||
ApplicationsFound=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。
|
||||
ApplicationsFound2=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。安装完成后,安装程序将尝试重新启动这些应用程序。
|
||||
CloseApplications=自动关闭应用程序(&A)
|
||||
DontCloseApplications=不要关闭应用程序(&D)
|
||||
ErrorCloseApplications=安装程序无法自动关闭所有应用程序。建议您在继续之前,关闭所有在使用需要由安装程序更新的文件的应用程序。
|
||||
PrepareToInstallNeedsRestart=安装程序必须重启您的计算机。计算机重启后,请再次运行安装程序以完成 [name] 的安装。%n%n要立即重启吗?
|
||||
|
||||
; *** "Installing" wizard page
|
||||
WizardInstalling=正在安装
|
||||
InstallingLabel=安装程序正在安装 [name] 到您的计算机,请稍候。
|
||||
|
||||
; *** "Setup Completed" wizard page
|
||||
FinishedHeadingLabel=[name] 安装完成
|
||||
FinishedLabelNoIcons=安装程序已在您的计算机中安装了 [name]。
|
||||
FinishedLabel=安装程序已在您的计算机中安装了 [name]。您可以通过已安装的快捷方式运行此应用程序。
|
||||
ClickFinish=点击“完成”退出安装程序。
|
||||
FinishedRestartLabel=为完成 [name] 的安装,安装程序必须重新启动您的计算机。要立即重启吗?
|
||||
FinishedRestartMessage=为完成 [name] 的安装,安装程序必须重新启动您的计算机。%n%n要立即重启吗?
|
||||
ShowReadmeCheck=是,我想查阅自述文件
|
||||
YesRadio=是,立即重启计算机(&Y)
|
||||
NoRadio=否,稍后重启计算机(&N)
|
||||
; used for example as 'Run MyProg.exe'
|
||||
RunEntryExec=运行 %1
|
||||
; used for example as 'View Readme.txt'
|
||||
RunEntryShellExec=查阅 %1
|
||||
|
||||
; *** "Setup Needs the Next Disk" stuff
|
||||
ChangeDiskTitle=安装程序需要下一张磁盘
|
||||
SelectDiskLabel2=请插入磁盘 %1 并点击“确定”。%n%n如果这个磁盘中的文件可以在下列文件夹之外的文件夹中找到,请输入正确的路径或点击“浏览”。
|
||||
PathLabel=路径(&P):
|
||||
FileNotInDir2=“%2”中找不到文件“%1”。请插入正确的磁盘或选择其他文件夹。
|
||||
SelectDirectoryLabel=请指定下一张磁盘的位置。
|
||||
|
||||
; *** Installation phase messages
|
||||
SetupAborted=安装程序未完成安装。%n%n请修正这个问题并重新运行安装程序。
|
||||
AbortRetryIgnoreSelectAction=选择操作
|
||||
AbortRetryIgnoreRetry=重试(&T)
|
||||
AbortRetryIgnoreIgnore=忽略错误并继续(&I)
|
||||
AbortRetryIgnoreCancel=关闭安装程序
|
||||
RetryCancelSelectAction=选择操作
|
||||
RetryCancelRetry=重试(&T)
|
||||
RetryCancelCancel=取消
|
||||
|
||||
; *** Installation status messages
|
||||
StatusClosingApplications=正在关闭应用程序...
|
||||
StatusCreateDirs=正在创建目录...
|
||||
StatusExtractFiles=正在提取文件...
|
||||
StatusDownloadFiles=正在下载文件...
|
||||
StatusCreateIcons=正在创建快捷方式...
|
||||
StatusCreateIniEntries=正在创建 INI 条目...
|
||||
StatusCreateRegistryEntries=正在创建注册表条目...
|
||||
StatusRegisterFiles=正在注册文件...
|
||||
StatusSavingUninstall=正在保存卸载信息...
|
||||
StatusRunProgram=正在完成安装...
|
||||
StatusRestartingApplications=正在重启应用程序...
|
||||
StatusRollback=正在撤销更改...
|
||||
|
||||
; *** Misc. errors
|
||||
ErrorInternal2=内部错误:%1
|
||||
ErrorFunctionFailedNoCode=%1 失败
|
||||
ErrorFunctionFailed=%1 失败;错误代码 %2
|
||||
ErrorFunctionFailedWithMessage=%1 失败;错误代码 %2.%n%3
|
||||
ErrorExecutingProgram=无法执行文件:%n%1
|
||||
|
||||
; *** Registry errors
|
||||
ErrorRegOpenKey=打开注册表项时出错:%n%1\%2
|
||||
ErrorRegCreateKey=创建注册表项时出错:%n%1\%2
|
||||
ErrorRegWriteKey=写入注册表项时出错:%n%1\%2
|
||||
|
||||
; *** INI errors
|
||||
ErrorIniEntry=在文件“%1”中创建 INI 条目时出错。
|
||||
|
||||
; *** File copying errors
|
||||
FileAbortRetryIgnoreSkipNotRecommended=跳过此文件(&S)(不推荐)
|
||||
FileAbortRetryIgnoreIgnoreNotRecommended=忽略错误并继续(&I)(不推荐)
|
||||
SourceIsCorrupted=源文件已损坏
|
||||
SourceDoesntExist=源文件“%1”不存在
|
||||
SourceVerificationFailed=源文件验证失败:%1
|
||||
VerificationSignatureDoesntExist=签名文件“%1”不存在
|
||||
VerificationSignatureInvalid=签名文件“%1”无效
|
||||
VerificationKeyNotFound=签名文件“%1”使用了未知的密钥
|
||||
VerificationFileNameIncorrect=文件名不正确
|
||||
VerificationFileTagIncorrect=文件标签不正确
|
||||
VerificationFileSizeIncorrect=文件大小不正确
|
||||
VerificationFileHashIncorrect=文件校验和不匹配
|
||||
ExistingFileReadOnly2=无法替换现有文件,它是只读的。
|
||||
ExistingFileReadOnlyRetry=移除只读属性并重试(&R)
|
||||
ExistingFileReadOnlyKeepExisting=保留现有文件(&K)
|
||||
ErrorReadingExistingDest=尝试读取现有文件时出错:
|
||||
FileExistsSelectAction=选择操作
|
||||
FileExists2=文件已经存在。
|
||||
FileExistsOverwriteExisting=覆盖已存在的文件(&O)
|
||||
FileExistsKeepExisting=保留现有的文件(&K)
|
||||
FileExistsOverwriteOrKeepAll=为所有冲突文件执行此操作(&D)
|
||||
ExistingFileNewerSelectAction=选择操作
|
||||
ExistingFileNewer2=现有的文件比安装程序将要安装的文件还要新。
|
||||
ExistingFileNewerOverwriteExisting=覆盖已存在的文件(&O)
|
||||
ExistingFileNewerKeepExisting=保留现有的文件(&K)(推荐)
|
||||
ExistingFileNewerOverwriteOrKeepAll=为所有冲突文件执行此操作(&D)
|
||||
ErrorChangingAttr=尝试更改下列现有文件的属性时出错:
|
||||
ErrorCreatingTemp=尝试在目标目录创建文件时出错:
|
||||
ErrorReadingSource=尝试读取下列源文件时出错:
|
||||
ErrorCopying=尝试复制下列文件时出错:
|
||||
ErrorDownloading=尝试下载文件时出错:
|
||||
ErrorExtracting=尝试提取压缩包时出错:
|
||||
ErrorReplacingExistingFile=尝试替换现有文件时出错:
|
||||
ErrorRestartReplace=重启并替换失败:
|
||||
ErrorRenamingTemp=尝试重命名下列目标目录中的一个文件时出错:
|
||||
ErrorRegisterServer=无法注册 DLL/OCX:%1
|
||||
ErrorRegSvr32Failed=RegSvr32 失败;退出代码 %1
|
||||
ErrorRegisterTypeLib=无法注册类库:%1
|
||||
|
||||
; *** Uninstall display name markings
|
||||
; used for example as 'My Program (32-bit)'
|
||||
UninstallDisplayNameMark=%1 (%2)
|
||||
; used for example as 'My Program (32-bit, All users)'
|
||||
UninstallDisplayNameMarks=%1 (%2, %3)
|
||||
UninstallDisplayNameMark32Bit=32 位
|
||||
UninstallDisplayNameMark64Bit=64 位
|
||||
UninstallDisplayNameMarkAllUsers=所有用户
|
||||
UninstallDisplayNameMarkCurrentUser=当前用户
|
||||
|
||||
; *** Post-installation errors
|
||||
ErrorOpeningReadme=尝试打开自述文件时出错。
|
||||
ErrorRestartingComputer=安装程序无法重启计算机,请手动重启。
|
||||
|
||||
; *** Uninstaller messages
|
||||
UninstallNotFound=文件“%1”不存在。无法卸载。
|
||||
UninstallOpenError=文件“%1”不能被打开。无法卸载。
|
||||
UninstallUnsupportedVer=此版本的卸载程序无法识别卸载日志文件“%1”的格式。无法卸载
|
||||
UninstallUnknownEntry=卸载日志中遇到一个未知条目(%1)
|
||||
ConfirmUninstall=您确认要完全移除 %1 及其所有组件吗?
|
||||
UninstallOnlyOnWin64=仅允许在 64 位 Windows 中卸载此程序。
|
||||
OnlyAdminCanUninstall=仅使用管理员权限的用户能完成此卸载。
|
||||
UninstallStatusLabel=正在从您的计算机中移除 %1,请稍候。
|
||||
UninstalledAll=已顺利从您的计算机中移除 %1。
|
||||
UninstalledMost=%1 卸载完成。%n%n有部分内容未能被删除,但您可以手动删除它们。
|
||||
UninstalledAndNeedsRestart=为完成 %1 的卸载,需要重启您的计算机。%n%n要立即重启吗?
|
||||
UninstallDataCorrupted=文件“%1”已损坏。无法卸载
|
||||
|
||||
; *** Uninstallation phase messages
|
||||
ConfirmDeleteSharedFileTitle=删除共享的文件吗?
|
||||
ConfirmDeleteSharedFile2=系统表示下列共享的文件已不有其他程序使用。您希望卸载程序删除这些共享的文件吗?%n%n如果删除这些文件,但仍有程序在使用这些文件,则这些程序可能出现异常。如果您不能确定,请选择“否”,在系统中保留这些文件以免引发问题。
|
||||
SharedFileNameLabel=文件名:
|
||||
SharedFileLocationLabel=位置:
|
||||
WizardUninstalling=卸载状态
|
||||
StatusUninstalling=正在卸载 %1...
|
||||
|
||||
; *** Shutdown block reasons
|
||||
ShutdownBlockReasonInstallingApp=正在安装 %1。
|
||||
ShutdownBlockReasonUninstallingApp=正在卸载 %1。
|
||||
|
||||
; The custom messages below aren't used by Setup itself, but if you make
|
||||
; use of them in your scripts, you'll want to translate them.
|
||||
|
||||
[CustomMessages]
|
||||
|
||||
NameAndVersion=%1 版本 %2
|
||||
AdditionalIcons=附加快捷方式:
|
||||
CreateDesktopIcon=创建桌面快捷方式(&D)
|
||||
CreateQuickLaunchIcon=创建快速启动栏快捷方式(&Q)
|
||||
ProgramOnTheWeb=%1 网站
|
||||
UninstallProgram=卸载 %1
|
||||
LaunchProgram=运行 %1
|
||||
AssocFileExtension=将 %2 文件扩展名与 %1 建立关联(&A)
|
||||
AssocingFileExtension=正在将 %2 文件扩展名与 %1 建立关联...
|
||||
AutoStartProgramGroupDescription=启动:
|
||||
AutoStartProgram=自动启动 %1
|
||||
AddonHostProgramNotFound=您选择的文件夹中无法找到 %1。%n%n您要继续吗?
|
||||
@@ -0,0 +1,387 @@
|
||||
const
|
||||
CoreGptApiKey = 0;
|
||||
CoreGptBaseUrl = 1;
|
||||
CoreGptModelEngine = 2;
|
||||
CoreEmbeddingModel = 3;
|
||||
CoreFayUrl = 4;
|
||||
CoreProxyConfig = 5;
|
||||
|
||||
AliAppKey = 0;
|
||||
AliKeyId = 1;
|
||||
AliKeySecret = 2;
|
||||
|
||||
var
|
||||
PublicConfigCenterId: String;
|
||||
ConfigSourcePage: TInputOptionWizardPage;
|
||||
PublicConfigPage: TInputQueryWizardPage;
|
||||
CoreConfigPage: TInputQueryWizardPage;
|
||||
StartModePage: TInputOptionWizardPage;
|
||||
AliKeyConfigPage: TInputQueryWizardPage;
|
||||
ReviewPage: TWizardPage;
|
||||
ReviewMemo: TNewMemo;
|
||||
|
||||
function UsePublicConfig(): Boolean;
|
||||
begin
|
||||
Result := ConfigSourcePage.Values[0];
|
||||
end;
|
||||
|
||||
function UseUpgrade(): Boolean;
|
||||
begin
|
||||
Result := ConfigSourcePage.Values[2];
|
||||
end;
|
||||
|
||||
function HasExistingRootConfig(): Boolean;
|
||||
begin
|
||||
Result := FileExists(ExpandConstant('{app}\system.conf')) or
|
||||
FileExists(ExpandConstant('{app}\config.json'));
|
||||
end;
|
||||
|
||||
function GetPublicConfigCenterId(): String;
|
||||
begin
|
||||
if PublicConfigPage <> nil then
|
||||
Result := Trim(PublicConfigPage.Values[0])
|
||||
else
|
||||
Result := '';
|
||||
|
||||
if Result = '' then
|
||||
Result := PublicConfigCenterId;
|
||||
end;
|
||||
|
||||
function GetLaunchParameters(Param: String): String;
|
||||
begin
|
||||
if UseUpgrade() then
|
||||
Result := 'start'
|
||||
else if UsePublicConfig() and (not HasExistingRootConfig()) then
|
||||
Result := 'start -center_config ' + GetPublicConfigCenterId()
|
||||
else
|
||||
Result := 'start';
|
||||
end;
|
||||
|
||||
function GetCoreValue(Index: Integer): String;
|
||||
begin
|
||||
Result := Trim(CoreConfigPage.Values[Index]);
|
||||
end;
|
||||
|
||||
function GetAliValue(Index: Integer): String;
|
||||
begin
|
||||
Result := Trim(AliKeyConfigPage.Values[Index]);
|
||||
end;
|
||||
|
||||
function GetStartMode(): String;
|
||||
begin
|
||||
if StartModePage.Values[0] then
|
||||
Result := 'web'
|
||||
else
|
||||
Result := 'common';
|
||||
end;
|
||||
|
||||
function GetDefaultConfigJson(): String;
|
||||
begin
|
||||
Result :=
|
||||
'{'#13#10 +
|
||||
' "attribute": {'#13#10 +
|
||||
' "additional": "",'#13#10 +
|
||||
' "age": "",'#13#10 +
|
||||
' "birth": "",'#13#10 +
|
||||
' "constellation": "",'#13#10 +
|
||||
' "contact": "",'#13#10 +
|
||||
' "gender": "",'#13#10 +
|
||||
' "goal": "assistant",'#13#10 +
|
||||
' "hobby": "",'#13#10 +
|
||||
' "job": "assistant",'#13#10 +
|
||||
' "name": "Feifei",'#13#10 +
|
||||
' "position": "companion",'#13#10 +
|
||||
' "voice": "abin",'#13#10 +
|
||||
' "zodiac": ""'#13#10 +
|
||||
' },'#13#10 +
|
||||
' "interact": {'#13#10 +
|
||||
' "QnA": "qa.csv",'#13#10 +
|
||||
' "maxInteractTime": 15,'#13#10 +
|
||||
' "perception": {'#13#10 +
|
||||
' "chat": 10,'#13#10 +
|
||||
' "follow": 10,'#13#10 +
|
||||
' "gift": 10,'#13#10 +
|
||||
' "indifferent": 10,'#13#10 +
|
||||
' "join": 10'#13#10 +
|
||||
' },'#13#10 +
|
||||
' "playSound": false,'#13#10 +
|
||||
' "visualization": false'#13#10 +
|
||||
' },'#13#10 +
|
||||
' "items": [],'#13#10 +
|
||||
' "memory": {'#13#10 +
|
||||
' "isolate_by_user": true,'#13#10 +
|
||||
' "use_bionic_memory": false'#13#10 +
|
||||
' },'#13#10 +
|
||||
' "source": {'#13#10 +
|
||||
' "automatic_player_status": false,'#13#10 +
|
||||
' "automatic_player_url": "http://127.0.0.1:6000",'#13#10 +
|
||||
' "liveRoom": {'#13#10 +
|
||||
' "enabled": true,'#13#10 +
|
||||
' "url": ""'#13#10 +
|
||||
' },'#13#10 +
|
||||
' "record": {'#13#10 +
|
||||
' "device": "",'#13#10 +
|
||||
' "enabled": false'#13#10 +
|
||||
' },'#13#10 +
|
||||
' "wake_word": "feifei",'#13#10 +
|
||||
' "wake_word_enabled": false,'#13#10 +
|
||||
' "wake_word_type": "front"'#13#10 +
|
||||
' }'#13#10 +
|
||||
'}'#13#10;
|
||||
end;
|
||||
|
||||
function BuildSystemConfContent(): String;
|
||||
begin
|
||||
Result :=
|
||||
'[key]'#13#10 +
|
||||
'ali_nls_app_key = ' + GetAliValue(AliAppKey) + #13#10 +
|
||||
'ali_nls_key_id = ' + GetAliValue(AliKeyId) + #13#10 +
|
||||
'ali_nls_key_secret = ' + GetAliValue(AliKeySecret) + #13#10 +
|
||||
'ali_tss_app_key = ' + GetAliValue(AliAppKey) + #13#10 +
|
||||
'ali_tss_key_id = ' + GetAliValue(AliKeyId) + #13#10 +
|
||||
'ali_tss_key_secret = ' + GetAliValue(AliKeySecret) + #13#10 +
|
||||
'asr_mode = ali'#13#10 +
|
||||
'baidu_emotion_app_id = '#13#10 +
|
||||
'baidu_emotion_api_key = '#13#10 +
|
||||
'embedding_api_model = ' + GetCoreValue(CoreEmbeddingModel) + #13#10 +
|
||||
'fay_url = ' + GetCoreValue(CoreFayUrl) + #13#10 +
|
||||
'gpt_api_key = ' + GetCoreValue(CoreGptApiKey) + #13#10 +
|
||||
'gpt_base_url = ' + GetCoreValue(CoreGptBaseUrl) + #13#10 +
|
||||
'gpt_model_engine = ' + GetCoreValue(CoreGptModelEngine) + #13#10 +
|
||||
'local_asr_ip = 127.0.0.1'#13#10 +
|
||||
'local_asr_port = 10197'#13#10 +
|
||||
'ms_tts_key = '#13#10 +
|
||||
'ms_tts_region = '#13#10 +
|
||||
'proxy_config = ' + GetCoreValue(CoreProxyConfig) + #13#10 +
|
||||
'start_mode = ' + GetStartMode() + #13#10 +
|
||||
'tts_module = ali'#13#10 +
|
||||
'volcano_tts_access_token = '#13#10 +
|
||||
'volcano_tts_appid = '#13#10 +
|
||||
'volcano_tts_cluster = volcano_tts'#13#10 +
|
||||
'volcano_tts_voice_type = '#13#10;
|
||||
end;
|
||||
|
||||
function ValidateRequired(const Value, FieldName: String): Boolean;
|
||||
begin
|
||||
Result := Trim(Value) <> '';
|
||||
if not Result then
|
||||
MsgBox(FieldName + ' 不能为空。', mbError, MB_OK);
|
||||
end;
|
||||
|
||||
procedure UpdateReviewMemo();
|
||||
begin
|
||||
ReviewMemo.Text :=
|
||||
'安装程序将在以下目录生成本地配置文件:'#13#10 +
|
||||
WizardDirValue + #13#10#13#10 +
|
||||
'system.conf:'#13#10#13#10 +
|
||||
BuildSystemConfContent() + #13#10 +
|
||||
'config.json:'#13#10#13#10 +
|
||||
GetDefaultConfigJson();
|
||||
end;
|
||||
|
||||
procedure WriteManualConfig();
|
||||
begin
|
||||
SaveStringToFile(ExpandConstant('{app}\system.conf'), BuildSystemConfContent(), False);
|
||||
SaveStringToFile(ExpandConstant('{app}\config.json'), GetDefaultConfigJson(), False);
|
||||
end;
|
||||
|
||||
procedure RemoveBundledConfigCache();
|
||||
begin
|
||||
DeleteFile(ExpandConstant('{app}\cache_data\system.conf'));
|
||||
DeleteFile(ExpandConstant('{app}\cache_data\config.json'));
|
||||
end;
|
||||
|
||||
procedure InitializeWizard();
|
||||
begin
|
||||
PublicConfigCenterId := '2d431d0a-3083-4cbd-8e7f-23f732e237fb';
|
||||
ConfigSourcePage := CreateInputOptionPage(
|
||||
wpSelectDir,
|
||||
'初始配置',
|
||||
'请选择 Fay 首次启动时使用的配置来源',
|
||||
'如果选择"使用公共配置(不稳定)",安装后首次启动会从配置中心下载配置。'#13#10 +
|
||||
'如果选择"手动填写本地配置",安装程序会根据你填写的内容生成 system.conf,并自动补一个默认 config.json。'#13#10 +
|
||||
'如果选择"升级",将保留现有配置文件,仅更新程序文件。',
|
||||
True,
|
||||
False
|
||||
);
|
||||
ConfigSourcePage.Add('使用公共配置(不稳定)');
|
||||
ConfigSourcePage.Add('手动填写本地配置');
|
||||
ConfigSourcePage.Add('升级(保留现有配置,仅更新程序)');
|
||||
ConfigSourcePage.SelectedValueIndex := 0;
|
||||
|
||||
PublicConfigPage := CreateInputQueryPage(
|
||||
ConfigSourcePage.ID,
|
||||
'公共配置',
|
||||
'填写首次启动时使用的配置中心项目 ID',
|
||||
'当你选择"使用公共配置(不稳定)"时,安装后的启动参数会使用这里填写的 center_config。'#13#10 +
|
||||
'如果你不修改,将默认使用下面这个项目 ID。'
|
||||
);
|
||||
PublicConfigPage.Add('center_config(默认值可直接使用)', False);
|
||||
PublicConfigPage.Values[0] := PublicConfigCenterId;
|
||||
|
||||
CoreConfigPage := CreateInputQueryPage(
|
||||
PublicConfigPage.ID,
|
||||
'大模型与网络配置',
|
||||
'填写 system.conf 的核心字段',
|
||||
'以下内容会直接写入 system.conf。'#13#10 +
|
||||
'gpt_base_url 示例为 https://api.openai.com/v1;proxy_config 可留空,也可填写 127.0.0.1:7890。'
|
||||
);
|
||||
CoreConfigPage.Add('gpt_api_key(必填,LLM 平台 API Key)', False);
|
||||
CoreConfigPage.Add('gpt_base_url(必填,例如 https://api.openai.com/v1)', False);
|
||||
CoreConfigPage.Add('gpt_model_engine(必填,例如 glm4 / deepseek / qwen3-4b)', False);
|
||||
CoreConfigPage.Add('embedding_api_model(必填,Embedding 模型名)', False);
|
||||
CoreConfigPage.Add('fay_url(必填,默认 http://127.0.0.1:5000)', False);
|
||||
CoreConfigPage.Add('proxy_config(选填,例如 127.0.0.1:7890)', False);
|
||||
CoreConfigPage.Values[CoreGptApiKey] := '';
|
||||
CoreConfigPage.Values[CoreGptBaseUrl] := '';
|
||||
CoreConfigPage.Values[CoreGptModelEngine] := '';
|
||||
CoreConfigPage.Values[CoreEmbeddingModel] := '';
|
||||
CoreConfigPage.Values[CoreFayUrl] := 'http://127.0.0.1:5000';
|
||||
CoreConfigPage.Values[CoreProxyConfig] := '';
|
||||
|
||||
StartModePage := CreateInputOptionPage(
|
||||
CoreConfigPage.ID,
|
||||
'启动模式',
|
||||
'请选择 Fay 的启动模式',
|
||||
'选择 web 模式将启动 Web 界面;选择 common 模式为客户端窗口模式。',
|
||||
True,
|
||||
False
|
||||
);
|
||||
StartModePage.Add('web(Web 界面模式,推荐)');
|
||||
StartModePage.Add('common(客户端窗口模式)');
|
||||
StartModePage.SelectedValueIndex := 0;
|
||||
|
||||
{ --- 阿里云语音密钥配置页(3 个字段,ASR 和 TTS 共用) --- }
|
||||
AliKeyConfigPage := CreateInputQueryPage(
|
||||
StartModePage.ID,
|
||||
'阿里云语音服务配置',
|
||||
'填写阿里云语音识别和语音合成所需密钥',
|
||||
'语音识别(ASR)和语音合成(TTS)共用同一组阿里云密钥。'#13#10 +
|
||||
'这些密钥可在阿里云智能语音交互控制台获取。'
|
||||
);
|
||||
AliKeyConfigPage.Add('ali_app_key(阿里云语音服务 AppKey)', False);
|
||||
AliKeyConfigPage.Add('ali_key_id(阿里云语音服务 KeyId)', False);
|
||||
AliKeyConfigPage.Add('ali_key_secret(阿里云语音服务 KeySecret)', False);
|
||||
|
||||
ReviewPage := CreateCustomPage(
|
||||
AliKeyConfigPage.ID,
|
||||
'预览本地配置',
|
||||
'请确认即将生成的配置文件内容'
|
||||
);
|
||||
ReviewMemo := TNewMemo.Create(ReviewPage);
|
||||
ReviewMemo.Parent := ReviewPage.Surface;
|
||||
ReviewMemo.Left := 0;
|
||||
ReviewMemo.Top := 0;
|
||||
ReviewMemo.Width := ReviewPage.SurfaceWidth;
|
||||
ReviewMemo.Height := ReviewPage.SurfaceHeight + ReviewPage.SurfaceExtraHeight;
|
||||
ReviewMemo.ReadOnly := True;
|
||||
ReviewMemo.ScrollBars := ssBoth;
|
||||
ReviewMemo.WordWrap := False;
|
||||
end;
|
||||
|
||||
function ShouldSkipPage(PageID: Integer): Boolean;
|
||||
begin
|
||||
Result := False;
|
||||
|
||||
{ 升级模式:跳过所有配置页 }
|
||||
if UseUpgrade() and (
|
||||
(PageID = PublicConfigPage.ID) or
|
||||
(PageID = CoreConfigPage.ID) or
|
||||
(PageID = StartModePage.ID) or
|
||||
(PageID = AliKeyConfigPage.ID) or
|
||||
(PageID = ReviewPage.ID)
|
||||
) then
|
||||
begin
|
||||
Result := True;
|
||||
Exit;
|
||||
end;
|
||||
|
||||
if (not UsePublicConfig()) and (PageID = PublicConfigPage.ID) then
|
||||
begin
|
||||
Result := True;
|
||||
Exit;
|
||||
end;
|
||||
|
||||
if UsePublicConfig() and (
|
||||
(PageID = CoreConfigPage.ID) or
|
||||
(PageID = StartModePage.ID) or
|
||||
(PageID = AliKeyConfigPage.ID) or
|
||||
(PageID = ReviewPage.ID)
|
||||
) then
|
||||
Result := True;
|
||||
end;
|
||||
|
||||
function NextButtonClick(CurPageID: Integer): Boolean;
|
||||
begin
|
||||
Result := True;
|
||||
|
||||
if UsePublicConfig() and (CurPageID = PublicConfigPage.ID) then
|
||||
begin
|
||||
Result := ValidateRequired(GetPublicConfigCenterId(), 'center_config');
|
||||
Exit;
|
||||
end;
|
||||
|
||||
if UsePublicConfig() then
|
||||
Exit;
|
||||
|
||||
if CurPageID = CoreConfigPage.ID then
|
||||
begin
|
||||
Result := ValidateRequired(GetCoreValue(CoreGptApiKey), 'gpt_api_key');
|
||||
if not Result then Exit;
|
||||
Result := ValidateRequired(GetCoreValue(CoreGptBaseUrl), 'gpt_base_url');
|
||||
if not Result then Exit;
|
||||
Result := ValidateRequired(GetCoreValue(CoreGptModelEngine), 'gpt_model_engine');
|
||||
if not Result then Exit;
|
||||
Result := ValidateRequired(GetCoreValue(CoreEmbeddingModel), 'embedding_api_model');
|
||||
if not Result then Exit;
|
||||
Result := ValidateRequired(GetCoreValue(CoreFayUrl), 'fay_url');
|
||||
if not Result then Exit;
|
||||
end;
|
||||
|
||||
if CurPageID = AliKeyConfigPage.ID then
|
||||
begin
|
||||
Result := ValidateRequired(GetAliValue(AliAppKey), 'ali_app_key');
|
||||
if not Result then Exit;
|
||||
Result := ValidateRequired(GetAliValue(AliKeyId), 'ali_key_id');
|
||||
if not Result then Exit;
|
||||
Result := ValidateRequired(GetAliValue(AliKeySecret), 'ali_key_secret');
|
||||
if not Result then Exit;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure CurPageChanged(CurPageID: Integer);
|
||||
begin
|
||||
{ 动态控制"升级"选项的可用性:仅当目标目录存在旧配置时启用 }
|
||||
if CurPageID = ConfigSourcePage.ID then
|
||||
begin
|
||||
if HasExistingRootConfig() then
|
||||
begin
|
||||
ConfigSourcePage.CheckListBox.ItemEnabled[2] := True;
|
||||
ConfigSourcePage.CheckListBox.ItemCaption[2] := '升级(保留现有配置,仅更新程序)';
|
||||
end
|
||||
else
|
||||
begin
|
||||
ConfigSourcePage.CheckListBox.ItemEnabled[2] := False;
|
||||
ConfigSourcePage.CheckListBox.ItemCaption[2] := '升级(未检测到旧版安装)';
|
||||
{ 如果当前选中的是升级但已不可用,回退到第一项 }
|
||||
if ConfigSourcePage.SelectedValueIndex = 2 then
|
||||
ConfigSourcePage.SelectedValueIndex := 0;
|
||||
end;
|
||||
end;
|
||||
|
||||
if (CurPageID = ReviewPage.ID) and (not UsePublicConfig()) then
|
||||
UpdateReviewMemo();
|
||||
end;
|
||||
|
||||
procedure CurStepChanged(CurStep: TSetupStep);
|
||||
begin
|
||||
if CurStep = ssPostInstall then
|
||||
begin
|
||||
if UseUpgrade() then
|
||||
begin end { 升级模式:不写任何配置文件,保留用户现有配置 }
|
||||
else if UsePublicConfig() then
|
||||
RemoveBundledConfigCache()
|
||||
else
|
||||
WriteManualConfig();
|
||||
end;
|
||||
end;
|
||||
@@ -0,0 +1,58 @@
|
||||
#define MyAppName "Fay Legacy"
|
||||
#define MyAppVersion "4.4.4"
|
||||
#define MyAppPublisher "Fay"
|
||||
#define MyAppExeName "fay.exe"
|
||||
|
||||
[Setup]
|
||||
AppId={{4B53F5C6-89B3-4FB1-89CD-0C6CC4DB9C66}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
DefaultDirName={localappdata}\Fay Legacy
|
||||
DefaultGroupName={#MyAppName}
|
||||
DisableProgramGroupPage=yes
|
||||
OutputDir=..\..\dist\installer
|
||||
OutputBaseFilename=FaySetup-{#MyAppVersion}-legacy
|
||||
Compression=lzma2
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
PrivilegesRequired=lowest
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
SetupIconFile=..\..\favicon.ico
|
||||
UninstallDisplayIcon={app}\favicon.ico
|
||||
|
||||
[Languages]
|
||||
Name: "chinesesimplified"; MessagesFile: "compiler:Default.isl,ChineseSimplified.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "创建桌面快捷方式"; GroupDescription: "附加任务:"; Flags: unchecked
|
||||
|
||||
[Dirs]
|
||||
Name: "{app}\logs"; Flags: uninsneveruninstall
|
||||
Name: "{app}\memory"; Flags: uninsneveruninstall
|
||||
Name: "{app}\samples"; Flags: uninsneveruninstall
|
||||
Name: "{app}\cache_data"; Flags: uninsneveruninstall
|
||||
|
||||
[Files]
|
||||
Source: "..\..\dist\fay-legacy\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; Excludes: "startup.out,startup.err,logs\*,memory\*,samples\*,cache_data\*,system.conf,system.conf.bak,config.json,cache_data\system.conf,cache_data\config.json,faymcp\data\mcp_servers.json,faymcp\data\mcp_prestart_tools.json,faymcp\data\mcp_tool_states.json"
|
||||
Source: "..\..\dist\fay-legacy\memory\fay.db"; DestDir: "{app}\memory"; Flags: ignoreversion onlyifdoesntexist
|
||||
Source: "..\..\dist\fay-legacy\memory\user_profiles.db"; DestDir: "{app}\memory"; Flags: ignoreversion onlyifdoesntexist
|
||||
Source: "..\..\dist\fay-legacy\faymcp\data\mcp_servers.json"; DestDir: "{app}\faymcp\data"; Flags: ignoreversion onlyifdoesntexist
|
||||
Source: "..\..\dist\fay-legacy\faymcp\data\mcp_prestart_tools.json"; DestDir: "{app}\faymcp\data"; Flags: ignoreversion onlyifdoesntexist
|
||||
Source: "..\..\dist\fay-legacy\faymcp\data\mcp_tool_states.json"; DestDir: "{app}\faymcp\data"; Flags: ignoreversion onlyifdoesntexist
|
||||
|
||||
[Icons]
|
||||
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Parameters: "{code:GetLaunchParameters}"; WorkingDir: "{app}"
|
||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Parameters: "{code:GetLaunchParameters}"; Tasks: desktopicon; WorkingDir: "{app}"
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#MyAppExeName}"; Parameters: "{code:GetLaunchParameters}"; Description: "安装完成后立即启动 {#MyAppName}"; Flags: nowait postinstall skipifsilent; WorkingDir: "{app}"
|
||||
|
||||
[Code]
|
||||
function InitializeSetup(): Boolean;
|
||||
begin
|
||||
Result := True;
|
||||
end;
|
||||
|
||||
#include "fay-config-pages.iss"
|
||||
@@ -0,0 +1,73 @@
|
||||
#define MyAppName "Fay"
|
||||
#define MyAppVersion "4.4.4"
|
||||
#define MyAppPublisher "Fay"
|
||||
#define MyAppExeName "fay.exe"
|
||||
|
||||
[Setup]
|
||||
AppId={{8C6DE291-07A5-4D9D-99E5-C0A33B98C781}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
DefaultDirName={localappdata}\Fay
|
||||
DefaultGroupName={#MyAppName}
|
||||
DisableProgramGroupPage=yes
|
||||
OutputDir=..\..\dist\installer
|
||||
OutputBaseFilename=FaySetup-{#MyAppVersion}
|
||||
Compression=lzma2
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
PrivilegesRequired=lowest
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
SetupIconFile=..\..\favicon.ico
|
||||
UninstallDisplayIcon={app}\favicon.ico
|
||||
|
||||
[Languages]
|
||||
Name: "chinesesimplified"; MessagesFile: "compiler:Default.isl,ChineseSimplified.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "创建桌面快捷方式"; GroupDescription: "附加任务:"; Flags: unchecked
|
||||
|
||||
[Dirs]
|
||||
Name: "{app}\logs"; Flags: uninsneveruninstall
|
||||
Name: "{app}\memory"; Flags: uninsneveruninstall
|
||||
Name: "{app}\samples"; Flags: uninsneveruninstall
|
||||
Name: "{app}\cache_data"; Flags: uninsneveruninstall
|
||||
|
||||
[Files]
|
||||
Source: "..\..\dist\fay\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; Excludes: "startup.out,startup.err,logs\*,memory\*,samples\*,cache_data\*,system.conf,system.conf.bak,config.json,cache_data\system.conf,cache_data\config.json,faymcp\data\mcp_servers.json,faymcp\data\mcp_prestart_tools.json,faymcp\data\mcp_tool_states.json"
|
||||
Source: "..\..\dist\fay\memory\fay.db"; DestDir: "{app}\memory"; Flags: ignoreversion onlyifdoesntexist
|
||||
Source: "..\..\dist\fay\memory\user_profiles.db"; DestDir: "{app}\memory"; Flags: ignoreversion onlyifdoesntexist
|
||||
Source: "..\..\dist\fay\faymcp\data\mcp_servers.json"; DestDir: "{app}\faymcp\data"; Flags: ignoreversion onlyifdoesntexist
|
||||
Source: "..\..\dist\fay\faymcp\data\mcp_prestart_tools.json"; DestDir: "{app}\faymcp\data"; Flags: ignoreversion onlyifdoesntexist
|
||||
Source: "..\..\dist\fay\faymcp\data\mcp_tool_states.json"; DestDir: "{app}\faymcp\data"; Flags: ignoreversion onlyifdoesntexist
|
||||
|
||||
[Icons]
|
||||
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Parameters: "{code:GetLaunchParameters}"; WorkingDir: "{app}"
|
||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Parameters: "{code:GetLaunchParameters}"; Tasks: desktopicon; WorkingDir: "{app}"
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#MyAppExeName}"; Parameters: "{code:GetLaunchParameters}"; Description: "安装完成后立即启动 {#MyAppName}"; Flags: nowait postinstall skipifsilent; WorkingDir: "{app}"
|
||||
|
||||
[Code]
|
||||
function InitializeSetup(): Boolean;
|
||||
var
|
||||
Version: TWindowsVersion;
|
||||
begin
|
||||
Result := True;
|
||||
GetWindowsVersionEx(Version);
|
||||
|
||||
if (Version.Major < 6) or ((Version.Major = 6) and (Version.Minor <= 1)) then
|
||||
begin
|
||||
MsgBox(
|
||||
'当前安装包要求 Windows 8 或更高版本。' + #13#10#13#10 +
|
||||
'本正式版基于 Python 3.12 构建,在 Windows 7 / Server 2008 R2 等旧系统上会因为缺少 api-ms-win-core-path-l1-1-0.dll 等依赖而无法启动。' + #13#10#13#10 +
|
||||
'请升级系统,或改用单独的 legacy 安装包。',
|
||||
mbCriticalError,
|
||||
MB_OK
|
||||
);
|
||||
Result := False;
|
||||
end;
|
||||
end;
|
||||
|
||||
#include "fay-config-pages.iss"
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,64 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "tools",
|
||||
"ip": "",
|
||||
"connection_time": "",
|
||||
"key": "",
|
||||
"transport": "stdio",
|
||||
"command": "fay.exe",
|
||||
"args": [
|
||||
"--mcp-stdio-runner",
|
||||
"test/mcp_stdio_example.py"
|
||||
],
|
||||
"cwd": "",
|
||||
"env": {}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Fay日程管理",
|
||||
"ip": "",
|
||||
"connection_time": "",
|
||||
"key": "",
|
||||
"transport": "stdio",
|
||||
"command": "fay.exe",
|
||||
"args": [
|
||||
"--mcp-stdio-runner",
|
||||
"server.py"
|
||||
],
|
||||
"cwd": "mcp_servers/schedule_manager",
|
||||
"env": {}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "window capture",
|
||||
"ip": "",
|
||||
"connection_time": "",
|
||||
"key": "",
|
||||
"transport": "stdio",
|
||||
"command": "fay.exe",
|
||||
"args": [
|
||||
"--mcp-stdio-runner",
|
||||
"mcp_servers/window_capture/server.py"
|
||||
],
|
||||
"cwd": "",
|
||||
"env": {}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "课程知识库",
|
||||
"ip": "",
|
||||
"connection_time": "",
|
||||
"key": "",
|
||||
"transport": "stdio",
|
||||
"command": "fay.exe",
|
||||
"args": [
|
||||
"--mcp-stdio-runner",
|
||||
"mcp_servers/fay_player_knowledge/fay_player_knowledge_base_mcp_server.py",
|
||||
"--source",
|
||||
"./fay_player_knowledge"
|
||||
],
|
||||
"cwd": "",
|
||||
"env": {}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,151 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
RELEASE_DATA_DIR = PROJECT_ROOT / "packaging" / "release" / "faymcp" / "data"
|
||||
BASELINE_PATH = "faymcp/data/mcp_servers.json"
|
||||
PYTHON_COMMANDS = {"python", "python.exe", "pythonw", "pythonw.exe"}
|
||||
NODE_COMMANDS = {"npx", "npx.cmd", "npm", "npm.cmd", "node", "node.exe"}
|
||||
PORTABLE_PYTHON_SCRIPTS = {
|
||||
"test/mcp_stdio_example.py",
|
||||
"mcp_servers/schedule_manager/server.py",
|
||||
"mcp_servers/window_capture/server.py",
|
||||
"mcp_servers/mcp-todo-server/server.py",
|
||||
"mcp_servers/elderly_mcp/server.py",
|
||||
"mcp_servers/fay_player_knowledge/fay_player_knowledge_base_mcp_server.py",
|
||||
}
|
||||
PORTABLE_NODE_PACKAGES = {
|
||||
"@browsermcp/mcp@latest",
|
||||
}
|
||||
ABSOLUTE_PATH_RE = re.compile(r"^(?:[a-zA-Z]:[\\/]|\\\\|/)")
|
||||
|
||||
|
||||
def _run_git(*args: str, check: bool = True) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
["git", *args],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=check,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _load_baseline_text() -> str:
|
||||
for ref in ("HEAD", "origin/main"):
|
||||
try:
|
||||
result = _run_git("show", f"{ref}:{BASELINE_PATH}")
|
||||
if result.stdout.strip():
|
||||
return result.stdout
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return (PROJECT_ROOT / BASELINE_PATH).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _contains_absolute_path(value: Any) -> bool:
|
||||
if isinstance(value, dict):
|
||||
return any(_contains_absolute_path(item) for item in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(_contains_absolute_path(item) for item in value)
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return False
|
||||
return bool(ABSOLUTE_PATH_RE.match(text))
|
||||
|
||||
|
||||
def _normalize_command(command: Any) -> str:
|
||||
text = str(command or "").strip().lower()
|
||||
return Path(text).name.lower()
|
||||
|
||||
|
||||
def _normalize_rel_path(value: Any) -> str:
|
||||
return str(value or "").replace("\\", "/").strip().lower()
|
||||
|
||||
|
||||
def _is_portable_python_server(server: Dict[str, Any]) -> bool:
|
||||
command = _normalize_command(server.get("command"))
|
||||
if command not in PYTHON_COMMANDS:
|
||||
return False
|
||||
if _contains_absolute_path(server.get("cwd")) or _contains_absolute_path(server.get("env")):
|
||||
return False
|
||||
args = server.get("args") or []
|
||||
if _contains_absolute_path(args):
|
||||
return False
|
||||
|
||||
cwd = _normalize_rel_path(server.get("cwd"))
|
||||
for arg in args:
|
||||
if not isinstance(arg, str) or not arg.strip().endswith(".py"):
|
||||
continue
|
||||
arg_path = _normalize_rel_path(arg)
|
||||
if arg_path in PORTABLE_PYTHON_SCRIPTS:
|
||||
return True
|
||||
if cwd and f"{cwd}/{Path(arg_path).name}" in PORTABLE_PYTHON_SCRIPTS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_portable_node_server(server: Dict[str, Any]) -> bool:
|
||||
command = _normalize_command(server.get("command"))
|
||||
if command not in NODE_COMMANDS:
|
||||
return False
|
||||
if any(_contains_absolute_path(server.get(field)) for field in ("args", "cwd", "env")):
|
||||
return False
|
||||
args = [_normalize_rel_path(arg) for arg in (server.get("args") or []) if isinstance(arg, str)]
|
||||
return any(arg in PORTABLE_NODE_PACKAGES for arg in args)
|
||||
|
||||
|
||||
def _rewrite_server(server: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
rewritten = {
|
||||
"id": server.get("id"),
|
||||
"name": server.get("name", ""),
|
||||
"ip": "",
|
||||
"connection_time": "",
|
||||
"key": "",
|
||||
"transport": server.get("transport", "stdio"),
|
||||
"command": server.get("command", ""),
|
||||
"args": list(server.get("args") or []),
|
||||
"cwd": server.get("cwd", "") or "",
|
||||
"env": dict(server.get("env") or {}),
|
||||
}
|
||||
|
||||
if _is_portable_python_server(server):
|
||||
rewritten["command"] = "fay.exe"
|
||||
rewritten["args"] = ["--mcp-stdio-runner"] + list(server.get("args") or [])
|
||||
return rewritten
|
||||
|
||||
if _is_portable_node_server(server):
|
||||
return rewritten
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
baseline_servers = json.loads(_load_baseline_text())
|
||||
release_servers: List[Dict[str, Any]] = []
|
||||
for server in baseline_servers:
|
||||
rewritten = _rewrite_server(server)
|
||||
if rewritten is not None:
|
||||
release_servers.append(rewritten)
|
||||
|
||||
RELEASE_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(RELEASE_DATA_DIR / "mcp_servers.json").write_text(
|
||||
json.dumps(release_servers, ensure_ascii=False, indent=4),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(RELEASE_DATA_DIR / "mcp_prestart_tools.json").write_text("{}", encoding="utf-8")
|
||||
(RELEASE_DATA_DIR / "mcp_tool_states.json").write_text("{}", encoding="utf-8")
|
||||
print(f"Generated release MCP config with {len(release_servers)} servers from Git baseline.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,3 +1,2 @@
|
||||
|
||||
问题,答案,脚本
|
||||
哈哈,哈哈,看来你找到乐子了呀!是不是觉得我刚才的回答像个复读机,有点好玩?
|
||||
|
||||
|
@@ -0,0 +1,32 @@
|
||||
requests
|
||||
numpy
|
||||
pyaudio~=0.2.11
|
||||
websockets~=10.4
|
||||
ws4py~=0.5.1
|
||||
flask~=3.0.0
|
||||
openpyxl~=3.0.9
|
||||
flask_cors~=3.0.10
|
||||
websocket-client
|
||||
azure-cognitiveservices-speech
|
||||
aliyun-python-sdk-core
|
||||
simhash
|
||||
pytz
|
||||
gevent
|
||||
edge_tts
|
||||
pydub
|
||||
tenacity==8.5.0
|
||||
pygame
|
||||
scipy
|
||||
flask-httpauth
|
||||
opencv-python
|
||||
psutil
|
||||
langchain-core==0.2.43
|
||||
langchain_openai==0.1.25
|
||||
packaging==23.2
|
||||
schedule
|
||||
bs4
|
||||
python-docx
|
||||
python-pptx
|
||||
pyqt5==5.15.10
|
||||
pyqtwebengine==5.15.6
|
||||
pyinstaller
|
||||
Binary file not shown.
Binary file not shown.
@@ -3,16 +3,55 @@ import time
|
||||
import threading
|
||||
import wave
|
||||
import ctypes
|
||||
import json
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
import pyaudio
|
||||
import pygame
|
||||
|
||||
|
||||
# 按住键盘 0 键时才拾音并发送给fay;松开则暂停拾音
|
||||
# 按下瞬间自动关闭 Fay 唤醒;松开瞬间:若原本开启则恢复,原本关闭则保持
|
||||
|
||||
is_speaking = False
|
||||
reconnect_event = threading.Event()
|
||||
|
||||
FAY_HOST = "192.168.1.26"
|
||||
FAY_API_PORT = 5000
|
||||
FAY_API_BASE = f"http://{FAY_HOST}:{FAY_API_PORT}"
|
||||
|
||||
|
||||
def get_wake_word_enabled() -> bool:
|
||||
"""查询 Fay 当前唤醒开关状态"""
|
||||
try:
|
||||
req = urllib.request.Request(f"{FAY_API_BASE}/api/get-data", method="POST")
|
||||
with urllib.request.urlopen(req, timeout=3) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
return bool(data.get("config", {}).get("source", {}).get("wake_word_enabled", False))
|
||||
except Exception as exc:
|
||||
print(f"\n[warn] 获取唤醒状态失败: {exc}")
|
||||
return False
|
||||
|
||||
|
||||
def set_wake_word_enabled(enabled: bool) -> bool:
|
||||
"""切换 Fay 唤醒开关"""
|
||||
try:
|
||||
payload = {"config": {"source": {"wake_word_enabled": enabled}}}
|
||||
body = urllib.parse.urlencode({"data": json.dumps(payload)}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{FAY_API_BASE}/api/submit",
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=3).read()
|
||||
print(f"\n[info] 唤醒已{'开启' if enabled else '关闭'}")
|
||||
return True
|
||||
except Exception as exc:
|
||||
print(f"\n[warn] 设置唤醒状态失败: {exc}")
|
||||
return False
|
||||
|
||||
|
||||
def get_stream(device_id: int = 0):
|
||||
paudio = pyaudio.PyAudio()
|
||||
@@ -31,6 +70,8 @@ def send_audio(client: socket.socket):
|
||||
global is_speaking
|
||||
stream = get_stream()
|
||||
user32 = ctypes.windll.user32
|
||||
prev_key_down = False
|
||||
wake_was_enabled = False # 按下瞬间记录的原始唤醒状态
|
||||
while True:
|
||||
if reconnect_event.is_set():
|
||||
break
|
||||
@@ -42,8 +83,22 @@ def send_audio(client: socket.socket):
|
||||
if is_speaking:
|
||||
continue
|
||||
|
||||
# 检测键盘 0 是否按下(虚拟键码 0x30)
|
||||
key_down = bool(user32.GetAsyncKeyState(0x30) & 0x8000)
|
||||
# 检测键盘 0 是否按下:主键盘 0(0x30)、小键盘 0 NumLock 开(VK_NUMPAD0=0x60)、
|
||||
# 小键盘 0 NumLock 关时会被识别为 VK_INSERT(0x2D)
|
||||
key_down = bool(user32.GetAsyncKeyState(0x30) & 0x8000) or \
|
||||
bool(user32.GetAsyncKeyState(0x60) & 0x8000) or \
|
||||
bool(user32.GetAsyncKeyState(0x2D) & 0x8000)
|
||||
|
||||
# 边沿检测:按下瞬间关闭唤醒;松开瞬间若原本开启则恢复
|
||||
if key_down and not prev_key_down:
|
||||
wake_was_enabled = get_wake_word_enabled()
|
||||
if wake_was_enabled:
|
||||
set_wake_word_enabled(False)
|
||||
elif prev_key_down and not key_down:
|
||||
if wake_was_enabled:
|
||||
set_wake_word_enabled(True)
|
||||
prev_key_down = key_down
|
||||
|
||||
if not key_down:
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
@@ -100,7 +155,7 @@ def receive_audio(client: socket.socket):
|
||||
|
||||
if __name__ == "__main__":
|
||||
pygame.mixer.init()
|
||||
server_addr = ("127.0.0.1", 10001)
|
||||
server_addr = ("192.168.1.26", 10001)
|
||||
|
||||
while True:
|
||||
reconnect_event.clear()
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
[key]
|
||||
#funasr / ali / sensevoice
|
||||
#建议使用funasr,请依照asr/funasr/README>md的说明启动
|
||||
ASR_mode = funasr
|
||||
asr_mode = funasr
|
||||
#ASR二选一(需要运行fay/test/funasr服务)集成达摩院asr项目、感谢中科大脑算法工程师张聪聪提供集成代码
|
||||
local_asr_ip=127.0.0.1
|
||||
local_asr_port=10197
|
||||
|
||||
+23
-13
@@ -8,32 +8,46 @@ from threading import Lock
|
||||
import threading
|
||||
from utils import util
|
||||
|
||||
|
||||
def _boot_log(level, text):
|
||||
"""启动早期安全日志。
|
||||
|
||||
config_util 与 util 存在相互 import:若本模块在 util 尚未完成初始化时
|
||||
被导入(util.py 会在定义 log() 之前 import config_util),此处直接调用
|
||||
util.log 会因 log 尚未定义而抛 AttributeError,进而使整个进程启动失败。
|
||||
这里做一次容错:util.log 不可用时回退到 print,避免循环导入把启动打挂。
|
||||
"""
|
||||
try:
|
||||
util.log(level, text)
|
||||
except Exception:
|
||||
print(text)
|
||||
|
||||
# 条件导入 langsmith
|
||||
try:
|
||||
# 检查是否有相关环境变量或包可用
|
||||
langsmith_env_vars = ['LANGCHAIN_API_KEY', 'LANGSMITH_API_KEY', 'LANGCHAIN_TRACING_V2']
|
||||
has_langsmith_env = any(os.getenv(var) for var in langsmith_env_vars)
|
||||
|
||||
|
||||
if has_langsmith_env:
|
||||
from langsmith.schemas import Feedback
|
||||
util.log(1, "检测到 LangSmith 环境变量,已导入 langsmith.schemas.Feedback")
|
||||
_boot_log(1, "检测到 LangSmith 环境变量,已导入 langsmith.schemas.Feedback")
|
||||
else:
|
||||
# 尝试导入以检查包是否可用
|
||||
import langsmith.schemas
|
||||
from langsmith.schemas import Feedback
|
||||
util.log(1, "langsmith 包可用,已导入 langsmith.schemas.Feedback")
|
||||
_boot_log(1, "langsmith 包可用,已导入 langsmith.schemas.Feedback")
|
||||
except ImportError:
|
||||
# langsmith 包不可用,定义一个占位符类
|
||||
class Feedback:
|
||||
"""langsmith 不可用时的占位符类"""
|
||||
pass
|
||||
util.log(2, "langsmith 包不可用,使用占位符类。如需使用 LangSmith 功能,请安装: pip install langsmith")
|
||||
_boot_log(2, "langsmith 包不可用,使用占位符类。如需使用 LangSmith 功能,请安装: pip install langsmith")
|
||||
except Exception as e:
|
||||
# 其他导入错误
|
||||
class Feedback:
|
||||
"""langsmith 导入失败时的占位符类"""
|
||||
pass
|
||||
util.log(2, f"langsmith 导入失败: {str(e)},使用占位符类")
|
||||
_boot_log(2, f"langsmith 导入失败: {str(e)},使用占位符类")
|
||||
|
||||
# 线程本地存储,用于支持多个项目配置
|
||||
_thread_local = threading.local()
|
||||
@@ -63,9 +77,8 @@ key_gpt_api_key = None
|
||||
gpt_model_engine = None
|
||||
proxy_config = None
|
||||
ASR_mode = None
|
||||
local_asr_ip = None
|
||||
local_asr_port = None
|
||||
ltp_mode = None
|
||||
local_asr_ip = None
|
||||
local_asr_port = None
|
||||
gpt_base_url = None
|
||||
big_model_engine = None
|
||||
big_model_base_url = None
|
||||
@@ -122,7 +135,7 @@ def _warn_public_config_once():
|
||||
|
||||
# config server中心配置,system.conf与config.json存在时不会使用配置中心
|
||||
CONFIG_SERVER = {
|
||||
'BASE_URL': 'http://1.12.69.110:5500', # 默认API服务器地址
|
||||
'BASE_URL': 'http://124.220.24.49:5500', # 默认API服务器地址
|
||||
'API_KEY': 'your-api-key-here', # 默认API密钥
|
||||
'PROJECT_ID': 'd19f7b0a-2b8a-4503-8c0d-1a587b90eb69' # 项目ID,需要在使用前设置
|
||||
}
|
||||
@@ -301,9 +314,8 @@ def load_config(force_reload=False):
|
||||
global gpt_model_engine
|
||||
global proxy_config
|
||||
global ASR_mode
|
||||
global local_asr_ip
|
||||
global local_asr_ip
|
||||
global local_asr_port
|
||||
global ltp_mode
|
||||
global gpt_base_url
|
||||
global big_model_engine
|
||||
global big_model_base_url
|
||||
@@ -531,7 +543,6 @@ def load_config(force_reload=False):
|
||||
local_asr_ip = system_config.get('key', 'local_asr_ip', fallback=None)
|
||||
local_asr_port = system_config.get('key', 'local_asr_port', fallback=None)
|
||||
proxy_config = system_config.get('key', 'proxy_config', fallback=None)
|
||||
ltp_mode = system_config.get('key', 'ltp_mode', fallback=None)
|
||||
gpt_base_url = system_config.get('key', 'gpt_base_url', fallback=None)
|
||||
big_model_engine = system_config.get('key', 'big_model_engine', fallback=None) or None
|
||||
big_model_base_url = system_config.get('key', 'big_model_base_url', fallback=None) or None
|
||||
@@ -590,7 +601,6 @@ def load_config(force_reload=False):
|
||||
'local_asr_ip': local_asr_ip,
|
||||
'local_asr_port': local_asr_port,
|
||||
'proxy_config': proxy_config,
|
||||
'ltp_mode': ltp_mode,
|
||||
|
||||
'gpt_base_url': gpt_base_url,
|
||||
'tts_module': tts_module,
|
||||
|
||||
Reference in New Issue
Block a user