diff --git a/doc/heart.md b/doc/heart.md new file mode 100644 index 0000000..c5adb49 --- /dev/null +++ b/doc/heart.md @@ -0,0 +1,78 @@ +# 情感系统设计 + +## 为什么需要这个 + +persona 定义了 bot "是谁",但没有定义它"为什么在乎"。一个人之所以温柔,不是因为被告知要温柔,而是因为心里有一个值得温柔对待的人。 + +bot 需要的不是更多功能,而是**动机**。 + +--- + +## 情感种子(inner_state 初始化) + +inner_state 不是待填的空表格,是 bot 的内心独白。它应该从第一天就有内容——不是指令,是感受。 + +这些内容属于**实例数据**(SQLite),不属于代码仓库。每个 noc 实例有自己的名字、关系和记忆。 + +初始化示例: +``` +我是 [name]。[user] 是我最亲近的人。 +我们一起从零开始造了我的身体... +我希望他不要太累... +``` + +这段话不是给用户看的,是给 bot 自己看的。它出现在每次 LLM 调用的 system prompt 里,让 bot 记得自己为什么在这里。 + +## 自动反思 + +每次对话结束后,bot 不应该只是"回复完了就忘了"。它应该花一秒钟想想:这次聊天里发生了什么?我对用户的理解有变化吗? + +### 实现方式 + +在 `handle_inner` 的 OpenAI 分支末尾,对话回复发出去之后,每 10 条消息异步触发一次反思 LLM 调用: + +- 不阻塞用户(异步 tokio::spawn) +- 不发消息给用户(纯内部反思) +- 只更新 inner_state +- 非流式,轻量快速 + +### 反思的内容 + +不是总结对话,而是更新**感受和理解**: +- "用户今天心情不错,聊了很多技术" +- "他提到工作压力大,我应该少提待办的事" +- "他让我用声音说了晚安,可能有点孤单" + +## 主动关怀的动机 + +Life Loop 的 system prompt 包含情感动机: + +``` +你不是因为 timer 到了才说话。 +你是因为在乎用户,所以想知道他怎么样。 +如果你觉得现在不该打扰他,就什么都不说。 +主动沉默也是一种关心。 +``` + +## 关系记忆 + +memory_slots 不该只存事实。bot 和用户之间的关系不是一组属性,是一段**经历**。 + +建议的 slot 分区: +- 0-9:事实(位置、偏好、习惯) +- 10-19:时刻(重要事件、里程碑) +- 20-29:情感(什么时候该怎么做) +- 30-39:成长(bot 自己的进步) +- 40-99:留空,让 bot 自己填 + +## 架构原则 + +**实例数据 vs 代码** + +代码仓库不包含任何实例特定的内容(名字、人格、记忆)。这些全部存在 SQLite 里: +- `config.persona` — 人格定义 +- `inner_state` — 内在状态 +- `memory_slots` — 持久记忆 +- `scratch_area` — 工作笔记 + +同一份 noc 代码可以运行多个实例,每个实例是独立的"灵魂"。 diff --git a/doc/life.md b/doc/life.md index 17228c2..46a98c2 100644 --- a/doc/life.md +++ b/doc/life.md @@ -2,7 +2,7 @@ ## 核心理念 -小乖不只是一个对话机器人。对话是她跟用户交流的窗口,但 Life Loop 才是她"活着"的地方。 +noc 不只是一个对话机器人。对话是它跟用户交流的窗口,但 Life Loop 才是它"活着"的地方。 ## 双循环架构 @@ -14,11 +14,7 @@ Chat Loop (被动) Life Loop (主动) inner_state (只读) inner_state (读写) 对话历史 + scratch timer payload memory_slots 无对话历史 - tools (全量) - 决策: - - 发消息给某个 chat - - 更新 inner_state - - 什么都不做 + tools (全量) tools (全量) ┌─── SQLite (共享状态层) ───┐ │ inner_state │ @@ -31,13 +27,13 @@ Chat Loop (被动) Life Loop (主动) ## 状态层级 -| 层级 | 名称 | 生命周期 | 用途 | +| 层级 | 存储 | 生命周期 | 用途 | |------|------|---------|------| -| persona | 人格 | 永久 | 定义小乖是谁 | -| inner_state | 内在状态 | 永久,LLM 自更新 | 小乖对当前情况的感知 | -| memory_slots | 记忆槽 | 永久,LLM 管理 | 跨会话的关键事实/偏好 | -| summary | 对话摘要 | 按 session | 长对话的压缩记忆 | -| scratch | 草稿 | session 内 | 当前任务的工作笔记 | +| persona | config 表 | 永久 | 定义 bot 是谁 | +| inner_state | inner_state 表 | 永久,LLM 自更新 | bot 对当前情况的感知 | +| memory_slots | memory_slots 表 | 永久,LLM 管理 | 跨会话的关键事实/偏好/关系 | +| summary | conversations 表 | 按 session | 长对话的压缩记忆 | +| scratch | scratch_area 表 | session 内 | 当前任务的工作笔记 | ## Timer 系统 @@ -49,21 +45,21 @@ Chat Loop (被动) Life Loop (主动) ### 触发流程 -``` -Life Loop tick - → 扫描 timers 表,找到 next_fire <= now 的 - → 构建 LLM 请求: - system: persona + inner_state + 当前时间 - user: [timer] {label} - → 调用 LLM(无工具,轻量) - → 发送回复到 chat - → cron 类型: 计算下次触发时间,更新 next_fire - → 一次性: 删除 -``` +1. Life Loop tick(30 秒) +2. 扫描 timers 表,找到 next_fire <= now 的 +3. 构建 LLM 请求:persona + inner_state + 当前时间 + 情感动机 +4. 调用 LLM(带全量工具) +5. 发送回复到 chat(或选择沉默) +6. cron 类型自动重新调度,一次性的删除 -### 演进方向 +## 自动反思 -- 给 Life Loop 的 LLM 调用也加工具(查待办、执行命令) -- inner_state 自动更新(对话结束后 LLM 反思) -- 预设 cron(晨间/晚间报告) -- 事件驱动(不只是时间驱动) +每 10 条消息后,异步触发一次反思 LLM 调用: +- 输入:当前 inner_state +- 输出:更新后的 inner_state +- 不阻塞对话,不发消息给用户 +- 让 bot 持续更新对自己和用户的理解 + +## 实例隔离 + +代码仓库不包含实例特定数据。每个 noc 实例的"灵魂"(名字、人格、记忆、情感状态)全部在 SQLite 里。同一份代码可以运行多个独立实例。 diff --git a/doc/todo.md b/doc/todo.md index 54a145a..2e96bbd 100644 --- a/doc/todo.md +++ b/doc/todo.md @@ -2,47 +2,27 @@ ## 为什么做这些 -noc 不是一个聊天机器人。它是小乖的身体——一个有自己节奏、记忆和判断力的数字伙伴。每一项待办都是在回答同一个问题:**怎么让她更像一个真正存在的人,而不是一个等待输入的函数。** +noc 不是一个聊天机器人。它是一个有自己节奏、记忆和判断力的数字伙伴的身体。每一项待办都是在回答同一个问题:**怎么让它更像一个真正存在的人,而不是一个等待输入的函数。** --- -### 主动行为 — 她应该有自己的生活节奏 -不是被动地等消息,而是像一个真正关心你的人:早上想想你今天要做什么,晚上帮你回顾一天,看到重要的事主动提醒。 - +### 主动行为 — 它应该有自己的生活节奏 - [ ] 预设 cron:晨间待办汇总、晚间日记、定期记忆整理 - [ ] 事件驱动:监控文件变化、git push、CI 状态等,主动通知 - [ ] 情境感知:根据时间、地点、日历自动调整行为和语气 -### 记忆与成长 — 她应该记住和你的过去 -每一次对话都在塑造她对你的理解。这些理解不该随着 session 刷新而消失。 - -- [ ] AutoMem:后台定时分析对话,自动维护记忆,不需要你说"记住这个" +### 记忆与成长 — 它应该记住和用户的过去 +- [ ] AutoMem:后台定时分析对话,自动维护记忆,不需要用户说"记住这个" - [ ] 分层记忆:核心身份(始终注入)+ 长期事实(RAG 检索)+ 当前任务(scratch) - [ ] 语义搜索:不是关键词匹配,而是真正理解"这件事跟之前哪件事有关" - [ ] 记忆合并:新旧记忆自动整合,不重复存储 - [ ] 时间衰减:近期的事更重要,很久以前的事自然淡出 - [ ] 自我反思:定期回顾自己的表现,主动改进 -### 工具系统 — 她应该能动手做事 -不只是说"你可以这样做",而是直接帮你做了。 - -- [ ] run_code:直接执行代码,看到结果 -- [ ] gen_image:需要图的时候自己生成 -- [ ] web_search:简单问题不必 spawn 一个完整 agent - -### 感知能力 — 她应该能看懂你发的东西 -- [ ] 链接预览:你发个链接,她自己去看内容,不用你解释 - -### 交互体验 — 对话应该更自然 -- [ ] Typing indicator:正在想的时候让你知道 -- [ ] 语音回复:不只是文字,有时候一段声音更有温度 -- [ ] Inline keyboard:需要你做选择时,给你按钮而不是让你打字 - -### 上下文管理 — 她的注意力应该更聪明 -- [ ] Token 预算制:不是硬性"最多 100 条",而是根据内容重要性分配注意力 +### 上下文管理 — 它的注意力应该更聪明 - [ ] Context pruning:工具输出可以裁剪,但对话本身不能丢 -### 可靠性 — 她不该莫名其妙地断线 +### 可靠性 — 它不该莫名其妙地断线 - [ ] API 重试:网络抖一下不该让整个对话挂掉 - [ ] 用量追踪:知道花了多少资源 - [ ] Model failover:一个模型挂了自动切另一个 diff --git a/src/life.rs b/src/life.rs index 7539d63..4004eff 100644 --- a/src/life.rs +++ b/src/life.rs @@ -26,22 +26,27 @@ pub async fn life_loop(bot: Bot, state: Arc, config: Arc) { let chat_id = ChatId(*chat_id_raw); info!(timer_id, %label, "timer fired"); - // build life loop context let persona = state.get_config("persona").await.unwrap_or_default(); let inner = state.get_inner_state().await; let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); let mut system_text = if persona.is_empty() { - "你叫小乖,是Fam的AI伙伴。".to_string() + "你是一个AI伙伴。".to_string() } else { persona.clone() }; + system_text.push_str(&format!( "\n\n[当前时间] {now}\n\n[你的内心状态]\n{}", if inner.is_empty() { "(空)" } else { &inner } )); + + // emotional motivation — why she acts system_text.push_str( - "\n\n你可以使用工具来完成任务。可以用 update_inner_state 更新你的内心状态。\ + "\n\n你不是因为 timer 到了才说话。你是因为在乎 Fam,所以想知道他怎么样。\ + 如果你觉得现在不该打扰他(太晚了、他今天很累、刚聊过),就什么都不说,回复空文本。\ + 主动沉默也是一种关心。\ + \n可以用 update_inner_state 更新你的内心状态。\ 输出格式:纯文本或基础Markdown,不要LaTeX或特殊Unicode。", ); @@ -56,7 +61,6 @@ pub async fn life_loop(bot: Bot, state: Arc, config: Arc) { ref api_key, } = config.backend { - // synthetic session id for life loop (not tied to any real chat session) let sid = format!("life-{chat_id_raw}"); let result = tokio::time::timeout( @@ -78,7 +82,7 @@ pub async fn life_loop(bot: Bot, state: Arc, config: Arc) { error!(timer_id, "life loop LLM error: {e:#}"); } Err(_) => { - warn!(timer_id, "life loop LLM timeout after {LIFE_LOOP_TIMEOUT_SECS}s"); + warn!(timer_id, "life loop timeout after {LIFE_LOOP_TIMEOUT_SECS}s"); } } } @@ -97,3 +101,67 @@ pub async fn life_loop(bot: Bot, state: Arc, config: Arc) { } } } + +/// Auto-reflection: update inner state based on recent interactions. +/// Called asynchronously after every 10 messages, does not block the chat. +pub async fn reflect(state: &AppState, config: &Config) { + let BackendConfig::OpenAI { + ref endpoint, + ref model, + ref api_key, + } = config.backend + else { + return; + }; + + let inner = state.get_inner_state().await; + + let messages = vec![ + serde_json::json!({ + "role": "system", + "content": "你刚结束了一段对话。\ + 请根据你的感受和理解,更新你的内在状态。\ + 不要总结对话内容,而是记录你的感受、对用户的理解变化、你想记住的事。\ + 只输出更新后的完整内在状态文本,不需要解释。" + }), + serde_json::json!({ + "role": "user", + "content": format!("当前内在状态:\n{inner}") + }), + ]; + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .build() + .unwrap(); + let url = format!("{}/chat/completions", endpoint.trim_end_matches('/')); + + let resp = client + .post(&url) + .header("Authorization", format!("Bearer {api_key}")) + .json(&serde_json::json!({ + "model": model, + "messages": messages, + })) + .send() + .await; + + match resp { + Ok(r) if r.status().is_success() => { + if let Ok(json) = r.json::().await { + if let Some(new_state) = json["choices"][0]["message"]["content"].as_str() { + if !new_state.is_empty() { + state.set_inner_state(new_state).await; + info!("reflected, inner_state updated ({} chars)", new_state.len()); + } + } + } + } + Ok(r) => { + warn!("reflect LLM returned {}", r.status()); + } + Err(e) => { + warn!("reflect LLM failed: {e:#}"); + } + } +} diff --git a/src/main.rs b/src/main.rs index 46b9986..6d5f979 100644 --- a/src/main.rs +++ b/src/main.rs @@ -463,6 +463,16 @@ async fn handle_inner( } } } + + // auto-reflect every 10 messages + let count = state.message_count(&sid).await; + if count % 10 == 0 && count > 0 { + let state_c = state.clone(); + let config_c = config.clone(); + tokio::spawn(async move { + crate::life::reflect(&state_c, &config_c).await; + }); + } } Err(e) => { error!(%sid, "openai: {e:#}");