diff --git a/README.md b/README.md new file mode 100644 index 0000000..8b8e88c --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +# AI 助手项目 + +这是一个基于 Google Gemini API 构建的 AI 助手项目,旨在提供一个交互式的聊天界面,支持多轮对话、记忆管理和可配置的系统指令。 + +## 项目结构 + +``` +. +├── app.py # 主应用程序入口,处理后端逻辑和API路由 +├── config.ini # 配置文件,用于存储API密钥、模型设置、文件路径等 +├── config.py # 配置加载和管理模块 +├── file_manager.py # 文件操作工具,用于读写JSON、管理目录等 +├── gemini_client.py # Gemini API 客户端,处理与Google Gemini API的交互 +├── memory_manager.py # 记忆管理模块,处理短期和长期记忆的存储与检索 +├── README.md # 项目说明文件 +├── requirements.txt # Python依赖列表 +├── utils.py # 常用工具函数,如日志配置、时间处理等 +├── data/ +│ ├── chat_logs/ # 存储聊天记录 +│ ├── features/ # 存储角色特性(例如:default_role/features.json) +│ └── memories/ # 存储记忆数据(例如:default_memory/memory.json) +└── static/ + ├── index.html # 前端主页面 + ├── css/ + │ ├── modern-style.css # 现代风格CSS + │ └── style.css # 主要CSS样式 + ├── js/ + │ ├── api.js # 前端API调用逻辑 + │ ├── app.js # 前端主应用逻辑 + │ ├── ui_manager.js # 前端UI管理逻辑 + │ └── lib/ # 前端第三方库 + │ ├── marked.min.js # Markdown解析库 + │ └── purify.min.js # HTML净化库 +``` + +## 功能特性 + +* 多轮对话: 支持与AI进行连续的多轮对话,保持上下文。 +* 记忆管理: + * 短期记忆: 存储最近的对话轮次,用于维持当前对话的连贯性。 + * 长期记忆: 通过摘要和嵌入技术,将关键信息存储为长期记忆,以便在后续对话中检索和利用。 +* 可配置性: + * 通过 `config.ini` 文件轻松配置 Gemini API 密钥、默认模型、上下文窗口大小、记忆保留轮次等。 + * 支持从环境变量加载 `GEMINI_API_KEY`。 +* 流式响应: 支持 Gemini API 的流式响应,提供更流畅的用户体验。 +* 前端界面: 提供一个简洁的Web界面,方便用户与AI助手交互。 +* 日志记录: 详细的日志记录,便于调试和问题追踪。 + +## 安装与运行 + +### 1. 克隆仓库 + +```bash +git clone https://github.com/your-username/your-repo-name.git +cd your-repo-name +``` +(请将 `your-username/your-repo-name.git` 替换为实际的仓库地址) + +### 2. 创建并激活虚拟环境 (推荐) + +```bash +python -m venv venv +# Windows +.\venv\Scripts\activate +# macOS/Linux +source venv/bin/activate +``` + +### 3. 安装依赖 + +```bash +pip install -r requirements.txt +``` + +### 4. 配置 API 密钥 + +在项目根目录下找到 `config.ini` 文件。 +将 `[API]` 部分的 `GEMINI_API_KEY` 替换为你的 Google Gemini API 密钥。 + +```ini +[API] +GEMINI_API_KEY = YOUR_GEMINI_API_KEY_HERE +``` +或者,你可以设置环境变量 `GEMINI_API_KEY`。环境变量的优先级高于 `config.ini` 文件中的配置。 + +### 5. 运行应用程序 + +```bash +python app.py +``` + +应用程序将在本地启动一个Web服务。通常,你可以在浏览器中访问 `http://127.0.0.1:5000` 来使用AI助手。 + +## 配置说明 + +项目的主要配置通过 `config.ini` 文件管理。以下是各个配置项的详细说明: + +### [API] 部分 + +* `gemini_api_base_url`: Gemini API 的基础 URL。如果使用代理或自定义端点,请修改此项。 +* `gemini_api_key`: 你的 Google Gemini API 密钥。建议通过环境变量 `GEMINI_API_KEY` 设置,优先级更高。 +* `default_gemini_model`: 默认使用的 Gemini 模型,例如 `models/gemini-2.0-flash`。 +* `memory_update_model`: 用于记忆更新(摘要和嵌入)的 Gemini 模型。 + +### [Application] 部分 + +* `context_window_size`: 短期记忆中保留的对话轮次数量,用于维持当前对话的上下文。 +* `memory_retention_turns`: 长期记忆更新的频率,每隔多少轮对话进行一次记忆摘要和存储。 +* `max_short_term_events`: 短期记忆中最大事件(消息)数量。 +* `features_dir`: 存储角色特性配置文件的目录路径。 +* `memories_dir`: 存储长期记忆数据文件的目录路径。 +* `chat_logs_dir`: 存储聊天记录文件的目录路径。 +* `app_log_file_path`: 应用程序日志文件的路径。 + +### [Session] 部分 + +* `current_role_id`: 当前会话使用的角色ID。 +* `current_memory_id`: 当前会话使用的记忆ID。 +* `current_chat_log_id`: 当前会话的聊天记录ID。 + +## 使用说明 + +在Web界面中输入你的问题或指令,AI助手将根据你的输入和历史对话进行回复。 + +## 贡献 + +欢迎贡献!如果你有任何改进建议或发现bug,请提交Issue或Pull Request。 + +## 许可证 + +本项目采用 MIT 许可证。详见 `LICENSE` 文件 (如果存在)。 diff --git a/memory_manager.py b/memory_manager.py new file mode 100644 index 0000000..229375b --- /dev/null +++ b/memory_manager.py @@ -0,0 +1,370 @@ +# memory_manager.py + +import json +import logging +import uuid # 用于生成短期事件的唯一ID +from config import get_config +import file_manager # 导入文件管理模块来加载/保存features和memory + +logger = logging.getLogger(__name__) + + +def build_system_prompt(current_role_id, current_memory_id, current_global_turn_count): + """ + 构建并返回当前会话的系统提示词。 + 这个提示词会包含AI的静态特征和动态记忆。 + :param current_role_id: 当前激活的角色的ID。 + :param current_memory_id: 当前激活的记忆集的ID。 + :param current_global_turn_count: 当前对话的总轮次,用于短期记忆的“last_active_turn”参考。 + :return: 组合好的系统提示词字符串。 + """ + try: + # 1. 加载当前角色的特征数据 + features_data = file_manager.load_active_features(current_role_id) + config_data = get_config()["Session"] + default_role_name = config_data.get("DEFAULT_ROLE_NAME", "通用AI助手") + default_memory_name = config_data.get("DEFAULT_MEMORY_NAME", "通用记忆集") + + if not features_data: + logger.warning(f"无法加载角色 '{current_role_id}' 的特征,将使用默认特征。") + features_data = file_manager.DEFAULT_FEATURE_CONTENT.copy() + features_data["角色名称"] = default_role_name + + # 2. 加载当前记忆集的记忆数据 + memory_data = file_manager.load_active_memory(current_memory_id) + if not memory_data: + logger.warning( + f"无法加载记忆集 '{current_memory_id}' 的记忆,将使用默认空记忆。" + ) + memory_data = file_manager.DEFAULT_MEMORY_CONTENT.copy() + memory_data["name"] = default_memory_name + if memory_data["long_term_facts"]: + memory_data["long_term_facts"][ + 0 + ] = f"此记忆集名为 '{default_memory_name}',目前未包含特定用户或AI的长期事实。" + else: + memory_data["long_term_facts"].append( + f"此记忆集名为 '{default_memory_name}',目前未包含特定用户或AI的长期事实。" + ) + + # 将 Python 对象转换为美化后的 JSON 字符串,确保中文不被转义 + features_json_str = json.dumps(features_data, ensure_ascii=False, indent=2) + memory_json_str = json.dumps(memory_data, ensure_ascii=False, indent=2) + + # 组合最终的系统提示词 + # 增加一些说明性文字,帮助模型更好理解传入的JSON结构 + system_prompt = ( + f"以下是你的语言和思维特征,请你严格遵循,并基于以下记忆内容回答问题。\n\n" + f"--- 语言和思维特征 (Features) ---\n" + f"{features_json_str}\n\n" + f"--- 当前记忆 (Memory) ---\n" + f"以下是关于你的长期记忆和短期事件的最新整理,请你充分利用这些信息来理解用户意图、提供个性化回复和维持对话连贯性。\n" + f"当前总对话轮次: {current_global_turn_count}\n" # 传递当前轮次给模型,作为last_active_turn的参考 + f"{memory_json_str}\n\n" + f"请记,你的所有回答都应体现并遵循上述特征和记忆。你是一个名为“{features_data.get('角色名称', default_role_name)}”的智能体。始终以专业、清晰、友好的态度进行交流,并优先利用现有记忆来提供更个性化和连贯的回答。" + ) + logger.debug("系统提示词构建成功。") + return system_prompt + except Exception as e: + logger.error(f"构建系统提示词时发生错误: {e}", exc_info=True) + # 降级为通用提示词,并从config中获取默认角色名称 + config_data = get_config()["Session"] + default_role_name = config_data.get("DEFAULT_ROLE_NAME", "通用AI助手") + return f"你是一个名为“{default_role_name}”的AI助手,旨在提供清晰、准确、友好的回答。" + + +def build_memory_update_prompt( + recent_chat_history_list, current_memory_data, current_global_turn_count +): + """ + 构建用于记忆更新的Prompt (V3)。 + :param recent_chat_history_list: 最近N轮对话的列表 (Python list of dicts)。 + :param current_memory_data: 当前memory.json内容的字典 (Python dict)。 + :param current_global_turn_count: 当前全局对话的总轮次。 + :return: 记忆更新指令字符串。 + """ + config = get_config()["Application"] + memory_retention_turns = config["MEMORY_RETENTION_TURNS"] + + # 将Python对象转换为JSON字符串,用于嵌入Prompt + recent_chat_history_json_str = json.dumps( + recent_chat_history_list, ensure_ascii=False, indent=2 + ) + current_memory_json_str = json.dumps( + current_memory_data, ensure_ascii=False, indent=2 + ) + + prompt = ( + "你是一个高级记忆整合与管理系统。你的任务是根据提供的现有记忆和最近的对话," + "分析其中的关键信息、用户偏好、重要事件、对话中的新概念以及话题状态。\n" + "请你严格按照以下JSON格式和更新规则,输出更新后的长短期记忆。\n\n" + "**当前全局对话轮次:** {current_global_turn_count}\n" + "**短期记忆保留阈值:** 超过 {memory_retention_turns} 轮未活跃的短期事件应被考虑移除或晋升。\n\n" + "**更新规则:**\n" + "1. **长期事实 (long_term_facts):**\n" + " - 包含所有对用户或AI角色长期重要、不变、或已确认的关键信息和事实(例如用户名字、长期兴趣、职业、已完成的重要事项等)。\n" + " - **晋升机制:** 从最近对话和短期事件中识别出新的、应晋升为长期记忆的关键事实。判断标准包括:信息被重复提及、用户明确确认、对用户身份或偏好有根本性影响、或事件已圆满结束且其核心结果是永久性的。请将其添加到此列表中。\n" + " - 如果现有长期事实有新的细节补充或需要修正,请更新其内容。\n" + " - 请确保每条事实简洁明了,通常为一句话或一个关键事实片段。避免冗余。\n" + "2. **短期事件 (short_term_events):**\n" + " - 这是一个列表,每个元素代表一个正在进行、最近发生、或仍在讨论中的短期事件或话题。\n" + " - **字段要求:** 每个短期事件对象必须包含以下字段:\n" + " - `id`: (字符串) 唯一标识符。如果是一个新事件,请生成一个简短且具有描述性的ID (例如 'AI_ethics_discussion', 'phone_recommendation'),要求英文小写,下划线分隔,最大长度30字符。如果模型没有生成ID,或ID不符合格式,后端会为其生成一个UUID。\n" + " - `topic`: (字符串) 事件或话题的核心内容,简洁概括,最多20字。\n" + " - `summary`: (字符串) 对事件的开始、发展和当前状态(结果)的清晰、简洁描述。请涵盖关键的细节和进展。总结应不超过100字。\n" + " - `status`: (字符串) 事件的当前状态,请选择以下之一:'进行中', '待跟进', '已解决', '已结束'。\n" + " - `last_active_turn`: (整数) 最近一次该事件或话题被讨论或有实质进展的全局对话轮次编号(基于`current_global_turn_count`更新)。\n" + " - `mentions_count`: (整数) 该事件或话题被明确提及或有实质进展的累积次数。新事件从1开始。\n" + " - **更新逻辑:**\n" + " - **新增:** 从最近对话中识别出新的、重要的短期事件,并添加到列表中。为其生成一个临时的、描述性的`id`,`last_active_turn`设为当前全局轮次,`mentions_count`设为1。\n" + " - **更新:** 对于已存在的短期事件,根据最近对话更新其`summary`, `status`。如果它在最近对话中被提及,请更新其`last_active_turn`为当前全局轮次,并增加`mentions_count`。\n" + " - **遗忘/删除:** 如果短期事件中的信息已完全整合进长期事实,或该事件明确已解决(`status`为'已解决'或'已结束')且无后续,或者其`last_active_turn`距离`current_global_turn_count`过远(超过`memory_retention_turns`),请将其从`short_term_events`列表中移除。\n" + " - **避免重复:** 确保`long_term_facts`和`short_term_events`中的信息不冗余。短期事件应侧重于动态和未完成部分,长期事实则侧重于稳定和核心信息。\n" + " - **保持精简:** `short_term_events`列表的长度应控制在合理范围,优先保留最重要的事件。可以删除重要性较低的已结束或不活跃事件。\n\n" + "--- 现有记忆 (Current Memory JSON) ---\n" + f"{current_memory_json_str}\n\n" + "--- 最近对话 (Recent Chat History JSON - 格式: [{'role': '...', 'parts': [{'text': '...'}]}]) ---\n" + f"{recent_chat_history_json_str}\n\n" + "请现在根据上述信息和规则,输出一个仅包含 'long_term_facts' 和 'short_term_events' 两个键的JSON对象。请严格遵循JSON格式,不要包含其他任何文本或解释。" + "```json\n" + "{\n" + ' "long_term_facts": [\n' + ' "用户名是张三。",\n' + ' "张三对科幻电影感兴趣,喜欢《银翼杀手》。"\n' + " ],\n" + ' "short_term_events": [\n' + " {\n" + ' "id": "phone_recommendation",\n' + ' "topic": "新手机推荐需求收集",\n' + ' "summary": "用户对安卓手机有偏好,预算4000-6000元,看重拍照和续航。",\n' + ' "status": "待跟进",\n' + ' "last_active_turn": 10,\n' + ' "mentions_count": 3\n' + " },\n" + " {\n" + ' "id": "weekend_plan_discussion",\n' + ' "topic": "周末活动计划",\n' + ' "summary": "用户提到周末想去公园散步,但天气预报有雨,正在考虑备选方案。",\n' + ' "status": "进行中",\n' + ' "last_active_turn": 12,\n' + ' "mentions_count": 1\n' + " }\n" + " ]\n" + "}\n" + "```\n" + ) + logger.debug("记忆更新提示词构建成功。") + return prompt + + +def process_memory_update_response( + model_response_text, current_memory_data, current_global_turn_count +): + """ + 处理Gemini模型返回的记忆更新JSON字符串,并进行后端逻辑修正和合并。 + :param model_response_text: Gemini模型返回的JSON字符串。 + :param current_memory_data: 当前内存中的记忆数据 (Python dict)。 + :param current_global_turn_count: 当前全局对话轮次。 + :return: 更新后的记忆数据 (Python dict)。 + """ + config = get_config()["Application"] + memory_retention_turns = config["MEMORY_RETENTION_TURNS"] + max_short_term_events = config["MAX_SHORT_TERM_EVENTS"] + + updated_memory = current_memory_data.copy() # 复制当前记忆作为基准 + + try: + # 尝试解析模型返回的JSON + # 有时模型会返回Markdown代码块,需要先提取 + if model_response_text.strip().startswith("```json"): + model_response_text = model_response_text.strip()[ + 7:-3 + ].strip() # 移除 ```json 和 ``` + + parsed_response = json.loads(model_response_text) + logger.debug(f"模型返回的记忆更新JSON已解析:{parsed_response}") + + # 1. 更新长期事实 (long_term_facts) + if "long_term_facts" in parsed_response and isinstance( + parsed_response["long_term_facts"], list + ): + updated_memory["long_term_facts"] = list( + set(parsed_response["long_term_facts"]) + ) # 去重 + logger.info( + f"长期事实已更新,共 {len(updated_memory['long_term_facts'])} 条。" + ) + else: + logger.warning("模型返回的长期事实格式不正确或缺失,保留原有长期事实。") + + # 2. 合并和处理短期事件 (short_term_events) + model_short_term_events = parsed_response.get("short_term_events", []) + if not isinstance(model_short_term_events, list): + logger.warning("模型返回的短期事件格式不正确,将忽略。") + model_short_term_events = [] + + existing_events_map = { + event["id"]: event + for event in current_memory_data.get("short_term_events", []) + if "id" in event + } + new_short_term_events_list = [] + + # 遍历模型返回的短期事件 + for event in model_short_term_events: + # 确保事件包含必要字段 + if not all( + k in event + for k in [ + "topic", + "summary", + "status", + "last_active_turn", + "mentions_count", + ] + ): + logger.warning(f"模型返回的短期事件缺少必要字段,跳过: {event}") + continue + + event_id = event.get("id") + is_new_event = False + final_event_id = None + + # 尝试使用模型提供的ID + if event_id and isinstance(event_id, str): + # 验证ID格式:英文小写,下划线分隔,最大长度30字符 + if ( + len(event_id) <= 30 + and all(c.isalnum() or c == "_" for c in event_id) + and event_id.islower() + ): + if event_id not in existing_events_map: + # 模型提供了有效且不冲突的ID + final_event_id = event_id + is_new_event = True + logger.debug(f"使用模型建议的有效新ID: {event_id}") + else: + # ID冲突,这是现有事件的更新 + final_event_id = event_id + logger.debug( + f"更新现有短期事件: {event.get('topic', '未知')} (ID: {event_id})" + ) + else: + logger.warning( + f"模型建议的短期事件ID '{event_id}' 格式不正确,将尝试生成新ID。" + ) + + # 如果模型ID无效或冲突,尝试基于topic生成 + if final_event_id is None and event.get("topic"): + temp_id = "".join( + c if c.isalnum() else "_" for c in event["topic"] + ).lower() + temp_id = temp_id.strip("_") + if len(temp_id) > 30: + temp_id = temp_id[:30] + + # 确保生成的ID是唯一的 + if temp_id and temp_id not in existing_events_map: + final_event_id = temp_id + is_new_event = True + logger.debug(f"基于topic生成描述性新ID: {final_event_id}") + else: + logger.warning( + f"基于topic '{event['topic']}' 生成的ID '{temp_id}' 已存在或无效,将生成UUID。" + ) + + # 如果所有尝试都失败,生成UUID + if final_event_id is None: + final_event_id = str(uuid.uuid4()) + is_new_event = True + logger.debug(f"无法生成描述性ID,生成新UUID: {final_event_id}") + + event["id"] = final_event_id + + if is_new_event: + # 确保新事件的活跃轮次和提及次数正确初始化 + event["last_active_turn"] = current_global_turn_count + event["mentions_count"] = 1 + logger.info(f"发现新短期事件: {event['topic']} (ID: {event['id']})") + else: + # 现有事件:更新其活跃轮次和提及次数 + event["last_active_turn"] = current_global_turn_count + # 从 existing_events_map 中获取旧的 mentions_count + old_event = existing_events_map.get(event["id"]) + if old_event and "mentions_count" in old_event: + event["mentions_count"] = old_event["mentions_count"] + 1 + else: + event["mentions_count"] = ( + 1 # 如果旧事件没有 mentions_count,则初始化为1 + ) + logger.debug(f"更新现有短期事件: {event['topic']} (ID: {event['id']})") + + new_short_term_events_list.append(event) + # 从现有事件映射中移除,以便后续处理未被模型提及的旧事件 + if final_event_id in existing_events_map: + del existing_events_map[final_event_id] + + # 将模型未提及但仍然活跃的旧事件添加回来 (后端辅助的遗忘机制) + for old_event_id, old_event in existing_events_map.items(): + if not all( + k in old_event + for k in [ + "topic", + "summary", + "status", + "last_active_turn", + "mentions_count", + ] + ): + logger.warning(f"跳过损坏的旧短期事件: {old_event}") + continue + + # 判断是否需要遗忘 + if current_global_turn_count - old_event[ + "last_active_turn" + ] > memory_retention_turns and old_event["status"] in ["已解决", "已结束"]: + logger.info( + f"遗忘旧短期事件 (不活跃且已解决/结束): {old_event['topic']} (ID: {old_event['id']})" + ) + continue # 不添加到新列表中,实现遗忘 + + new_short_term_events_list.append(old_event) + logger.debug( + f"保留旧短期事件 (仍活跃或未解决): {old_event['topic']} (ID: {old_event['id']})" + ) + + # 强制截断短期事件列表,确保不超过最大数量 + if len(new_short_term_events_list) > max_short_term_events: + # 优先保留最新的和提及次数多的 + new_short_term_events_list.sort( + key=lambda x: ( + x.get("last_active_turn", 0), + x.get("mentions_count", 0), + ), + reverse=True, + ) + removed_count = len(new_short_term_events_list) - max_short_term_events + logger.warning( + f"短期事件列表超出 {max_short_term_events} 个,移除 {removed_count} 个最不活跃的事件。" + ) + new_short_term_events_list = new_short_term_events_list[ + :max_short_term_events + ] + + updated_memory["short_term_events"] = new_short_term_events_list + logger.info( + f"短期事件已处理,共 {len(updated_memory['short_term_events'])} 条。" + ) + + except json.JSONDecodeError as e: + logger.error( + f"模型返回的记忆更新响应不是有效JSON,无法解析: {model_response_text[:200]}... - 错误: {e}" + ) + # 返回原始记忆,不进行更新 + return current_memory_data + except Exception as e: + logger.error(f"处理记忆更新响应时发生未知错误: {e}", exc_info=True) + # 返回原始记忆,不进行更新 + return current_memory_data + + return updated_memory diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..36b411b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Flask +requests diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..fe42a2a --- /dev/null +++ b/utils.py @@ -0,0 +1,68 @@ +# utils.py + +import logging +import uuid +import os +import datetime + + +def setup_logging(log_file_path): + """ + 配置应用程序的日志系统。 + 日志将输出到控制台和指定的日志文件。 + :param log_file_path: 日志文件的完整路径。 + """ + # 确保日志目录存在 + log_dir = os.path.dirname(log_file_path) + if not os.path.exists(log_dir): + os.makedirs(log_dir) + + # 创建一个根日志器 + logger = logging.getLogger() + logger.setLevel(logging.INFO) # 设置最低日志级别为INFO + + # 移除已有的处理器,防止重复日志 + if logger.handlers: + for handler in logger.handlers[:]: + logger.removeHandler(handler) + + # 创建一个文件处理器,用于将日志写入文件 + file_handler = logging.FileHandler(log_file_path, encoding="utf-8") + file_handler.setLevel(logging.INFO) # 文件处理器级别为INFO + + # 创建一个控制台处理器,用于将日志输出到控制台 + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) # 控制台处理器级别为INFO + + # 定义日志格式 + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + file_handler.setFormatter(formatter) + console_handler.setFormatter(formatter) + + # 将处理器添加到日志器 + logger.addHandler(file_handler) + logger.addHandler(console_handler) + + logging.info(f"日志系统已配置,日志将写入 {log_file_path}") + + +def generate_uuid(): + """ + 生成一个基于时间戳和随机数的唯一UUID字符串。 + :return: 32位小写UUID字符串。 + """ + # UUID4是随机生成,适合作为唯一ID + return str(uuid.uuid4()) + + +def get_current_timestamp(): + """ + 获取当前时间的标准格式字符串。 + :return: 例如 "2023-10-27 10:00:00" + """ + return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +# 可以定义一个通用的错误处理装饰器或其他实用函数