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:
guo zebin
2026-08-06 19:23:44 +08:00
parent 2462bd269e
commit 74b49ae41c
39 changed files with 5873 additions and 4164 deletions
+2
View File
@@ -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
View File
@@ -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"
}
}
}
-2
View File
@@ -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
View File
@@ -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
View File
@@ -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(默认 16kHzASR 要求)
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
View File
@@ -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. 解析 JSONtool → 继续 / 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)、希望工具循环不阻塞用户
+109
View File
@@ -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 命名空间约定。任何外部 agentClaude 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 工具,外部 agentClaude 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 Codeharness 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 让你的数字人活得更长、更真。
+505
View File
@@ -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.4breakout 策略买入信号触发",
"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"
]
}
```
#### 事例 GFay 自动生成的反思(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. **锁外** 算 importanceLLM 评分 010)和 embeddingAPI 向量)
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_*` 工具。
#### 例 5Claude 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.4breakout 策略买入信号触发",
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)
```
#### 例 9Cursor 修 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` |
+17
View File
@@ -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
+189
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
}
}
{}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 的服务器
+8 -4
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+57 -62
View File
@@ -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
+115
View File
@@ -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
+344
View File
@@ -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())
+50
View File
@@ -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"
}
}
+62
View File
@@ -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_urlkey 始终复用 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
+6
View File
@@ -0,0 +1,6 @@
"""Runtime hook: 阻止 tenacity 导入 tornadoFay 不需要 tornado)。
tenacity.__init__ 执行前 tornado 注册为不可导入
使 tenacity 走到 `except ImportError: tornado = None` 分支
"""
import sys
sys.modules["tornado"] = None
+419
View File
@@ -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=简体中文翻译由 Kira847320916@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您要继续吗?
+387
View File
@@ -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/v1proxy_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('webWeb 界面模式,推荐)');
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;
+58
View File
@@ -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"
+73
View File
@@ -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 @@
{}
+151
View File
@@ -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
View File
@@ -1,3 +1,2 @@
问题,答案,脚本
哈哈,哈哈,看来你找到乐子了呀!是不是觉得我刚才的回答像个复读机,有点好玩?
1 问题,答案,脚本 问题 答案 脚本
2 哈哈,哈哈,看来你找到乐子了呀!是不是觉得我刚才的回答像个复读机,有点好玩?
+32
View File
@@ -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.
+58 -3
View File
@@ -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 是否按下:主键盘 00x30)、小键盘 0 NumLock 开(VK_NUMPAD0=0x60
# 小键盘 0 NumLock 关时会被识别为 VK_INSERT0x2D
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
View File
@@ -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
View File
@@ -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,