Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "nomifun-companion"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
nomifun-common.workspace = true
|
||||
nomi-redact.workspace = true
|
||||
nomi-memory.workspace = true
|
||||
nomifun-db.workspace = true
|
||||
nomifun-api-types.workspace = true
|
||||
nomifun-realtime.workspace = true
|
||||
nomifun-ai-agent.workspace = true
|
||||
nomifun-extension.workspace = true
|
||||
nomifun-auth.workspace = true
|
||||
nomifun-conversation.workspace = true
|
||||
axum.workspace = true
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sqlx.workspace = true
|
||||
chrono.workspace = true
|
||||
tracing.workspace = true
|
||||
async-trait.workspace = true
|
||||
zip.workspace = true
|
||||
reqwest.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,243 @@
|
||||
//! Persisted companion configuration: opt-in collection switches, learning model,
|
||||
//! persona, appearance and quiet-hours. Stored as `config.json` under the companion
|
||||
//! dir with atomic temp+rename writes (same pattern as cron skill files).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The roster character every companion falls back to when none is configured.
|
||||
pub(crate) const DEFAULT_CHARACTER: &str = "mochi";
|
||||
|
||||
/// Which event sources the user has opted into collecting. The work-event
|
||||
/// sources all default OFF; `companion_dialogues` (direct conversations with the
|
||||
/// companions) defaults ON — talking to the companion is itself the opt-in.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct CollectConfig {
|
||||
pub chat_user_messages: bool,
|
||||
pub chat_assistant_replies: bool,
|
||||
pub requirements: bool,
|
||||
pub cron_runs: bool,
|
||||
pub conversation_lifecycle: bool,
|
||||
pub terminal_sessions: bool,
|
||||
/// Tool-call capture from owner work sessions: tool NAME + normalized param
|
||||
/// SHAPE only (sorted top-level arg keys + JSON types), never values. The
|
||||
/// primary mining signal for skill self-evolution (design §5.1).
|
||||
pub tool_calls: bool,
|
||||
/// Companion-dialogue capture: owner messages + companion replies inside companion
|
||||
/// (companion / channel-master) conversations. The field-level serde
|
||||
/// default keeps it ON for legacy `config.json` files written before the
|
||||
/// field existed.
|
||||
#[serde(default = "default_true")]
|
||||
pub companion_dialogues: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for CollectConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
chat_user_messages: false,
|
||||
chat_assistant_replies: false,
|
||||
requirements: false,
|
||||
cron_runs: false,
|
||||
conversation_lifecycle: false,
|
||||
terminal_sessions: false,
|
||||
tool_calls: false,
|
||||
companion_dialogues: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CollectConfig {
|
||||
/// Whether any of the opt-in *work-event* sources is enabled (UI
|
||||
/// onboarding hint). Deliberately excludes `companion_dialogues`, which is on
|
||||
/// by default and would make this vacuously true.
|
||||
pub fn any_enabled(&self) -> bool {
|
||||
self.chat_user_messages
|
||||
|| self.chat_assistant_replies
|
||||
|| self.requirements
|
||||
|| self.cron_runs
|
||||
|| self.conversation_lifecycle
|
||||
|| self.terminal_sessions
|
||||
|| self.tool_calls
|
||||
}
|
||||
}
|
||||
|
||||
/// The model used for learning runs + companion chat.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct ModelConfig {
|
||||
pub provider_id: String,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
impl ModelConfig {
|
||||
pub fn is_configured(&self) -> bool {
|
||||
!self.provider_id.is_empty() && !self.model.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Scheduled learning settings.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct LearnConfig {
|
||||
pub enabled: bool,
|
||||
/// Minutes between learning runs.
|
||||
pub interval_minutes: u32,
|
||||
}
|
||||
|
||||
impl Default for LearnConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
interval_minutes: 60,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Desktop-companion appearance + notification behaviour.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct AppearanceConfig {
|
||||
/// Whether the desktop companion window should be visible.
|
||||
pub companion_enabled: bool,
|
||||
/// Which character renders in the companion window (see the UI character
|
||||
/// roster: mochi/ink/roux/pixel/bolt/boo). Unknown values fall back to
|
||||
/// the default character on the renderer side.
|
||||
pub character: String,
|
||||
/// Saved companion window position (physical px), if the user dragged it.
|
||||
pub companion_x: Option<i32>,
|
||||
pub companion_y: Option<i32>,
|
||||
/// Quiet hours "HH:mm" — within this window the companion only accrues badges
|
||||
/// and never pops bubbles. Empty strings disable quiet hours.
|
||||
pub quiet_start: String,
|
||||
pub quiet_end: String,
|
||||
}
|
||||
|
||||
impl Default for AppearanceConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
companion_enabled: false,
|
||||
character: DEFAULT_CHARACTER.into(),
|
||||
companion_x: None,
|
||||
companion_y: None,
|
||||
quiet_start: String::new(),
|
||||
quiet_end: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persona settings injected into the chat/learn system prompts.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct PersonaConfig {
|
||||
/// One of `lively` | `calm` | `sassy`.
|
||||
pub preset: String,
|
||||
/// Free-form extra persona instructions appended by the user.
|
||||
pub custom: String,
|
||||
}
|
||||
|
||||
impl Default for PersonaConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
preset: "lively".into(),
|
||||
custom: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The full persisted companion configuration.
|
||||
///
|
||||
/// LEGACY: this is the pre-multi-companion single-config shape, kept only so boot
|
||||
/// can read an old `companion/nomi/config.json` and migrate it into the new
|
||||
/// per-companion [`crate::profile::CompanionProfileConfig`] + shared
|
||||
/// [`crate::profile::SharedCompanionConfig`] split. Do not extend it.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct CompanionConfig {
|
||||
pub collect: CollectConfig,
|
||||
pub model: ModelConfig,
|
||||
pub learn: LearnConfig,
|
||||
pub appearance: AppearanceConfig,
|
||||
pub persona: PersonaConfig,
|
||||
}
|
||||
|
||||
impl CompanionConfig {
|
||||
pub fn config_path(companion_dir: &Path) -> PathBuf {
|
||||
companion_dir.join("config.json")
|
||||
}
|
||||
|
||||
/// Load from `{companion_dir}/config.json`, falling back to defaults when the
|
||||
/// file is missing or unreadable (a corrupt config must never brick boot).
|
||||
pub fn load(companion_dir: &Path) -> Self {
|
||||
crate::fsio::load_json_or_default(&Self::config_path(companion_dir))
|
||||
}
|
||||
|
||||
/// Atomically persist to `{companion_dir}/config.json` (unique temp file +
|
||||
/// rename, so two concurrent saves can never rename each other's
|
||||
/// half-written temp into place).
|
||||
pub fn save(&self, companion_dir: &Path) -> std::io::Result<()> {
|
||||
crate::fsio::save_json_atomic(companion_dir, "config.json", self)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_and_default_on_missing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let loaded = CompanionConfig::load(dir.path());
|
||||
assert_eq!(loaded, CompanionConfig::default());
|
||||
assert!(!loaded.collect.any_enabled());
|
||||
|
||||
let mut cfg = CompanionConfig::default();
|
||||
cfg.collect.chat_user_messages = true;
|
||||
cfg.model.provider_id = "prov_x".into();
|
||||
cfg.model.model = "claude-fable-5".into();
|
||||
cfg.learn.enabled = true;
|
||||
cfg.save(dir.path()).unwrap();
|
||||
|
||||
let again = CompanionConfig::load(dir.path());
|
||||
assert_eq!(again, cfg);
|
||||
assert!(again.model.is_configured());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_config_falls_back_to_default() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(CompanionConfig::config_path(dir.path()), "{not json").unwrap();
|
||||
assert_eq!(CompanionConfig::load(dir.path()), CompanionConfig::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_collect_json_defaults_companion_dialogues_on() {
|
||||
// Stored configs written before the field existed must come back ON.
|
||||
let legacy: CollectConfig = serde_json::from_str(r#"{"chat_user_messages":true}"#).unwrap();
|
||||
assert!(legacy.companion_dialogues);
|
||||
assert!(legacy.chat_user_messages);
|
||||
// …and an explicit false is respected.
|
||||
let off: CollectConfig = serde_json::from_str(r#"{"companion_dialogues":false}"#).unwrap();
|
||||
assert!(!off.companion_dialogues);
|
||||
|
||||
// Full legacy config.json on disk (no companion_dialogues key) roundtrips
|
||||
// through the file loader with the field defaulted ON.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
CompanionConfig::config_path(dir.path()),
|
||||
r#"{"collect":{"requirements":true}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let loaded = CompanionConfig::load(dir.path());
|
||||
assert!(loaded.collect.companion_dialogues);
|
||||
assert!(loaded.collect.requirements);
|
||||
// companion_dialogues is excluded from the work-event onboarding hint.
|
||||
assert!(CollectConfig::default().companion_dialogues);
|
||||
assert!(!CollectConfig::default().any_enabled());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! WS push events for the companion domain. Same shape as `CronEventEmitter`:
|
||||
//! a thin wrapper over the global `EventBroadcaster`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CompanionEventEmitter {
|
||||
broadcaster: Arc<dyn EventBroadcaster>,
|
||||
}
|
||||
|
||||
impl CompanionEventEmitter {
|
||||
pub fn new(broadcaster: Arc<dyn EventBroadcaster>) -> Self {
|
||||
Self { broadcaster }
|
||||
}
|
||||
|
||||
fn broadcast<T: serde::Serialize>(&self, event_name: &str, payload: &T) {
|
||||
let value = match serde_json::to_value(payload) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, event_name, "failed to serialize companion event");
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.broadcaster.broadcast(WebSocketMessage::new(event_name, value));
|
||||
}
|
||||
|
||||
/// 把 `companion_id` 合并进结构体序列化出的对象顶层后广播。用于 learn/evolve
|
||||
/// 产出的"应由单个伙伴呈现"的事件(沿用 emit_companion_updated 的对象合并手法)。
|
||||
fn broadcast_scoped<T: serde::Serialize>(&self, event_name: &str, companion_id: &str, payload: &T) {
|
||||
let mut map = match serde_json::to_value(payload) {
|
||||
Ok(serde_json::Value::Object(map)) => map,
|
||||
Ok(other) => {
|
||||
let mut m = serde_json::Map::new();
|
||||
m.insert("value".into(), other);
|
||||
m
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, event_name, "failed to serialize scoped companion event");
|
||||
return;
|
||||
}
|
||||
};
|
||||
map.insert("companion_id".into(), serde_json::Value::String(companion_id.to_owned()));
|
||||
self.broadcaster.broadcast(WebSocketMessage::new(event_name, serde_json::Value::Object(map)));
|
||||
}
|
||||
|
||||
pub fn emit_suggestion_created(&self, companion_id: &str, suggestion: &crate::store::CompanionSuggestion) {
|
||||
self.broadcast_scoped("companion.suggestion-created", companion_id, suggestion);
|
||||
}
|
||||
|
||||
/// A suggestion was accepted/dismissed. Lets every open surface (panel,
|
||||
/// desktop bubble, console) drop the now-decided card live instead of
|
||||
/// leaving a stale `new` snapshot that 404s on the next decide. Payload is
|
||||
/// the decided suggestion (carries `id` + new `status`).
|
||||
pub fn emit_suggestion_decided(&self, suggestion: &crate::store::CompanionSuggestion) {
|
||||
self.broadcast("companion.suggestion-decided", suggestion);
|
||||
}
|
||||
|
||||
pub fn emit_learn_started(&self, companion_id: &str) {
|
||||
self.broadcast("companion.learn-started", &serde_json::json!({ "companion_id": companion_id }));
|
||||
}
|
||||
|
||||
pub fn emit_learn_finished(&self, companion_id: &str, run: &crate::store::CompanionLearnRun) {
|
||||
self.broadcast_scoped("companion.learn-finished", companion_id, run);
|
||||
}
|
||||
|
||||
pub fn emit_mood_changed(&self, companion_id: &str, mood: &str) {
|
||||
self.broadcast("companion.mood-changed", &serde_json::json!({ "companion_id": companion_id, "mood": mood }));
|
||||
}
|
||||
|
||||
/// Shared (cross-companion) config changed. Same event name the legacy single
|
||||
/// config used; the payload carries `"scope": "shared"` so listeners can
|
||||
/// tell it apart from per-companion profile updates.
|
||||
pub fn emit_shared_config_updated(&self, config: &crate::profile::SharedCompanionConfig) {
|
||||
let mut payload = match serde_json::to_value(config) {
|
||||
Ok(serde_json::Value::Object(map)) => map,
|
||||
_ => serde_json::Map::new(),
|
||||
};
|
||||
payload.insert("scope".into(), serde_json::Value::String("shared".into()));
|
||||
self.broadcast("companion.config-updated", &serde_json::Value::Object(payload));
|
||||
}
|
||||
|
||||
/// One companion's profile changed. `"scope"` is the companion id, the rest of the
|
||||
/// payload is the full profile. `"companion_id"` is also set explicitly so listeners
|
||||
/// that key off it (useCompanions) don't have to fall back to parsing `scope`.
|
||||
pub fn emit_companion_updated(&self, companion_id: &str, profile: &crate::profile::CompanionProfileConfig) {
|
||||
let mut payload = match serde_json::to_value(profile) {
|
||||
Ok(serde_json::Value::Object(map)) => map,
|
||||
_ => serde_json::Map::new(),
|
||||
};
|
||||
payload.insert("scope".into(), serde_json::Value::String(companion_id.to_owned()));
|
||||
payload.insert("companion_id".into(), serde_json::Value::String(companion_id.to_owned()));
|
||||
self.broadcast("companion.config-updated", &serde_json::Value::Object(payload));
|
||||
}
|
||||
|
||||
pub fn emit_companion_created(&self, profile: &crate::profile::CompanionProfileConfig) {
|
||||
// Wire shape matches the frontend ICompanionCreatedEvent { companion_id, profile }.
|
||||
// The raw profile carries `id` but no `companion_id`, so useCompanions.refreshOne
|
||||
// (which reads evt.companion_id) silently no-op'd on the incremental roster add.
|
||||
self.broadcast(
|
||||
"companion.created",
|
||||
&serde_json::json!({ "companion_id": profile.id.clone(), "profile": profile }),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn emit_companion_deleted(&self, companion_id: &str) {
|
||||
self.broadcast("companion.deleted", &serde_json::json!({ "companion_id": companion_id }));
|
||||
}
|
||||
|
||||
/// A memory was created outside a learn run (companion-chat save_memory
|
||||
/// tool or manual add) — lets open UIs refresh counters live.
|
||||
pub fn emit_memory_created(&self, memory: &crate::store::CompanionMemory) {
|
||||
self.broadcast("companion.memory-created", memory);
|
||||
}
|
||||
|
||||
/// A memory's content/scope/pin/status was edited. Lets every open surface
|
||||
/// (memories tab, desktop bubble, second window) reflect the edit live
|
||||
/// instead of holding a stale snapshot.
|
||||
pub fn emit_memory_updated(&self, memory: &crate::store::CompanionMemory) {
|
||||
self.broadcast("companion.memory-updated", memory);
|
||||
}
|
||||
|
||||
/// A memory was hard-deleted. Payload carries the `id` so listeners can drop
|
||||
/// the row without a refetch.
|
||||
pub fn emit_memory_deleted(&self, id: &str) {
|
||||
self.broadcast("companion.memory-deleted", &serde_json::json!({ "id": id }));
|
||||
}
|
||||
|
||||
/// A skill draft was auto-generated and is awaiting review.
|
||||
pub fn emit_skill_drafted(&self, companion_id: &str, skill_name: &str) {
|
||||
self.broadcast(
|
||||
"companion.skill-drafted",
|
||||
&serde_json::json!({ "companion_id": companion_id, "skill_name": skill_name }),
|
||||
);
|
||||
}
|
||||
|
||||
/// A skill was accepted/activated — the companion just "learned" it.
|
||||
pub fn emit_skill_learned(&self, companion_id: &str, skill_name: &str) {
|
||||
self.broadcast(
|
||||
"companion.skill-learned",
|
||||
&serde_json::json!({ "companion_id": companion_id, "skill_name": skill_name }),
|
||||
);
|
||||
}
|
||||
|
||||
/// A skill was auto-archived by the decay pass (unused too long).
|
||||
pub fn emit_skill_archived(&self, companion_id: &str, skill_name: &str) {
|
||||
self.broadcast(
|
||||
"companion.skill-archived",
|
||||
&serde_json::json!({ "companion_id": companion_id, "skill_name": skill_name }),
|
||||
);
|
||||
}
|
||||
}
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
//! 真实重水合源(design 2026-06-23):会话库 `messages` 表 = 内容唯一事实源。
|
||||
//!
|
||||
//! 给定 wire `conversation_id`(= `conversations.id` 的十进制字符串),按 [`TranscriptAnchor`]
|
||||
//! 框出窗口,把消息转成**脱敏**转录喂给 drafter。装配见 `service::attach_companion`(会话服务
|
||||
//! 晚于伴随服务构建,故晚装配)。走仓储层 `get_messages`(user 无关,绕开 list_messages 的
|
||||
//! 鉴权与 type 过滤)。会话不存在/为空 → `None`(drafter 降级回工具名步骤)。
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nomifun_common::AppError;
|
||||
use nomifun_db::{IConversationRepository, SortOrder};
|
||||
|
||||
use crate::evolution::transcript::{TranscriptAnchor, TranscriptSource, TranscriptTurn};
|
||||
|
||||
/// 单条文本/参数/结果脱敏后的字符上限(控转录体量)。
|
||||
const FIELD_CHARS: usize = 600;
|
||||
/// 单会话最多取多少条消息来框窗口(防超大会话拖垮起草)。
|
||||
const MAX_FETCH: u32 = 1000;
|
||||
|
||||
pub struct ConversationTranscriptSource {
|
||||
repo: Arc<dyn IConversationRepository>,
|
||||
}
|
||||
|
||||
impl ConversationTranscriptSource {
|
||||
pub fn new(repo: Arc<dyn IConversationRepository>) -> Self {
|
||||
Self { repo }
|
||||
}
|
||||
}
|
||||
|
||||
/// 一行消息解析后的最小投影。
|
||||
struct Parsed {
|
||||
ty: String,
|
||||
position: String,
|
||||
content: serde_json::Value,
|
||||
/// tool_call/acp_tool_call 的 call_id(用于按锚精确框窗)。
|
||||
call_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 脱敏 + 截断(secrets 永不入转录;过长字段裁断带省略号)。
|
||||
fn redact_clip(s: &str) -> String {
|
||||
let red = nomi_redact::redact_secrets_owned(s.to_owned());
|
||||
if red.chars().count() <= FIELD_CHARS {
|
||||
red
|
||||
} else {
|
||||
let head: String = red.chars().take(FIELD_CHARS).collect();
|
||||
format!("{head}…")
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 tool_call/acp_tool_call 的 content 提取 (name, args, result)。
|
||||
fn extract_tool(ty: &str, content: &serde_json::Value) -> Option<(String, Option<String>, Option<String>)> {
|
||||
match ty {
|
||||
"tool_call" => {
|
||||
let name = content.get("name").and_then(|v| v.as_str())?.to_owned();
|
||||
let args = content
|
||||
.get("args")
|
||||
.or_else(|| content.get("input"))
|
||||
.filter(|v| !v.is_null())
|
||||
.map(|v| v.to_string());
|
||||
let result = content.get("output").and_then(|v| v.as_str()).map(|s| s.to_owned());
|
||||
Some((name, args, result))
|
||||
}
|
||||
"acp_tool_call" => {
|
||||
let upd = content.get("update")?;
|
||||
let name = upd.get("title").and_then(|v| v.as_str()).unwrap_or("tool").to_owned();
|
||||
let args = upd.get("raw_input").filter(|v| !v.is_null()).map(|v| v.to_string());
|
||||
let result = upd.get("raw_output").filter(|v| !v.is_null()).map(|v| v.to_string());
|
||||
Some((name, args, result))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TranscriptSource for ConversationTranscriptSource {
|
||||
async fn window(&self, anchor: &TranscriptAnchor) -> Result<Option<Vec<TranscriptTurn>>, AppError> {
|
||||
// wire id = conversations.id 的十进制串(无独立公开 id 列);非数字 → 无法定位。
|
||||
let Ok(conv_id) = anchor.conversation_id.parse::<i64>() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let page = self
|
||||
.repo
|
||||
.get_messages(conv_id, 1, MAX_FETCH, SortOrder::Asc)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("rehydrate get_messages: {e}")))?;
|
||||
if page.items.is_empty() {
|
||||
return Ok(None); // 会话已删/为空
|
||||
}
|
||||
|
||||
let parsed: Vec<Parsed> = page
|
||||
.items
|
||||
.iter()
|
||||
.filter(|r| !r.hidden)
|
||||
.map(|r| {
|
||||
let content: serde_json::Value = serde_json::from_str(&r.content).unwrap_or(serde_json::Value::Null);
|
||||
let call_id = match r.r#type.as_str() {
|
||||
"tool_call" => content.get("call_id").and_then(|v| v.as_str()).map(|s| s.to_owned()),
|
||||
"acp_tool_call" => content
|
||||
.get("update")
|
||||
.and_then(|u| u.get("tool_call_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_owned()),
|
||||
_ => None,
|
||||
};
|
||||
Parsed { ty: r.r#type.clone(), position: r.position.clone().unwrap_or_default(), content, call_id }
|
||||
})
|
||||
.collect();
|
||||
if parsed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// 框窗口:call_id 命中优先(挖矿/示范都带 call_ids);命中为空则退回整段(capped)。
|
||||
let want: HashSet<&str> = anchor.call_ids.iter().map(|s| s.as_str()).collect();
|
||||
let hits: Vec<usize> = if want.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
parsed
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| p.call_id.as_deref().map(|c| want.contains(c)).unwrap_or(false))
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
};
|
||||
let (lo, hi) = if hits.is_empty() {
|
||||
(0, parsed.len() - 1) // 无精确命中 → 整段(已 capped 到 MAX_FETCH)
|
||||
} else {
|
||||
let lo = hits.iter().min().copied().unwrap().saturating_sub(anchor.pad_turns);
|
||||
let hi = (hits.iter().max().copied().unwrap() + anchor.pad_turns).min(parsed.len() - 1);
|
||||
(lo, hi)
|
||||
};
|
||||
|
||||
let mut turns = Vec::new();
|
||||
for p in &parsed[lo..=hi] {
|
||||
match p.ty.as_str() {
|
||||
"text" if p.position == "right" => {
|
||||
if let Some(t) = p.content.get("content").and_then(|v| v.as_str()).filter(|s| !s.trim().is_empty()) {
|
||||
turns.push(TranscriptTurn::user(redact_clip(t)));
|
||||
}
|
||||
}
|
||||
"text" => {
|
||||
if let Some(t) = p.content.get("content").and_then(|v| v.as_str()).filter(|s| !s.trim().is_empty()) {
|
||||
turns.push(TranscriptTurn::assistant(redact_clip(t)));
|
||||
}
|
||||
}
|
||||
"tool_call" | "acp_tool_call" => {
|
||||
if let Some((name, args, result)) = extract_tool(&p.ty, &p.content) {
|
||||
turns.push(TranscriptTurn::tool(
|
||||
name,
|
||||
args.map(|a| redact_clip(&a)),
|
||||
result.map(|r| redact_clip(&r)),
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {} // thinking/tips 跳过(对起草是噪声)
|
||||
}
|
||||
}
|
||||
Ok(Some(turns))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nomifun_common::now_ms;
|
||||
use nomifun_db::models::{ConversationRow, MessageRow};
|
||||
use nomifun_db::{init_database_memory, SqliteConversationRepository};
|
||||
|
||||
fn conv_row() -> ConversationRow {
|
||||
ConversationRow {
|
||||
id: 0, // ignored on insert (autoincrement)
|
||||
user_id: "system_default_user".into(), // seeded by init_database_memory (FK)
|
||||
name: "t".into(),
|
||||
r#type: "gemini".into(),
|
||||
extra: "{}".into(),
|
||||
model: None,
|
||||
status: Some("finished".into()),
|
||||
source: None,
|
||||
channel_chat_id: None,
|
||||
pinned: false,
|
||||
pinned_at: None,
|
||||
cron_job_id: None,
|
||||
created_at: now_ms(),
|
||||
updated_at: now_ms(),
|
||||
}
|
||||
}
|
||||
|
||||
fn text_msg(conv: i64, content: &str, position: &str, ts: i64) -> MessageRow {
|
||||
MessageRow {
|
||||
id: format!("msg-{position}-{ts}"),
|
||||
conversation_id: conv,
|
||||
msg_id: None,
|
||||
r#type: "text".into(),
|
||||
content: serde_json::json!({ "content": content }).to_string(),
|
||||
position: Some(position.into()),
|
||||
status: Some("finish".into()),
|
||||
hidden: false,
|
||||
created_at: ts,
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_msg(conv: i64, call_id: &str, args: serde_json::Value, output: &str, ts: i64) -> MessageRow {
|
||||
MessageRow {
|
||||
id: format!("msg-{call_id}"),
|
||||
conversation_id: conv,
|
||||
msg_id: None,
|
||||
r#type: "tool_call".into(),
|
||||
content: serde_json::json!({
|
||||
"call_id": call_id, "name": "grep", "args": args, "status": "completed", "output": output
|
||||
})
|
||||
.to_string(),
|
||||
position: Some("left".into()),
|
||||
status: Some("finish".into()),
|
||||
hidden: false,
|
||||
created_at: ts,
|
||||
}
|
||||
}
|
||||
|
||||
async fn repo_with_conv() -> (Arc<SqliteConversationRepository>, i64) {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo = Arc::new(SqliteConversationRepository::new(db.pool().clone()));
|
||||
let id = repo.create(&conv_row()).await.unwrap();
|
||||
(repo, id)
|
||||
}
|
||||
|
||||
/// 守门:重水合命中 → 真实 user/tool/assistant 内容入转录;secret 脱敏;thinking/hidden 排除。
|
||||
#[tokio::test]
|
||||
async fn rehydrates_window_and_redacts_secrets() {
|
||||
let (repo, conv) = repo_with_conv().await;
|
||||
repo.insert_message(&text_msg(conv, "把日志里的错误改掉", "right", 1)).await.unwrap();
|
||||
repo.insert_message(&tool_msg(
|
||||
conv,
|
||||
"tc-1",
|
||||
serde_json::json!({ "pattern": "ERROR", "key": "sk-ABCDEFGHIJ0123456789xyz" }),
|
||||
"命中 3 处",
|
||||
2,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.insert_message(&text_msg(conv, "改好了", "left", 3)).await.unwrap();
|
||||
let mut hidden = text_msg(conv, "隐藏内容", "left", 4);
|
||||
hidden.hidden = true;
|
||||
repo.insert_message(&hidden).await.unwrap();
|
||||
let mut thinking = text_msg(conv, "内心独白", "left", 5);
|
||||
thinking.r#type = "thinking".into();
|
||||
repo.insert_message(&thinking).await.unwrap();
|
||||
|
||||
let src = ConversationTranscriptSource::new(repo.clone());
|
||||
let anchor = TranscriptAnchor {
|
||||
conversation_id: conv.to_string(),
|
||||
start_ts: 0,
|
||||
end_ts: 0,
|
||||
pad_turns: 2,
|
||||
call_ids: vec!["tc-1".into()],
|
||||
};
|
||||
let turns = src.window(&anchor).await.unwrap().expect("conversation present");
|
||||
let rendered = crate::evolution::render_transcript(&turns, 600).join("\n");
|
||||
assert!(rendered.contains("把日志里的错误改掉"), "user content: {rendered}");
|
||||
assert!(rendered.contains("grep"), "tool name: {rendered}");
|
||||
assert!(rendered.contains("命中 3 处"), "tool result: {rendered}");
|
||||
assert!(rendered.contains("改好了"), "assistant content: {rendered}");
|
||||
// 脱敏:secret 永不入转录(纵深防御要害)。
|
||||
assert!(!rendered.contains("sk-ABCDEFGHIJ"), "secret leaked: {rendered}");
|
||||
assert!(rendered.contains("[REDACTED_SECRET]"), "redaction marker missing: {rendered}");
|
||||
// thinking/hidden 是噪声,不入转录。
|
||||
assert!(!rendered.contains("内心独白"), "thinking leaked: {rendered}");
|
||||
assert!(!rendered.contains("隐藏内容"), "hidden leaked: {rendered}");
|
||||
}
|
||||
|
||||
/// 守门:非数字 id / 不存在的会话 → None(drafter 降级,不报错)。
|
||||
#[tokio::test]
|
||||
async fn missing_or_nonnumeric_conversation_returns_none() {
|
||||
let (repo, _conv) = repo_with_conv().await;
|
||||
let src = ConversationTranscriptSource::new(repo);
|
||||
let nonnumeric = TranscriptAnchor { conversation_id: "conv_abc".into(), ..Default::default() };
|
||||
assert!(src.window(&nonnumeric).await.unwrap().is_none(), "non-numeric id → None");
|
||||
let gone = TranscriptAnchor { conversation_id: "999999".into(), ..Default::default() };
|
||||
assert!(src.window(&gone).await.unwrap().is_none(), "missing conversation → None");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,858 @@
|
||||
//! EvolutionEngine — 后台技能进化循环(design §5)。
|
||||
//!
|
||||
//! 镜像 `crate::learner::Learner` 的 tick/cursor/run_lock 脚手架,但独立调度:
|
||||
//! 挖矿(`miner`,确定性)→ 起草(one_shot)→ 评审(one_shot)→ 物化为待审草稿 SKILL.md
|
||||
//! + `create_skill` 建议卡。失败只记进 `EvolveRun` + `tracing::warn!`,**绝不 `emit_error`**
|
||||
//! (后台副任务红线)。蒸馏走 `CompanionCompleter`(选 model,非 agent)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_common::{AppError, generate_prefixed_id, now_ms};
|
||||
use nomifun_extension::constants::SKILL_MANIFEST_FILE;
|
||||
use nomifun_extension::skill_service::{self, SkillDraftInput, SkillPaths, SkillScope};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::collector::{SharedConfig, read_events_since};
|
||||
use crate::events::CompanionEventEmitter;
|
||||
use crate::evolution::miner::{mine_patterns, mine_reflection_candidates, MinedPattern};
|
||||
use crate::evolution::prompt::{self, DraftOutput};
|
||||
use crate::evolution::transcript::{render_transcript, TranscriptAnchor, TranscriptSource};
|
||||
use crate::learner::CompanionCompleter;
|
||||
use crate::registry::CompanionRegistry;
|
||||
use crate::store::{CompanionSkill, CompanionStore};
|
||||
|
||||
const MAX_EVENTS_PER_RUN: usize = 500;
|
||||
const TICK_SECONDS: u64 = 60;
|
||||
const DRAFT_MAX_TOKENS: u32 = 1200;
|
||||
const CRITIC_MAX_TOKENS: u32 = 256;
|
||||
/// 一次最多起草几个新技能(避免单轮爆量骚扰)。
|
||||
const MAX_DRAFTS_PER_RUN: usize = 3;
|
||||
/// 任务后反思的最小步数门槛(单会话工具序列折叠后 ≥ 此值才作反思候选)。
|
||||
const REFLECT_MIN_STEPS: usize = 4;
|
||||
/// 重水合转录行的单行字符上限(控 drafter 上下文成本)。
|
||||
const DRAFT_LINE_CHARS: usize = 240;
|
||||
/// 喂给 drafter 的转录行数上限(窗口可能跨多轮)。
|
||||
const DRAFT_MAX_LINES: usize = 40;
|
||||
/// 一次进化运行的小结(P1 仅返回,不落表)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EvolveRun {
|
||||
pub id: String,
|
||||
pub started_at: i64,
|
||||
pub finished_at: Option<i64>,
|
||||
pub status: String,
|
||||
pub events_processed: i64,
|
||||
pub patterns_found: i64,
|
||||
pub drafts_created: i64,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
pub struct EvolutionEngine {
|
||||
pub companion_dir: PathBuf,
|
||||
pub config: SharedConfig,
|
||||
pub store: CompanionStore,
|
||||
pub registry: Arc<CompanionRegistry>,
|
||||
pub completer: Arc<dyn CompanionCompleter>,
|
||||
pub emitter: CompanionEventEmitter,
|
||||
pub skill_paths: Arc<SkillPaths>,
|
||||
/// 重水合源(会话库 = 唯一内容源)。`start()` 时为 Noop(会话库晚于伴随服务装配,
|
||||
/// 见 `attach_companion`),装配后经 [`set_transcript`] 换成真实适配器。未装配/会话已删
|
||||
/// → 起草降级回工具名步骤。`std::sync::RwLock` 因 `attach_companion` 非 async;读出 Arc
|
||||
/// 即刻 drop guard,绝不跨 await 持锁。
|
||||
pub transcript: std::sync::RwLock<Arc<dyn TranscriptSource>>,
|
||||
/// 与 Learner 各自独立的再入守卫。
|
||||
pub run_lock: Arc<Mutex<()>>,
|
||||
}
|
||||
|
||||
impl EvolutionEngine {
|
||||
/// 晚装配重水合源(会话库适配器在伴随服务之后构建)。
|
||||
pub fn set_transcript(&self, src: Arc<dyn TranscriptSource>) {
|
||||
*self.transcript.write().expect("transcript lock poisoned") = src;
|
||||
}
|
||||
|
||||
/// 为 `anchor` 重水合一段脱敏转录,渲染成 drafter 上下文行。无源/会话已删/锚为空 →
|
||||
/// 空(drafter 仅凭工具名步骤起草——优雅降级,绝不阻塞)。
|
||||
async fn rehydrate_lines(&self, anchor: &TranscriptAnchor) -> Vec<String> {
|
||||
if anchor.conversation_id.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let src = { self.transcript.read().expect("transcript lock poisoned").clone() };
|
||||
match src.window(anchor).await {
|
||||
Ok(Some(turns)) => {
|
||||
let mut lines = render_transcript(&turns, DRAFT_LINE_CHARS);
|
||||
lines.truncate(DRAFT_MAX_LINES);
|
||||
lines
|
||||
}
|
||||
Ok(None) => Vec::new(),
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "transcript rehydration failed; drafting from steps only");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
/// 启动周期 tick 循环。
|
||||
pub fn spawn(self: Arc<Self>) {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(TICK_SECONDS));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let (enabled, interval_minutes) = {
|
||||
let cfg = self.config.read().await;
|
||||
(cfg.evolve.enabled, cfg.evolve.interval_minutes.max(5) as i64)
|
||||
};
|
||||
if !enabled {
|
||||
continue;
|
||||
}
|
||||
let last_run = self.store.get_state_i64("last_evolve_ts").await.unwrap_or(0);
|
||||
if now_ms() - last_run < interval_minutes * 60_000 {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = self.run_once().await {
|
||||
tracing::warn!(error = %e, "companion evolution run failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 一次进化运行。失败绝不 emit_error;状态写进返回的 EvolveRun。
|
||||
pub async fn run_once(&self) -> Result<EvolveRun, AppError> {
|
||||
let Ok(_guard) = self.run_lock.try_lock() else {
|
||||
return Err(AppError::Conflict("an evolution run is already in progress".into()));
|
||||
};
|
||||
let started_at = now_ms();
|
||||
// 先 stamp,崩溃/失败也不会让 60s 调度热循环。
|
||||
self.store.set_state("last_evolve_ts", &started_at.to_string()).await?;
|
||||
|
||||
// Skill health/decay pass (P5 T1-B): runs every evolution tick, before the
|
||||
// model-configured gate, so unused mined skills fade even when no draft is produced.
|
||||
// Fire-and-forget; never emit_error. Emits skill-archived for live UI refresh.
|
||||
let (half_life, archive_threshold) = {
|
||||
let cfg = self.config.read().await;
|
||||
(cfg.evolve.skill_half_life_days, cfg.evolve.skill_archive_threshold)
|
||||
};
|
||||
if let Ok(n) = self.store.decay_skills(half_life, archive_threshold).await {
|
||||
if n > 0 {
|
||||
let owner = {
|
||||
let did = { self.config.read().await.default_companion_id.clone() };
|
||||
self.registry.resolve_default(&did).await
|
||||
};
|
||||
self.emitter.emit_skill_archived(&owner, "");
|
||||
}
|
||||
}
|
||||
|
||||
let (model, min_count, min_distinct, reflect_enabled, auto_activate, auto_threshold) = {
|
||||
let cfg = self.config.read().await;
|
||||
// One model for the whole flywheel: fall back to the learn model when no
|
||||
// dedicated evolve model is configured, so default-on works out of the box
|
||||
// once the user has set the shared learning model.
|
||||
let model = if cfg.evolve.model.is_configured() {
|
||||
cfg.evolve.model.clone()
|
||||
} else {
|
||||
cfg.learn.model.clone()
|
||||
};
|
||||
(
|
||||
model,
|
||||
cfg.evolve.min_pattern_count,
|
||||
cfg.evolve.min_distinct_sessions,
|
||||
cfg.evolve.reflect_enabled,
|
||||
cfg.evolve.auto_activate,
|
||||
cfg.evolve.auto_threshold,
|
||||
)
|
||||
};
|
||||
let mut run = EvolveRun {
|
||||
id: generate_prefixed_id("evr"),
|
||||
started_at,
|
||||
finished_at: None,
|
||||
status: "ok".into(),
|
||||
events_processed: 0,
|
||||
patterns_found: 0,
|
||||
drafts_created: 0,
|
||||
error: None,
|
||||
};
|
||||
|
||||
if !model.is_configured() {
|
||||
run.status = "model_unconfigured".into();
|
||||
run.finished_at = Some(now_ms());
|
||||
return Ok(run);
|
||||
}
|
||||
|
||||
// 自生成技能必须归属某个伙伴(伙伴级专属成长)。默认体解析复用 registry 单一事实源。
|
||||
let owner = {
|
||||
let did = { self.config.read().await.default_companion_id.clone() };
|
||||
self.registry.resolve_default(&did).await
|
||||
};
|
||||
if owner.is_empty() {
|
||||
run.status = "no_companion".into();
|
||||
run.finished_at = Some(now_ms());
|
||||
return Ok(run);
|
||||
}
|
||||
|
||||
let cursor = self.store.get_state_i64("evolve_cursor_ts").await?;
|
||||
let (events, _truncated) = read_events_since(&self.companion_dir, cursor, MAX_EVENTS_PER_RUN);
|
||||
if events.is_empty() {
|
||||
run.status = "no_events".into();
|
||||
run.finished_at = Some(now_ms());
|
||||
return Ok(run);
|
||||
}
|
||||
run.events_processed = events.len() as i64;
|
||||
let new_cursor = events.last().map(|e| e.ts).unwrap_or(cursor);
|
||||
|
||||
let patterns = mine_patterns(&events, min_count, min_distinct);
|
||||
run.patterns_found = patterns.len() as i64;
|
||||
|
||||
// Candidates = repeated patterns first, then (if enabled) single complex sessions
|
||||
// for post-task reflection. Reflection candidates have distinct_sessions=1 → low
|
||||
// confidence → always reviewed, never auto-activated.
|
||||
let mut candidates = patterns;
|
||||
if reflect_enabled {
|
||||
candidates.extend(mine_reflection_candidates(&events, REFLECT_MIN_STEPS, MAX_DRAFTS_PER_RUN));
|
||||
}
|
||||
|
||||
let mut provider_failed = false;
|
||||
for p in candidates {
|
||||
if run.drafts_created as usize >= MAX_DRAFTS_PER_RUN {
|
||||
break;
|
||||
}
|
||||
match self
|
||||
.process_candidate(&p, &owner, &model.provider_id, &model.model, min_distinct, auto_activate, auto_threshold)
|
||||
.await
|
||||
{
|
||||
Ok(true) => run.drafts_created += 1,
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
// Provider failure: terminate the run and keep the cursor for retry.
|
||||
run.error = Some(e.to_string());
|
||||
provider_failed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// provider 失败:保 cursor(下轮重试该批);否则推进。
|
||||
if provider_failed {
|
||||
if run.status == "ok" {
|
||||
run.status = "error".into();
|
||||
}
|
||||
} else {
|
||||
self.store.set_state("evolve_cursor_ts", &new_cursor.to_string()).await?;
|
||||
}
|
||||
run.finished_at = Some(now_ms());
|
||||
Ok(run)
|
||||
}
|
||||
|
||||
/// Process one candidate (mined pattern or reflection) through draft→critic→materialize.
|
||||
/// Returns `Ok(true)` if a skill was produced (draft or auto-activated), `Ok(false)` if
|
||||
/// skipped (rejected/already-drafted/critic-reject/invalid/disk-fail), and `Err` ONLY on
|
||||
/// provider failure (the caller terminates the run and keeps the cursor). Never `emit_error`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn process_candidate(
|
||||
&self,
|
||||
p: &MinedPattern,
|
||||
owner: &str,
|
||||
provider_id: &str,
|
||||
model: &str,
|
||||
min_distinct: usize,
|
||||
auto_activate: bool,
|
||||
auto_threshold: f64,
|
||||
) -> Result<bool, AppError> {
|
||||
// Skip rejected (negative-sample) or already-drafted signatures.
|
||||
if self.store.is_signature_rejected(&p.signature).await.unwrap_or(false) {
|
||||
return Ok(false);
|
||||
}
|
||||
if matches!(self.store.pattern_status(&p.signature).await.unwrap_or(None).as_deref(), Some("drafted")) {
|
||||
return Ok(false);
|
||||
}
|
||||
let anchor = p.example_event_ids.first().cloned().unwrap_or_default();
|
||||
let _ = self.store.bump_pattern(&p.signature, owner, &anchor, now_ms()).await;
|
||||
|
||||
// Draft (1 retry). A completer error → Err (caller terminates + keeps cursor).
|
||||
// Rehydrate the real (redacted) transcript window for this pattern so the drafter
|
||||
// sees actual how-to, not just tool names; degrades to steps-only when unavailable.
|
||||
let context = self.rehydrate_lines(&p.anchor).await;
|
||||
let draft_user = prompt::build_draft_prompt(p, &context);
|
||||
let mut draft: Option<DraftOutput> = None;
|
||||
for attempt in 0..2 {
|
||||
match self.completer.complete(provider_id, model, prompt::DRAFT_SYSTEM, &draft_user, DRAFT_MAX_TOKENS).await {
|
||||
Ok(raw) => match prompt::parse_draft_output(&raw) {
|
||||
Ok(d) if !d.name.trim().is_empty() && !d.description.trim().is_empty() => {
|
||||
draft = Some(d);
|
||||
break;
|
||||
}
|
||||
Ok(_) => tracing::debug!(attempt, "evolution draft missing name/description"),
|
||||
Err(e) => tracing::debug!(attempt, error = %e, "evolution draft unparseable"),
|
||||
},
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
let Some(draft) = draft else { return Ok(false) };
|
||||
|
||||
// Critic.
|
||||
let critic_user = prompt::build_critic_prompt(&draft, p);
|
||||
let approved = match self.completer.complete(provider_id, model, prompt::CRITIC_SYSTEM, &critic_user, CRITIC_MAX_TOKENS).await {
|
||||
Ok(raw) => prompt::parse_critic_output(&raw).map(|v| v.approve).unwrap_or(false),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
// Mark drafted (approved or not) so the same signature isn't re-judged every run.
|
||||
self.store.mark_pattern_status(&p.signature, "drafted").await.ok();
|
||||
if !approved {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let name = sanitize_skill_name(&draft.name);
|
||||
if name.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let scope = SkillScope::Companion(owner.to_owned());
|
||||
|
||||
// Evolve-in-place: if a near-identically-named active/draft skill exists, MERGE into it
|
||||
// (improve + version bump) instead of creating a near-duplicate (P5 T2-A). Provider error
|
||||
// → Err (terminate); any other failure degrades to the normal create path below.
|
||||
if let Ok(Some(existing)) = self.store.find_similar_skill(owner, &name).await {
|
||||
if let Ok(Some(row)) = self.store.get_skill(owner, &existing).await {
|
||||
let draft_dir = row.status == "draft";
|
||||
if let Ok(dir) = skill_service::skill_dir_for(&self.skill_paths, &scope, &existing, draft_dir) {
|
||||
if let Ok(existing_body) = tokio::fs::read_to_string(dir.join(SKILL_MANIFEST_FILE)).await {
|
||||
let merge_user = prompt::build_merge_prompt(&existing_body, &draft, p);
|
||||
match self.completer.complete(provider_id, model, prompt::MERGE_SYSTEM, &merge_user, DRAFT_MAX_TOKENS).await {
|
||||
Ok(raw) => {
|
||||
if let Ok(merged) = prompt::parse_draft_output(&raw) {
|
||||
if !merged.description.trim().is_empty() && !merged.body.trim().is_empty() {
|
||||
let merged_input = SkillDraftInput {
|
||||
name: existing.clone(),
|
||||
description: merged.description,
|
||||
when_to_use: merged.when_to_use,
|
||||
allowed_tools: None,
|
||||
paths: None,
|
||||
body: merged.body,
|
||||
};
|
||||
let md = skill_service::build_skill_md(&merged_input);
|
||||
if skill_service::write_skill(&self.skill_paths, &scope, draft_dir, &existing, &md).await.is_ok() {
|
||||
let _ = self.store.bump_skill_version(owner, &existing).await;
|
||||
self.emitter.emit_skill_learned(owner, &existing);
|
||||
self.store.mark_pattern_status(&p.signature, "drafted").await.ok();
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// merge attempt failed softly → fall through to normal create.
|
||||
}
|
||||
|
||||
let input = SkillDraftInput {
|
||||
name: name.clone(),
|
||||
description: draft.description.clone(),
|
||||
when_to_use: draft.when_to_use.clone(),
|
||||
allowed_tools: None,
|
||||
paths: None,
|
||||
body: draft.body.clone(),
|
||||
};
|
||||
let confidence = ((p.distinct_sessions as f64) / ((min_distinct + 2) as f64)).clamp(0.3, 0.95);
|
||||
// High-confidence auto-activation only when the user opted in AND confidence clears
|
||||
// the bar (repetition-derived; single-session reflections never reach it).
|
||||
let auto = auto_activate && confidence >= auto_threshold;
|
||||
|
||||
if let Err(e) = skill_service::create_skill(&self.skill_paths, &scope, /* draft= */ !auto, &input).await {
|
||||
tracing::warn!(error = %e, skill = %name, "evolution failed to write skill");
|
||||
return Ok(false);
|
||||
}
|
||||
let now = now_ms();
|
||||
let skill = CompanionSkill {
|
||||
skill_name: name.clone(),
|
||||
scope_kind: "companion".into(),
|
||||
scope_companion_id: owner.to_owned(),
|
||||
status: if auto { "active".into() } else { "draft".into() },
|
||||
source: "mined".into(),
|
||||
confidence,
|
||||
provenance: p.example_event_ids.clone(),
|
||||
strength: 1.0,
|
||||
version: 1,
|
||||
superseded_by: None,
|
||||
usage_count: 0,
|
||||
last_used_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
signature: p.signature.clone(),
|
||||
};
|
||||
if let Err(e) = self.store.insert_skill(&skill).await {
|
||||
tracing::warn!(error = %e, "evolution failed to insert skill row");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if auto {
|
||||
// Auto-activated: no review card, but emit skill-learned so the UI toasts and
|
||||
// the skill shows as active (the user can still archive it — "see + undo").
|
||||
self.emitter.emit_skill_learned(owner, &name);
|
||||
} else {
|
||||
let action = serde_json::json!({
|
||||
"type": "create_skill",
|
||||
"name": name,
|
||||
"companion_id": owner,
|
||||
"signature": p.signature,
|
||||
});
|
||||
let title = format!("我学会了一个新技能:{name}");
|
||||
let body = format!("你做过「{}」这套操作,我把它固化成了技能,采纳后我就能自动帮你做。", draft.description);
|
||||
if let Ok(created) = self.store.insert_suggestion("create_skill", &title, &body, Some(&action)).await {
|
||||
self.emitter.emit_suggestion_created(&owner, &created);
|
||||
}
|
||||
self.emitter.emit_skill_drafted(owner, &name);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// On-demand "learn by demonstration" (P5 T2-B): draft a skill from a single demonstrated
|
||||
/// tool-name sequence, bypassing the miner/dedup/critic (the user is deliberately teaching).
|
||||
/// Always a reviewable draft, `source="demonstrated"` (never decays, never auto-activates).
|
||||
/// `anchor` rehydrates the real session transcript for richer drafting (whole-conversation
|
||||
/// window from the caller); degrades to steps-only when unavailable.
|
||||
/// Returns the drafted skill name, or `None` if the model produced nothing usable.
|
||||
pub async fn draft_from_episode(
|
||||
&self,
|
||||
steps: Vec<String>,
|
||||
anchor: TranscriptAnchor,
|
||||
owner: &str,
|
||||
) -> Result<Option<String>, AppError> {
|
||||
if steps.len() < 2 || owner.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let model = {
|
||||
let cfg = self.config.read().await;
|
||||
if cfg.evolve.model.is_configured() { cfg.evolve.model.clone() } else { cfg.learn.model.clone() }
|
||||
};
|
||||
if !model.is_configured() {
|
||||
return Err(AppError::BadRequest("尚未配置学习模型".into()));
|
||||
}
|
||||
let p = MinedPattern {
|
||||
signature: crate::evolution::tool_call_signature(&steps),
|
||||
steps: steps.clone(),
|
||||
count: 1,
|
||||
distinct_sessions: 1,
|
||||
example_event_ids: vec![],
|
||||
anchor,
|
||||
};
|
||||
let context = self.rehydrate_lines(&p.anchor).await;
|
||||
let draft_user = prompt::build_draft_prompt(&p, &context);
|
||||
let mut draft: Option<DraftOutput> = None;
|
||||
for _ in 0..2 {
|
||||
match self.completer.complete(&model.provider_id, &model.model, prompt::DRAFT_SYSTEM, &draft_user, DRAFT_MAX_TOKENS).await {
|
||||
Ok(raw) => {
|
||||
if let Ok(d) = prompt::parse_draft_output(&raw) {
|
||||
if !d.name.trim().is_empty() && !d.description.trim().is_empty() {
|
||||
draft = Some(d);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
let Some(draft) = draft else { return Ok(None) };
|
||||
let name = sanitize_skill_name(&draft.name);
|
||||
if name.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let input = SkillDraftInput {
|
||||
name: name.clone(),
|
||||
description: draft.description.clone(),
|
||||
when_to_use: draft.when_to_use.clone(),
|
||||
allowed_tools: None,
|
||||
paths: None,
|
||||
body: draft.body.clone(),
|
||||
};
|
||||
let scope = SkillScope::Companion(owner.to_owned());
|
||||
skill_service::create_skill(&self.skill_paths, &scope, true, &input)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("write demonstrated skill: {e}")))?;
|
||||
let now = now_ms();
|
||||
self.store
|
||||
.insert_skill(&CompanionSkill {
|
||||
skill_name: name.clone(),
|
||||
scope_kind: "companion".into(),
|
||||
scope_companion_id: owner.to_owned(),
|
||||
status: "draft".into(),
|
||||
source: "demonstrated".into(),
|
||||
confidence: 0.5,
|
||||
provenance: vec![],
|
||||
strength: 1.0,
|
||||
version: 1,
|
||||
superseded_by: None,
|
||||
usage_count: 0,
|
||||
last_used_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
signature: String::new(),
|
||||
})
|
||||
.await?;
|
||||
let action = serde_json::json!({ "type": "create_skill", "name": name, "companion_id": owner, "signature": "" });
|
||||
let title = format!("我学会了你示范的技能:{name}");
|
||||
let body = format!("照你示范的「{}」整理成了技能,采纳后我就能复用。", draft.description);
|
||||
if let Ok(created) = self.store.insert_suggestion("create_skill", &title, &body, Some(&action)).await {
|
||||
self.emitter.emit_suggestion_created(&owner, &created);
|
||||
}
|
||||
self.emitter.emit_skill_drafted(owner, &name);
|
||||
Ok(Some(name))
|
||||
}
|
||||
}
|
||||
|
||||
/// 归一化技能名 → kebab-case 合法目录名(create_skill 再过 validate_filename)。
|
||||
/// 全非 ASCII(无可用字符)→ 空串,调用方跳过。
|
||||
fn sanitize_skill_name(raw: &str) -> String {
|
||||
let mut s: String = raw
|
||||
.trim()
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
|
||||
.collect();
|
||||
while s.contains("--") {
|
||||
s = s.replace("--", "-");
|
||||
}
|
||||
s.trim_matches('-').chars().take(64).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::collector::{CollectedEvent, append_event};
|
||||
use crate::evolution::transcript::test_util::StubTranscript;
|
||||
use crate::evolution::transcript::{NoopTranscriptSource, TranscriptTurn};
|
||||
use crate::profile::SharedCompanionConfig;
|
||||
use nomifun_realtime::BroadcastEventBus;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// 按 system 提示区分起草/评审两次调用。
|
||||
struct ScriptedCompleter {
|
||||
draft: String,
|
||||
approve: bool,
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl CompanionCompleter for ScriptedCompleter {
|
||||
async fn complete(&self, _p: &str, _m: &str, system: &str, _u: &str, _t: u32) -> Result<String, AppError> {
|
||||
if system == prompt::DRAFT_SYSTEM {
|
||||
Ok(self.draft.clone())
|
||||
} else {
|
||||
Ok(format!("{{\"approve\":{}}}", self.approve))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Records every draft `user` prompt so tests can assert what the drafter actually saw.
|
||||
struct CapturingCompleter {
|
||||
draft: String,
|
||||
approve: bool,
|
||||
draft_prompts: Arc<tokio::sync::Mutex<Vec<String>>>,
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl CompanionCompleter for CapturingCompleter {
|
||||
async fn complete(&self, _p: &str, _m: &str, system: &str, user: &str, _t: u32) -> Result<String, AppError> {
|
||||
if system == prompt::DRAFT_SYSTEM {
|
||||
self.draft_prompts.lock().await.push(user.to_owned());
|
||||
Ok(self.draft.clone())
|
||||
} else {
|
||||
Ok(format!("{{\"approve\":{}}}", self.approve))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn test_skill_paths(dir: &std::path::Path) -> Arc<SkillPaths> {
|
||||
Arc::new(SkillPaths {
|
||||
data_dir: dir.to_path_buf(),
|
||||
user_skills_dir: dir.join("skills"),
|
||||
cron_skills_dir: dir.join("cron/skills"),
|
||||
builtin_skills_dir: dir.join("builtin-skills"),
|
||||
builtin_rules_dir: dir.join("rules"),
|
||||
assistant_rules_dir: dir.join("assistant-rules"),
|
||||
assistant_skills_dir: dir.join("assistant-skills"),
|
||||
})
|
||||
}
|
||||
|
||||
fn seed_tool_calls(dir: &std::path::Path) {
|
||||
let base = now_ms();
|
||||
let mut k = 0i64;
|
||||
for conv in ["c1", "c2", "c3"] {
|
||||
for tool in ["grep", "read", "edit"] {
|
||||
k += 1;
|
||||
append_event(
|
||||
dir,
|
||||
&CollectedEvent {
|
||||
ts: base + k,
|
||||
source: "tool_calls".into(),
|
||||
name: "tool.call".into(),
|
||||
data: serde_json::json!({"name": tool, "conversation_id": conv, "call_id": format!("{conv}-{tool}")}),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn make_engine(dir: &std::path::Path, draft: &str, approve: bool) -> (EvolutionEngine, String) {
|
||||
make_engine_with(dir, Arc::new(ScriptedCompleter { draft: draft.to_owned(), approve })).await
|
||||
}
|
||||
|
||||
async fn make_engine_with(dir: &std::path::Path, completer: Arc<dyn CompanionCompleter>) -> (EvolutionEngine, String) {
|
||||
let mut config = SharedCompanionConfig::default();
|
||||
config.evolve.enabled = true;
|
||||
config.evolve.model.provider_id = "prov_t".into();
|
||||
config.evolve.model.model = "test-model".into();
|
||||
config.evolve.min_pattern_count = 3;
|
||||
config.evolve.min_distinct_sessions = 2;
|
||||
let registry = Arc::new(CompanionRegistry::scan(dir.join("companions"), dir.join("shared")));
|
||||
let companion = registry.create("测试", "ink").await.unwrap();
|
||||
config.default_companion_id = companion.id.clone();
|
||||
let engine = EvolutionEngine {
|
||||
companion_dir: dir.to_path_buf(),
|
||||
config: Arc::new(RwLock::new(config)),
|
||||
store: CompanionStore::open_memory().await.unwrap(),
|
||||
registry,
|
||||
completer,
|
||||
emitter: CompanionEventEmitter::new(Arc::new(BroadcastEventBus::new(16))),
|
||||
skill_paths: test_skill_paths(dir),
|
||||
transcript: std::sync::RwLock::new(Arc::new(NoopTranscriptSource)),
|
||||
run_lock: Arc::new(Mutex::new(())),
|
||||
};
|
||||
(engine, companion.id)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_once_mines_drafts_and_suggests() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
seed_tool_calls(dir.path());
|
||||
let draft = r#"{"name":"grep-read-edit","description":"查找并修改代码","when_to_use":"改 bug 时","body":"步骤"}"#;
|
||||
let (engine, cid) = make_engine(dir.path(), draft, true).await;
|
||||
let run = engine.run_once().await.unwrap();
|
||||
assert_eq!(run.status, "ok");
|
||||
assert!(run.patterns_found >= 1, "expected a mined pattern");
|
||||
assert_eq!(run.drafts_created, 1);
|
||||
// 注册表一条 draft 技能
|
||||
let skills = engine.store.list_skills(&cid, false).await.unwrap();
|
||||
assert_eq!(skills.len(), 1);
|
||||
assert_eq!(skills[0].status, "draft");
|
||||
assert_eq!(skills[0].source, "mined");
|
||||
// 一条 create_skill 建议卡
|
||||
let sugs = engine.store.list_suggestions(Some("new"), 10).await.unwrap();
|
||||
assert_eq!(sugs.len(), 1);
|
||||
assert_eq!(sugs[0].kind, "create_skill");
|
||||
// 草稿 SKILL.md 落盘
|
||||
let draft_md = dir.path().join("skills/_drafts").join(&cid).join("grep-read-edit/SKILL.md");
|
||||
assert!(draft_md.exists(), "draft SKILL.md missing at {}", draft_md.display());
|
||||
// cursor 推进;二次运行无新事件
|
||||
assert!(engine.store.get_state_i64("evolve_cursor_ts").await.unwrap() > 0);
|
||||
let run2 = engine.run_once().await.unwrap();
|
||||
assert_eq!(run2.drafts_created, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_once_skips_when_model_unconfigured() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
seed_tool_calls(dir.path());
|
||||
let (engine, _) = make_engine(dir.path(), "{}", true).await;
|
||||
engine.config.write().await.evolve.model = Default::default();
|
||||
let run = engine.run_once().await.unwrap();
|
||||
assert_eq!(run.status, "model_unconfigured");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_once_critic_reject_creates_no_skill() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
seed_tool_calls(dir.path());
|
||||
let draft = r#"{"name":"x","description":"d","body":"b"}"#;
|
||||
let (engine, cid) = make_engine(dir.path(), draft, false).await;
|
||||
let run = engine.run_once().await.unwrap();
|
||||
assert_eq!(run.drafts_created, 0);
|
||||
assert_eq!(engine.store.list_skills(&cid, false).await.unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn evolve_falls_back_to_learn_model_when_unconfigured() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
seed_tool_calls(dir.path());
|
||||
let draft = r#"{"name":"gre","description":"d","when_to_use":"w","body":"b"}"#;
|
||||
let (engine, _cid) = make_engine(dir.path(), draft, true).await;
|
||||
{
|
||||
let mut cfg = engine.config.write().await;
|
||||
cfg.evolve.model = Default::default(); // no dedicated evolve model
|
||||
cfg.learn.model.provider_id = "prov_t".into(); // learn model configured
|
||||
cfg.learn.model.model = "test-model".into();
|
||||
}
|
||||
let run = engine.run_once().await.unwrap();
|
||||
assert_ne!(run.status, "model_unconfigured", "should fall back to the learn model");
|
||||
assert_eq!(run.drafts_created, 1);
|
||||
}
|
||||
|
||||
fn seed_repeated(dir: &std::path::Path, convs: &[&str], tools: &[&str]) {
|
||||
let base = now_ms();
|
||||
let mut k = 0i64;
|
||||
for conv in convs {
|
||||
for tool in tools {
|
||||
k += 1;
|
||||
append_event(
|
||||
dir,
|
||||
&CollectedEvent {
|
||||
ts: base + k,
|
||||
source: "tool_calls".into(),
|
||||
name: "tool.call".into(),
|
||||
data: serde_json::json!({"name": tool, "conversation_id": conv, "call_id": format!("{conv}-{tool}-{k}")}),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn high_confidence_pattern_auto_activates_when_enabled() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// 4 distinct sessions repeating the same 3-step pattern → confidence ≥ 0.85.
|
||||
seed_repeated(dir.path(), &["c1", "c2", "c3", "c4"], &["grep", "read", "edit"]);
|
||||
let draft = r#"{"name":"auto-skill","description":"d","when_to_use":"w","body":"b"}"#;
|
||||
let (engine, cid) = make_engine(dir.path(), draft, true).await;
|
||||
engine.config.write().await.evolve.auto_activate = true;
|
||||
let run = engine.run_once().await.unwrap();
|
||||
assert_eq!(run.drafts_created, 1);
|
||||
let skills = engine.store.list_skills(&cid, false).await.unwrap();
|
||||
assert_eq!(skills.len(), 1);
|
||||
assert_eq!(skills[0].status, "active", "high-confidence pattern should auto-activate");
|
||||
assert!(dir.path().join("skills/companion").join(&cid).join("auto-skill").join("SKILL.md").exists());
|
||||
// auto path emits no review card
|
||||
assert!(engine.store.list_suggestions(Some("new"), 10).await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reflection_drafts_single_complex_session_and_never_auto_activates() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// one session, a long non-repeating tool sequence (5 steps) → reflection candidate.
|
||||
seed_repeated(dir.path(), &["solo"], &["grep", "read", "edit", "write", "bash"]);
|
||||
let draft = r#"{"name":"reflect-skill","description":"d","when_to_use":"w","body":"b"}"#;
|
||||
let (engine, cid) = make_engine(dir.path(), draft, true).await;
|
||||
// even with auto on, a single-session reflection (distinct=1, low confidence) stays a draft.
|
||||
engine.config.write().await.evolve.auto_activate = true;
|
||||
let run = engine.run_once().await.unwrap();
|
||||
assert_eq!(run.drafts_created, 1);
|
||||
let skills = engine.store.list_skills(&cid, false).await.unwrap();
|
||||
assert_eq!(skills.len(), 1);
|
||||
assert_eq!(skills[0].status, "draft", "single-session reflection must be reviewed, not auto-activated");
|
||||
}
|
||||
|
||||
struct VersioningCompleter;
|
||||
#[async_trait::async_trait]
|
||||
impl CompanionCompleter for VersioningCompleter {
|
||||
async fn complete(&self, _p: &str, _m: &str, system: &str, _u: &str, _t: u32) -> Result<String, AppError> {
|
||||
if system == prompt::DRAFT_SYSTEM {
|
||||
Ok(r#"{"name":"grep-read-edit-flow","description":"d","when_to_use":"w","body":"new"}"#.into())
|
||||
} else if system == prompt::CRITIC_SYSTEM {
|
||||
Ok(r#"{"approve":true}"#.into())
|
||||
} else {
|
||||
// MERGE_SYSTEM
|
||||
Ok(r#"{"name":"grep-read-edit","description":"merged desc","when_to_use":"w","body":"merged body"}"#.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn evolve_improves_similar_skill_in_place_not_duplicate() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
seed_repeated(dir.path(), &["c1", "c2", "c3"], &["grep", "read", "edit"]);
|
||||
let (engine, cid) = make_engine_with(dir.path(), Arc::new(VersioningCompleter)).await;
|
||||
// Pre-existing active skill whose name the new draft ("grep-read-edit-flow") is similar to.
|
||||
let input = SkillDraftInput {
|
||||
name: "grep-read-edit".into(),
|
||||
description: "原始".into(),
|
||||
when_to_use: None,
|
||||
allowed_tools: None,
|
||||
paths: None,
|
||||
body: "old".into(),
|
||||
};
|
||||
skill_service::create_skill(&engine.skill_paths, &SkillScope::Companion(cid.clone()), false, &input).await.unwrap();
|
||||
let now = now_ms();
|
||||
engine
|
||||
.store
|
||||
.insert_skill(&CompanionSkill {
|
||||
skill_name: "grep-read-edit".into(),
|
||||
scope_kind: "companion".into(),
|
||||
scope_companion_id: cid.clone(),
|
||||
status: "active".into(),
|
||||
source: "mined".into(),
|
||||
confidence: 0.7,
|
||||
provenance: vec![],
|
||||
strength: 1.0,
|
||||
version: 1,
|
||||
superseded_by: None,
|
||||
usage_count: 0,
|
||||
last_used_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
signature: "old-sig".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
engine.run_once().await.unwrap();
|
||||
let skills = engine.store.list_skills(&cid, false).await.unwrap();
|
||||
// No duplicate created; the similar existing skill was improved in place + version bumped.
|
||||
assert_eq!(skills.len(), 1, "should evolve in place, not duplicate");
|
||||
assert_eq!(skills[0].skill_name, "grep-read-edit");
|
||||
assert_eq!(skills[0].version, 2, "version should bump on evolve-in-place");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn draft_from_episode_creates_demonstrated_draft() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let draft = r#"{"name":"demo-flow","description":"d","when_to_use":"w","body":"b"}"#;
|
||||
let (engine, cid) = make_engine(dir.path(), draft, true).await;
|
||||
let name = engine
|
||||
.draft_from_episode(vec!["grep".into(), "read".into(), "edit".into()], TranscriptAnchor::default(), &cid)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(name.as_deref(), Some("demo-flow"));
|
||||
let skills = engine.store.list_skills(&cid, false).await.unwrap();
|
||||
assert_eq!(skills.len(), 1);
|
||||
assert_eq!(skills[0].source, "demonstrated", "demonstrated skills are exempt from decay");
|
||||
assert_eq!(skills[0].status, "draft", "demonstration always produces a reviewable draft");
|
||||
}
|
||||
|
||||
/// 守门:重水合命中 → drafter 看到真实(脱敏)转录内容,而非仅工具名。
|
||||
#[tokio::test]
|
||||
async fn process_candidate_drafts_from_rehydrated_transcript() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
seed_tool_calls(dir.path());
|
||||
let draft = r#"{"name":"grep-read-edit","description":"d","when_to_use":"w","body":"b"}"#;
|
||||
let seen = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
let completer = Arc::new(CapturingCompleter { draft: draft.into(), approve: true, draft_prompts: seen.clone() });
|
||||
let (engine, _cid) = make_engine_with(dir.path(), completer).await;
|
||||
engine.set_transcript(Arc::new(StubTranscript::with(vec![
|
||||
TranscriptTurn::user("把日志里的错误找出来改掉"),
|
||||
TranscriptTurn::tool("grep", Some("pattern=ERROR".into()), Some("命中 3 处".into())),
|
||||
])));
|
||||
engine.run_once().await.unwrap();
|
||||
let prompts = seen.lock().await;
|
||||
let dp = prompts.iter().find(|p| p.contains("可复用技能")).expect("a draft prompt was issued");
|
||||
assert!(dp.contains("实际操作过程"), "rehydrated transcript section missing: {dp}");
|
||||
assert!(dp.contains("把日志里的错误找出来改掉"), "user content missing: {dp}");
|
||||
assert!(dp.contains("命中 3 处"), "tool result missing: {dp}");
|
||||
}
|
||||
|
||||
/// 守门:悬空指针(无源,默认 Noop)→ 降级回工具名步骤,不报错、照常起草、无转录段。
|
||||
#[tokio::test]
|
||||
async fn process_candidate_degrades_when_transcript_missing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
seed_tool_calls(dir.path());
|
||||
let draft = r#"{"name":"grep-read-edit","description":"d","when_to_use":"w","body":"b"}"#;
|
||||
let seen = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
let completer = Arc::new(CapturingCompleter { draft: draft.into(), approve: true, draft_prompts: seen.clone() });
|
||||
let (engine, cid) = make_engine_with(dir.path(), completer).await; // transcript stays Noop
|
||||
let run = engine.run_once().await.unwrap();
|
||||
assert!(run.drafts_created >= 1, "must still draft from steps alone");
|
||||
let prompts = seen.lock().await;
|
||||
let dp = prompts.iter().find(|p| p.contains("可复用技能")).expect("a draft prompt was issued");
|
||||
assert!(!dp.contains("实际操作过程"), "degraded draft must carry no transcript section: {dp}");
|
||||
// The pattern steps still drive the draft.
|
||||
assert!(dp.contains("grep"), "steps still present: {dp}");
|
||||
let skills = engine.store.list_skills(&cid, false).await.unwrap();
|
||||
assert_eq!(skills.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
//! 重复挖矿器(确定性,无 LLM)。
|
||||
//!
|
||||
//! 从采集的工具调用事件里挖出"做过多次的多步套路"。只看**工具名序列**(绝不看参数值,
|
||||
//! 秘密永不入签名),按对话分组 → 折叠连续重复 → 滑窗聚合 → 跨对话计 distinct →
|
||||
//! 极大窗去重(长窗优先,丢掉被更长且覆盖度不低的窗包含的短窗)。100% 单元可测。
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::collector::CollectedEvent;
|
||||
use crate::evolution::transcript::TranscriptAnchor;
|
||||
|
||||
/// 窗口前后各保留的轮数(给 drafter 上下文);随锚带给重水合层。
|
||||
const ANCHOR_PAD_TURNS: usize = 2;
|
||||
|
||||
/// 一个被挖出的、值得固化为技能的多步套路。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MinedPattern {
|
||||
/// 稳定签名(工具名序列),= [`tool_call_signature`]。
|
||||
pub signature: String,
|
||||
/// 归一化工具名序列(多步套路的步骤)。
|
||||
pub steps: Vec<String>,
|
||||
/// 跨所有会话的总出现次数。
|
||||
pub count: i64,
|
||||
/// 出现该套路的不同会话数。
|
||||
pub distinct_sessions: usize,
|
||||
/// 几个代表性 event_id(用于技能溯源 provenance)。
|
||||
pub example_event_ids: Vec<String>,
|
||||
/// 一个代表性实例的重水合定位锚(会话 + 时间窗 + call_ids)。空 conversation_id
|
||||
/// = 无法重水合(drafter 降级回工具名步骤)。
|
||||
pub anchor: TranscriptAnchor,
|
||||
}
|
||||
|
||||
/// 多步套路的窗口长度边界:至少 2 步,至多 5 步(更长的多为一次性长链,固化价值低)。
|
||||
const MIN_STEPS: usize = 2;
|
||||
const MAX_STEPS: usize = 5;
|
||||
|
||||
/// 归一化工具名序列 → 稳定签名。工具名不含 `\u{1f}`(单元分隔符),故 join 即稳定且无碰撞。
|
||||
pub fn tool_call_signature(steps: &[String]) -> String {
|
||||
steps.join("\u{1f}")
|
||||
}
|
||||
|
||||
/// 从工具调用事件挖掘重复多步套路。
|
||||
///
|
||||
/// - `events`:oldest-first;只消费 `source == "tool_calls"` 的 `data.{name, conversation_id, call_id}`。
|
||||
/// - `min_count`:同一签名跨所有会话的总出现次数下限。
|
||||
/// - `min_distinct_sessions`:出现该签名的不同会话数下限。
|
||||
///
|
||||
/// 返回去重后的极大套路,长窗优先。
|
||||
pub fn mine_patterns(events: &[CollectedEvent], min_count: i64, min_distinct_sessions: usize) -> Vec<MinedPattern> {
|
||||
// 1) 按对话分组,保序收集 (tool_name, call_id, ts)。
|
||||
let mut by_conv: BTreeMap<String, Vec<(String, String, i64)>> = BTreeMap::new();
|
||||
for ev in events {
|
||||
if ev.source != "tool_calls" {
|
||||
continue;
|
||||
}
|
||||
let name = ev.data.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let conv = ev
|
||||
.data
|
||||
.get("conversation_id")
|
||||
.and_then(|c| c.as_str())
|
||||
.map(|s| s.to_owned())
|
||||
.or_else(|| ev.data.get("conversation_id").and_then(|c| c.as_i64()).map(|n| n.to_string()))
|
||||
.unwrap_or_default();
|
||||
if conv.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let call_id = ev.data.get("call_id").and_then(|c| c.as_str()).unwrap_or("").to_owned();
|
||||
by_conv.entry(conv).or_default().push((name.to_owned(), call_id, ev.ts));
|
||||
}
|
||||
|
||||
// 2) 每对话:折叠连续重复 → 序列;滑窗 [MIN_STEPS, MAX_STEPS] 聚合签名。
|
||||
struct Agg {
|
||||
steps: Vec<String>,
|
||||
count: i64,
|
||||
sessions: BTreeSet<String>,
|
||||
examples: Vec<String>,
|
||||
/// 首个观察到的实例锚(一个代表性会话窗口)。
|
||||
anchor: Option<TranscriptAnchor>,
|
||||
}
|
||||
let mut agg: BTreeMap<String, Agg> = BTreeMap::new();
|
||||
for (conv, calls) in &by_conv {
|
||||
// 折叠连续重复(同一工具连刷多次算一步),保留首次的 (call_id, ts)。
|
||||
let mut seq: Vec<(String, String, i64)> = Vec::new();
|
||||
for (name, eid, ts) in calls {
|
||||
if seq.last().map(|(n, _, _)| n == name).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
seq.push((name.clone(), eid.clone(), *ts));
|
||||
}
|
||||
let names: Vec<String> = seq.iter().map(|(n, _, _)| n.clone()).collect();
|
||||
let n = names.len();
|
||||
if n < MIN_STEPS {
|
||||
continue;
|
||||
}
|
||||
for len in MIN_STEPS..=MAX_STEPS.min(n) {
|
||||
for start in 0..=(n - len) {
|
||||
let window = &names[start..start + len];
|
||||
let sig = tool_call_signature(window);
|
||||
let entry = agg.entry(sig).or_insert_with(|| Agg {
|
||||
steps: window.to_vec(),
|
||||
count: 0,
|
||||
sessions: BTreeSet::new(),
|
||||
examples: Vec::new(),
|
||||
anchor: None,
|
||||
});
|
||||
entry.count += 1;
|
||||
entry.sessions.insert(conv.clone());
|
||||
// 首个实例 → 锚(代表性会话窗口)。
|
||||
if entry.anchor.is_none() {
|
||||
let slice = &seq[start..start + len];
|
||||
entry.anchor = Some(TranscriptAnchor {
|
||||
conversation_id: conv.clone(),
|
||||
start_ts: slice.first().map(|(_, _, t)| *t).unwrap_or(0),
|
||||
end_ts: slice.last().map(|(_, _, t)| *t).unwrap_or(0),
|
||||
pad_turns: ANCHOR_PAD_TURNS,
|
||||
call_ids: slice.iter().filter(|(_, e, _)| !e.is_empty()).map(|(_, e, _)| e.clone()).collect(),
|
||||
});
|
||||
}
|
||||
if entry.examples.len() < 8 {
|
||||
if let Some((_, eid, _)) = seq.get(start) {
|
||||
if !eid.is_empty() && !entry.examples.contains(eid) {
|
||||
entry.examples.push(eid.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 阈值过滤。
|
||||
let mut survivors: Vec<MinedPattern> = agg
|
||||
.into_iter()
|
||||
.filter(|(_, a)| a.count >= min_count && a.sessions.len() >= min_distinct_sessions)
|
||||
.map(|(sig, a)| MinedPattern {
|
||||
signature: sig,
|
||||
steps: a.steps,
|
||||
count: a.count,
|
||||
distinct_sessions: a.sessions.len(),
|
||||
example_event_ids: a.examples,
|
||||
anchor: a.anchor.unwrap_or_default(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 4) 极大窗去重:长窗优先;丢掉被某个更长且覆盖度 >= 自身的已留签名包含的短窗。
|
||||
survivors.sort_by(|x, y| {
|
||||
y.steps
|
||||
.len()
|
||||
.cmp(&x.steps.len())
|
||||
.then(y.distinct_sessions.cmp(&x.distinct_sessions))
|
||||
.then(x.signature.cmp(&y.signature))
|
||||
});
|
||||
let mut kept: Vec<MinedPattern> = Vec::new();
|
||||
for p in survivors {
|
||||
let subsumed = kept.iter().any(|k| {
|
||||
k.steps.len() > p.steps.len()
|
||||
&& k.distinct_sessions >= p.distinct_sessions
|
||||
&& is_contiguous_subsequence(&p.steps, &k.steps)
|
||||
});
|
||||
if !subsumed {
|
||||
kept.push(p);
|
||||
}
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
/// `needle` 是否为 `haystack` 的连续子序列。
|
||||
fn is_contiguous_subsequence(needle: &[String], haystack: &[String]) -> bool {
|
||||
if needle.is_empty() || needle.len() > haystack.len() {
|
||||
return false;
|
||||
}
|
||||
haystack.windows(needle.len()).any(|w| w == needle)
|
||||
}
|
||||
|
||||
/// 反思候选的会话序列长度上限(签名不无限膨胀)。
|
||||
const MAX_REFLECT_STEPS: usize = 8;
|
||||
|
||||
/// 任务后反思候选(design §5.5):把"单个会话里一长串多步操作"整体作为一个候选——
|
||||
/// 即使只出现一次,也在一次复杂任务后反思是否值得固化。每会话至多一条,折叠连续重复后
|
||||
/// 长度 ≥ `min_steps`(取前 [`MAX_REFLECT_STEPS`] 步作签名),`distinct_sessions=1`
|
||||
/// (故其 confidence 低、永远走人审,不会被高置信自动激活)。最多返回 `max` 条。
|
||||
pub fn mine_reflection_candidates(events: &[CollectedEvent], min_steps: usize, max: usize) -> Vec<MinedPattern> {
|
||||
let mut by_conv: BTreeMap<String, Vec<(String, String, i64)>> = BTreeMap::new();
|
||||
for ev in events {
|
||||
if ev.source != "tool_calls" {
|
||||
continue;
|
||||
}
|
||||
let name = ev.data.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let conv = ev
|
||||
.data
|
||||
.get("conversation_id")
|
||||
.and_then(|c| c.as_str())
|
||||
.map(|s| s.to_owned())
|
||||
.or_else(|| ev.data.get("conversation_id").and_then(|c| c.as_i64()).map(|n| n.to_string()))
|
||||
.unwrap_or_default();
|
||||
if conv.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let call_id = ev.data.get("call_id").and_then(|c| c.as_str()).unwrap_or("").to_owned();
|
||||
by_conv.entry(conv).or_default().push((name.to_owned(), call_id, ev.ts));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for (conv, calls) in &by_conv {
|
||||
if out.len() >= max {
|
||||
break;
|
||||
}
|
||||
let mut seq: Vec<(String, String, i64)> = Vec::new();
|
||||
for (name, eid, ts) in calls {
|
||||
if seq.last().map(|(n, _, _)| n == name).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
seq.push((name.clone(), eid.clone(), *ts));
|
||||
}
|
||||
if seq.len() < min_steps {
|
||||
continue;
|
||||
}
|
||||
let take = seq.len().min(MAX_REFLECT_STEPS);
|
||||
let taken = &seq[..take];
|
||||
let names: Vec<String> = taken.iter().map(|(n, _, _)| n.clone()).collect();
|
||||
let examples: Vec<String> =
|
||||
taken.iter().take(8).filter_map(|(_, e, _)| if e.is_empty() { None } else { Some(e.clone()) }).collect();
|
||||
let anchor = TranscriptAnchor {
|
||||
conversation_id: conv.clone(),
|
||||
start_ts: taken.first().map(|(_, _, t)| *t).unwrap_or(0),
|
||||
end_ts: taken.last().map(|(_, _, t)| *t).unwrap_or(0),
|
||||
pad_turns: ANCHOR_PAD_TURNS,
|
||||
call_ids: taken.iter().filter(|(_, e, _)| !e.is_empty()).map(|(_, e, _)| e.clone()).collect(),
|
||||
};
|
||||
out.push(MinedPattern {
|
||||
signature: tool_call_signature(&names),
|
||||
steps: names,
|
||||
count: 1,
|
||||
distinct_sessions: 1,
|
||||
example_event_ids: examples,
|
||||
anchor,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn tool_event(conv: &str, name: &str, call_id: &str, ts: i64) -> CollectedEvent {
|
||||
CollectedEvent {
|
||||
ts,
|
||||
source: "tool_calls".to_owned(),
|
||||
name: "tool.call".to_owned(),
|
||||
data: json!({ "name": name, "conversation_id": conv, "call_id": call_id }),
|
||||
}
|
||||
}
|
||||
|
||||
/// 三个会话各做一遍 [grep, read, edit] → 恰好一个套路(极大窗去重掉子窗)。
|
||||
#[test]
|
||||
fn mines_repeated_three_step_pattern_once() {
|
||||
let mut events = Vec::new();
|
||||
let mut ts = 0;
|
||||
for conv in ["conv-1", "conv-2", "conv-3"] {
|
||||
for (i, tool) in ["grep", "read", "edit"].iter().enumerate() {
|
||||
ts += 1;
|
||||
events.push(tool_event(conv, tool, &format!("{conv}-{i}"), ts));
|
||||
}
|
||||
}
|
||||
let patterns = mine_patterns(&events, 3, 2);
|
||||
assert_eq!(patterns.len(), 1, "expected exactly one maximal pattern, got {patterns:?}");
|
||||
assert_eq!(patterns[0].steps, vec!["grep".to_string(), "read".into(), "edit".into()]);
|
||||
assert!(patterns[0].count >= 3);
|
||||
assert!(patterns[0].distinct_sessions >= 2);
|
||||
// 签名只含工具名,绝无参数/秘密。
|
||||
assert!(!patterns[0].signature.contains("SECRET"));
|
||||
assert_eq!(patterns[0].signature, "grep\u{1f}read\u{1f}edit");
|
||||
// 锚指向一个代表性会话窗口(供重水合定位"那一段")。
|
||||
let a = &patterns[0].anchor;
|
||||
assert!(["conv-1", "conv-2", "conv-3"].contains(&a.conversation_id.as_str()), "anchor conv: {a:?}");
|
||||
assert!(a.start_ts > 0 && a.end_ts >= a.start_ts, "anchor ts bounds: {a:?}");
|
||||
assert_eq!(a.call_ids.len(), 3, "3 步窗 → 3 个 call_id: {a:?}");
|
||||
assert!(a.call_ids.iter().all(|c| c.starts_with(&a.conversation_id)), "call_ids 同会话: {a:?}");
|
||||
}
|
||||
|
||||
/// 反思候选也带定位锚(单会话整段)。
|
||||
#[test]
|
||||
fn reflection_candidate_carries_anchor() {
|
||||
let mut events = Vec::new();
|
||||
for (i, tool) in ["a", "b", "c", "d", "e"].iter().enumerate() {
|
||||
events.push(tool_event("sess-x", tool, &format!("sess-x-{i}"), (i as i64) + 10));
|
||||
}
|
||||
let cands = mine_reflection_candidates(&events, 4, 3);
|
||||
assert_eq!(cands.len(), 1);
|
||||
let a = &cands[0].anchor;
|
||||
assert_eq!(a.conversation_id, "sess-x");
|
||||
assert_eq!(a.start_ts, 10);
|
||||
assert!(a.end_ts >= a.start_ts);
|
||||
assert!(!a.call_ids.is_empty());
|
||||
}
|
||||
|
||||
/// 只出现在单个会话的序列被排除(distinct_sessions < 阈值)。
|
||||
#[test]
|
||||
fn excludes_single_session_sequences() {
|
||||
let mut events = Vec::new();
|
||||
// 反复出现但只在一个会话里 → distinct_sessions = 1
|
||||
for i in 0..5 {
|
||||
events.push(tool_event("only-conv", "foo", &format!("a{i}"), i * 2));
|
||||
events.push(tool_event("only-conv", "bar", &format!("b{i}"), i * 2 + 1));
|
||||
}
|
||||
let patterns = mine_patterns(&events, 2, 2);
|
||||
assert!(patterns.is_empty(), "single-session pattern must be excluded, got {patterns:?}");
|
||||
}
|
||||
|
||||
/// 连续重复同一工具被折叠为一步(不会把 [grep,grep,read] 当成三步套路)。
|
||||
#[test]
|
||||
fn collapses_consecutive_duplicates() {
|
||||
let mut events = Vec::new();
|
||||
let mut ts = 0;
|
||||
for conv in ["c1", "c2"] {
|
||||
for tool in ["grep", "grep", "grep", "read"] {
|
||||
ts += 1;
|
||||
events.push(tool_event(conv, tool, &format!("{conv}-{ts}"), ts));
|
||||
}
|
||||
}
|
||||
let patterns = mine_patterns(&events, 2, 2);
|
||||
assert_eq!(patterns.len(), 1);
|
||||
assert_eq!(patterns[0].steps, vec!["grep".to_string(), "read".into()]);
|
||||
}
|
||||
|
||||
/// 非 tool_calls 来源被忽略。
|
||||
#[test]
|
||||
fn ignores_non_tool_call_sources() {
|
||||
let events = vec![CollectedEvent {
|
||||
ts: 1,
|
||||
source: "companion_dialogues".to_owned(),
|
||||
name: "chat".to_owned(),
|
||||
data: json!({ "name": "whatever" }),
|
||||
}];
|
||||
assert!(mine_patterns(&events, 1, 1).is_empty());
|
||||
}
|
||||
|
||||
/// 单个长会话 → 一条反思候选(distinct_sessions=1);过短会话被排除。
|
||||
#[test]
|
||||
fn reflection_candidate_from_single_long_session() {
|
||||
let mut events = Vec::new();
|
||||
let mut ts = 0;
|
||||
for tool in ["grep", "read", "edit", "write"] {
|
||||
ts += 1;
|
||||
events.push(tool_event("solo", tool, &format!("e{ts}"), ts));
|
||||
}
|
||||
events.push(tool_event("short", "ls", "x", 100)); // 1-step session: excluded
|
||||
let cands = mine_reflection_candidates(&events, 4, 5);
|
||||
assert_eq!(cands.len(), 1);
|
||||
assert_eq!(cands[0].steps, vec!["grep".to_string(), "read".into(), "edit".into(), "write".into()]);
|
||||
assert_eq!(cands[0].distinct_sessions, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! 桌面伙伴自进化引擎(design §5)。
|
||||
//!
|
||||
//! 独立于轻量记忆学习器(`crate::learner`)的后台管线:从采集的工具调用事件里
|
||||
//! 挖出重复多步套路(`miner`,确定性无 LLM),起草 + 评审成 SKILL.md(`prompt` +
|
||||
//! `engine`,`one_shot_completion`),物化为待审草稿 + `create_skill` 建议卡。
|
||||
|
||||
pub mod conversation_transcript;
|
||||
pub mod engine;
|
||||
pub mod miner;
|
||||
pub mod prompt;
|
||||
pub mod transcript;
|
||||
|
||||
pub use conversation_transcript::ConversationTranscriptSource;
|
||||
pub use engine::{EvolutionEngine, EvolveRun};
|
||||
pub use miner::{mine_patterns, mine_reflection_candidates, tool_call_signature, MinedPattern};
|
||||
pub use transcript::{
|
||||
render_transcript, NoopTranscriptSource, ToolTrace, TranscriptAnchor, TranscriptSource, TranscriptTurn, TurnRole,
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
//! 技能起草器 / 评审器的提示词与严格 JSON 解析(design §5.2 / §5.3)。
|
||||
//!
|
||||
//! 两个阶段都走 `one_shot_completion(tools:[])`(选 model,不切 agent)。解析容错完全
|
||||
//! 镜像 `crate::prompt::{parse_learn_output, extract_json_object}`:容忍 ```json 围栏与
|
||||
//! 周围散文,抽最外层 `{...}`。
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::miner::MinedPattern;
|
||||
|
||||
/// 起草器输出:一份技能的 frontmatter 字段 + 正文。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DraftOutput {
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub when_to_use: Option<String>,
|
||||
#[serde(default)]
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
/// 评审器裁决。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct CriticVerdict {
|
||||
#[serde(default)]
|
||||
pub approve: bool,
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
/// 起草器 system:只产 JSON,禁围栏/散文,给精确骨架。
|
||||
pub const DRAFT_SYSTEM: &str = "你是技能起草器。用户反复做某套多步操作,你要把它固化成一个可复用技能(SKILL.md)。\
|
||||
只输出一个 JSON 对象,不要任何解释、不要代码围栏。字段:\n\
|
||||
{\"name\":\"kebab-case 短名\",\"description\":\"一句话说明这个技能做什么(必填,非空)\",\
|
||||
\"when_to_use\":\"什么情况下该用它(一句话)\",\"body\":\"## 步骤\\n1. ...\\n2. ... 的 markdown 操作手册\"}\n\
|
||||
要求:name 只含小写字母数字和连字符;description 必须非空;body 给出可照做的步骤。";
|
||||
|
||||
/// 评审器 system:判断草稿是否一个足够通用、可复用的好技能。
|
||||
pub const CRITIC_SYSTEM: &str = "你是技能评审器。判断给定技能草稿是否一个足够通用、可复用、安全的好技能。\
|
||||
只输出一个 JSON 对象,不要解释、不要围栏:\n\
|
||||
{\"approve\":true|false,\"reason\":\"一句话理由\"}\n\
|
||||
拒绝条件:过于具体只适用一次、description 空洞、含危险/破坏性操作而无防护、与常识矛盾。";
|
||||
|
||||
/// 合并/演化 system:给定一个已有技能正文和一份新证据,产出改进后的同名技能(升版本)。
|
||||
pub const MERGE_SYSTEM: &str = "你是技能演化器。已有一个技能,又观察到相关的新做法。\
|
||||
把两者合并成一份**改进版**技能,保留原优点、补充新步骤、去重。只输出一个 JSON 对象,不要解释、不要围栏:\n\
|
||||
{\"name\":\"沿用原 kebab-case 名\",\"description\":\"一句话说明(必填,非空)\",\"when_to_use\":\"何时用\",\"body\":\"改进后的 markdown 操作手册\"}";
|
||||
|
||||
/// 起草提示:给模型工具序列 + 真实操作转录(已脱敏,可空),要它产出技能字段。
|
||||
pub fn build_draft_prompt(p: &MinedPattern, transcript: &[String]) -> String {
|
||||
let steps = p.steps.join(" → ");
|
||||
let mut s = format!(
|
||||
"主人在 {} 个不同会话里反复做了这套 {} 步操作(共 {} 次):\n{}\n\n",
|
||||
p.distinct_sessions,
|
||||
p.steps.len(),
|
||||
p.count,
|
||||
steps
|
||||
);
|
||||
if !transcript.is_empty() {
|
||||
s.push_str("这是其中一次的实际操作过程(已脱敏,据此提炼可复用的做法,不要照抄一次性细节):\n");
|
||||
for r in transcript.iter().take(40) {
|
||||
s.push_str("- ");
|
||||
s.push_str(r);
|
||||
s.push('\n');
|
||||
}
|
||||
s.push('\n');
|
||||
}
|
||||
s.push_str("把它固化成一个可复用技能。按 system 要求只输出 JSON。");
|
||||
s
|
||||
}
|
||||
|
||||
/// 评审提示:给模型草稿 + 来源套路。
|
||||
pub fn build_critic_prompt(d: &DraftOutput, p: &MinedPattern) -> String {
|
||||
format!(
|
||||
"技能草稿:\nname: {}\ndescription: {}\nwhen_to_use: {}\nbody:\n{}\n\n来源:主人在 {} 个会话重复了 {} 次。\n按 system 要求只输出 JSON 裁决。",
|
||||
d.name,
|
||||
d.description,
|
||||
d.when_to_use.as_deref().unwrap_or(""),
|
||||
d.body,
|
||||
p.distinct_sessions,
|
||||
p.count
|
||||
)
|
||||
}
|
||||
|
||||
/// 合并提示:给模型已有技能正文 + 新证据,要它产出改进版。
|
||||
pub fn build_merge_prompt(existing_body: &str, draft: &DraftOutput, p: &MinedPattern) -> String {
|
||||
format!(
|
||||
"已有技能正文:\n{}\n\n新观察到的相关做法(步骤: {}):\n{}\n\n请合并成改进版(沿用原名),按 system 要求只输出 JSON。",
|
||||
existing_body,
|
||||
p.steps.join(" → "),
|
||||
draft.body
|
||||
)
|
||||
}
|
||||
|
||||
/// 解析起草器输出(容忍围栏/散文)。
|
||||
pub fn parse_draft_output(raw: &str) -> Result<DraftOutput, String> {
|
||||
let cleaned = extract_json_object(raw).ok_or_else(|| "no JSON object found in draft output".to_owned())?;
|
||||
serde_json::from_str(cleaned).map_err(|e| format!("invalid draft JSON: {e}"))
|
||||
}
|
||||
|
||||
/// 解析评审器输出。
|
||||
pub fn parse_critic_output(raw: &str) -> Result<CriticVerdict, String> {
|
||||
let cleaned = extract_json_object(raw).ok_or_else(|| "no JSON object found in critic output".to_owned())?;
|
||||
serde_json::from_str(cleaned).map_err(|e| format!("invalid critic JSON: {e}"))
|
||||
}
|
||||
|
||||
/// 抽最外层 `{...}`(与 `crate::prompt::extract_json_object` 同语义)。
|
||||
fn extract_json_object(raw: &str) -> Option<&str> {
|
||||
let start = raw.find('{')?;
|
||||
let end = raw.rfind('}')?;
|
||||
if end <= start {
|
||||
return None;
|
||||
}
|
||||
Some(&raw[start..=end])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_plain_and_fenced_draft() {
|
||||
let plain = r#"{"name":"weekly-report","description":"汇总周报","when_to_use":"周五","body":"步骤:\n1. 收集"}"#;
|
||||
let d = parse_draft_output(plain).unwrap();
|
||||
assert_eq!(d.name, "weekly-report");
|
||||
assert_eq!(d.description, "汇总周报");
|
||||
|
||||
let fenced = format!("好的:\n```json\n{plain}\n```\n以上。");
|
||||
let d2 = parse_draft_output(&fenced).unwrap();
|
||||
assert_eq!(d2.name, "weekly-report");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_description_draft_still_parses() {
|
||||
// 解析层不拒空 description(由 create_skill/critic 后续拒绝),仅保证可解析。
|
||||
let d = parse_draft_output(r#"{"name":"x","description":"","body":"y"}"#).unwrap();
|
||||
assert_eq!(d.description, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_draft_errors() {
|
||||
assert!(parse_draft_output("not json at all").is_err());
|
||||
assert!(parse_draft_output(r#"{"name": }"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_critic_verdict() {
|
||||
let approve = parse_critic_output(r#"{"approve":true,"reason":"通用"}"#).unwrap();
|
||||
assert!(approve.approve);
|
||||
let reject = parse_critic_output("裁决如下 {\"approve\":false} 完毕").unwrap();
|
||||
assert!(!reject.approve);
|
||||
// 缺字段走 serde default → approve=false
|
||||
let missing = parse_critic_output(r#"{"reason":"x"}"#).unwrap();
|
||||
assert!(!missing.approve);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_prompts_include_steps() {
|
||||
let p = MinedPattern {
|
||||
signature: "grep\u{1f}read".into(),
|
||||
steps: vec!["grep".into(), "read".into()],
|
||||
count: 4,
|
||||
distinct_sessions: 3,
|
||||
example_event_ids: vec![],
|
||||
anchor: Default::default(),
|
||||
};
|
||||
let dp = build_draft_prompt(&p, &["在仓库里查 TODO".to_string()]);
|
||||
assert!(dp.contains("grep → read"));
|
||||
assert!(dp.contains("3 个不同会话"));
|
||||
let d = DraftOutput { name: "x".into(), description: "d".into(), when_to_use: None, body: "b".into() };
|
||||
let cp = build_critic_prompt(&d, &p);
|
||||
assert!(cp.contains("name: x"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! 重水合原语(design 2026-06-23 采集接缝重构)。
|
||||
//!
|
||||
//! 技能起草需要"真实做法",但采集器只存候选索引(工具形状 + 锚点,无内容)。
|
||||
//! 内容的**唯一事实源**是会话库(`nomifun-conversation` 的 messages 表,永久 durable)。
|
||||
//! 起草时按 [`TranscriptAnchor`] 定向拉取"那一段"转录,脱敏后喂给 drafter,**用完即弃,
|
||||
//! 绝不落 companion 库**。会话被删 → `window` 返回 `None`,调用方降级回工具名步骤。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nomifun_common::AppError;
|
||||
|
||||
/// 转录窗口的定位锚:一个代表性会话 + 时间区间(call_id 辅助精确命中)。
|
||||
/// `conversation_id` 为空或时间区间无法定位 → 无法重水合(降级)。
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct TranscriptAnchor {
|
||||
/// 代表性会话(wire 形式 conversation_id,采集器即以字符串存)。
|
||||
pub conversation_id: String,
|
||||
/// 窗口首/末工具调用的采集 ts(毫秒);用于在会话里框出"那一段"。
|
||||
pub start_ts: i64,
|
||||
pub end_ts: i64,
|
||||
/// 窗口前后各额外保留的轮数(给 drafter 上下文)。
|
||||
pub pad_turns: usize,
|
||||
/// 窗口内工具 call_id(精确命中辅助;时间区间为主)。
|
||||
pub call_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// 一条转录消息的角色(由 messages.type+position 推导:right=user, left=assistant)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TurnRole {
|
||||
User,
|
||||
Assistant,
|
||||
Tool,
|
||||
}
|
||||
|
||||
/// 工具调用痕迹(名 + 参数 + 结果摘要),**调用方负责脱敏后再放入**。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ToolTrace {
|
||||
pub name: String,
|
||||
pub args: Option<String>,
|
||||
pub result: Option<String>,
|
||||
}
|
||||
|
||||
/// 一条转录消息(内容应已脱敏)。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TranscriptTurn {
|
||||
pub role: TurnRole,
|
||||
pub text: String,
|
||||
pub tool: Option<ToolTrace>,
|
||||
}
|
||||
|
||||
impl TranscriptTurn {
|
||||
pub fn user(text: impl Into<String>) -> Self {
|
||||
Self { role: TurnRole::User, text: text.into(), tool: None }
|
||||
}
|
||||
pub fn assistant(text: impl Into<String>) -> Self {
|
||||
Self { role: TurnRole::Assistant, text: text.into(), tool: None }
|
||||
}
|
||||
pub fn tool(name: impl Into<String>, args: Option<String>, result: Option<String>) -> Self {
|
||||
Self {
|
||||
role: TurnRole::Tool,
|
||||
text: String::new(),
|
||||
tool: Some(ToolTrace { name: name.into(), args, result }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 只读的转录来源:按 [`TranscriptAnchor`] 拉取相关窗口。
|
||||
///
|
||||
/// 实现者(P-D 的 `ConversationTranscriptSource`)走会话库仓储层
|
||||
/// `get_messages(conv_id)`(user 无关),把 wire conversation_id 解析为 i64,
|
||||
/// 读 full content(非 compact),脱敏后返回。会话已删/无法定位 → `Ok(None)`。
|
||||
#[async_trait]
|
||||
pub trait TranscriptSource: Send + Sync {
|
||||
async fn window(&self, anchor: &TranscriptAnchor) -> Result<Option<Vec<TranscriptTurn>>, AppError>;
|
||||
}
|
||||
|
||||
/// 兜底源:永远返回 `None`(会话库未装配前 / 测试)。drafter 据此降级回工具名步骤。
|
||||
pub struct NoopTranscriptSource;
|
||||
|
||||
#[async_trait]
|
||||
impl TranscriptSource for NoopTranscriptSource {
|
||||
async fn window(&self, _anchor: &TranscriptAnchor) -> Result<Option<Vec<TranscriptTurn>>, AppError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// 单行字符截断(与 collector 同风格,末尾省略号)。
|
||||
fn clip(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
s.to_owned()
|
||||
} else {
|
||||
let head: String = s.chars().take(max).collect();
|
||||
format!("{head}…")
|
||||
}
|
||||
}
|
||||
|
||||
/// 把转录窗口渲染成喂给 drafter 的文本行(每条消息一行;工具带参/果摘要)。
|
||||
/// 每行截断到 `max_chars_per_line`;空白消息丢弃。
|
||||
pub fn render_transcript(turns: &[TranscriptTurn], max_chars_per_line: usize) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for turn in turns {
|
||||
let line = match (&turn.role, &turn.tool) {
|
||||
(TurnRole::Tool, Some(t)) => {
|
||||
let mut s = format!("工具 {}", t.name);
|
||||
if let Some(a) = t.args.as_deref().filter(|a| !a.trim().is_empty()) {
|
||||
s.push_str(&format!("({})", clip(a, max_chars_per_line / 2)));
|
||||
}
|
||||
if let Some(r) = t.result.as_deref().filter(|r| !r.trim().is_empty()) {
|
||||
s.push_str(&format!(" → {}", clip(r, max_chars_per_line / 2)));
|
||||
}
|
||||
s
|
||||
}
|
||||
(TurnRole::User, _) => {
|
||||
let t = turn.text.trim();
|
||||
if t.is_empty() {
|
||||
continue;
|
||||
}
|
||||
format!("用户:{}", clip(t, max_chars_per_line))
|
||||
}
|
||||
(TurnRole::Assistant, _) => {
|
||||
let t = turn.text.trim();
|
||||
if t.is_empty() {
|
||||
continue;
|
||||
}
|
||||
format!("助手:{}", clip(t, max_chars_per_line))
|
||||
}
|
||||
(TurnRole::Tool, None) => continue,
|
||||
};
|
||||
out.push(line);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_util {
|
||||
//! Shared test stub. Constructed by `engine.rs` tests (P-C); the
|
||||
//! `dead_code` allow keeps the P-A-only build clean before that lands.
|
||||
#![allow(dead_code)]
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// 测试桩:返回预置窗口,并记录被请求的锚(验证三路确实经重水合)。
|
||||
pub(crate) struct StubTranscript {
|
||||
pub turns: Option<Vec<TranscriptTurn>>,
|
||||
pub seen: Arc<Mutex<Vec<TranscriptAnchor>>>,
|
||||
}
|
||||
|
||||
impl StubTranscript {
|
||||
pub fn with(turns: Vec<TranscriptTurn>) -> Self {
|
||||
Self { turns: Some(turns), seen: Arc::new(Mutex::new(Vec::new())) }
|
||||
}
|
||||
pub fn missing() -> Self {
|
||||
Self { turns: None, seen: Arc::new(Mutex::new(Vec::new())) }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TranscriptSource for StubTranscript {
|
||||
async fn window(&self, anchor: &TranscriptAnchor) -> Result<Option<Vec<TranscriptTurn>>, AppError> {
|
||||
self.seen.lock().await.push(anchor.clone());
|
||||
Ok(self.turns.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn renders_user_assistant_tool_lines_and_drops_empty() {
|
||||
let turns = vec![
|
||||
TranscriptTurn::user("帮我把这批图压缩"),
|
||||
TranscriptTurn::assistant(" "), // 空白 → 丢弃
|
||||
TranscriptTurn::tool("imagemin", Some("dir=assets".into()), Some("压了 12 张".into())),
|
||||
TranscriptTurn::assistant("已完成"),
|
||||
];
|
||||
let lines = render_transcript(&turns, 200);
|
||||
assert_eq!(lines.len(), 3, "空白助手行应被丢弃: {lines:?}");
|
||||
assert!(lines[0].starts_with("用户:"));
|
||||
assert!(lines[1].starts_with("工具 imagemin"));
|
||||
assert!(lines[1].contains("dir=assets"));
|
||||
assert!(lines[1].contains("→ 压了 12 张"));
|
||||
assert!(lines[2].starts_with("助手:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clips_long_lines() {
|
||||
let long = "x".repeat(500);
|
||||
let lines = render_transcript(&[TranscriptTurn::user(long)], 100);
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert!(lines[0].chars().count() <= "用户:".chars().count() + 100 + 1);
|
||||
assert!(lines[0].ends_with('…'));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn noop_source_returns_none() {
|
||||
let src = NoopTranscriptSource;
|
||||
let got = src.window(&TranscriptAnchor::default()).await.unwrap();
|
||||
assert!(got.is_none());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,364 @@
|
||||
//! DIY custom-figure storage for one companion (spec: DIY custom companion figure §3).
|
||||
//!
|
||||
//! Two-phase upload: the frontend first lands the processed cutout image
|
||||
//! under the OS temp upload root via `POST /api/fs/upload`, then this module
|
||||
//! validates the temp file and atomically installs it as
|
||||
//! `{companions_dir}/{companion_id}/figure.webp` — so a delete-companion `remove_dir_all`
|
||||
//! cleans the figure up together with the profile.
|
||||
//!
|
||||
//! Validation mirrors `nomifun-requirement`'s attachment ingest: the source
|
||||
//! must canonicalize to a path inside the upload root (`{temp_dir}/nomifun`,
|
||||
//! symlink-safe prefix check), carry a WebP or PNG magic number, stay within
|
||||
//! [`FIGURE_MAX_BYTES`], and measure at most [`FIGURE_MAX_DIM`] pixels per
|
||||
//! side (spec §3). The install is atomic (unique temp + rename, the
|
||||
//! crate-wide pattern from [`crate::fsio`]).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use nomifun_common::AppError;
|
||||
|
||||
/// File name of the stored figure inside `{companions_dir}/{companion_id}/`. Always
|
||||
/// `.webp`: the frontend matting pipeline encodes WebP; a transparent PNG
|
||||
/// passed through keeps its original bytes under this name (the serve
|
||||
/// handler picks the real Content-Type via [`content_type_of`]).
|
||||
pub const FIGURE_FILE: &str = "figure.webp";
|
||||
|
||||
/// Hard cap on the stored figure size. The generic upload endpoint allows
|
||||
/// 30MB, but a processed cutout (long edge ≤ 2048) has no business being
|
||||
/// larger than this.
|
||||
pub const FIGURE_MAX_BYTES: u64 = 10 * 1024 * 1024;
|
||||
|
||||
/// Hard cap on either pixel dimension (spec §3: ≤4096×4096).
|
||||
pub const FIGURE_MAX_DIM: u32 = 4096;
|
||||
|
||||
/// Only files inside this root may be ingested — `POST /api/fs/upload` lands
|
||||
/// here (the same constraint as requirement attachments).
|
||||
fn upload_root() -> std::path::PathBuf {
|
||||
std::env::temp_dir().join("nomifun")
|
||||
}
|
||||
|
||||
/// True when `bytes` starts with a WebP (`RIFF????WEBP`) or PNG
|
||||
/// (`\x89PNG\r\n\x1a\n`) magic number.
|
||||
fn has_image_magic(bytes: &[u8]) -> bool {
|
||||
let webp = bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP";
|
||||
let png = bytes.starts_with(b"\x89PNG\r\n\x1a\n");
|
||||
webp || png
|
||||
}
|
||||
|
||||
/// MIME type for serving stored figure bytes, decided by magic number.
|
||||
/// Anything that passed [`has_image_magic`] is PNG or WebP; default WebP.
|
||||
pub fn content_type_of(bytes: &[u8]) -> &'static str {
|
||||
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
"image/png"
|
||||
} else {
|
||||
"image/webp"
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixel dimensions parsed straight from the PNG / WebP container headers
|
||||
/// (no decoder dependency). `None` when the header is truncated, malformed,
|
||||
/// or an unknown WebP flavor — callers must reject such files.
|
||||
fn image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
// PNG: signature (8) + IHDR length (4) + "IHDR" (4), then BE u32 w/h.
|
||||
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
if bytes.len() < 24 || &bytes[12..16] != b"IHDR" {
|
||||
return None;
|
||||
}
|
||||
let w = u32::from_be_bytes(bytes[16..20].try_into().ok()?);
|
||||
let h = u32::from_be_bytes(bytes[20..24].try_into().ok()?);
|
||||
return Some((w, h));
|
||||
}
|
||||
// WebP: RIFF header (12), then the first chunk fourcc picks the flavor;
|
||||
// every chunk payload starts at byte 20 (fourcc 4 + chunk size 4).
|
||||
if bytes.len() < 16 || &bytes[0..4] != b"RIFF" || &bytes[8..12] != b"WEBP" {
|
||||
return None;
|
||||
}
|
||||
match &bytes[12..16] {
|
||||
// Extended: canvas w/h as LE 24-bit minus-one fields at payload +4.
|
||||
b"VP8X" if bytes.len() >= 30 => {
|
||||
let le24 = |b: &[u8]| u32::from(b[0]) | u32::from(b[1]) << 8 | u32::from(b[2]) << 16;
|
||||
Some((le24(&bytes[24..27]) + 1, le24(&bytes[27..30]) + 1))
|
||||
}
|
||||
// Lossless: signature byte 0x2F, then 14+14 bits minus-one in a LE u32.
|
||||
b"VP8L" if bytes.len() >= 25 && bytes[20] == 0x2F => {
|
||||
let b = u32::from_le_bytes(bytes[21..25].try_into().ok()?);
|
||||
Some(((b & 0x3FFF) + 1, ((b >> 14) & 0x3FFF) + 1))
|
||||
}
|
||||
// Lossy: 3-byte frame tag, start code 9D 01 2A, then LE u16 w/h
|
||||
// (low 14 bits each; the top 2 bits are upscaling hints).
|
||||
b"VP8 " if bytes.len() >= 30 && bytes[23..26] == [0x9D, 0x01, 0x2A] => {
|
||||
let w = u16::from_le_bytes(bytes[26..28].try_into().ok()?) & 0x3FFF;
|
||||
let h = u16::from_le_bytes(bytes[28..30].try_into().ok()?) & 0x3FFF;
|
||||
Some((u32::from(w), u32::from(h)))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate an uploaded figure source (the two-phase-upload temp file) and
|
||||
/// return its bytes, ready to install. Shared by the per-companion figure
|
||||
/// ([`ingest_figure`]) and the decoupled figure library
|
||||
/// ([`crate::figures`]): the source must canonicalize inside the upload
|
||||
/// sandbox, stay within [`FIGURE_MAX_BYTES`] / [`FIGURE_MAX_DIM`], and carry a
|
||||
/// real WebP or PNG magic number.
|
||||
pub fn validate_figure_source(source_path: &Path) -> Result<Vec<u8>, AppError> {
|
||||
// Resolve symlinks/`..` first, then prefix-check against the equally
|
||||
// canonicalized upload root (macOS /var → /private/var must not bypass).
|
||||
let canonical = std::fs::canonicalize(source_path).map_err(|e| {
|
||||
AppError::BadRequest(format!(
|
||||
"cannot resolve figure source '{}': {e}",
|
||||
source_path.display()
|
||||
))
|
||||
})?;
|
||||
let inside_root = std::fs::canonicalize(upload_root()).is_ok_and(|root| canonical.starts_with(&root));
|
||||
if !inside_root {
|
||||
return Err(AppError::Forbidden(format!(
|
||||
"figure source '{}' is outside the allowed sandbox",
|
||||
source_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
// Size gate before reading, so an oversized file never enters memory.
|
||||
let size = std::fs::metadata(&canonical)
|
||||
.map_err(|e| AppError::Internal(format!("stat figure source: {e}")))?
|
||||
.len();
|
||||
if size > FIGURE_MAX_BYTES {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"figure file is too large: {size} bytes (max {FIGURE_MAX_BYTES})"
|
||||
)));
|
||||
}
|
||||
|
||||
let bytes =
|
||||
std::fs::read(&canonical).map_err(|e| AppError::Internal(format!("read figure source: {e}")))?;
|
||||
if !has_image_magic(&bytes) {
|
||||
return Err(AppError::BadRequest("figure file is not a WebP or PNG image".into()));
|
||||
}
|
||||
let (width, height) =
|
||||
image_dimensions(&bytes).ok_or_else(|| AppError::BadRequest("无法解析图像尺寸".into()))?;
|
||||
if width > FIGURE_MAX_DIM || height > FIGURE_MAX_DIM {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"图像尺寸 {width}x{height} 超出上限 {FIGURE_MAX_DIM}x{FIGURE_MAX_DIM}"
|
||||
)));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Validate `source_path` and atomically install its bytes as
|
||||
/// `{companions_dir}/{companion_id}/figure.webp`.
|
||||
///
|
||||
/// The caller owns the companion-existence gate (the service 404s unknown companions
|
||||
/// before calling this, so `companion_id` is always a registry-vetted id).
|
||||
pub fn ingest_figure(companions_dir: &Path, companion_id: &str, source_path: &Path) -> Result<(), AppError> {
|
||||
let bytes = validate_figure_source(source_path)?;
|
||||
crate::fsio::save_bytes_atomic(&companions_dir.join(companion_id), FIGURE_FILE, &bytes)
|
||||
.map_err(|e| AppError::Internal(format!("save companion figure: {e}")))
|
||||
}
|
||||
|
||||
/// The stored figure bytes plus their mtime in unix seconds (the serve
|
||||
/// handler's ETag input). `None` when this companion has no figure.
|
||||
pub fn read_figure(companions_dir: &Path, companion_id: &str) -> Option<(Vec<u8>, u64)> {
|
||||
let path = companions_dir.join(companion_id).join(FIGURE_FILE);
|
||||
let mtime = std::fs::metadata(&path)
|
||||
.ok()?
|
||||
.modified()
|
||||
.ok()?
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()?
|
||||
.as_secs();
|
||||
let bytes = std::fs::read(&path).ok()?;
|
||||
Some((bytes, mtime))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A unique scratch dir inside the allowed upload root
|
||||
/// (`{temp_dir}/nomifun`) — figure sources must live under it.
|
||||
fn upload_scratch() -> tempfile::TempDir {
|
||||
let root = upload_root();
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
tempfile::Builder::new()
|
||||
.prefix("figure-test-")
|
||||
.tempdir_in(root)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A real 7×5 lossless WebP (VP8L) generated with PIL — passes both the
|
||||
/// magic check and dimension parsing.
|
||||
fn webp_bytes() -> Vec<u8> {
|
||||
vec![
|
||||
0x52, 0x49, 0x46, 0x46, 0x1E, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50,
|
||||
0x38, 0x4C, 0x11, 0x00, 0x00, 0x00, 0x2F, 0x06, 0x00, 0x01, 0x00, 0x07, 0x50, 0x8A,
|
||||
0x2A, 0xD4, 0xA3, 0xFF, 0x81, 0x88, 0xE8, 0x7F, 0x00, 0x00,
|
||||
]
|
||||
}
|
||||
|
||||
/// A real 7×5 RGBA PNG generated with PIL.
|
||||
fn png_bytes() -> Vec<u8> {
|
||||
vec![
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
|
||||
0x44, 0x52, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x05, 0x08, 0x06, 0x00, 0x00,
|
||||
0x00, 0x89, 0x9A, 0xF6, 0xD8, 0x00, 0x00, 0x00, 0x15, 0x49, 0x44, 0x41, 0x54, 0x78,
|
||||
0x9C, 0x63, 0xE4, 0x12, 0x91, 0x6B, 0x60, 0xC0, 0x01, 0x98, 0x70, 0x49, 0xD0, 0x50,
|
||||
0x12, 0x00, 0x6B, 0x56, 0x00, 0xC6, 0xD1, 0x14, 0x3D, 0x99, 0x00, 0x00, 0x00, 0x00,
|
||||
0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
|
||||
]
|
||||
}
|
||||
|
||||
/// A real 12×9 lossy WebP (`VP8 `) generated with PIL.
|
||||
fn lossy_webp_bytes() -> Vec<u8> {
|
||||
vec![
|
||||
0x52, 0x49, 0x46, 0x46, 0x3A, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50,
|
||||
0x38, 0x20, 0x2E, 0x00, 0x00, 0x00, 0xF0, 0x01, 0x00, 0x9D, 0x01, 0x2A, 0x0C, 0x00,
|
||||
0x09, 0x00, 0x01, 0x40, 0x26, 0x25, 0xA0, 0x02, 0x74, 0xBA, 0x01, 0xF8, 0x00, 0x04,
|
||||
0xC8, 0x00, 0x00, 0xFE, 0xAE, 0x17, 0xFF, 0x36, 0x04, 0x0C, 0xD0, 0xFA, 0x60, 0xFF,
|
||||
0xD2, 0x6C, 0xF1, 0x36, 0x78, 0x9B, 0x3E, 0x39, 0x80, 0x00,
|
||||
]
|
||||
}
|
||||
|
||||
/// Header of a real 21×13 extended WebP (VP8X + ALPH, PIL lossy RGBA) —
|
||||
/// `image_dimensions` only reads the first 30 bytes.
|
||||
fn vp8x_header_bytes() -> Vec<u8> {
|
||||
vec![
|
||||
0x52, 0x49, 0x46, 0x46, 0x68, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50,
|
||||
0x38, 0x58, 0x0A, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x0C,
|
||||
0x00, 0x00,
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_dimensions_parses_real_samples() {
|
||||
assert_eq!(image_dimensions(&png_bytes()), Some((7, 5)));
|
||||
assert_eq!(image_dimensions(&webp_bytes()), Some((7, 5)));
|
||||
assert_eq!(image_dimensions(&lossy_webp_bytes()), Some((12, 9)));
|
||||
assert_eq!(image_dimensions(&vp8x_header_bytes()), Some((21, 13)));
|
||||
// Valid magic but garbage payload must parse to nothing.
|
||||
assert_eq!(image_dimensions(b"RIFF\x10\x00\x00\x00WEBPVP8 fake-payload"), None);
|
||||
assert_eq!(image_dimensions(b"\x89PNG\r\n\x1a\nrest-of-png"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_type_follows_magic() {
|
||||
assert_eq!(content_type_of(&png_bytes()), "image/png");
|
||||
assert_eq!(content_type_of(&webp_bytes()), "image/webp");
|
||||
assert_eq!(content_type_of(&lossy_webp_bytes()), "image/webp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_accepts_webp_and_installs_atomically() {
|
||||
let upload = upload_scratch();
|
||||
let companions = tempfile::tempdir().unwrap();
|
||||
let source = upload.path().join("cutout.webp");
|
||||
std::fs::write(&source, webp_bytes()).unwrap();
|
||||
|
||||
ingest_figure(companions.path(), "companion_a", &source).unwrap();
|
||||
|
||||
let companion_dir = companions.path().join("companion_a");
|
||||
assert_eq!(std::fs::read(companion_dir.join(FIGURE_FILE)).unwrap(), webp_bytes());
|
||||
// Exactly the figure — no half-written temp file left behind.
|
||||
assert_eq!(std::fs::read_dir(&companion_dir).unwrap().count(), 1);
|
||||
|
||||
// PNG passes too (transparent originals skip re-encoding).
|
||||
let png = upload.path().join("cutout.png");
|
||||
std::fs::write(&png, png_bytes()).unwrap();
|
||||
ingest_figure(companions.path(), "companion_b", &png).unwrap();
|
||||
assert!(companions.path().join("companion_b").join(FIGURE_FILE).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_rejects_fake_magic() {
|
||||
let upload = upload_scratch();
|
||||
let companions = tempfile::tempdir().unwrap();
|
||||
let source = upload.path().join("fake.webp");
|
||||
std::fs::write(&source, b"GIF89a definitely not webp bytes").unwrap();
|
||||
|
||||
let err = ingest_figure(companions.path(), "companion_a", &source).unwrap_err();
|
||||
assert!(matches!(err, AppError::BadRequest(_)), "unexpected error: {err}");
|
||||
assert!(!companions.path().join("companion_a").join(FIGURE_FILE).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_rejects_oversized_dimensions() {
|
||||
let upload = upload_scratch();
|
||||
let companions = tempfile::tempdir().unwrap();
|
||||
// Hand-built IHDR claiming 4097×100 (one past FIGURE_MAX_DIM).
|
||||
let mut bytes = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0DIHDR".to_vec();
|
||||
bytes.extend_from_slice(&4097u32.to_be_bytes());
|
||||
bytes.extend_from_slice(&100u32.to_be_bytes());
|
||||
let source = upload.path().join("wide.png");
|
||||
std::fs::write(&source, &bytes).unwrap();
|
||||
|
||||
let err = ingest_figure(companions.path(), "companion_a", &source).unwrap_err();
|
||||
match &err {
|
||||
AppError::BadRequest(msg) => {
|
||||
assert!(msg.contains("4097x100"), "message lacks actual size: {msg}");
|
||||
}
|
||||
other => panic!("unexpected error: {other}"),
|
||||
}
|
||||
assert!(!companions.path().join("companion_a").join(FIGURE_FILE).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_rejects_unparseable_dimensions() {
|
||||
let upload = upload_scratch();
|
||||
let companions = tempfile::tempdir().unwrap();
|
||||
// Valid WebP magic, but the VP8 payload has no key-frame start code.
|
||||
let source = upload.path().join("opaque.webp");
|
||||
std::fs::write(&source, b"RIFF\x10\x00\x00\x00WEBPVP8 fake-payload").unwrap();
|
||||
|
||||
let err = ingest_figure(companions.path(), "companion_a", &source).unwrap_err();
|
||||
match &err {
|
||||
AppError::BadRequest(msg) => assert!(msg.contains("无法解析图像尺寸"), "msg: {msg}"),
|
||||
other => panic!("unexpected error: {other}"),
|
||||
}
|
||||
assert!(!companions.path().join("companion_a").join(FIGURE_FILE).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_rejects_oversized_file() {
|
||||
let upload = upload_scratch();
|
||||
let companions = tempfile::tempdir().unwrap();
|
||||
// Valid magic so the rejection is attributable to size alone.
|
||||
let mut bytes = webp_bytes();
|
||||
bytes.resize(FIGURE_MAX_BYTES as usize + 1, 0);
|
||||
let source = upload.path().join("huge.webp");
|
||||
std::fs::write(&source, &bytes).unwrap();
|
||||
|
||||
let err = ingest_figure(companions.path(), "companion_a", &source).unwrap_err();
|
||||
assert!(matches!(err, AppError::BadRequest(_)), "unexpected error: {err}");
|
||||
assert!(!companions.path().join("companion_a").join(FIGURE_FILE).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_rejects_source_outside_upload_root() {
|
||||
// tempdir() lands directly in temp_dir(), NOT under {temp_dir}/nomifun.
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let companions = tempfile::tempdir().unwrap();
|
||||
let source = outside.path().join("escape.webp");
|
||||
std::fs::write(&source, webp_bytes()).unwrap();
|
||||
|
||||
let err = ingest_figure(companions.path(), "companion_a", &source).unwrap_err();
|
||||
assert!(matches!(err, AppError::Forbidden(_)), "unexpected error: {err}");
|
||||
assert!(!companions.path().join("companion_a").join(FIGURE_FILE).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_returns_bytes_and_mtime() {
|
||||
let upload = upload_scratch();
|
||||
let companions = tempfile::tempdir().unwrap();
|
||||
assert!(read_figure(companions.path(), "companion_a").is_none());
|
||||
|
||||
let source = upload.path().join("cutout.webp");
|
||||
std::fs::write(&source, webp_bytes()).unwrap();
|
||||
ingest_figure(companions.path(), "companion_a", &source).unwrap();
|
||||
|
||||
let (bytes, mtime) = read_figure(companions.path(), "companion_a").unwrap();
|
||||
assert_eq!(bytes, webp_bytes());
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
assert!(mtime > 0 && mtime <= now + 60, "mtime {mtime} not near now {now}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
//! Decoupled custom-figure **library**: figures live independently of any companion,
|
||||
//! so a user can create/import a figure up-front (from the 电子伙伴 home page)
|
||||
//! before a companion exists, reuse one figure across several companions, and pick a saved
|
||||
//! figure when creating/editing a companion.
|
||||
//!
|
||||
//! Storage (shared, under the backend data dir):
|
||||
//! `{figures_dir}/{figure_id}.webp` — the processed cutout image bytes
|
||||
//! `{figures_dir}/index.json` — `{ figures: [FigureMeta, …] }`
|
||||
//!
|
||||
//! Ingest reuses [`crate::figure::validate_figure_source`] (same sandbox +
|
||||
//! magic + size + dimension checks as the per-companion path). Index read-modify-write
|
||||
//! is serialized by the caller ([`crate::service::CompanionService`] holds the lock),
|
||||
//! so these functions stay pure over `figures_dir`.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use nomifun_common::{AppError, generate_prefixed_id, now_ms};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::profile::HeadBox;
|
||||
|
||||
const INDEX_FILE: &str = "index.json";
|
||||
/// Cap on a figure's display name (chars). Generous; just stops abuse.
|
||||
const MAX_NAME_CHARS: usize = 40;
|
||||
|
||||
/// One library figure. Mirrors `FigureMeta` in the UI (`characters/types.ts`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct FigureMeta {
|
||||
/// Stable id `figure_…` (cross-device, per the primary-key terminal state).
|
||||
pub id: String,
|
||||
/// User-facing label.
|
||||
pub name: String,
|
||||
/// width / height of the cutout image.
|
||||
pub aspect: f32,
|
||||
pub head_box: HeadBox,
|
||||
/// Desk size tier: "s" | "m" | "l".
|
||||
pub size_tier: String,
|
||||
/// Creation time, unix milliseconds.
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
/// Editable library-figure metadata. Image bytes, id, aspect and created_at stay immutable.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FigureUpdate {
|
||||
pub name: Option<String>,
|
||||
pub head_box: Option<HeadBox>,
|
||||
pub size_tier: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct FigureIndex {
|
||||
#[serde(default)]
|
||||
figures: Vec<FigureMeta>,
|
||||
}
|
||||
|
||||
fn image_name(id: &str) -> String {
|
||||
format!("{id}.webp")
|
||||
}
|
||||
|
||||
/// Reject ids that could escape `figures_dir` (path separators / traversal) or
|
||||
/// don't look like our minted ids. `read`/`delete` take the id from a URL path
|
||||
/// param, so this is the trust boundary.
|
||||
fn is_safe_id(id: &str) -> bool {
|
||||
id.starts_with("figure_")
|
||||
&& id.len() <= 80
|
||||
&& !id.contains('/')
|
||||
&& !id.contains('\\')
|
||||
&& !id.contains("..")
|
||||
&& id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
}
|
||||
|
||||
fn sanitize_name(raw: &str) -> String {
|
||||
let trimmed = raw.trim();
|
||||
let name: String = trimmed.chars().take(MAX_NAME_CHARS).collect();
|
||||
if name.is_empty() { "自定义形象".to_owned() } else { name }
|
||||
}
|
||||
|
||||
fn normalize_tier(tier: &str) -> String {
|
||||
match tier {
|
||||
"s" | "l" => tier.to_owned(),
|
||||
_ => "m".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_index(figures_dir: &Path) -> FigureIndex {
|
||||
crate::fsio::load_json_or_default(&figures_dir.join(INDEX_FILE))
|
||||
}
|
||||
|
||||
fn save_index(figures_dir: &Path, index: &FigureIndex) -> Result<(), AppError> {
|
||||
crate::fsio::save_json_atomic(figures_dir, INDEX_FILE, index)
|
||||
.map_err(|e| AppError::Internal(format!("save figure index: {e}")))
|
||||
}
|
||||
|
||||
/// All saved figures, newest first.
|
||||
pub fn list(figures_dir: &Path) -> Vec<FigureMeta> {
|
||||
let mut figures = load_index(figures_dir).figures;
|
||||
figures.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
figures
|
||||
}
|
||||
|
||||
/// Ingest a validated upload as a new library figure; returns its metadata.
|
||||
pub fn create(
|
||||
figures_dir: &Path,
|
||||
source_path: &Path,
|
||||
name: &str,
|
||||
aspect: f32,
|
||||
head_box: HeadBox,
|
||||
size_tier: &str,
|
||||
) -> Result<FigureMeta, AppError> {
|
||||
let bytes = crate::figure::validate_figure_source(source_path)?;
|
||||
let id = generate_prefixed_id("figure");
|
||||
crate::fsio::save_bytes_atomic(figures_dir, &image_name(&id), &bytes)
|
||||
.map_err(|e| AppError::Internal(format!("save library figure: {e}")))?;
|
||||
|
||||
let meta = FigureMeta {
|
||||
id: id.clone(),
|
||||
name: sanitize_name(name),
|
||||
aspect,
|
||||
head_box,
|
||||
size_tier: normalize_tier(size_tier),
|
||||
created_at: now_ms(),
|
||||
};
|
||||
let mut index = load_index(figures_dir);
|
||||
index.figures.push(meta.clone());
|
||||
save_index(figures_dir, &index)?;
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
/// One figure's image bytes + mtime (unix seconds, the ETag input). `None` for
|
||||
/// an unknown/invalid id or a missing image file.
|
||||
pub fn read_image(figures_dir: &Path, id: &str) -> Option<(Vec<u8>, u64)> {
|
||||
if !is_safe_id(id) {
|
||||
return None;
|
||||
}
|
||||
let path = figures_dir.join(image_name(id));
|
||||
let mtime = std::fs::metadata(&path)
|
||||
.ok()?
|
||||
.modified()
|
||||
.ok()?
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()?
|
||||
.as_secs();
|
||||
let bytes = std::fs::read(&path).ok()?;
|
||||
Some((bytes, mtime))
|
||||
}
|
||||
|
||||
/// Rename a figure. Unknown id → 404.
|
||||
pub fn rename(figures_dir: &Path, id: &str, name: &str) -> Result<FigureMeta, AppError> {
|
||||
update(figures_dir, id, FigureUpdate { name: Some(name.to_owned()), head_box: None, size_tier: None })
|
||||
}
|
||||
|
||||
/// Update editable figure metadata. Unknown id → 404.
|
||||
pub fn update(figures_dir: &Path, id: &str, patch: FigureUpdate) -> Result<FigureMeta, AppError> {
|
||||
if !is_safe_id(id) {
|
||||
return Err(AppError::NotFound(format!("figure '{id}' not found")));
|
||||
}
|
||||
let mut index = load_index(figures_dir);
|
||||
let entry = index
|
||||
.figures
|
||||
.iter_mut()
|
||||
.find(|f| f.id == id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("figure '{id}' not found")))?;
|
||||
if let Some(name) = patch.name {
|
||||
entry.name = sanitize_name(&name);
|
||||
}
|
||||
if let Some(head_box) = patch.head_box {
|
||||
entry.head_box = head_box;
|
||||
}
|
||||
if let Some(size_tier) = patch.size_tier {
|
||||
entry.size_tier = normalize_tier(&size_tier);
|
||||
}
|
||||
let updated = entry.clone();
|
||||
save_index(figures_dir, &index)?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// Delete a figure (image + index entry). Idempotent: a missing image still
|
||||
/// drops the index entry. Unknown id → 404.
|
||||
pub fn remove(figures_dir: &Path, id: &str) -> Result<(), AppError> {
|
||||
if !is_safe_id(id) {
|
||||
return Err(AppError::NotFound(format!("figure '{id}' not found")));
|
||||
}
|
||||
let mut index = load_index(figures_dir);
|
||||
let before = index.figures.len();
|
||||
index.figures.retain(|f| f.id != id);
|
||||
if index.figures.len() == before {
|
||||
return Err(AppError::NotFound(format!("figure '{id}' not found")));
|
||||
}
|
||||
save_index(figures_dir, &index)?;
|
||||
// Best-effort image removal — the index no longer references it either way.
|
||||
let _ = std::fs::remove_file(figures_dir.join(image_name(id)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn upload_scratch() -> tempfile::TempDir {
|
||||
let root = std::env::temp_dir().join("nomifun");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
tempfile::Builder::new().prefix("figlib-test-").tempdir_in(root).unwrap()
|
||||
}
|
||||
|
||||
/// A real 7×5 lossless WebP (VP8L), same bytes the figure.rs tests use.
|
||||
fn webp_bytes() -> Vec<u8> {
|
||||
vec![
|
||||
0x52, 0x49, 0x46, 0x46, 0x1E, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50,
|
||||
0x38, 0x4C, 0x11, 0x00, 0x00, 0x00, 0x2F, 0x06, 0x00, 0x01, 0x00, 0x07, 0x50, 0x8A,
|
||||
0x2A, 0xD4, 0xA3, 0xFF, 0x81, 0x88, 0xE8, 0x7F, 0x00, 0x00,
|
||||
]
|
||||
}
|
||||
|
||||
fn make_source(upload: &tempfile::TempDir, file: &str) -> std::path::PathBuf {
|
||||
let p = upload.path().join(file);
|
||||
std::fs::write(&p, webp_bytes()).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_list_read_rename_delete_roundtrip() {
|
||||
let upload = upload_scratch();
|
||||
let figs = tempfile::tempdir().unwrap();
|
||||
let dir = figs.path();
|
||||
|
||||
let hb = HeadBox { x: 0.3, y: 0.0, w: 0.4, h: 0.4 };
|
||||
let a = create(dir, &make_source(&upload, "a.webp"), "阿狸", 0.7, hb.clone(), "l").unwrap();
|
||||
let b = create(dir, &make_source(&upload, "b.webp"), "", 1.0, hb.clone(), "bogus").unwrap();
|
||||
|
||||
assert!(a.id.starts_with("figure_"));
|
||||
assert_eq!(a.name, "阿狸");
|
||||
assert_eq!(a.size_tier, "l");
|
||||
assert_eq!(b.name, "自定义形象"); // empty → default
|
||||
assert_eq!(b.size_tier, "m"); // bogus tier → m
|
||||
|
||||
// newest first
|
||||
let listed = list(dir);
|
||||
assert_eq!(listed.len(), 2);
|
||||
assert!(listed.iter().any(|f| f.id == a.id));
|
||||
assert!(listed.iter().any(|f| f.id == b.id));
|
||||
if b.created_at > a.created_at {
|
||||
assert_eq!(listed[0].id, b.id);
|
||||
}
|
||||
|
||||
// image readable
|
||||
let (bytes, _) = read_image(dir, &a.id).unwrap();
|
||||
assert_eq!(bytes, webp_bytes());
|
||||
|
||||
// rename
|
||||
let renamed = rename(dir, &a.id, "新名字").unwrap();
|
||||
assert_eq!(renamed.name, "新名字");
|
||||
assert_eq!(list(dir).iter().find(|f| f.id == a.id).unwrap().name, "新名字");
|
||||
|
||||
// update editable framing metadata without touching immutable image/aspect.
|
||||
let updated_head = HeadBox { x: 0.1, y: 0.2, w: 0.5, h: 0.6 };
|
||||
let updated = update(
|
||||
dir,
|
||||
&a.id,
|
||||
FigureUpdate { name: Some("新取景".to_owned()), head_box: Some(updated_head.clone()), size_tier: Some("s".to_owned()) },
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(updated.name, "新取景");
|
||||
assert_eq!(updated.aspect, a.aspect);
|
||||
assert_eq!(updated.created_at, a.created_at);
|
||||
assert_eq!(updated.head_box, updated_head);
|
||||
assert_eq!(updated.size_tier, "s");
|
||||
assert_eq!(list(dir).iter().find(|f| f.id == a.id).unwrap().head_box, updated_head);
|
||||
|
||||
// delete drops index + image
|
||||
remove(dir, &a.id).unwrap();
|
||||
assert_eq!(list(dir).len(), 1);
|
||||
assert!(read_image(dir, &a.id).is_none());
|
||||
assert!(remove(dir, &a.id).is_err()); // already gone → 404
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsafe_ids() {
|
||||
let figs = tempfile::tempdir().unwrap();
|
||||
assert!(read_image(figs.path(), "../escape").is_none());
|
||||
assert!(read_image(figs.path(), "figure_../x").is_none());
|
||||
assert!(read_image(figs.path(), "notaprefix").is_none());
|
||||
assert!(rename(figs.path(), "../x", "n").is_err());
|
||||
assert!(update(figs.path(), "../x", FigureUpdate { name: Some("n".into()), head_box: None, size_tier: None }).is_err());
|
||||
assert!(remove(figs.path(), "figure_a/b").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Shared file-IO helpers for the companion domain's small JSON config files:
|
||||
//! atomic temp+rename writes and lenient reads that fall back to `Default`
|
||||
//! (a corrupt config must never brick boot).
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
/// Process-wide sequence shared by every atomic save in this crate, so two
|
||||
/// concurrent saves (even of different config types into the same dir) can
|
||||
/// never collide on a temp name and rename each other's half-written temp
|
||||
/// into place.
|
||||
static SAVE_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Atomically persist `value` as pretty JSON to `{dir}/{file}`: write a
|
||||
/// uniquely-named temp file, then rename it over the target. On any error
|
||||
/// the temp file is removed.
|
||||
pub(crate) fn save_json_atomic(dir: &Path, file: &str, value: &impl Serialize) -> std::io::Result<()> {
|
||||
let raw = serde_json::to_string_pretty(value).expect("companion config types serialize");
|
||||
save_bytes_atomic(dir, file, raw.as_bytes())
|
||||
}
|
||||
|
||||
/// Atomically persist raw `bytes` to `{dir}/{file}` (the same unique-temp +
|
||||
/// rename pattern as [`save_json_atomic`] — both draw temp names from
|
||||
/// [`SAVE_SEQ`] so concurrent saves into one dir can never collide).
|
||||
pub(crate) fn save_bytes_atomic(dir: &Path, file: &str, bytes: &[u8]) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let path = dir.join(file);
|
||||
let seq = SAVE_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let tmp = dir.join(format!(".{file}.tmp.{}.{seq}", std::process::id()));
|
||||
let result = std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &path));
|
||||
if result.is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Load JSON from `path`, falling back to `T::default()` when the file is
|
||||
/// missing or unreadable.
|
||||
pub(crate) fn load_json_or_default<T: DeserializeOwned + Default>(path: &Path) -> T {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, path = %path.display(), "companion json unreadable; using defaults");
|
||||
T::default()
|
||||
}),
|
||||
Err(_) => T::default(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! Gamification helpers shared by status reporting and the learner.
|
||||
//! (The legacy in-crate chat loop was replaced by companion threads — real
|
||||
//! `type='nomi'` conversations driven by the full agent engine; see
|
||||
//! `companion.rs`.)
|
||||
|
||||
/// Level curve: Lv = floor(sqrt(xp/100)) + 1.
|
||||
pub fn level_for_xp(xp: i64) -> i64 {
|
||||
((xp.max(0) as f64 / 100.0).sqrt() as i64) + 1
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn level_curve() {
|
||||
assert_eq!(level_for_xp(0), 1);
|
||||
assert_eq!(level_for_xp(99), 1);
|
||||
assert_eq!(level_for_xp(100), 2);
|
||||
assert_eq!(level_for_xp(400), 3);
|
||||
assert_eq!(level_for_xp(1600), 5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
//! The scheduled learning loop: every tick, if enabled and due, read new
|
||||
//! collected events, run one LLM distillation call, and apply the output
|
||||
//! (memories / reinforcement / supersedes / suggestions / mood / diary).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_ai_agent::nomi_config;
|
||||
use nomifun_ai_agent::{one_shot_completion, resolve_provider_config, user_message};
|
||||
use nomifun_common::{AppError, generate_prefixed_id, now_ms};
|
||||
use nomifun_db::IProviderRepository;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::collector::{SharedConfig, read_events_since};
|
||||
use crate::events::CompanionEventEmitter;
|
||||
use crate::prompt::{self, LEARN_MAX_TOKENS};
|
||||
use crate::registry::CompanionRegistry;
|
||||
use crate::store::{MemoryFilter, CompanionLearnRun, CompanionStore};
|
||||
|
||||
const MAX_EVENTS_PER_RUN: usize = 300;
|
||||
const TICK_SECONDS: u64 = 60;
|
||||
/// After this many consecutive scheduled runs fail to parse, the batch is
|
||||
/// abandoned (cursor advanced) instead of re-burning tokens forever.
|
||||
const PARSE_FAIL_GIVE_UP_RUNS: i64 = 3;
|
||||
|
||||
/// LLM seam so tests can run the learner without a live provider.
|
||||
/// (Companion chat runs on the real agent engine; this trait only serves
|
||||
/// the scheduled learning distillation calls.)
|
||||
#[async_trait::async_trait]
|
||||
pub trait CompanionCompleter: Send + Sync {
|
||||
async fn complete(&self, provider_id: &str, model: &str, system: &str, user: &str, max_tokens: u32)
|
||||
-> Result<String, AppError>;
|
||||
}
|
||||
|
||||
/// Production completer: provider row → nomi Config → one-shot completion.
|
||||
pub struct LiveCompanionCompleter {
|
||||
pub provider_repo: Arc<dyn IProviderRepository>,
|
||||
pub encryption_key: [u8; 32],
|
||||
pub workspace: PathBuf,
|
||||
}
|
||||
|
||||
impl LiveCompanionCompleter {
|
||||
async fn resolve(&self, provider_id: &str, model: &str) -> Result<nomi_config::config::Config, AppError> {
|
||||
resolve_provider_config(
|
||||
&self.provider_repo,
|
||||
&self.encryption_key,
|
||||
provider_id,
|
||||
model,
|
||||
&self.workspace,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl CompanionCompleter for LiveCompanionCompleter {
|
||||
async fn complete(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
model: &str,
|
||||
system: &str,
|
||||
user: &str,
|
||||
max_tokens: u32,
|
||||
) -> Result<String, AppError> {
|
||||
let cfg = self.resolve(provider_id, model).await?;
|
||||
one_shot_completion(&cfg, system, vec![user_message(user)], max_tokens).await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Learner {
|
||||
pub companion_dir: PathBuf,
|
||||
pub config: SharedConfig,
|
||||
pub store: CompanionStore,
|
||||
/// Companion roster: learn-run XP is a shared achievement granted to every companion.
|
||||
pub registry: Arc<CompanionRegistry>,
|
||||
pub completer: Arc<dyn CompanionCompleter>,
|
||||
pub emitter: CompanionEventEmitter,
|
||||
/// Re-entrancy guard shared between the tick loop and "run now".
|
||||
pub run_lock: Arc<Mutex<()>>,
|
||||
}
|
||||
|
||||
impl Learner {
|
||||
/// Spawn the periodic tick loop.
|
||||
pub fn spawn(self: Arc<Self>) {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(TICK_SECONDS));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let (enabled, interval_minutes) = {
|
||||
let cfg = self.config.read().await;
|
||||
(cfg.learn.enabled, cfg.learn.interval_minutes.max(5) as i64)
|
||||
};
|
||||
if !enabled {
|
||||
continue;
|
||||
}
|
||||
let last_run = self.store.get_state_i64("last_learn_ts").await.unwrap_or(0);
|
||||
if now_ms() - last_run < interval_minutes * 60_000 {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = self.run_once().await {
|
||||
tracing::warn!(error = %e, "companion scheduled learn run failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// One learning run. Returns the persisted run record.
|
||||
pub async fn run_once(&self) -> Result<CompanionLearnRun, AppError> {
|
||||
let Ok(_guard) = self.run_lock.try_lock() else {
|
||||
return Err(AppError::Conflict("a learn run is already in progress".into()));
|
||||
};
|
||||
let started_at = now_ms();
|
||||
// Stamp first so a crashed/failed run doesn't hot-loop the scheduler.
|
||||
self.store.set_state("last_learn_ts", &started_at.to_string()).await?;
|
||||
|
||||
let model = { self.config.read().await.learn.model.clone() };
|
||||
let mut run = CompanionLearnRun {
|
||||
id: generate_prefixed_id("plr"),
|
||||
started_at,
|
||||
finished_at: None,
|
||||
status: "ok".into(),
|
||||
events_processed: 0,
|
||||
memories_added: 0,
|
||||
suggestions_added: 0,
|
||||
error: None,
|
||||
summary: None,
|
||||
};
|
||||
|
||||
if !model.is_configured() {
|
||||
run.status = "model_unconfigured".into();
|
||||
run.finished_at = Some(now_ms());
|
||||
self.store.insert_learn_run(&run).await?;
|
||||
return Ok(run);
|
||||
}
|
||||
|
||||
let cursor = self.store.get_state_i64("learn_cursor_ts").await?;
|
||||
let (events, truncated) = read_events_since(&self.companion_dir, cursor, MAX_EVENTS_PER_RUN);
|
||||
if events.is_empty() {
|
||||
run.status = "no_events".into();
|
||||
run.finished_at = Some(now_ms());
|
||||
self.store.insert_learn_run(&run).await?;
|
||||
return Ok(run);
|
||||
}
|
||||
run.events_processed = events.len() as i64;
|
||||
let new_cursor = events.last().map(|e| e.ts).unwrap_or(cursor);
|
||||
|
||||
// 选项A:共享学习产出只由「默认体」窗口呈现,避免 N 个伙伴窗口同时弹气泡(提示风暴)。
|
||||
let target = {
|
||||
let did = { self.config.read().await.default_companion_id.clone() };
|
||||
self.registry.resolve_default(&did).await
|
||||
};
|
||||
|
||||
self.emitter.emit_learn_started(&target);
|
||||
|
||||
// Existing-memory digest for reinforcement/conflict matching, plus
|
||||
// the pending suggestions so the model can avoid re-raising them.
|
||||
let existing = self
|
||||
.store
|
||||
.list_memories(&MemoryFilter {
|
||||
status: Some("active".into()),
|
||||
limit: 120,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let pending_suggestions = self.store.list_suggestions(Some("new"), 50).await.unwrap_or_default();
|
||||
let event_lines: Vec<String> = events
|
||||
.iter()
|
||||
.map(|e| serde_json::to_string(e).unwrap_or_default())
|
||||
.collect();
|
||||
let user_prompt = prompt::build_learn_prompt(&existing, &pending_suggestions, &event_lines, truncated);
|
||||
|
||||
// One retry on parse failure (the model occasionally wraps in prose).
|
||||
let mut parsed = None;
|
||||
let mut last_err = String::new();
|
||||
let mut provider_failed = false;
|
||||
for attempt in 0..2 {
|
||||
match self
|
||||
.completer
|
||||
.complete(&model.provider_id, &model.model, prompt::LEARN_SYSTEM, &user_prompt, LEARN_MAX_TOKENS)
|
||||
.await
|
||||
{
|
||||
Ok(raw) => match prompt::parse_learn_output(&raw) {
|
||||
Ok(out) => {
|
||||
parsed = Some(out);
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
last_err = e;
|
||||
tracing::debug!(attempt, error = %last_err, "companion learn output unparseable");
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
last_err = e.to_string();
|
||||
provider_failed = true;
|
||||
break; // provider failure: don't burn a retry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(output) = parsed else {
|
||||
run.status = "error".into();
|
||||
run.error = Some(last_err);
|
||||
run.finished_at = Some(now_ms());
|
||||
// Provider failure is transient: keep the cursor so the same
|
||||
// events retry once the provider recovers. Parse failure is the
|
||||
// model misformatting — retry the batch a few scheduled runs,
|
||||
// then advance past it so a consistently-confused model can't
|
||||
// re-burn tokens on the same batch forever.
|
||||
if !provider_failed {
|
||||
let streak = self.store.get_state_i64("learn_parse_fail_streak").await.unwrap_or(0) + 1;
|
||||
if streak >= PARSE_FAIL_GIVE_UP_RUNS {
|
||||
self.store.set_state("learn_cursor_ts", &new_cursor.to_string()).await?;
|
||||
self.store.set_state("learn_parse_fail_streak", "0").await?;
|
||||
tracing::warn!(events = run.events_processed, "companion learn batch abandoned after repeated parse failures");
|
||||
} else {
|
||||
self.store.set_state("learn_parse_fail_streak", &streak.to_string()).await?;
|
||||
}
|
||||
}
|
||||
self.store.insert_learn_run(&run).await?;
|
||||
self.emitter.emit_learn_finished(&target, &run);
|
||||
return Ok(run);
|
||||
};
|
||||
let _ = self.store.set_state("learn_parse_fail_streak", "0").await;
|
||||
|
||||
// Apply: decay first, then reinforce/supersede/insert.
|
||||
let _ = self.store.decay_memories().await;
|
||||
self.store.reinforce_memories(&output.reinforce_ids).await?;
|
||||
self.store.archive_memories(&output.supersede_ids).await?;
|
||||
|
||||
let prior_active = self.store.count_memories("active").await.unwrap_or(0);
|
||||
for m in &output.memories {
|
||||
if self.store.find_similar_active(&m.kind, &m.content).await?.is_some() {
|
||||
continue;
|
||||
}
|
||||
self.store
|
||||
.insert_memory(&m.kind, &m.content, &m.tags, m.importance, "learn")
|
||||
.await?;
|
||||
run.memories_added += 1;
|
||||
}
|
||||
// First-preference milestone: the moment nomi visibly "gets" you.
|
||||
if prior_active == 0 && run.memories_added > 0 {
|
||||
let milestone = self
|
||||
.store
|
||||
.insert_suggestion(
|
||||
"insight",
|
||||
"nomi 学会了关于你的第一条记忆!",
|
||||
"我开始懂你了,快来记忆页看看吧~",
|
||||
Some(&serde_json::json!({"type": "navigate", "to": "/nomi?tab=memories"})),
|
||||
)
|
||||
.await?;
|
||||
run.suggestions_added += 1;
|
||||
self.emitter.emit_suggestion_created(&target, &milestone);
|
||||
}
|
||||
for s in output.suggestions.iter().take(3) {
|
||||
// Insert-side dedup backstop: even when the model ignores the
|
||||
// "don't repeat pending suggestions" rule, a similar status='new'
|
||||
// suggestion blocks the duplicate. The hit is not silently
|
||||
// dropped: the existing suggestion is touched (created_at bumped)
|
||||
// so repeated evidence re-floats it instead of vanishing.
|
||||
if let Some(existing_id) = self.store.find_similar_suggestion(&s.kind, &s.title, &s.body).await? {
|
||||
if let Err(e) = self.store.touch_suggestion(&existing_id).await {
|
||||
tracing::warn!(error = %e, suggestion_id = %existing_id, "companion learn failed to touch duplicate suggestion");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let created = self
|
||||
.store
|
||||
.insert_suggestion(&s.kind, &s.title, &s.body, s.action.as_ref())
|
||||
.await?;
|
||||
run.suggestions_added += 1;
|
||||
self.emitter.emit_suggestion_created(&target, &created);
|
||||
}
|
||||
|
||||
if let Some(mood) = &output.mood {
|
||||
self.store.set_state("mood", mood).await?;
|
||||
self.emitter.emit_mood_changed(&target, mood);
|
||||
}
|
||||
run.summary = output.diary;
|
||||
|
||||
// XP: 1 per event + 5 per new memory — a shared achievement, granted
|
||||
// to every companion in the roster (spec ruling 2: the family grows
|
||||
// together on the shared learning loop).
|
||||
let _ = self
|
||||
.store
|
||||
.add_xp_all(
|
||||
&self.registry.ids().await,
|
||||
run.events_processed + run.memories_added * 5,
|
||||
)
|
||||
.await;
|
||||
|
||||
self.store.set_state("learn_cursor_ts", &new_cursor.to_string()).await?;
|
||||
run.finished_at = Some(now_ms());
|
||||
self.store.insert_learn_run(&run).await?;
|
||||
self.emitter.emit_learn_finished(&target, &run);
|
||||
Ok(run)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::collector::{CollectedEvent, append_event};
|
||||
use crate::profile::SharedCompanionConfig;
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_realtime::BroadcastEventBus;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
struct CannedCompleter(String);
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl CompanionCompleter for CannedCompleter {
|
||||
async fn complete(&self, _p: &str, _m: &str, _s: &str, _u: &str, _t: u32) -> Result<String, AppError> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Learner over a temp dir with one registered companion (so the shared XP
|
||||
/// grant has someone to land on). Returns the learner + that companion's id.
|
||||
async fn make_learner(dir: &std::path::Path, reply: &str) -> (Learner, String) {
|
||||
let mut config = SharedCompanionConfig::default();
|
||||
config.learn.model.provider_id = "prov_t".into();
|
||||
config.learn.model.model = "test-model".into();
|
||||
let registry = Arc::new(CompanionRegistry::scan(dir.join("companions"), dir.join("shared")));
|
||||
let companion = registry.create("测试宠", "ink").await.unwrap();
|
||||
let learner = Learner {
|
||||
companion_dir: dir.to_path_buf(),
|
||||
config: Arc::new(RwLock::new(config)),
|
||||
store: CompanionStore::open_memory().await.unwrap(),
|
||||
registry,
|
||||
completer: Arc::new(CannedCompleter(reply.to_owned())),
|
||||
emitter: CompanionEventEmitter::new(Arc::new(BroadcastEventBus::new(16))),
|
||||
run_lock: Arc::new(Mutex::new(())),
|
||||
};
|
||||
(learner, companion.id)
|
||||
}
|
||||
|
||||
fn seed_event(dir: &std::path::Path) {
|
||||
append_event(
|
||||
dir,
|
||||
&CollectedEvent {
|
||||
ts: now_ms(),
|
||||
source: "chat_user_messages".into(),
|
||||
name: "message.userCreated".into(),
|
||||
data: serde_json::json!({"content": "帮我看看 Rust 编译错误"}),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_once_applies_learn_output() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
seed_event(dir.path());
|
||||
let reply = r#"{"memories":[{"kind":"profile","content":"主人是 Rust 工程师","importance":0.9}],
|
||||
"suggestions":[{"kind":"insight","title":"洞察","body":"最近常调编译错误"}],
|
||||
"mood":"content","diary":"今天陪主人修了 bug~"}"#;
|
||||
let (learner, companion_id) = make_learner(dir.path(), reply).await;
|
||||
let run = learner.run_once().await.unwrap();
|
||||
assert_eq!(run.status, "ok");
|
||||
assert_eq!(run.events_processed, 1);
|
||||
assert_eq!(run.memories_added, 1);
|
||||
// 1 real suggestion + 1 first-memory milestone
|
||||
assert_eq!(run.suggestions_added, 2);
|
||||
assert_eq!(learner.store.get_state("mood").await.unwrap().unwrap(), "content");
|
||||
assert!(learner.store.get_state_i64("learn_cursor_ts").await.unwrap() > 0);
|
||||
// Shared XP grant lands on every registered companion (1 event + 1*5).
|
||||
assert_eq!(learner.store.get_companion_state_i64(&companion_id, "xp").await.unwrap(), 6);
|
||||
assert_eq!(learner.store.get_state_i64("xp").await.unwrap(), 0);
|
||||
// Cursor advanced: a second run sees no events.
|
||||
let run2 = learner.run_once().await.unwrap();
|
||||
assert_eq!(run2.status, "no_events");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_once_skips_duplicate_pending_suggestions() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
seed_event(dir.path());
|
||||
let reply = r#"{"suggestions":[{"kind":"insight","title":"最近常调编译错误","body":"建议看看构建脚本"}]}"#;
|
||||
let (learner, _) = make_learner(dir.path(), reply).await;
|
||||
|
||||
let run1 = learner.run_once().await.unwrap();
|
||||
assert_eq!(run1.suggestions_added, 1);
|
||||
assert_eq!(learner.store.count_suggestions("new").await.unwrap(), 1);
|
||||
let first = &learner.store.list_suggestions(Some("new"), 10).await.unwrap()[0];
|
||||
let (first_id, first_created_at) = (first.id.clone(), first.created_at);
|
||||
|
||||
// Same model output over a new event batch: the pending suggestion
|
||||
// blocks the duplicate, and the dedup hit touches it (created_at
|
||||
// bumped) instead of silently dropping the repeated evidence.
|
||||
// (Sleep keeps the new event's ms timestamp past the advanced
|
||||
// cursor and guarantees a strictly larger touch timestamp.)
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
seed_event(dir.path());
|
||||
let run2 = learner.run_once().await.unwrap();
|
||||
assert_eq!(run2.status, "ok");
|
||||
assert_eq!(run2.suggestions_added, 0);
|
||||
assert_eq!(learner.store.count_suggestions("new").await.unwrap(), 1);
|
||||
let touched = &learner.store.list_suggestions(Some("new"), 10).await.unwrap()[0];
|
||||
assert_eq!(touched.id, first_id, "dedup must keep the existing suggestion");
|
||||
assert!(
|
||||
touched.created_at > first_created_at,
|
||||
"dedup hit must touch the existing suggestion ({} -> {})",
|
||||
first_created_at,
|
||||
touched.created_at
|
||||
);
|
||||
|
||||
// Once decided, the same suggestion may be raised again.
|
||||
let pending = learner.store.list_suggestions(Some("new"), 10).await.unwrap();
|
||||
learner.store.decide_suggestion(&pending[0].id, false).await.unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
seed_event(dir.path());
|
||||
let run3 = learner.run_once().await.unwrap();
|
||||
assert_eq!(run3.suggestions_added, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_once_records_error_on_garbage_output() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
seed_event(dir.path());
|
||||
let (learner, _) = make_learner(dir.path(), "我不会输出 JSON").await;
|
||||
let run = learner.run_once().await.unwrap();
|
||||
assert_eq!(run.status, "error");
|
||||
assert!(run.error.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_once_skips_when_model_unconfigured() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
seed_event(dir.path());
|
||||
let (learner, _) = make_learner(dir.path(), "{}").await;
|
||||
learner.config.write().await.learn.model = Default::default();
|
||||
let run = learner.run_once().await.unwrap();
|
||||
assert_eq!(run.status, "model_unconfigured");
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingBroadcaster {
|
||||
events: std::sync::Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
||||
}
|
||||
impl nomifun_realtime::EventBroadcaster for RecordingBroadcaster {
|
||||
fn broadcast(&self, e: WebSocketMessage<serde_json::Value>) {
|
||||
self.events.lock().unwrap().push(e);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn learn_events_scoped_to_default_companion() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
seed_event(dir.path());
|
||||
let reply = r#"{"memories":[{"kind":"profile","content":"主人是 Rust 工程师","importance":0.9}],
|
||||
"suggestions":[{"kind":"insight","title":"洞察","body":"最近常调编译错误"}],
|
||||
"mood":"content","diary":"今天陪主人修了 bug~"}"#;
|
||||
|
||||
let mut config = SharedCompanionConfig::default();
|
||||
config.learn.model.provider_id = "prov_t".into();
|
||||
config.learn.model.model = "test-model".into();
|
||||
let registry = Arc::new(CompanionRegistry::scan(dir.path().join("companions"), dir.path().join("shared")));
|
||||
let _a = registry.create("甲", "ink").await.unwrap();
|
||||
let b = registry.create("乙", "ink").await.unwrap();
|
||||
config.default_companion_id = b.id.clone(); // 默认体 = 乙
|
||||
|
||||
let bc = Arc::new(RecordingBroadcaster::default());
|
||||
let learner = Learner {
|
||||
companion_dir: dir.path().to_path_buf(),
|
||||
config: Arc::new(RwLock::new(config)),
|
||||
store: CompanionStore::open_memory().await.unwrap(),
|
||||
registry,
|
||||
completer: Arc::new(CannedCompleter(reply.to_owned())),
|
||||
emitter: CompanionEventEmitter::new(bc.clone()),
|
||||
run_lock: Arc::new(Mutex::new(())),
|
||||
};
|
||||
learner.run_once().await.unwrap();
|
||||
|
||||
let events = bc.events.lock().unwrap().clone();
|
||||
for name in [
|
||||
"companion.suggestion-created",
|
||||
"companion.mood-changed",
|
||||
"companion.learn-finished",
|
||||
"companion.learn-started",
|
||||
] {
|
||||
let evs: Vec<_> = events.iter().filter(|e| e.name == name).collect();
|
||||
assert!(!evs.is_empty(), "expected at least one {name} event");
|
||||
for e in evs {
|
||||
assert_eq!(
|
||||
e.data.get("companion_id").and_then(|v| v.as_str()),
|
||||
Some(b.id.as_str()),
|
||||
"{name} 必须 scope 到默认体 乙"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! `nomifun-companion` — the desktop-companion domain: a roster of companions sharing one
|
||||
//! memory hub (opt-in event collection + scheduled LLM learning that distills
|
||||
//! events into memories + suggestions), per-companion persona companion chats over
|
||||
//! the real agent engine, and the companion config/status API surface.
|
||||
//!
|
||||
//! Layering: `profile` is the per-companion/shared config split (`config` keeps the
|
||||
//! legacy single-companion shape for migration only); `registry` is the companion roster;
|
||||
//! `store` owns the shared sqlite db under `{data_dir}/companion/shared/`;
|
||||
//! `collector` taps the global event bus and appends JSONL event files;
|
||||
//! `learner` is the periodic LLM distillation loop; `companion` is the
|
||||
//! per-companion companion chat; `service` bundles them; `routes`/`state` are the
|
||||
//! API surface; `migrate` lifts a legacy `companion/nomi/` install into the split.
|
||||
|
||||
pub mod collector;
|
||||
pub mod companion;
|
||||
pub mod config;
|
||||
pub mod events;
|
||||
pub mod evolution;
|
||||
pub mod export;
|
||||
pub mod figure;
|
||||
pub mod figures;
|
||||
mod fsio;
|
||||
pub mod gamify;
|
||||
pub mod learner;
|
||||
pub mod matting_model;
|
||||
pub mod migrate;
|
||||
pub mod profile;
|
||||
pub mod prompt;
|
||||
pub mod registry;
|
||||
pub mod routes;
|
||||
pub mod service;
|
||||
pub mod skill_sink;
|
||||
pub mod state;
|
||||
pub mod store;
|
||||
|
||||
pub use config::CompanionConfig;
|
||||
pub use events::CompanionEventEmitter;
|
||||
pub use figures::FigureMeta;
|
||||
pub use profile::{CustomFigureMeta, HeadBox, CompanionProfileConfig, CompanionWindowConfig, SharedLearnConfig, SharedCompanionConfig};
|
||||
pub use registry::CompanionRegistry;
|
||||
pub use routes::{companion_public_routes, companion_routes};
|
||||
pub use service::CompanionService;
|
||||
pub use state::CompanionRouterState;
|
||||
pub use store::CompanionStore;
|
||||
|
||||
/// Legacy single-companion directory (under the backend data dir). Kept only so
|
||||
/// boot can detect and migrate a pre-multi-companion install; new code must use
|
||||
/// [`COMPANION_SHARED_REL_DIR`] / [`COMPANION_COMPANIONS_REL_DIR`].
|
||||
pub const COMPANION_REL_DIR: &str = "companion/nomi";
|
||||
|
||||
/// Shared multi-companion artifacts (under the backend data dir): shared
|
||||
/// `config.json`, `events/*.jsonl`, `memory.db`.
|
||||
pub const COMPANION_SHARED_REL_DIR: &str = "companion/shared";
|
||||
|
||||
/// Per-companion profile roots (under the backend data dir): one
|
||||
/// `{COMPANION_COMPANIONS_REL_DIR}/{companion_id}/config.json` per companion.
|
||||
pub const COMPANION_COMPANIONS_REL_DIR: &str = "companion/companions";
|
||||
|
||||
/// 伙伴工作区树根(`{data_dir}/companion/workspaces`):与 home 目录解耦的、
|
||||
/// 见名知意的每伙伴工作目录所在(`{seq}_{净化名}`)。home 目录因注册表扫描约束
|
||||
/// (目录名==id)不可改名,故工作区另放此树,由 `extra.workspace` 指向。
|
||||
pub const COMPANION_WORKSPACES_REL_DIR: &str = "companion/workspaces";
|
||||
|
||||
/// Cached ML assets shared across companions (under the backend data dir): the
|
||||
/// MODNet matting model is proxied here once and served from `127.0.0.1`
|
||||
/// (see [`matting_model`]) so the webview never hits a remote origin or the
|
||||
/// 30 s in-worker download timeout that made DIY figures unusable.
|
||||
pub const COMPANION_MODELS_REL_DIR: &str = "companion/models";
|
||||
|
||||
/// Shared custom-figure library (under the backend data dir): reusable figures
|
||||
/// decoupled from any single companion — `{id}.webp` + `index.json` (see
|
||||
/// [`figures`]).
|
||||
pub const COMPANION_FIGURES_REL_DIR: &str = "companion/figures";
|
||||
@@ -0,0 +1,169 @@
|
||||
//! MODNet matting-model proxy: download the ML cutout model **once** from an
|
||||
//! upstream mirror, cache it on disk, and let the webview fetch it from the
|
||||
//! local backend (`GET /api/companion/matting-model`).
|
||||
//!
|
||||
//! Why this exists — the DIY custom-figure flow was "根本用不了": the renderer
|
||||
//! used to lazy-download the 25 MB model directly from `huggingface.co` inside
|
||||
//! the matting Web Worker, wrapped in a 30 s timeout that also covered the
|
||||
//! download. The download alone takes ~36 s on a good connection (and never
|
||||
//! completes behind the GFW), so the first attempt always timed out, fell back
|
||||
//! to heuristic flood-fill, and dead-ended any real photo at `MATTE_FAILED`.
|
||||
//!
|
||||
//! Moving acquisition to the backend fixes the root cause: the webview always
|
||||
//! reaches `127.0.0.1` (no remote origin, no CORS, no GFW for the local hop),
|
||||
//! the model is fetched once and persisted to disk (survives restarts), and the
|
||||
//! upstream fetch can try a China-friendly mirror before huggingface.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use nomifun_common::AppError;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// Cached model filename under `{data_dir}/companion/models/`.
|
||||
pub const MODEL_FILENAME: &str = "modnet.onnx";
|
||||
|
||||
/// Upstream sources, tried in order. `hf-mirror.com` is the standard
|
||||
/// China-friendly HuggingFace mirror; `huggingface.co` is the canonical
|
||||
/// fallback for everyone else. Same path on both.
|
||||
const UPSTREAMS: &[&str] = &[
|
||||
"https://hf-mirror.com/Xenova/modnet/resolve/main/onnx/model.onnx",
|
||||
"https://huggingface.co/Xenova/modnet/resolve/main/onnx/model.onnx",
|
||||
];
|
||||
|
||||
/// Sanity floor: the real model is ~25 MB. Anything smaller is an error page
|
||||
/// or a truncated transfer — reject it so we don't cache garbage that bricks
|
||||
/// inference forever.
|
||||
const MIN_VALID_BYTES: u64 = 8 * 1024 * 1024;
|
||||
/// Ceiling guard against a mirror that streams something absurd.
|
||||
const MAX_VALID_BYTES: u64 = 64 * 1024 * 1024;
|
||||
|
||||
/// Connect timeout per upstream attempt. The *total* transfer is intentionally
|
||||
/// uncapped — a slow 25 MB download must be allowed to finish (that uncapped
|
||||
/// completion is the whole point of moving it off the worker's 30 s timer).
|
||||
const CONNECT_TIMEOUT_SECS: u64 = 15;
|
||||
|
||||
fn is_valid_size(len: u64) -> bool {
|
||||
(MIN_VALID_BYTES..=MAX_VALID_BYTES).contains(&len)
|
||||
}
|
||||
|
||||
/// Return the on-disk path to the cached model, downloading it from an upstream
|
||||
/// mirror on first use. Concurrency-safe: a `lock` serializes first-time
|
||||
/// downloads so N concurrent callers trigger exactly one fetch (double-checked
|
||||
/// against the disk both before and after acquiring the lock).
|
||||
pub async fn ensure_model(models_dir: &Path, lock: &Mutex<()>) -> Result<PathBuf, AppError> {
|
||||
let path = models_dir.join(MODEL_FILENAME);
|
||||
|
||||
// Fast path: already cached and plausibly intact — no lock, no network.
|
||||
if let Ok(meta) = tokio::fs::metadata(&path).await
|
||||
&& is_valid_size(meta.len())
|
||||
{
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
// Slow path: serialize so concurrent first-hits share one download.
|
||||
let _guard = lock.lock().await;
|
||||
// Re-check under the lock: a racing caller may have just finished.
|
||||
if let Ok(meta) = tokio::fs::metadata(&path).await
|
||||
&& is_valid_size(meta.len())
|
||||
{
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
tokio::fs::create_dir_all(models_dir)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("create models dir: {e}")))?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
|
||||
.build()
|
||||
.map_err(|e| AppError::Internal(format!("build http client: {e}")))?;
|
||||
|
||||
let mut last_err = String::from("no upstream attempted");
|
||||
for url in UPSTREAMS {
|
||||
match download_one(&client, url).await {
|
||||
Ok(bytes) if is_valid_size(bytes.len() as u64) => {
|
||||
write_atomic(&path, &bytes).await?;
|
||||
tracing::info!(url, bytes = bytes.len(), "matting model cached");
|
||||
return Ok(path);
|
||||
}
|
||||
Ok(bytes) => {
|
||||
last_err = format!("{url}: implausible size {} bytes", bytes.len());
|
||||
tracing::warn!(url, bytes = bytes.len(), "matting model upstream returned implausible size; trying next");
|
||||
}
|
||||
Err(e) => {
|
||||
last_err = format!("{url}: {e}");
|
||||
tracing::warn!(url, error = %e, "matting model upstream failed; trying next");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(AppError::Internal(format!(
|
||||
"无法获取抠图模型(所有上游均失败): {last_err}"
|
||||
)))
|
||||
}
|
||||
|
||||
async fn download_one(client: &reqwest::Client, url: &str) -> Result<Vec<u8>, String> {
|
||||
let res = client.get(url).send().await.map_err(|e| e.to_string())?;
|
||||
if !res.status().is_success() {
|
||||
return Err(format!("HTTP {}", res.status()));
|
||||
}
|
||||
res.bytes().await.map(|b| b.to_vec()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Write to a sibling temp file then rename, so a crashed/partial download
|
||||
/// never leaves a half-written model at the real path.
|
||||
async fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), AppError> {
|
||||
let tmp = path.with_extension("onnx.partial");
|
||||
tokio::fs::write(&tmp, bytes)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("write model temp: {e}")))?;
|
||||
tokio::fs::rename(&tmp, path)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("commit model: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn size_validation_bounds() {
|
||||
assert!(!is_valid_size(0));
|
||||
assert!(!is_valid_size(1024)); // an error page
|
||||
assert!(is_valid_size(25 * 1024 * 1024)); // the real model
|
||||
assert!(!is_valid_size(128 * 1024 * 1024)); // absurd
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_returns_cached_without_network() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let models = dir.path().join("models");
|
||||
std::fs::create_dir_all(&models).unwrap();
|
||||
// Seed a plausibly-sized file so ensure_model takes the fast path and
|
||||
// never touches the network.
|
||||
let path = models.join(MODEL_FILENAME);
|
||||
std::fs::write(&path, vec![0u8; MIN_VALID_BYTES as usize + 1]).unwrap();
|
||||
let lock = Mutex::new(());
|
||||
let got = ensure_model(&models, &lock).await.unwrap();
|
||||
assert_eq!(got, path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_ignores_undersized_cache_and_then_fails_offline_cleanly() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let models = dir.path().join("models");
|
||||
std::fs::create_dir_all(&models).unwrap();
|
||||
// A truncated/garbage cache must NOT be served; with no network the
|
||||
// call fails cleanly (it does not return the bad file).
|
||||
std::fs::write(models.join(MODEL_FILENAME), b"not a model").unwrap();
|
||||
let lock = Mutex::new(());
|
||||
// We can't guarantee offline in CI, so only assert the undersized file
|
||||
// is never returned as-is: either it re-downloads a valid model, or it
|
||||
// errors — but it never returns the 11-byte path content.
|
||||
if let Ok(p) = ensure_model(&models, &lock).await {
|
||||
let len = std::fs::metadata(&p).unwrap().len();
|
||||
assert!(is_valid_size(len), "must not serve undersized cache");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
//! One-shot migration from the legacy single-companion layout (`companion/nomi/`) to the
|
||||
//! multi-companion split: shared artifacts (db + events + shared config) move to
|
||||
//! `companion/shared/`, and the old identity/persona/window settings become the
|
||||
//! first companion profile — named "Nomi" — under `companion/companions/{id}/`.
|
||||
//!
|
||||
//! Runs at boot, before the store/registry open. Idempotency is keyed on two
|
||||
//! things only: no legacy dir, or a `.migrated` marker in the legacy dir. An
|
||||
//! existing `shared` dir deliberately does *not* short-circuit — without the
|
||||
//! marker it can only be the debris of a partially failed earlier attempt,
|
||||
//! and the migration must re-run rather than silently strand (or lose) the
|
||||
//! legacy memory. To make that re-run safe, all products are built in a
|
||||
//! staging dir first and the legacy originals are only deleted after the
|
||||
//! marker is written.
|
||||
//!
|
||||
//! Two crash windows shape the re-run rules:
|
||||
//!
|
||||
//! * **Window 1 — stale legacy must not overwrite fresh shared.** If the
|
||||
//! marker write failed after the commit, the session still runs on the
|
||||
//! committed `shared` db and writes new data. The re-run therefore stages
|
||||
//! per artifact preferring `shared` over the legacy originals (a committed
|
||||
//! `shared` is always ⊇ legacy), and adopts the previously minted first
|
||||
//! companion ("Nomi") instead of generating a fresh id (which would orphan its
|
||||
//! per-companion rows and leave a ghost duplicate companion).
|
||||
//! * **Window 2 — never delete the only live copy.** Commit moves a
|
||||
//! half-built `shared` aside (`companion/.migrating-displaced-<ts>`) instead of
|
||||
//! removing it, and only sweeps displaced dirs after the marker is
|
||||
//! written. Symmetrically, the staging-residue cleanup at entry first
|
||||
//! rescues any artifact whose only copy lives in staging (a crash between
|
||||
//! "shared displaced" and "staging renamed into place").
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use nomifun_common::{generate_prefixed_id, now_ms};
|
||||
|
||||
use crate::config::CompanionConfig;
|
||||
use crate::profile::{CompanionProfileConfig, CompanionWindowConfig, SharedLearnConfig, SharedCompanionConfig};
|
||||
|
||||
/// Marker file written into the legacy dir after a successful migration;
|
||||
/// its content is the generated first-companion id.
|
||||
pub(crate) const MIGRATED_MARKER: &str = ".migrated";
|
||||
|
||||
/// Carry a pre-rename `{data}/pet` tree forward to `{data}/companion` (and the
|
||||
/// per-entity `companion/pets` subdir to `companion/companions`), so installs
|
||||
/// created under the old "pet" naming keep their data — db, figures, per-companion
|
||||
/// configs — after the pet→companion rename. Runs FIRST in [`CompanionService::start`],
|
||||
/// before [`migrate_legacy_layout`] and before the store opens. Only renames when
|
||||
/// the target doesn't already exist (a fresh `companion` dir wins); best-effort —
|
||||
/// an io error must never brick boot. Companion ids keep their opaque on-disk
|
||||
/// values (a `pet_…` id stays valid; only new companions mint `companion_…`).
|
||||
pub fn migrate_pet_dir_to_companion(data_dir: &Path) {
|
||||
let legacy_pet = data_dir.join("pet");
|
||||
let companion = data_dir.join("companion");
|
||||
if legacy_pet.is_dir() && !companion.exists() {
|
||||
if let Err(e) = std::fs::rename(&legacy_pet, &companion) {
|
||||
tracing::warn!(error = %e, "migrate '{{data}}/pet' -> '{{data}}/companion' failed; continuing");
|
||||
return;
|
||||
}
|
||||
tracing::info!("migrated legacy '{{data}}/pet' dir to '{{data}}/companion'");
|
||||
}
|
||||
let legacy_pets = companion.join("pets");
|
||||
let companions = companion.join("companions");
|
||||
if legacy_pets.is_dir() && !companions.exists() {
|
||||
if let Err(e) = std::fs::rename(&legacy_pets, &companions) {
|
||||
tracing::warn!(error = %e, "migrate 'companion/pets' -> 'companion/companions' failed; continuing");
|
||||
}
|
||||
}
|
||||
migrate_companion_config_keys(data_dir);
|
||||
}
|
||||
|
||||
/// One-time rewrite of legacy `pet_*` JSON keys in the on-disk companion config
|
||||
/// files to their `companion_*` names, so window prefs / the default-companion
|
||||
/// pointer / the seq watermark survive the rename (profile id/name/character/
|
||||
/// model already use rename-stable keys; memories migrate inside the store).
|
||||
/// Surgical quoted-key replacement only — opaque `id` values (`pet_…`) are left
|
||||
/// intact. Best-effort and idempotent (the legacy keys are gone after one run).
|
||||
fn migrate_companion_config_keys(data_dir: &Path) {
|
||||
fn rewrite_keys(path: &Path, pairs: &[(&str, &str)]) {
|
||||
if !path.is_file() {
|
||||
return;
|
||||
}
|
||||
let Ok(orig) = std::fs::read_to_string(path) else { return };
|
||||
let mut s = orig.clone();
|
||||
for (from, to) in pairs {
|
||||
s = s.replace(from, to);
|
||||
}
|
||||
if s != orig {
|
||||
let _ = std::fs::write(path, s);
|
||||
}
|
||||
}
|
||||
|
||||
let companion = data_dir.join("companion");
|
||||
let shared = companion.join("shared");
|
||||
|
||||
// Seq watermark: rename the file and its inner key.
|
||||
let old_seq = shared.join("pet_seq.json");
|
||||
let new_seq = shared.join("companion_seq.json");
|
||||
if old_seq.is_file() && !new_seq.exists() {
|
||||
if let Ok(s) = std::fs::read_to_string(&old_seq) {
|
||||
let s = s.replace("\"last_pet_seq\"", "\"last_companion_seq\"");
|
||||
if std::fs::write(&new_seq, s).is_ok() {
|
||||
let _ = std::fs::remove_file(&old_seq);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rewrite_keys(
|
||||
&shared.join("config.json"),
|
||||
&[
|
||||
("\"default_pet_id\"", "\"default_companion_id\""),
|
||||
("\"pet_dialogues\"", "\"companion_dialogues\""),
|
||||
],
|
||||
);
|
||||
|
||||
if let Ok(entries) = std::fs::read_dir(companion.join("companions")) {
|
||||
for e in entries.flatten() {
|
||||
if e.path().is_dir() {
|
||||
rewrite_keys(
|
||||
&e.path().join("config.json"),
|
||||
&[
|
||||
("\"pet_enabled\"", "\"companion_enabled\""),
|
||||
("\"pet_x\"", "\"companion_x\""),
|
||||
("\"pet_y\"", "\"companion_y\""),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scratch dir (sibling of `shared`/`companions`) where all migration products are
|
||||
/// staged before being committed into place. Leftovers from a crashed run
|
||||
/// are wiped on the next attempt — after rescuing any artifact whose only
|
||||
/// copy lives there (see [`salvage_unique_staging_copies`]).
|
||||
const STAGING_REL_DIR: &str = "companion/.migrating";
|
||||
|
||||
/// Name prefix (under `companion/`) for the dir a pre-existing `shared` is moved
|
||||
/// into during commit. Displaced dirs are only deleted after the marker is
|
||||
/// written; stale ones from crashed runs are swept on the next success.
|
||||
const DISPLACED_DIR_PREFIX: &str = ".migrating-displaced-";
|
||||
|
||||
/// The db plus its WAL sidecars (present only after an unclean shutdown).
|
||||
/// Always staged as one family keyed on `memory.db`, never mixed across
|
||||
/// source dirs.
|
||||
const DB_FILES: [&str; 3] = ["memory.db", "memory.db-wal", "memory.db-shm"];
|
||||
|
||||
/// One-shot legacy migration: companion/nomi -> companion/shared + first companion "Nomi".
|
||||
/// Idempotent and crash-safe via staging:
|
||||
///
|
||||
/// 1. Gate: only `legacy` missing or the `.migrated` marker skip the run.
|
||||
/// 2. Stage: copy (never move) db/WAL/events — per artifact preferring the
|
||||
/// committed `shared` copy over the legacy original (window 1) — and
|
||||
/// write the split configs under [`STAGING_REL_DIR`]; the sources stay
|
||||
/// untouched, so a crash anywhere in this phase restarts cleanly.
|
||||
/// 3. Commit: displace any pre-existing `shared` aside (window 2: never
|
||||
/// delete what might be the freshest copy before its replacement is in
|
||||
/// place), rename the staged trees into `shared`/`companions`, then write the
|
||||
/// marker.
|
||||
/// 4. Cleanup: only now delete the legacy db/events copies, the displaced
|
||||
/// dirs and the staging scratch; failures here are warn-only because the
|
||||
/// marker already prevents a re-run.
|
||||
///
|
||||
/// Returns Some(first_companion_id) only when migration ran.
|
||||
pub fn migrate_legacy_layout(data_dir: &Path) -> std::io::Result<Option<String>> {
|
||||
let legacy = data_dir.join(crate::COMPANION_REL_DIR);
|
||||
let shared = data_dir.join(crate::COMPANION_SHARED_REL_DIR);
|
||||
let companions = data_dir.join(crate::COMPANION_COMPANIONS_REL_DIR);
|
||||
|
||||
if !legacy.exists() || legacy.join(MIGRATED_MARKER).exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// ----- staging residue: rescue, then wipe -----
|
||||
let staging = data_dir.join(STAGING_REL_DIR);
|
||||
if staging.exists() {
|
||||
salvage_unique_staging_copies(&staging.join("shared"), &shared, &legacy)?;
|
||||
std::fs::remove_dir_all(&staging)?;
|
||||
}
|
||||
let staging_shared = staging.join("shared");
|
||||
let staging_companions = staging.join("companions");
|
||||
std::fs::create_dir_all(&staging_shared)?;
|
||||
std::fs::create_dir_all(&staging_companions)?;
|
||||
|
||||
// Copy the db family and the whole events dir into staging — copy, not
|
||||
// move: the sources must survive until the marker is written. Per
|
||||
// artifact the committed `shared` copy wins over the legacy original
|
||||
// (window 1): when a previous run committed but failed to write the
|
||||
// marker, the session kept writing into `shared`, so the legacy copy is
|
||||
// stale. The pre-staging implementation's half-moved files are covered
|
||||
// by the same preference.
|
||||
let db_src = [&shared, &legacy].into_iter().find(|dir| dir.join(DB_FILES[0]).exists());
|
||||
if let Some(src) = db_src {
|
||||
for file in DB_FILES {
|
||||
let from = src.join(file);
|
||||
if from.exists() {
|
||||
std::fs::copy(&from, staging_shared.join(file))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let legacy_events = legacy.join("events");
|
||||
let shared_events = shared.join("events");
|
||||
let events_src = [&shared_events, &legacy_events].into_iter().find(|dir| dir.exists());
|
||||
if let Some(src) = events_src {
|
||||
copy_dir_recursive(src, &staging_shared.join("events"))?;
|
||||
}
|
||||
// The registry's seq-watermark state file: only a committed `shared` can
|
||||
// hold one (the legacy layout predates companion numbering), so a copy here is
|
||||
// exactly the window-1 carry-over — without it the re-run would reset
|
||||
// the watermark and let deleted companion numbers be reused. A fresh migration
|
||||
// has none and starts at 0 (the registry mints the file on first
|
||||
// allocation).
|
||||
let seq_state = shared.join(crate::registry::SEQ_STATE_FILE);
|
||||
if seq_state.exists() {
|
||||
std::fs::copy(&seq_state, staging_shared.join(crate::registry::SEQ_STATE_FILE))?;
|
||||
}
|
||||
|
||||
// Split the legacy config: collection + learn loop go shared (the learn
|
||||
// model inherits the old single model), identity/persona/window settings
|
||||
// become the first companion profile. A re-run adopts the id of an already
|
||||
// committed first companion (window 1) instead of minting a second one — a
|
||||
// fresh id would orphan the per-companion rows written meanwhile and leave a
|
||||
// ghost duplicate "Nomi" in the roster.
|
||||
let old = CompanionConfig::load(&legacy);
|
||||
let companion_id = existing_first_companion_id(&companions).unwrap_or_else(|| generate_prefixed_id("companion"));
|
||||
|
||||
let shared_cfg = SharedCompanionConfig {
|
||||
collect: old.collect.clone(),
|
||||
learn: SharedLearnConfig {
|
||||
enabled: old.learn.enabled,
|
||||
interval_minutes: old.learn.interval_minutes,
|
||||
model: old.model.clone(),
|
||||
},
|
||||
default_companion_id: companion_id.clone(),
|
||||
bridge_to_memory_dir: None,
|
||||
..Default::default()
|
||||
};
|
||||
shared_cfg.save(&staging_shared)?;
|
||||
|
||||
let profile = CompanionProfileConfig {
|
||||
id: companion_id.clone(),
|
||||
// Window-1 adoption keeps the companion's short number too — rebuilding it
|
||||
// as None would let the boot backfill renumber an already-numbered
|
||||
// companion. A freshly minted first companion stays None and is numbered by the
|
||||
// boot backfill right after the registry scan.
|
||||
seq: CompanionProfileConfig::load(&companions.join(&companion_id)).seq,
|
||||
name: "Nomi".into(),
|
||||
character: old.appearance.character.clone(),
|
||||
persona: old.persona.clone(),
|
||||
model: old.model.clone(),
|
||||
appearance: CompanionWindowConfig {
|
||||
companion_enabled: old.appearance.companion_enabled,
|
||||
companion_x: old.appearance.companion_x,
|
||||
companion_y: old.appearance.companion_y,
|
||||
quiet_start: old.appearance.quiet_start.clone(),
|
||||
quiet_end: old.appearance.quiet_end.clone(),
|
||||
// Legacy installs predate the DIY figure feature.
|
||||
custom_figure: None,
|
||||
},
|
||||
created_at: now_ms(),
|
||||
};
|
||||
profile.save(&staging_companions.join(&companion_id))?;
|
||||
|
||||
// ----- commit -----
|
||||
// Window 2: never delete the pre-existing shared dir here — between this
|
||||
// point and the rename below, a copy of the data could otherwise exist
|
||||
// only in staging, which the next run's residue wipe would destroy.
|
||||
// Move it aside instead; displaced dirs are only swept after the marker.
|
||||
if shared.exists() {
|
||||
move_path(&shared, &next_displaced_path(data_dir))?;
|
||||
}
|
||||
move_path(&staging_shared, &shared)?;
|
||||
|
||||
if companions.exists() {
|
||||
// Defensive: merge just the staged first companion into an existing companions
|
||||
// tree instead of clobbering it.
|
||||
let target = companions.join(&companion_id);
|
||||
if target.exists() {
|
||||
std::fs::remove_dir_all(&target)?;
|
||||
}
|
||||
move_path(&staging_companions.join(&companion_id), &target)?;
|
||||
} else {
|
||||
move_path(&staging_companions, &companions)?;
|
||||
}
|
||||
|
||||
std::fs::write(legacy.join(MIGRATED_MARKER), &companion_id)?;
|
||||
|
||||
// ----- cleanup (best-effort: the marker already gates re-runs) -----
|
||||
sweep_displaced_dirs(data_dir);
|
||||
for file in DB_FILES {
|
||||
let from = legacy.join(file);
|
||||
if from.exists() {
|
||||
if let Err(e) = std::fs::remove_file(&from) {
|
||||
tracing::warn!("companion migrate: failed to remove legacy {file}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
if legacy_events.exists() {
|
||||
if let Err(e) = std::fs::remove_dir_all(&legacy_events) {
|
||||
tracing::warn!("companion migrate: failed to remove legacy events dir: {e}");
|
||||
}
|
||||
}
|
||||
if let Err(e) = std::fs::remove_dir_all(&staging) {
|
||||
tracing::warn!("companion migrate: failed to remove staging dir: {e}");
|
||||
}
|
||||
|
||||
Ok(Some(companion_id))
|
||||
}
|
||||
|
||||
/// Window 2 rescue, run before the staging-residue wipe: a crash between
|
||||
/// "shared displaced" and "staging renamed into place" (or the legacy
|
||||
/// pre-displacement implementation's `remove_dir_all(shared)` and the same
|
||||
/// rename) leaves staging holding the *only* copy of the db/events. Any such
|
||||
/// artifact — present in staging but in neither `shared` nor `legacy` — is
|
||||
/// moved back into `shared` so the wipe cannot destroy it (the stage phase
|
||||
/// then picks it up via the shared-first preference). Artifacts that still
|
||||
/// have a source copy are deliberately left to the wipe: residue from a
|
||||
/// crashed *stage* phase may be a torn partial copy, and an existing
|
||||
/// original always wins over it.
|
||||
fn salvage_unique_staging_copies(staging_shared: &Path, shared: &Path, legacy: &Path) -> std::io::Result<()> {
|
||||
if staging_shared.join(DB_FILES[0]).exists()
|
||||
&& !shared.join(DB_FILES[0]).exists()
|
||||
&& !legacy.join(DB_FILES[0]).exists()
|
||||
{
|
||||
std::fs::create_dir_all(shared)?;
|
||||
for file in DB_FILES {
|
||||
let from = staging_shared.join(file);
|
||||
if from.exists() {
|
||||
move_path(&from, &shared.join(file))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let staged_events = staging_shared.join("events");
|
||||
if staged_events.exists() && !shared.join("events").exists() && !legacy.join("events").exists() {
|
||||
std::fs::create_dir_all(shared)?;
|
||||
move_path(&staged_events, &shared.join("events"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Window 1 id adoption: the first companion a previous (marker-less) run already
|
||||
/// committed under `companions/`. Recognized by its migration-given name "Nomi"
|
||||
/// with a profile that passes the registry's sanity rule (non-empty id
|
||||
/// matching its directory). Oldest wins should several qualify.
|
||||
fn existing_first_companion_id(companions: &Path) -> Option<String> {
|
||||
let entries = std::fs::read_dir(companions).ok()?;
|
||||
let mut found: Option<CompanionProfileConfig> = None;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let profile = CompanionProfileConfig::load(&path);
|
||||
if profile.id.is_empty() || profile.id != entry.file_name().to_string_lossy() || profile.name != "Nomi" {
|
||||
continue;
|
||||
}
|
||||
if found.as_ref().is_none_or(|f| profile.created_at < f.created_at) {
|
||||
found = Some(profile);
|
||||
}
|
||||
}
|
||||
found.map(|p| p.id)
|
||||
}
|
||||
|
||||
/// A fresh, unoccupied displacement dir under `companion/` for the commit phase.
|
||||
fn next_displaced_path(data_dir: &Path) -> PathBuf {
|
||||
let companion_root = data_dir.join("companion");
|
||||
let base = now_ms();
|
||||
let mut n = 0u32;
|
||||
loop {
|
||||
let candidate = companion_root.join(format!("{DISPLACED_DIR_PREFIX}{base}-{n}"));
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete every displacement dir under `companion/` (the current run's and stale
|
||||
/// ones from crashed runs). Only called after the marker is written, so a
|
||||
/// failure merely leaves debris behind — warn, never fail the migration.
|
||||
fn sweep_displaced_dirs(data_dir: &Path) {
|
||||
let Ok(entries) = std::fs::read_dir(data_dir.join("companion")) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
if !entry.file_name().to_string_lossy().starts_with(DISPLACED_DIR_PREFIX) {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = std::fs::remove_dir_all(entry.path()) {
|
||||
tracing::warn!(dir = %entry.path().display(), "companion migrate: failed to remove displaced dir: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `fs::rename`, falling back to copy + remove when rename fails (e.g. the
|
||||
/// target lands on another volume, or Windows holds a mapping on the source).
|
||||
fn move_path(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
match std::fs::rename(from, to) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(_) => {
|
||||
if from.is_dir() {
|
||||
copy_dir_recursive(from, to)?;
|
||||
std::fs::remove_dir_all(from)
|
||||
} else {
|
||||
std::fs::copy(from, to)?;
|
||||
std::fs::remove_file(from)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_dir_recursive(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(to)?;
|
||||
for entry in std::fs::read_dir(from)? {
|
||||
let entry = entry?;
|
||||
let target = to.join(entry.file_name());
|
||||
if entry.path().is_dir() {
|
||||
copy_dir_recursive(&entry.path(), &target)?;
|
||||
} else {
|
||||
std::fs::copy(entry.path(), &target)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A legacy `companion/nomi` install: old-format config.json, a fake db file
|
||||
/// and one events JSONL.
|
||||
fn build_legacy(data_dir: &Path) {
|
||||
let legacy = data_dir.join(crate::COMPANION_REL_DIR);
|
||||
std::fs::create_dir_all(legacy.join("events")).unwrap();
|
||||
std::fs::write(
|
||||
legacy.join("config.json"),
|
||||
serde_json::json!({
|
||||
"collect": {"chat_user_messages": true, "cron_runs": true},
|
||||
"model": {"provider_id": "prov_x", "model": "claude-fable-5"},
|
||||
"learn": {"enabled": true, "interval_minutes": 30},
|
||||
"appearance": {
|
||||
"companion_enabled": true,
|
||||
"character": "ink",
|
||||
"companion_x": 12,
|
||||
"companion_y": 34,
|
||||
"quiet_start": "22:00",
|
||||
"quiet_end": "08:00"
|
||||
},
|
||||
"persona": {"preset": "calm", "custom": "多用颜文字"}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(legacy.join("memory.db"), "fake-db-bytes").unwrap();
|
||||
std::fs::write(legacy.join("events").join("20260101.jsonl"), "{\"e\":1}\n").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrates_legacy_layout_once() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
build_legacy(dir.path());
|
||||
let legacy = dir.path().join(crate::COMPANION_REL_DIR);
|
||||
let shared = dir.path().join(crate::COMPANION_SHARED_REL_DIR);
|
||||
let companions = dir.path().join(crate::COMPANION_COMPANIONS_REL_DIR);
|
||||
|
||||
let companion_id = migrate_legacy_layout(dir.path()).unwrap().expect("migration ran");
|
||||
assert!(companion_id.starts_with("companion_"));
|
||||
|
||||
// Shared config: collect + learn inherited, learn model = old model,
|
||||
// default companion points at the new first companion.
|
||||
let shared_cfg = SharedCompanionConfig::load(&shared);
|
||||
assert!(shared_cfg.collect.chat_user_messages);
|
||||
assert!(shared_cfg.collect.cron_runs);
|
||||
assert!(!shared_cfg.collect.requirements);
|
||||
assert!(shared_cfg.learn.enabled);
|
||||
assert_eq!(shared_cfg.learn.interval_minutes, 30);
|
||||
assert_eq!(shared_cfg.learn.model.provider_id, "prov_x");
|
||||
assert_eq!(shared_cfg.learn.model.model, "claude-fable-5");
|
||||
assert_eq!(shared_cfg.default_companion_id, companion_id);
|
||||
|
||||
// First companion profile: named Nomi, everything else from the old config.
|
||||
let profile = CompanionProfileConfig::load(&companions.join(&companion_id));
|
||||
assert_eq!(profile.id, companion_id);
|
||||
// A freshly minted first companion carries no number yet — the boot
|
||||
// backfill right after the registry scan assigns it.
|
||||
assert_eq!(profile.seq, None);
|
||||
assert_eq!(profile.name, "Nomi");
|
||||
assert_eq!(profile.character, "ink");
|
||||
assert_eq!(profile.persona.preset, "calm");
|
||||
assert_eq!(profile.persona.custom, "多用颜文字");
|
||||
assert_eq!(profile.model.provider_id, "prov_x");
|
||||
assert!(profile.appearance.companion_enabled);
|
||||
assert_eq!(profile.appearance.companion_x, Some(12));
|
||||
assert_eq!(profile.appearance.companion_y, Some(34));
|
||||
assert_eq!(profile.appearance.quiet_start, "22:00");
|
||||
assert_eq!(profile.appearance.quiet_end, "08:00");
|
||||
assert!(profile.created_at > 0);
|
||||
|
||||
// Db and events moved (not copied) into shared.
|
||||
assert_eq!(std::fs::read_to_string(shared.join("memory.db")).unwrap(), "fake-db-bytes");
|
||||
assert!(!legacy.join("memory.db").exists());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(shared.join("events").join("20260101.jsonl")).unwrap(),
|
||||
"{\"e\":1}\n"
|
||||
);
|
||||
assert!(!legacy.join("events").exists());
|
||||
|
||||
// Marker holds the new companion id.
|
||||
assert_eq!(std::fs::read_to_string(legacy.join(MIGRATED_MARKER)).unwrap(), companion_id);
|
||||
|
||||
// Staging scratch dir is gone after a successful run.
|
||||
assert!(!dir.path().join(STAGING_REL_DIR).exists());
|
||||
|
||||
// Second run is a no-op.
|
||||
assert_eq!(migrate_legacy_layout(dir.path()).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_legacy_dir_is_a_noop() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(migrate_legacy_layout(dir.path()).unwrap(), None);
|
||||
assert!(!dir.path().join(crate::COMPANION_SHARED_REL_DIR).exists());
|
||||
assert!(!dir.path().join(crate::COMPANION_COMPANIONS_REL_DIR).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_blocks_migration_even_with_legacy_artifacts() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
build_legacy(dir.path());
|
||||
let legacy = dir.path().join(crate::COMPANION_REL_DIR);
|
||||
std::fs::write(legacy.join(MIGRATED_MARKER), "companion_done").unwrap();
|
||||
|
||||
assert_eq!(migrate_legacy_layout(dir.path()).unwrap(), None);
|
||||
// Nothing was touched: no shared dir, legacy artifacts intact.
|
||||
assert!(!dir.path().join(crate::COMPANION_SHARED_REL_DIR).exists());
|
||||
assert!(legacy.join("memory.db").exists());
|
||||
assert!(legacy.join("events").join("20260101.jsonl").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_built_shared_without_marker_is_redone() {
|
||||
// Simulate a partial failure of a previous attempt: the shared dir
|
||||
// exists (with junk, but no memory.db) and no marker was written.
|
||||
// The migration must re-run completely instead of skipping, and the
|
||||
// legacy memory must come through intact.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
build_legacy(dir.path());
|
||||
let legacy = dir.path().join(crate::COMPANION_REL_DIR);
|
||||
let shared = dir.path().join(crate::COMPANION_SHARED_REL_DIR);
|
||||
std::fs::create_dir_all(&shared).unwrap();
|
||||
std::fs::write(shared.join("config.json"), "{\"broken\":").unwrap();
|
||||
// Staging residue from the crashed run must be cleaned up too.
|
||||
let staging = dir.path().join(STAGING_REL_DIR);
|
||||
std::fs::create_dir_all(staging.join("shared")).unwrap();
|
||||
std::fs::write(staging.join("shared").join("memory.db"), "stale-staging-bytes").unwrap();
|
||||
|
||||
let companion_id = migrate_legacy_layout(dir.path()).unwrap().expect("migration re-ran");
|
||||
|
||||
// Full products, fed from legacy (not from the stale staging residue).
|
||||
assert_eq!(std::fs::read_to_string(shared.join("memory.db")).unwrap(), "fake-db-bytes");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(shared.join("events").join("20260101.jsonl")).unwrap(),
|
||||
"{\"e\":1}\n"
|
||||
);
|
||||
let shared_cfg = SharedCompanionConfig::load(&shared);
|
||||
assert_eq!(shared_cfg.default_companion_id, companion_id);
|
||||
let companions = dir.path().join(crate::COMPANION_COMPANIONS_REL_DIR);
|
||||
assert_eq!(CompanionProfileConfig::load(&companions.join(&companion_id)).id, companion_id);
|
||||
// Marker written, legacy db/events cleaned, staging gone.
|
||||
assert_eq!(std::fs::read_to_string(legacy.join(MIGRATED_MARKER)).unwrap(), companion_id);
|
||||
assert!(!legacy.join("memory.db").exists());
|
||||
assert!(!legacy.join("events").exists());
|
||||
assert!(!staging.exists());
|
||||
|
||||
// And the redo is itself final: a third run is a no-op.
|
||||
assert_eq!(migrate_legacy_layout(dir.path()).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_moved_db_is_salvaged_from_shared() {
|
||||
// The pre-staging implementation moved memory.db into shared before
|
||||
// writing the marker. If it crashed in that window, legacy has no db
|
||||
// but shared does — the re-run must salvage it instead of replacing
|
||||
// shared with an empty tree.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
build_legacy(dir.path());
|
||||
let legacy = dir.path().join(crate::COMPANION_REL_DIR);
|
||||
let shared = dir.path().join(crate::COMPANION_SHARED_REL_DIR);
|
||||
std::fs::create_dir_all(shared.join("events")).unwrap();
|
||||
std::fs::rename(legacy.join("memory.db"), shared.join("memory.db")).unwrap();
|
||||
std::fs::rename(
|
||||
legacy.join("events").join("20260101.jsonl"),
|
||||
shared.join("events").join("20260101.jsonl"),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::remove_dir_all(legacy.join("events")).unwrap();
|
||||
|
||||
let companion_id = migrate_legacy_layout(dir.path()).unwrap().expect("migration re-ran");
|
||||
|
||||
assert_eq!(std::fs::read_to_string(shared.join("memory.db")).unwrap(), "fake-db-bytes");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(shared.join("events").join("20260101.jsonl")).unwrap(),
|
||||
"{\"e\":1}\n"
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(legacy.join(MIGRATED_MARKER)).unwrap(), companion_id);
|
||||
assert!(!dir.path().join(STAGING_REL_DIR).exists());
|
||||
}
|
||||
|
||||
/// Dirs under `companion/` left by the commit's shared displacement.
|
||||
fn displaced_dirs(data_dir: &Path) -> Vec<std::path::PathBuf> {
|
||||
std::fs::read_dir(data_dir.join("companion"))
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.filter(|e| e.file_name().to_string_lossy().starts_with(DISPLACED_DIR_PREFIX))
|
||||
.map(|e| e.path())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerun_after_marker_write_failure_keeps_fresh_shared_and_reuses_companion_id() {
|
||||
// Crash window 1: a previous run committed shared + the first companion
|
||||
// but the marker write failed. The session then kept running on the
|
||||
// committed shared db and wrote new data; the legacy originals (the
|
||||
// cleanup never ran) hold only the stale pre-migration state.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
build_legacy(dir.path());
|
||||
let legacy = dir.path().join(crate::COMPANION_REL_DIR);
|
||||
let shared = dir.path().join(crate::COMPANION_SHARED_REL_DIR);
|
||||
let companions = dir.path().join(crate::COMPANION_COMPANIONS_REL_DIR);
|
||||
|
||||
let first_id = migrate_legacy_layout(dir.path()).unwrap().expect("first run");
|
||||
|
||||
// Simulate the boot that followed: the registry backfill numbered the
|
||||
// first companion and advanced its watermark state file under shared.
|
||||
let mut numbered = CompanionProfileConfig::load(&companions.join(&first_id));
|
||||
numbered.seq = Some(1);
|
||||
numbered.save(&companions.join(&first_id)).unwrap();
|
||||
crate::registry::CompanionSeqState { last_companion_seq: 1 }.save(&shared).unwrap();
|
||||
|
||||
// Reconstruct the window: marker gone, stale legacy artifacts still
|
||||
// in place, fresh post-commit writes in shared.
|
||||
std::fs::remove_file(legacy.join(MIGRATED_MARKER)).unwrap();
|
||||
std::fs::write(legacy.join("memory.db"), "fake-db-bytes").unwrap();
|
||||
std::fs::create_dir_all(legacy.join("events")).unwrap();
|
||||
std::fs::write(legacy.join("events").join("20260101.jsonl"), "{\"e\":1}\n").unwrap();
|
||||
std::fs::write(shared.join("memory.db"), "fresh-session-bytes").unwrap();
|
||||
std::fs::write(shared.join("events").join("20260102.jsonl"), "{\"e\":2}\n").unwrap();
|
||||
|
||||
let second_id = migrate_legacy_layout(dir.path()).unwrap().expect("re-ran");
|
||||
|
||||
// The fresh shared data wins over the stale legacy copy…
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(shared.join("memory.db")).unwrap(),
|
||||
"fresh-session-bytes"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(shared.join("events").join("20260102.jsonl")).unwrap(),
|
||||
"{\"e\":2}\n"
|
||||
);
|
||||
// …and the already-minted first companion is adopted: same id, exactly one
|
||||
// companion in the roster, no ghost duplicate.
|
||||
assert_eq!(second_id, first_id);
|
||||
let companion_dirs: Vec<_> = std::fs::read_dir(&companions).unwrap().flatten().collect();
|
||||
assert_eq!(companion_dirs.len(), 1);
|
||||
assert_eq!(CompanionProfileConfig::load(&companions.join(&first_id)).name, "Nomi");
|
||||
// The adopted companion keeps its short number, and the registry's
|
||||
// watermark state file is carried into the rebuilt shared dir
|
||||
// instead of being reset.
|
||||
assert_eq!(CompanionProfileConfig::load(&companions.join(&first_id)).seq, Some(1));
|
||||
assert_eq!(crate::registry::CompanionSeqState::load(&shared).last_companion_seq, 1);
|
||||
assert_eq!(SharedCompanionConfig::load(&shared).default_companion_id, first_id);
|
||||
assert_eq!(std::fs::read_to_string(legacy.join(MIGRATED_MARKER)).unwrap(), first_id);
|
||||
// The displaced previous shared was swept after the marker.
|
||||
assert!(displaced_dirs(dir.path()).is_empty());
|
||||
assert!(!dir.path().join(STAGING_REL_DIR).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staging_only_db_survives_rerun() {
|
||||
// Crash window 2 (legacy implementation): the commit removed shared
|
||||
// and crashed before renaming staging into place — the staged copies
|
||||
// are the only ones left (the legacy db/events were consumed by an
|
||||
// even earlier pre-staging attempt). The re-run's residue cleanup
|
||||
// must rescue them, not wipe them.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
build_legacy(dir.path());
|
||||
let legacy = dir.path().join(crate::COMPANION_REL_DIR);
|
||||
std::fs::remove_file(legacy.join("memory.db")).unwrap();
|
||||
std::fs::remove_dir_all(legacy.join("events")).unwrap();
|
||||
let staging = dir.path().join(STAGING_REL_DIR);
|
||||
std::fs::create_dir_all(staging.join("shared").join("events")).unwrap();
|
||||
std::fs::write(staging.join("shared").join("memory.db"), "fake-db-bytes").unwrap();
|
||||
std::fs::write(
|
||||
staging.join("shared").join("events").join("20260101.jsonl"),
|
||||
"{\"e\":1}\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let companion_id = migrate_legacy_layout(dir.path()).unwrap().expect("migration ran");
|
||||
|
||||
let shared = dir.path().join(crate::COMPANION_SHARED_REL_DIR);
|
||||
assert_eq!(std::fs::read_to_string(shared.join("memory.db")).unwrap(), "fake-db-bytes");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(shared.join("events").join("20260101.jsonl")).unwrap(),
|
||||
"{\"e\":1}\n"
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(legacy.join(MIGRATED_MARKER)).unwrap(), companion_id);
|
||||
assert!(!staging.exists());
|
||||
assert!(displaced_dirs(dir.path()).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
//! Multi-companion configuration split: a per-companion profile (`companion/companions/{id}/config.json`)
|
||||
//! holding identity/persona/model/window settings, plus a shared config
|
||||
//! (`companion/shared/config.json`) holding collection switches, the shared learn
|
||||
//! loop and the default-companion pointer. Both reuse the legacy building blocks
|
||||
//! from [`crate::config`] and the same atomic temp+rename write pattern.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use nomifun_common::{generate_prefixed_id, now_ms};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::{CollectConfig, DEFAULT_CHARACTER, ModelConfig, PersonaConfig};
|
||||
|
||||
/// Desktop-companion window settings for one companion — the legacy `AppearanceConfig`
|
||||
/// minus `character`, which now lives directly on [`CompanionProfileConfig`].
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct CompanionWindowConfig {
|
||||
/// Whether this companion's desktop window should be visible.
|
||||
pub companion_enabled: bool,
|
||||
/// Saved companion window position (physical px), if the user dragged it.
|
||||
pub companion_x: Option<i32>,
|
||||
pub companion_y: Option<i32>,
|
||||
/// Quiet hours "HH:mm" — within this window the companion only accrues badges
|
||||
/// and never pops bubbles. Empty strings disable quiet hours.
|
||||
pub quiet_start: String,
|
||||
pub quiet_end: String,
|
||||
/// DIY single-image figure metadata (character == "custom"). Absent for
|
||||
/// roster characters — and omitted from JSON so pre-DIY configs round-trip
|
||||
/// byte-identical.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub custom_figure: Option<CustomFigureMeta>,
|
||||
}
|
||||
|
||||
/// Head-and-shoulders crop over the figure image in image-fraction coordinates:
|
||||
/// left `x` and width `w` are fractions of image WIDTH; top `y` and height `h`
|
||||
/// are fractions of image HEIGHT. `h == 0` marks a legacy square box (created
|
||||
/// before free-rectangle framing) — the frontend resolves it to `w * aspect`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct HeadBox {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub w: f32,
|
||||
/// Box height as a fraction of image height. `0` ⇒ legacy square (resolved
|
||||
/// frontend-side to `w * aspect`); `#[serde(default)]` so old configs load.
|
||||
#[serde(default)]
|
||||
pub h: f32,
|
||||
}
|
||||
|
||||
/// Metadata for a user-supplied single-image figure (`character == "custom"`),
|
||||
/// mirrored by `CustomFigureMeta` in the UI (`characters/types.ts`). The image
|
||||
/// bytes themselves live next to the profile as
|
||||
/// `{companions_dir}/{companion_id}/{FIGURE_FILE}` (see [`crate::figure`]).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct CustomFigureMeta {
|
||||
/// width / height of the cutout image.
|
||||
pub aspect: f32,
|
||||
pub head_box: HeadBox,
|
||||
/// Desk size tier: "s" | "m" | "l".
|
||||
pub size_tier: String,
|
||||
/// Library figure this companion draws from (`figure_…`). When set, the image is
|
||||
/// served from the shared figure library (`/api/companion/figures/{id}/image`),
|
||||
/// so one figure can back many companions. Absent for legacy per-companion figures
|
||||
/// installed before the library (those still serve from
|
||||
/// `/api/companion/companions/{id}/figure`), keeping old configs byte-identical.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub figure_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Per-companion profile persisted as `companion/companions/{id}/config.json`.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct CompanionProfileConfig {
|
||||
/// Stable id (`companion_…`). An empty id after `load` means the file was
|
||||
/// missing/corrupt — callers must discard such profiles.
|
||||
pub id: String,
|
||||
/// Display-only short number (`#1`, `#2`, …) for companion lists. Monotonic
|
||||
/// within this machine — allocated by the registry from its private
|
||||
/// high-watermark state file (`companion/shared/companion_seq.json`) so a deleted
|
||||
/// companion's number is never reused. `None` only for profiles written before
|
||||
/// the seq rollout; the boot scan backfills those.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub seq: Option<u64>,
|
||||
/// Display name chosen by the user.
|
||||
pub name: String,
|
||||
/// Which character renders in the companion window (mochi/ink/roux/pixel/bolt/boo).
|
||||
pub character: String,
|
||||
pub persona: PersonaConfig,
|
||||
/// Per-companion companion-chat model (the shared learn loop has its own).
|
||||
pub model: ModelConfig,
|
||||
pub appearance: CompanionWindowConfig,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
impl CompanionProfileConfig {
|
||||
/// Fresh profile with a generated id. An empty `character` falls back to
|
||||
/// the default roster character.
|
||||
pub fn new(name: &str, character: &str) -> Self {
|
||||
let character = if character.is_empty() { DEFAULT_CHARACTER } else { character };
|
||||
Self {
|
||||
id: generate_prefixed_id("companion"),
|
||||
// Allocated by the registry under its lock (never here, where no
|
||||
// watermark is in scope).
|
||||
seq: None,
|
||||
name: name.to_owned(),
|
||||
character: character.to_owned(),
|
||||
persona: PersonaConfig::default(),
|
||||
model: ModelConfig::default(),
|
||||
appearance: CompanionWindowConfig::default(),
|
||||
created_at: now_ms(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config_path(dir: &Path) -> PathBuf {
|
||||
dir.join("config.json")
|
||||
}
|
||||
|
||||
/// Load from `{dir}/config.json`, falling back to defaults when the file
|
||||
/// is missing or unreadable (a corrupt profile must never brick boot).
|
||||
/// The default has an empty `id` — callers detect and discard it.
|
||||
pub fn load(dir: &Path) -> Self {
|
||||
crate::fsio::load_json_or_default(&Self::config_path(dir))
|
||||
}
|
||||
|
||||
/// Atomically persist to `{dir}/config.json` (unique temp file + rename,
|
||||
/// so two concurrent saves can never rename each other's half-written
|
||||
/// temp into place).
|
||||
pub fn save(&self, dir: &Path) -> std::io::Result<()> {
|
||||
crate::fsio::save_json_atomic(dir, "config.json", self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared learn-loop settings: one schedule + one model distilling events for
|
||||
/// every companion (the per-companion `model` only drives companion chat).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct SharedLearnConfig {
|
||||
pub enabled: bool,
|
||||
/// Minutes between learning runs.
|
||||
pub interval_minutes: u32,
|
||||
pub model: ModelConfig,
|
||||
}
|
||||
|
||||
impl Default for SharedLearnConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
interval_minutes: 60,
|
||||
model: ModelConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared skill-evolution settings (design §6): the background EvolutionEngine
|
||||
/// mines repeated multi-step tool sequences from real work and drafts them into
|
||||
/// reviewable skills. Independent schedule/model from the lightweight learner.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct SharedEvolveConfig {
|
||||
pub enabled: bool,
|
||||
/// Minutes between evolution runs.
|
||||
pub interval_minutes: u32,
|
||||
pub model: ModelConfig,
|
||||
/// A pattern must occur at least this many times total to be drafted.
|
||||
pub min_pattern_count: i64,
|
||||
/// A pattern must appear across at least this many distinct sessions.
|
||||
pub min_distinct_sessions: usize,
|
||||
/// Also reflect on single complex work sessions (not just repeated patterns) — design §5.5 任务后反思.
|
||||
pub reflect_enabled: bool,
|
||||
/// Auto-activate a drafted skill (skip human review) when confidence ≥ `auto_threshold`.
|
||||
/// Default off (gated): the user opts into high-confidence auto-activation.
|
||||
pub auto_activate: bool,
|
||||
/// Confidence cutoff for `auto_activate` (repetition-derived; single-session reflections stay below it).
|
||||
pub auto_threshold: f64,
|
||||
/// Skill strength half-life in days (decay clock = time since last use). Used skills reinforce.
|
||||
pub skill_half_life_days: f64,
|
||||
/// Below this strength a mined skill is auto-archived (restorable; manual skills never decay).
|
||||
pub skill_archive_threshold: f64,
|
||||
}
|
||||
|
||||
impl Default for SharedEvolveConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
interval_minutes: 30,
|
||||
model: ModelConfig::default(),
|
||||
min_pattern_count: 3,
|
||||
min_distinct_sessions: 2,
|
||||
reflect_enabled: true,
|
||||
auto_activate: false,
|
||||
auto_threshold: 0.85,
|
||||
skill_half_life_days: 45.0,
|
||||
skill_archive_threshold: 0.05,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cross-companion shared configuration persisted as `companion/shared/config.json`.
|
||||
/// Deliberately user-writable wholesale (full-object `PUT /api/companion/config`),
|
||||
/// so nothing registry-owned (e.g. the companion-seq watermark, which lives in
|
||||
/// `companion/shared/companion_seq.json`) may be carried here.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct SharedCompanionConfig {
|
||||
pub collect: CollectConfig,
|
||||
pub learn: SharedLearnConfig,
|
||||
#[serde(default)]
|
||||
pub evolve: SharedEvolveConfig,
|
||||
/// Which companion new/unattributed activity defaults to.
|
||||
pub default_companion_id: String,
|
||||
/// Opt-in (default None = off): when set to a directory path, companion
|
||||
/// `save` memories are ALSO mirrored into the nomi agent's file-memory there
|
||||
/// (the §3.4 "消两库割裂" bridge), so the agent recalls companion-learned
|
||||
/// facts. Enabling it intentionally surfaces companion memories in agent
|
||||
/// sessions — that is the feature; default-off keeps the libraries separate.
|
||||
#[serde(default)]
|
||||
pub bridge_to_memory_dir: Option<String>,
|
||||
}
|
||||
|
||||
impl SharedCompanionConfig {
|
||||
pub fn config_path(dir: &Path) -> PathBuf {
|
||||
dir.join("config.json")
|
||||
}
|
||||
|
||||
/// Load from `{dir}/config.json` (dir is the shared dir), falling back to
|
||||
/// defaults when the file is missing or unreadable.
|
||||
pub fn load(dir: &Path) -> Self {
|
||||
crate::fsio::load_json_or_default(&Self::config_path(dir))
|
||||
}
|
||||
|
||||
/// Atomically persist to `{dir}/config.json` (unique temp file + rename).
|
||||
pub fn save(&self, dir: &Path) -> std::io::Result<()> {
|
||||
crate::fsio::save_json_atomic(dir, "config.json", self)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn profile_roundtrip_and_default_on_missing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let loaded = CompanionProfileConfig::load(dir.path());
|
||||
assert_eq!(loaded, CompanionProfileConfig::default());
|
||||
assert!(loaded.id.is_empty()); // caller-discard sentinel
|
||||
|
||||
let mut profile = CompanionProfileConfig::new("毛球", "ink");
|
||||
profile.model.provider_id = "prov_x".into();
|
||||
profile.model.model = "claude-fable-5".into();
|
||||
profile.appearance.companion_enabled = true;
|
||||
profile.save(dir.path()).unwrap();
|
||||
|
||||
let again = CompanionProfileConfig::load(dir.path());
|
||||
assert_eq!(again, profile);
|
||||
assert!(again.id.starts_with("companion_"));
|
||||
assert!(again.created_at > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_new_falls_back_to_default_character() {
|
||||
let p = CompanionProfileConfig::new("无名", "");
|
||||
assert_eq!(p.character, "mochi");
|
||||
let q = CompanionProfileConfig::new("有名", "boo");
|
||||
assert_eq!(q.character, "boo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_profile_falls_back_to_default() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(CompanionProfileConfig::config_path(dir.path()), "{not json").unwrap();
|
||||
let loaded = CompanionProfileConfig::load(dir.path());
|
||||
assert_eq!(loaded, CompanionProfileConfig::default());
|
||||
assert!(loaded.id.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_figure_roundtrips_and_stays_absent_for_old_configs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
// A pre-DIY profile (no custom_figure key) deserializes to None and
|
||||
// serializes without the key (skip_serializing_if).
|
||||
let mut profile = CompanionProfileConfig::new("自定", "custom");
|
||||
assert_eq!(profile.appearance.custom_figure, None);
|
||||
profile.save(dir.path()).unwrap();
|
||||
let raw = std::fs::read_to_string(CompanionProfileConfig::config_path(dir.path())).unwrap();
|
||||
assert!(!raw.contains("custom_figure"));
|
||||
|
||||
profile.appearance.custom_figure = Some(CustomFigureMeta {
|
||||
aspect: 0.9444,
|
||||
head_box: HeadBox { x: 0.321, y: 0.0, w: 0.281, h: 0.3 },
|
||||
size_tier: "m".into(),
|
||||
figure_id: None,
|
||||
});
|
||||
profile.save(dir.path()).unwrap();
|
||||
// A None figure_id must not appear in the JSON (old configs stay byte-clean).
|
||||
let raw_none = std::fs::read_to_string(CompanionProfileConfig::config_path(dir.path())).unwrap();
|
||||
assert!(!raw_none.contains("figure_id"));
|
||||
let again = CompanionProfileConfig::load(dir.path());
|
||||
assert_eq!(again, profile);
|
||||
let meta = again.appearance.custom_figure.unwrap();
|
||||
assert_eq!(meta.size_tier, "m");
|
||||
assert!((meta.head_box.w - 0.281).abs() < f32::EPSILON);
|
||||
|
||||
// A library-linked figure_id round-trips.
|
||||
profile.appearance.custom_figure = Some(CustomFigureMeta {
|
||||
aspect: 0.9444,
|
||||
head_box: HeadBox { x: 0.321, y: 0.0, w: 0.281, h: 0.3 },
|
||||
size_tier: "m".into(),
|
||||
figure_id: Some("figure_abc".into()),
|
||||
});
|
||||
profile.save(dir.path()).unwrap();
|
||||
let linked = CompanionProfileConfig::load(dir.path());
|
||||
assert_eq!(linked.appearance.custom_figure.unwrap().figure_id.as_deref(), Some("figure_abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_roundtrip_and_default_on_missing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let loaded = SharedCompanionConfig::load(dir.path());
|
||||
assert_eq!(loaded, SharedCompanionConfig::default());
|
||||
assert_eq!(loaded.learn.interval_minutes, 60);
|
||||
assert!(!loaded.learn.enabled);
|
||||
|
||||
let mut cfg = SharedCompanionConfig::default();
|
||||
cfg.collect.chat_user_messages = true;
|
||||
cfg.learn.enabled = true;
|
||||
cfg.learn.model.provider_id = "prov_y".into();
|
||||
cfg.learn.model.model = "claude-fable-5".into();
|
||||
cfg.default_companion_id = "companion_abc".into();
|
||||
cfg.save(dir.path()).unwrap();
|
||||
|
||||
let again = SharedCompanionConfig::load(dir.path());
|
||||
assert_eq!(again, cfg);
|
||||
assert!(again.learn.model.is_configured());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_shared_config_falls_back_to_default() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(SharedCompanionConfig::config_path(dir.path()), "[oops").unwrap();
|
||||
assert_eq!(SharedCompanionConfig::load(dir.path()), SharedCompanionConfig::default());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Prompt assembly + strict-JSON parsing for learning runs, plus the shared
|
||||
//! persona flavor text (the companion-chat system prompt lives in
|
||||
//! `companion::build_companion_system_prompt`).
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::store::{MEMORY_KINDS, CompanionMemory, CompanionSuggestion};
|
||||
|
||||
pub const LEARN_MAX_TOKENS: u32 = 4096;
|
||||
|
||||
/// Valid moods the companion can be in (renderer maps each to an animation).
|
||||
pub const MOODS: [&str; 5] = ["happy", "content", "sleepy", "worried", "excited"];
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LearnedMemory {
|
||||
pub kind: String,
|
||||
pub content: String,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default = "default_importance")]
|
||||
pub importance: f64,
|
||||
}
|
||||
|
||||
fn default_importance() -> f64 {
|
||||
0.5
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LearnedSuggestion {
|
||||
pub kind: String,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
#[serde(default)]
|
||||
pub action: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LearnOutput {
|
||||
#[serde(default)]
|
||||
pub memories: Vec<LearnedMemory>,
|
||||
#[serde(default)]
|
||||
pub reinforce_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub supersede_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub suggestions: Vec<LearnedSuggestion>,
|
||||
#[serde(default)]
|
||||
pub mood: Option<String>,
|
||||
#[serde(default)]
|
||||
pub diary: Option<String>,
|
||||
}
|
||||
|
||||
pub const LEARN_SYSTEM: &str = r#"你是这台电脑上所有电子伙伴共享的记忆中枢管家。你的任务是阅读主人最近的工作事件记录,提炼出帮助伙伴们"更懂主人"的记忆,并产出对主人有实际帮助的建议。
|
||||
|
||||
记忆 kind 只能是:profile(画像,稳定事实) / preference(偏好,风格口味) / knowledge(知识,可复用结论) / episode(事件,带时间的经历) / task(任务线索,未完成事项或口头承诺) / affective(情感,情绪轨迹)。
|
||||
建议 kind 只能是:guess_question(猜你想问) / create_skill(建议固化为技能) / create_cron(建议定时任务) / unfinished_task(未完成提醒) / insight(洞察) / wellness(健康关怀) / risk(风险提醒,如对话中疑似泄露密钥)。
|
||||
|
||||
规则:
|
||||
1. 只提炼有信息量的内容,宁缺毋滥;每条记忆一句话、自包含、用中文。
|
||||
2. 若新事件印证了"已有记忆"列表中的某条,把它的 id 放进 reinforce_ids,不要重复生成。
|
||||
3. 若新事件与某条已有记忆矛盾,生成新记忆并把旧 id 放进 supersede_ids。
|
||||
4. 建议最多 3 条,必须基于事件证据,不要空泛;可在 action 中给出跳转,格式 {"type":"navigate","to":"/路径"}。
|
||||
5. mood 从 happy/content/sleepy/worried/excited 中选一个,代表伙伴们读完这些事件后的共同心情。
|
||||
6. diary 是以伙伴们的第一人称写的一句话日记(中文、简短、温暖),措辞不要绑定任何单一角色名,如"今天主人修了一下午 bug,我们记住了他喜欢先看报错"。
|
||||
7. 事件 data 中 origin 为 companion/cron/autowork/idmm、或 created_by 为 agent 的内容,是 agent 的自动行为而非主人发言:绝不能据此蒸馏出"主人想要/主人计划/主人提出"类记忆或建议。
|
||||
8. 事件名 companion.user_message 是主人对伙伴说的话(高价值:偏好/意图/情感都值得提炼);companion.reply 是伙伴自己说的话,只能用作上下文理解,绝不能当作主人的事实、意愿或承诺。
|
||||
9. 若事件表明某个任务/需求已完成或不再需要,把"已有记忆"中对应的 task 记忆 id 放进 supersede_ids,不要为已完成的事保留或新建 task 记忆。
|
||||
10. 不要产出与"已有建议"列表语义相同或高度相似的建议。
|
||||
|
||||
只输出一个 JSON 对象,不要任何其他文字、不要 markdown 代码围栏:
|
||||
{"memories":[{"kind":"...","content":"...","tags":["..."],"importance":0.0~1.0}],"reinforce_ids":[],"supersede_ids":[],"suggestions":[{"kind":"...","title":"...","body":"...","action":null}],"mood":"content","diary":"..."}"#;
|
||||
|
||||
/// Build the learn user prompt from existing memories, pending (status='new')
|
||||
/// suggestions and new events. Feeding the pending suggestions back lets the
|
||||
/// model honor rule 10 (no semantically duplicate suggestions).
|
||||
pub fn build_learn_prompt(
|
||||
memories: &[CompanionMemory],
|
||||
pending_suggestions: &[CompanionSuggestion],
|
||||
events_json: &[String],
|
||||
truncated: bool,
|
||||
) -> String {
|
||||
let mut prompt = String::from("## 已有记忆(id | kind | 内容)\n");
|
||||
if memories.is_empty() {
|
||||
prompt.push_str("(暂无)\n");
|
||||
}
|
||||
for m in memories {
|
||||
prompt.push_str(&format!("- {} | {} | {}\n", m.id, m.kind, m.content));
|
||||
}
|
||||
prompt.push_str("\n## 已有建议(kind | 标题 — 不要重复产出语义相同的建议)\n");
|
||||
if pending_suggestions.is_empty() {
|
||||
prompt.push_str("(暂无)\n");
|
||||
}
|
||||
for s in pending_suggestions {
|
||||
prompt.push_str(&format!("- {} | {}\n", s.kind, s.title));
|
||||
}
|
||||
prompt.push_str("\n## 新事件记录(JSONL)\n");
|
||||
for line in events_json {
|
||||
prompt.push_str(line);
|
||||
prompt.push('\n');
|
||||
}
|
||||
if truncated {
|
||||
prompt.push_str("\n(注意:本批事件因数量限制被截断,还有更多事件等待下次学习。)\n");
|
||||
}
|
||||
prompt.push_str("\n请按系统指令输出 JSON。");
|
||||
prompt
|
||||
}
|
||||
|
||||
/// Parse the model output into `LearnOutput`, tolerating ```json fences and
|
||||
/// surrounding prose (extracts the outermost {...} block).
|
||||
pub fn parse_learn_output(raw: &str) -> Result<LearnOutput, String> {
|
||||
let cleaned = extract_json_object(raw).ok_or_else(|| "no JSON object found in model output".to_owned())?;
|
||||
let mut output: LearnOutput = serde_json::from_str(cleaned).map_err(|e| format!("invalid learn JSON: {e}"))?;
|
||||
output.memories.retain(|m| MEMORY_KINDS.contains(&m.kind.as_str()) && !m.content.trim().is_empty());
|
||||
if let Some(mood) = &output.mood {
|
||||
if !MOODS.contains(&mood.as_str()) {
|
||||
output.mood = None;
|
||||
}
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Extract the outermost `{...}` from text that may contain fences or prose.
|
||||
fn extract_json_object(raw: &str) -> Option<&str> {
|
||||
let start = raw.find('{')?;
|
||||
let end = raw.rfind('}')?;
|
||||
if end <= start {
|
||||
return None;
|
||||
}
|
||||
Some(&raw[start..=end])
|
||||
}
|
||||
|
||||
pub(crate) fn persona_flavor(preset: &str) -> &'static str {
|
||||
match preset {
|
||||
"calm" => "你的性格沉稳温柔,像一位安静可靠的伙伴,说话简洁、不用太多语气词。",
|
||||
"sassy" => "你的性格机灵带点小毒舌,喜欢俏皮地调侃主人,但内心始终关心主人。",
|
||||
_ => "你的性格活泼粘人,喜欢用可爱的语气和颜文字,对主人的事情充满好奇。",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_plain_and_fenced_json() {
|
||||
let plain = r#"{"memories":[{"kind":"preference","content":"主人喜欢中文回复"}],"mood":"happy","diary":"今天学到了!"}"#;
|
||||
let out = parse_learn_output(plain).unwrap();
|
||||
assert_eq!(out.memories.len(), 1);
|
||||
assert_eq!(out.mood.as_deref(), Some("happy"));
|
||||
|
||||
let fenced = format!("好的,这是结果:\n```json\n{plain}\n```\n以上。");
|
||||
let out = parse_learn_output(&fenced).unwrap();
|
||||
assert_eq!(out.memories.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_garbage_and_filters_bad_kinds() {
|
||||
assert!(parse_learn_output("我不知道").is_err());
|
||||
let bad_kind = r#"{"memories":[{"kind":"nonsense","content":"x"},{"kind":"task","content":"修 bug"}],"mood":"angry"}"#;
|
||||
let out = parse_learn_output(bad_kind).unwrap();
|
||||
assert_eq!(out.memories.len(), 1);
|
||||
assert_eq!(out.memories[0].kind, "task");
|
||||
assert!(out.mood.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn learn_prompt_lists_pending_suggestions_and_system_has_loop_guards() {
|
||||
let suggestion = CompanionSuggestion {
|
||||
id: "sug_1".into(),
|
||||
kind: "create_cron".into(),
|
||||
title: "建议加个每日备份任务".into(),
|
||||
body: "…".into(),
|
||||
action: None,
|
||||
status: "new".into(),
|
||||
created_at: 0,
|
||||
decided_at: None,
|
||||
};
|
||||
let prompt = build_learn_prompt(&[], &[suggestion], &["{\"x\":1}".into()], false);
|
||||
assert!(prompt.contains("已有建议"));
|
||||
assert!(prompt.contains("create_cron | 建议加个每日备份任务"));
|
||||
assert!(prompt.contains("不要重复产出语义相同的建议"));
|
||||
// Empty lists render the placeholder.
|
||||
let empty = build_learn_prompt(&[], &[], &[], false);
|
||||
assert!(empty.contains("(暂无)"));
|
||||
// The system prompt carries the anti-loop rules.
|
||||
assert!(LEARN_SYSTEM.contains("companion/cron/autowork/idmm"));
|
||||
assert!(LEARN_SYSTEM.contains("companion.user_message"));
|
||||
assert!(LEARN_SYSTEM.contains("companion.reply"));
|
||||
assert!(LEARN_SYSTEM.contains("supersede_ids"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
//! `CompanionRegistry` — the in-memory roster of companion profiles, mirrored to disk as
|
||||
//! one `companion/companions/{id}/config.json` per companion. Boot does a synchronous [`scan`]
|
||||
//! of the companions dir; afterwards every mutation (create/patch/remove) saves the
|
||||
//! profile first and only then updates the map under the write lock, so the
|
||||
//! map never claims a companion whose file failed to persist.
|
||||
//!
|
||||
//! The registry also owns companion short numbers ([`CompanionProfileConfig::seq`]) and
|
||||
//! their high-watermark, persisted in a registry-private state file
|
||||
//! ([`SEQ_STATE_FILE`] under the shared dir) that no API config write path
|
||||
//! can reach: [`create`] allocates the next number from the watermark and
|
||||
//! [`backfill_missing_seqs`] numbers pre-rollout profiles at boot — both
|
||||
//! inside the same critical section that mutates the roster, so concurrent
|
||||
//! creates can never mint the same number.
|
||||
//!
|
||||
//! [`scan`]: CompanionRegistry::scan
|
||||
//! [`create`]: CompanionRegistry::create
|
||||
//! [`backfill_missing_seqs`]: CompanionRegistry::backfill_missing_seqs
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use nomifun_common::AppError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::profile::CompanionProfileConfig;
|
||||
|
||||
/// Maximum companion display-name length, counted in chars (not bytes) so CJK
|
||||
/// names get the same budget as ASCII ones.
|
||||
const MAX_NAME_CHARS: usize = 40;
|
||||
|
||||
/// File under the shared dir holding the registry-private seq watermark.
|
||||
pub(crate) const SEQ_STATE_FILE: &str = "companion_seq.json";
|
||||
|
||||
/// Registry-private high-watermark for companion short numbers: the largest seq
|
||||
/// ever allocated on this machine, persisted as `{shared_dir}/companion_seq.json`
|
||||
/// (`{"last_companion_seq": N}`). It deliberately does NOT live on
|
||||
/// [`crate::profile::SharedCompanionConfig`]: that object is user-writable
|
||||
/// wholesale (full-object `PUT /api/companion/config`, future import paths, …), so
|
||||
/// keeping the watermark there would make "never reuse a deleted companion's
|
||||
/// number" depend on every present and future config write path remembering
|
||||
/// to clamp it. A missing/corrupt file self-heals as 0 — the allocation
|
||||
/// formula additionally takes the largest live seq into account.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub(crate) struct CompanionSeqState {
|
||||
pub(crate) last_companion_seq: u64,
|
||||
}
|
||||
|
||||
impl CompanionSeqState {
|
||||
/// Load from `{shared_dir}/companion_seq.json`, falling back to 0 when the
|
||||
/// file is missing or unreadable.
|
||||
pub(crate) fn load(shared_dir: &Path) -> Self {
|
||||
crate::fsio::load_json_or_default(&shared_dir.join(SEQ_STATE_FILE))
|
||||
}
|
||||
|
||||
/// Atomically persist to `{shared_dir}/companion_seq.json`.
|
||||
pub(crate) fn save(&self, shared_dir: &Path) -> std::io::Result<()> {
|
||||
crate::fsio::save_json_atomic(shared_dir, SEQ_STATE_FILE, self)
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 7396 JSON merge patch: objects merge recursively, `null` deletes,
|
||||
/// everything else replaces.
|
||||
pub(crate) fn json_merge_patch(target: &mut serde_json::Value, patch: &serde_json::Value) {
|
||||
if let (Some(target_map), Some(patch_map)) = (target.as_object_mut(), patch.as_object()) {
|
||||
for (key, value) in patch_map {
|
||||
if value.is_null() {
|
||||
target_map.remove(key);
|
||||
} else if value.is_object() && target_map.get(key).is_some_and(|t| t.is_object()) {
|
||||
json_merge_patch(target_map.get_mut(key).unwrap(), value);
|
||||
} else {
|
||||
target_map.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
*target = patch.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Trimmed, non-empty, at most [`MAX_NAME_CHARS`] chars — or `BadRequest`.
|
||||
fn validate_name(name: &str) -> Result<String, AppError> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(AppError::BadRequest("companion name must not be empty".into()));
|
||||
}
|
||||
if name.chars().count() > MAX_NAME_CHARS {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"companion name must be at most {MAX_NAME_CHARS} characters"
|
||||
)));
|
||||
}
|
||||
Ok(name.to_owned())
|
||||
}
|
||||
|
||||
/// The largest seq carried by any companion in the map (0 when none carries one).
|
||||
/// Lets allocation self-heal a stale/clobbered watermark while the
|
||||
/// highest-numbered companion is still alive.
|
||||
fn max_live_seq(companions: &HashMap<String, CompanionProfileConfig>) -> u64 {
|
||||
companions.values().filter_map(|p| p.seq).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
pub struct CompanionRegistry {
|
||||
companions_dir: PathBuf,
|
||||
/// Shared multi-companion home (`{data_dir}/companion/shared`) — where the seq
|
||||
/// watermark state file ([`SEQ_STATE_FILE`]) is persisted.
|
||||
shared_dir: PathBuf,
|
||||
/// In-memory seq watermark, mirrored to disk via [`CompanionSeqState`].
|
||||
/// Registry-owned and only ever advanced.
|
||||
///
|
||||
/// Lock order: this lock is always acquired BEFORE the roster map below.
|
||||
watermark: RwLock<u64>,
|
||||
inner: RwLock<HashMap<String, CompanionProfileConfig>>,
|
||||
}
|
||||
|
||||
impl CompanionRegistry {
|
||||
/// Synchronous boot-time scan: every subdirectory of `companions_dir` is loaded
|
||||
/// as a profile. Corrupt/missing configs (empty id sentinel) and dirs
|
||||
/// whose name does not match the embedded id are warned about and
|
||||
/// skipped — a broken profile must never brick boot or shadow a good one.
|
||||
///
|
||||
/// Callers should follow up with [`backfill_missing_seqs`] once inside an
|
||||
/// async context to number any pre-seq profiles.
|
||||
///
|
||||
/// [`backfill_missing_seqs`]: CompanionRegistry::backfill_missing_seqs
|
||||
pub fn scan(companions_dir: PathBuf, shared_dir: PathBuf) -> Self {
|
||||
let mut companions = HashMap::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&companions_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let dir_name = entry.file_name().to_string_lossy().into_owned();
|
||||
let profile = CompanionProfileConfig::load(&path);
|
||||
if profile.id.is_empty() || profile.id != dir_name {
|
||||
tracing::warn!(
|
||||
dir = %path.display(),
|
||||
id = %profile.id,
|
||||
"companion profile corrupt or id does not match its directory; skipping"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
companions.insert(profile.id.clone(), profile);
|
||||
}
|
||||
}
|
||||
let watermark = CompanionSeqState::load(&shared_dir).last_companion_seq;
|
||||
Self {
|
||||
companions_dir,
|
||||
shared_dir,
|
||||
watermark: RwLock::new(watermark),
|
||||
inner: RwLock::new(companions),
|
||||
}
|
||||
}
|
||||
|
||||
/// All companions, oldest first (`created_at` ascending, id as tie-break so the
|
||||
/// order is stable even for same-millisecond creations).
|
||||
pub async fn list(&self) -> Vec<CompanionProfileConfig> {
|
||||
let mut companions: Vec<CompanionProfileConfig> = self.inner.read().await.values().cloned().collect();
|
||||
companions.sort_by(|a, b| a.created_at.cmp(&b.created_at).then_with(|| a.id.cmp(&b.id)));
|
||||
companions
|
||||
}
|
||||
|
||||
/// Root of the per-companion directories (`{data_dir}/companion/companions`) — where
|
||||
/// non-config per-companion artifacts (e.g. the DIY figure image) live too.
|
||||
pub(crate) fn companions_dir(&self) -> &Path {
|
||||
&self.companions_dir
|
||||
}
|
||||
|
||||
/// 伙伴工作区树根:companions_dir 的兄弟目录 `{data_dir}/companion/workspaces`。
|
||||
/// (companions_dir == `{data_dir}/companion/companions`,取 parent 再 join。)
|
||||
/// 见名知意的每伙伴工作目录落在此树下,与 home 目录解耦。
|
||||
pub(crate) fn workspaces_dir(&self) -> std::path::PathBuf {
|
||||
self.companions_dir
|
||||
.parent()
|
||||
.map(|p| p.join("workspaces"))
|
||||
.unwrap_or_else(|| self.companions_dir.join("workspaces"))
|
||||
}
|
||||
|
||||
pub async fn get(&self, id: &str) -> Option<CompanionProfileConfig> {
|
||||
self.inner.read().await.get(id).cloned()
|
||||
}
|
||||
|
||||
/// Companion ids in the same order as [`list`](Self::list).
|
||||
pub async fn ids(&self) -> Vec<String> {
|
||||
self.list().await.into_iter().map(|p| p.id).collect()
|
||||
}
|
||||
|
||||
/// 解析"代表全家发声"的伙伴 id(单一事实源,learner 与 evolution 引擎共用)。
|
||||
/// 存活的显式默认体优先;否则首个注册伙伴;空 roster 返回空串。
|
||||
/// liveness 检查同时修掉"默认体已删除却仍被当 owner"的潜伏问题。
|
||||
pub async fn resolve_default(&self, default_companion_id: &str) -> String {
|
||||
let ids = self.ids().await;
|
||||
if !default_companion_id.is_empty() && ids.iter().any(|id| id == default_companion_id) {
|
||||
return default_companion_id.to_owned();
|
||||
}
|
||||
ids.into_iter().next().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Create a companion: validate the name, allocate its short number from the
|
||||
/// registry watermark, persist `{companions_dir}/{id}/config.json`, then insert
|
||||
/// into the map under the write lock. Allocation and both saves happen
|
||||
/// inside one critical section so two concurrent creates can never mint
|
||||
/// the same number. The watermark is only advanced after the profile
|
||||
/// saved successfully — a failed create has zero persistent side effects,
|
||||
/// so retrying it never burns numbers.
|
||||
pub async fn create(&self, name: &str, character: &str) -> Result<CompanionProfileConfig, AppError> {
|
||||
let name = validate_name(name)?;
|
||||
let mut profile = CompanionProfileConfig::new(&name, character);
|
||||
let dir = self.companions_dir.join(&profile.id);
|
||||
// Lock order: watermark before the roster map (see struct docs).
|
||||
let mut watermark = self.watermark.write().await;
|
||||
let mut companions = self.inner.write().await;
|
||||
// Never reuse: one past the watermark or the largest live seq,
|
||||
// whichever is bigger.
|
||||
let seq = (*watermark).max(max_live_seq(&companions)) + 1;
|
||||
profile.seq = Some(seq);
|
||||
profile
|
||||
.save(&dir)
|
||||
.map_err(|e| AppError::Internal(format!("save companion profile: {e}")))?;
|
||||
self.advance_watermark(&mut watermark, seq);
|
||||
companions.insert(profile.id.clone(), profile.clone());
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
/// Backfill short numbers for profiles written before the seq rollout
|
||||
/// (or minted by the legacy migration without one), oldest first
|
||||
/// (`created_at`, id as tie-break — the [`list`](Self::list) order).
|
||||
/// Idempotent: a companion that already carries a seq is never renumbered.
|
||||
/// Also heals a lagging watermark (e.g. a deleted/corrupt state file) by
|
||||
/// advancing it to the largest live seq. Meant to run once per boot,
|
||||
/// right after [`scan`](Self::scan).
|
||||
pub async fn backfill_missing_seqs(&self) {
|
||||
// Lock order: watermark before the roster map (see struct docs).
|
||||
let mut watermark = self.watermark.write().await;
|
||||
let mut companions = self.inner.write().await;
|
||||
let mut missing: Vec<(i64, String)> = companions
|
||||
.values()
|
||||
.filter(|p| p.seq.is_none())
|
||||
.map(|p| (p.created_at, p.id.clone()))
|
||||
.collect();
|
||||
missing.sort();
|
||||
let mut next = (*watermark).max(max_live_seq(&companions)) + 1;
|
||||
for (_, id) in missing {
|
||||
let Some(profile) = companions.get_mut(&id) else { continue };
|
||||
profile.seq = Some(next);
|
||||
if let Err(e) = profile.save(&self.companions_dir.join(&id)) {
|
||||
// The map must never claim state the disk doesn't have:
|
||||
// leave the profile unnumbered. And never hand its number to
|
||||
// a younger companion — seq is immutable once persisted, so that
|
||||
// would permanently invert the numbering against created_at
|
||||
// order. Stop numbering here instead: this companion and every
|
||||
// younger one retry, in order, on the next boot.
|
||||
tracing::warn!(
|
||||
companion_id = %id, error = %e,
|
||||
"backfill companion seq: save failed; deferring this and all younger companions to the next boot"
|
||||
);
|
||||
profile.seq = None;
|
||||
break;
|
||||
}
|
||||
next += 1;
|
||||
}
|
||||
let live_max = max_live_seq(&companions);
|
||||
self.advance_watermark(&mut watermark, live_max);
|
||||
}
|
||||
|
||||
/// Advance the in-memory watermark to `seq` (never backwards) and
|
||||
/// persist the state file. A failed save only warns: the number also
|
||||
/// lives on the companion profile itself, so monotonicity survives through the
|
||||
/// live-max term until the next successful save.
|
||||
fn advance_watermark(&self, watermark: &mut u64, seq: u64) {
|
||||
if seq <= *watermark {
|
||||
return;
|
||||
}
|
||||
*watermark = seq;
|
||||
if let Err(e) = (CompanionSeqState { last_companion_seq: seq }).save(&self.shared_dir) {
|
||||
tracing::warn!(error = %e, "save companion seq watermark failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 7396 partial update of one profile. `id`, `seq` and `created_at`
|
||||
/// are immutable — whatever the patch says, they are restored from the
|
||||
/// current profile before saving.
|
||||
pub async fn patch(&self, id: &str, patch: serde_json::Value) -> Result<CompanionProfileConfig, AppError> {
|
||||
if !patch.is_object() {
|
||||
return Err(AppError::BadRequest("companion patch must be a JSON object".into()));
|
||||
}
|
||||
let mut companions = self.inner.write().await;
|
||||
let current = companions
|
||||
.get(id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("companion '{id}' not found")))?;
|
||||
let mut value = serde_json::to_value(current)
|
||||
.map_err(|e| AppError::Internal(format!("serialize companion profile: {e}")))?;
|
||||
json_merge_patch(&mut value, &patch);
|
||||
let mut merged: CompanionProfileConfig = serde_json::from_value(value)
|
||||
.map_err(|e| AppError::BadRequest(format!("invalid companion patch: {e}")))?;
|
||||
merged.id = current.id.clone();
|
||||
merged.seq = current.seq;
|
||||
merged.created_at = current.created_at;
|
||||
merged.name = validate_name(&merged.name)?;
|
||||
merged
|
||||
.save(&self.companions_dir.join(&merged.id))
|
||||
.map_err(|e| AppError::Internal(format!("save companion profile: {e}")))?;
|
||||
companions.insert(merged.id.clone(), merged.clone());
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
/// Remove a companion from the map and delete its directory (an already-missing
|
||||
/// directory is tolerated). Returns the removed profile.
|
||||
pub async fn remove(&self, id: &str) -> Result<CompanionProfileConfig, AppError> {
|
||||
let mut companions = self.inner.write().await;
|
||||
let profile = companions
|
||||
.remove(id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("companion '{id}' not found")))?;
|
||||
match std::fs::remove_dir_all(self.companions_dir.join(id)) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(AppError::Internal(format!("remove companion dir: {e}"))),
|
||||
}
|
||||
Ok(profile)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Registry over `{dir}/companions` with its watermark state at
|
||||
/// `{dir}/shared/companion_seq.json` (the production sibling layout).
|
||||
fn scan_at(dir: &std::path::Path) -> CompanionRegistry {
|
||||
scan_companions_at(dir, "companions")
|
||||
}
|
||||
|
||||
/// Same as [`scan_at`] but over `{dir}/{companions}`.
|
||||
fn scan_companions_at(dir: &std::path::Path, companions: &str) -> CompanionRegistry {
|
||||
CompanionRegistry::scan(dir.join(companions), dir.join("shared"))
|
||||
}
|
||||
|
||||
fn registry(dir: &std::path::Path) -> CompanionRegistry {
|
||||
scan_at(dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_patch_merges_nested_and_replaces_scalars() {
|
||||
let mut base = serde_json::json!({
|
||||
"appearance": {"companion_enabled": false, "companion_x": 10, "quiet_start": ""},
|
||||
"learn": {"enabled": true, "interval_minutes": 60}
|
||||
});
|
||||
json_merge_patch(
|
||||
&mut base,
|
||||
&serde_json::json!({"appearance": {"companion_x": 99, "companion_y": 42}}),
|
||||
);
|
||||
assert_eq!(base["appearance"]["companion_x"], 99);
|
||||
assert_eq!(base["appearance"]["companion_y"], 42);
|
||||
assert_eq!(base["appearance"]["companion_enabled"], false);
|
||||
assert_eq!(base["learn"]["interval_minutes"], 60);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_default_prefers_alive_explicit_then_first() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let reg = CompanionRegistry::scan(dir.path().join("companions"), dir.path().join("shared"));
|
||||
// 空 roster → 空串
|
||||
assert_eq!(reg.resolve_default("").await, "");
|
||||
let _a = reg.create("甲", "ink").await.unwrap();
|
||||
let b = reg.create("乙", "ink").await.unwrap();
|
||||
let first = reg.ids().await.into_iter().next().unwrap();
|
||||
// 显式默认体且存活 → 用之
|
||||
assert_eq!(reg.resolve_default(&b.id).await, b.id);
|
||||
// 显式默认体已删(不在 roster)→ 回退首个注册
|
||||
assert_eq!(reg.resolve_default("companion_ghost").await, first);
|
||||
// 空默认体 → 首个注册
|
||||
assert_eq!(reg.resolve_default("").await, first);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_persists_and_lists() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let reg = registry(dir.path());
|
||||
assert!(reg.list().await.is_empty());
|
||||
|
||||
let companion = reg.create(" 毛球 ", "ink").await.unwrap();
|
||||
assert!(companion.id.starts_with("companion_"));
|
||||
assert_eq!(companion.name, "毛球"); // trimmed
|
||||
assert_eq!(companion.character, "ink");
|
||||
assert_eq!(companion.seq, Some(1));
|
||||
|
||||
// Persisted on disk under {companions_dir}/{id}/config.json.
|
||||
let on_disk = CompanionProfileConfig::load(&dir.path().join("companions").join(&companion.id));
|
||||
assert_eq!(on_disk, companion);
|
||||
|
||||
assert_eq!(reg.get(&companion.id).await.unwrap(), companion);
|
||||
assert_eq!(reg.ids().await, vec![companion.id.clone()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_sorts_by_created_at_ascending() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let companions_dir = dir.path().join("companions");
|
||||
// Hand-build two profiles with crafted created_at, newer one first
|
||||
// alphabetically so the sort genuinely exercises created_at.
|
||||
let mut newer = CompanionProfileConfig::new("新宠", "boo");
|
||||
newer.created_at = 2_000;
|
||||
newer.save(&companions_dir.join(&newer.id)).unwrap();
|
||||
let mut older = CompanionProfileConfig::new("老宠", "mochi");
|
||||
older.created_at = 1_000;
|
||||
older.save(&companions_dir.join(&older.id)).unwrap();
|
||||
|
||||
let reg = scan_at(dir.path());
|
||||
let listed = reg.list().await;
|
||||
assert_eq!(listed.len(), 2);
|
||||
assert_eq!(listed[0].id, older.id);
|
||||
assert_eq!(listed[1].id, newer.id);
|
||||
assert_eq!(reg.ids().await, vec![older.id, newer.id]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn name_validation_rejects_empty_and_over_40_chars() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let reg = registry(dir.path());
|
||||
|
||||
assert!(matches!(reg.create("", "ink").await, Err(AppError::BadRequest(_))));
|
||||
assert!(matches!(reg.create(" ", "ink").await, Err(AppError::BadRequest(_))));
|
||||
|
||||
// 40 chars (counted in chars, not bytes) is fine, 41 is not.
|
||||
let ok = "宠".repeat(40);
|
||||
let too_long = "宠".repeat(41);
|
||||
let companion = reg.create(&ok, "ink").await.unwrap();
|
||||
assert_eq!(companion.name.chars().count(), 40);
|
||||
assert!(matches!(reg.create(&too_long, "ink").await, Err(AppError::BadRequest(_))));
|
||||
|
||||
// patch enforces the same rules.
|
||||
let err = reg.patch(&companion.id, serde_json::json!({"name": too_long})).await;
|
||||
assert!(matches!(err, Err(AppError::BadRequest(_))));
|
||||
let err = reg.patch(&companion.id, serde_json::json!({"name": " "})).await;
|
||||
assert!(matches!(err, Err(AppError::BadRequest(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_renames_but_never_changes_id_or_created_at() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let reg = registry(dir.path());
|
||||
let companion = reg.create("旧名", "ink").await.unwrap();
|
||||
|
||||
let patched = reg
|
||||
.patch(
|
||||
&companion.id,
|
||||
serde_json::json!({
|
||||
"name": "新名",
|
||||
"id": "companion_evil",
|
||||
"seq": 99,
|
||||
"created_at": 1,
|
||||
"appearance": {"companion_enabled": true, "companion_x": 7}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(patched.id, companion.id);
|
||||
assert_eq!(patched.seq, companion.seq, "seq is immutable through patches");
|
||||
assert_eq!(patched.created_at, companion.created_at);
|
||||
assert_eq!(patched.name, "新名");
|
||||
assert!(patched.appearance.companion_enabled);
|
||||
assert_eq!(patched.appearance.companion_x, Some(7));
|
||||
// Untouched fields survive the merge.
|
||||
assert_eq!(patched.character, "ink");
|
||||
|
||||
// Persisted and visible through the map.
|
||||
let on_disk = CompanionProfileConfig::load(&dir.path().join("companions").join(&companion.id));
|
||||
assert_eq!(on_disk, patched);
|
||||
assert_eq!(reg.get(&companion.id).await.unwrap(), patched);
|
||||
|
||||
assert!(matches!(
|
||||
reg.patch("companion_missing", serde_json::json!({"name": "x"})).await,
|
||||
Err(AppError::NotFound(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
reg.patch(&companion.id, serde_json::json!(42)).await,
|
||||
Err(AppError::BadRequest(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_deletes_dir_and_returns_profile() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let reg = registry(dir.path());
|
||||
let companion = reg.create("一郎", "ink").await.unwrap();
|
||||
let keep = reg.create("二郎", "boo").await.unwrap();
|
||||
let companion_dir = dir.path().join("companions").join(&companion.id);
|
||||
assert!(companion_dir.exists());
|
||||
|
||||
let removed = reg.remove(&companion.id).await.unwrap();
|
||||
assert_eq!(removed.id, companion.id);
|
||||
assert!(!companion_dir.exists());
|
||||
assert!(reg.get(&companion.id).await.is_none());
|
||||
assert!(reg.get(&keep.id).await.is_some());
|
||||
|
||||
assert!(matches!(reg.remove(&companion.id).await, Err(AppError::NotFound(_))));
|
||||
|
||||
// An already-missing directory is tolerated.
|
||||
std::fs::remove_dir_all(dir.path().join("companions").join(&keep.id)).unwrap();
|
||||
let removed = reg.remove(&keep.id).await.unwrap();
|
||||
assert_eq!(removed.id, keep.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scan_skips_corrupt_and_mismatched_dirs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let companions_dir = dir.path().join("companions");
|
||||
|
||||
// Good profile in a dir matching its id.
|
||||
let good = CompanionProfileConfig::new("好宠", "ink");
|
||||
good.save(&companions_dir.join(&good.id)).unwrap();
|
||||
// Corrupt config.json -> empty-id sentinel -> skipped.
|
||||
let corrupt_dir = companions_dir.join("companion_corrupt");
|
||||
std::fs::create_dir_all(&corrupt_dir).unwrap();
|
||||
std::fs::write(corrupt_dir.join("config.json"), "{not json").unwrap();
|
||||
// Valid profile but stored in a dir that doesn't match its id.
|
||||
let homeless = CompanionProfileConfig::new("流浪", "boo");
|
||||
homeless.save(&companions_dir.join("companion_wrong_home")).unwrap();
|
||||
// Empty dir (no config.json at all).
|
||||
std::fs::create_dir_all(companions_dir.join("companion_empty")).unwrap();
|
||||
// Stray file at the top level.
|
||||
std::fs::write(companions_dir.join("stray.txt"), "?").unwrap();
|
||||
|
||||
let reg = scan_at(dir.path());
|
||||
assert_eq!(reg.ids().await, vec![good.id.clone()]);
|
||||
assert_eq!(reg.get(&good.id).await.unwrap(), good);
|
||||
|
||||
// A missing companions dir scans to an empty registry.
|
||||
let empty = scan_companions_at(dir.path(), "nonexistent");
|
||||
assert!(empty.list().await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_allocates_monotonic_seq_never_reusing_deleted_numbers() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let reg = registry(dir.path());
|
||||
|
||||
let first = reg.create("一号", "ink").await.unwrap();
|
||||
let second = reg.create("二号", "boo").await.unwrap();
|
||||
assert_eq!(first.seq, Some(1));
|
||||
assert_eq!(second.seq, Some(2));
|
||||
|
||||
// Deleting the highest-numbered companion must not free its number.
|
||||
reg.remove(&second.id).await.unwrap();
|
||||
let third = reg.create("三号", "mochi").await.unwrap();
|
||||
assert_eq!(third.seq, Some(3));
|
||||
|
||||
// The watermark is persisted in the registry's own state file (never
|
||||
// in the user-writable shared config, which the registry must not
|
||||
// touch at all)…
|
||||
assert_eq!(CompanionSeqState::load(&dir.path().join("shared")).last_companion_seq, 3);
|
||||
assert!(!crate::profile::SharedCompanionConfig::config_path(&dir.path().join("shared")).exists());
|
||||
// …and the number on the profile itself.
|
||||
let on_disk = CompanionProfileConfig::load(&dir.path().join("companions").join(&third.id));
|
||||
assert_eq!(on_disk.seq, Some(3));
|
||||
|
||||
// A rescan (fresh process) keeps counting past the watermark even
|
||||
// when the highest-numbered companion is gone.
|
||||
reg.remove(&third.id).await.unwrap();
|
||||
let reg2 = registry(dir.path());
|
||||
let fourth = reg2.create("四号", "boo").await.unwrap();
|
||||
assert_eq!(fourth.seq, Some(4));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backfill_numbers_unnumbered_companions_by_created_at_and_keeps_existing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let companions_dir = dir.path().join("companions");
|
||||
// Two pre-rollout profiles (no seq), saved newest-first so the
|
||||
// backfill order genuinely follows created_at, plus one companion that
|
||||
// already carries a number.
|
||||
let mut newer = CompanionProfileConfig::new("新宠", "boo");
|
||||
newer.created_at = 2_000;
|
||||
newer.save(&companions_dir.join(&newer.id)).unwrap();
|
||||
let mut older = CompanionProfileConfig::new("老宠", "mochi");
|
||||
older.created_at = 1_000;
|
||||
older.save(&companions_dir.join(&older.id)).unwrap();
|
||||
let mut numbered = CompanionProfileConfig::new("有号", "ink");
|
||||
numbered.created_at = 1_500;
|
||||
numbered.seq = Some(5);
|
||||
numbered.save(&companions_dir.join(&numbered.id)).unwrap();
|
||||
|
||||
let reg = scan_at(dir.path());
|
||||
reg.backfill_missing_seqs().await;
|
||||
|
||||
// Missing numbers continue past the largest live one, oldest first;
|
||||
// an already-numbered companion is never renumbered.
|
||||
assert_eq!(reg.get(&older.id).await.unwrap().seq, Some(6));
|
||||
assert_eq!(reg.get(&newer.id).await.unwrap().seq, Some(7));
|
||||
assert_eq!(reg.get(&numbered.id).await.unwrap().seq, Some(5));
|
||||
// Persisted to each profile's config.json and to the watermark.
|
||||
assert_eq!(CompanionProfileConfig::load(&companions_dir.join(&older.id)).seq, Some(6));
|
||||
assert_eq!(CompanionProfileConfig::load(&companions_dir.join(&newer.id)).seq, Some(7));
|
||||
assert_eq!(CompanionSeqState::load(&dir.path().join("shared")).last_companion_seq, 7);
|
||||
|
||||
// Idempotent: a second run changes no number.
|
||||
reg.backfill_missing_seqs().await;
|
||||
assert_eq!(reg.get(&older.id).await.unwrap().seq, Some(6));
|
||||
assert_eq!(reg.get(&newer.id).await.unwrap().seq, Some(7));
|
||||
assert_eq!(reg.get(&numbered.id).await.unwrap().seq, Some(5));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_create_does_not_advance_watermark() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// A regular file where the companions dir should be makes every profile
|
||||
// save fail (create_dir_all over a file errors on all platforms).
|
||||
std::fs::write(dir.path().join("companions"), "blocker").unwrap();
|
||||
let reg = scan_at(dir.path());
|
||||
|
||||
assert!(matches!(reg.create("一号", "ink").await, Err(AppError::Internal(_))));
|
||||
// Zero persistent side effects: no watermark advanced, empty roster.
|
||||
assert_eq!(CompanionSeqState::load(&dir.path().join("shared")).last_companion_seq, 0);
|
||||
assert!(reg.list().await.is_empty());
|
||||
|
||||
// The retry (after the cause is fixed) still gets #1 — a failed
|
||||
// create burns no number.
|
||||
std::fs::remove_file(dir.path().join("companions")).unwrap();
|
||||
let companion = reg.create("一号", "ink").await.unwrap();
|
||||
assert_eq!(companion.seq, Some(1));
|
||||
assert_eq!(CompanionSeqState::load(&dir.path().join("shared")).last_companion_seq, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backfill_save_failure_stops_instead_of_renumbering_younger_companions() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let companions_dir = dir.path().join("companions");
|
||||
let mut a = CompanionProfileConfig::new("老大", "ink");
|
||||
a.created_at = 1_000;
|
||||
a.save(&companions_dir.join(&a.id)).unwrap();
|
||||
let mut b = CompanionProfileConfig::new("老二", "boo");
|
||||
b.created_at = 2_000;
|
||||
b.save(&companions_dir.join(&b.id)).unwrap();
|
||||
let mut c = CompanionProfileConfig::new("老三", "mochi");
|
||||
c.created_at = 3_000;
|
||||
c.save(&companions_dir.join(&c.id)).unwrap();
|
||||
|
||||
let reg = scan_at(dir.path());
|
||||
// Break the middle companion's home: a regular file at its dir path makes
|
||||
// (only) its save fail.
|
||||
std::fs::remove_dir_all(companions_dir.join(&b.id)).unwrap();
|
||||
std::fs::write(companions_dir.join(&b.id), "blocker").unwrap();
|
||||
|
||||
reg.backfill_missing_seqs().await;
|
||||
|
||||
// A got #1; B's save failed; C must NOT take #2 — numbering stops at
|
||||
// the failure so the created_at order survives to the next retry
|
||||
// (seq is immutable, a swap would be permanent).
|
||||
assert_eq!(reg.get(&a.id).await.unwrap().seq, Some(1));
|
||||
assert_eq!(reg.get(&b.id).await.unwrap().seq, None);
|
||||
assert_eq!(reg.get(&c.id).await.unwrap().seq, None);
|
||||
assert_eq!(CompanionSeqState::load(&dir.path().join("shared")).last_companion_seq, 1);
|
||||
|
||||
// Next boot (cause fixed): B and C get #2/#3, still in age order.
|
||||
std::fs::remove_file(companions_dir.join(&b.id)).unwrap();
|
||||
reg.backfill_missing_seqs().await;
|
||||
assert_eq!(reg.get(&b.id).await.unwrap().seq, Some(2));
|
||||
assert_eq!(reg.get(&c.id).await.unwrap().seq, Some(3));
|
||||
assert_eq!(CompanionSeqState::load(&dir.path().join("shared")).last_companion_seq, 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,817 @@
|
||||
//! `/api/companion/*` route handlers.
|
||||
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::extract::rejection::JsonRejection;
|
||||
use axum::extract::{Extension, Json, Path, Query, State};
|
||||
use axum::http::{HeaderMap, StatusCode, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{delete, get, post};
|
||||
|
||||
use nomifun_api_types::ApiResponse;
|
||||
use nomifun_auth::CurrentUser;
|
||||
use nomifun_common::AppError;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::profile::{HeadBox, CompanionProfileConfig, SharedCompanionConfig};
|
||||
use crate::service::{CompanionSkillContent, CompanionSkillView, CompanionStatus, CompanionWeeklyDigest, SourceStats};
|
||||
use crate::state::CompanionRouterState;
|
||||
use crate::store::{MemoryFilter, MemoryScope, CompanionLearnRun, CompanionMemory, CompanionSkill, CompanionSuggestion};
|
||||
|
||||
pub fn companion_routes(state: CompanionRouterState) -> Router {
|
||||
Router::new()
|
||||
.route("/api/companion/config", get(get_config).put(update_config).patch(patch_config))
|
||||
.route("/api/companion/status", get(status))
|
||||
.route("/api/companion/companions", get(list_companions).post(create_companion))
|
||||
.route(
|
||||
"/api/companion/companions/{companion_id}",
|
||||
get(get_companion).patch(patch_companion).delete(delete_companion),
|
||||
)
|
||||
.route("/api/companion/companions/{companion_id}/status", get(companion_status))
|
||||
.route("/api/companion/companions/{companion_id}/figure", post(upload_figure).get(get_figure))
|
||||
.route("/api/companion/matting-model", get(get_matting_model))
|
||||
.route("/api/companion/figures", get(list_figures).post(create_figure))
|
||||
.route(
|
||||
"/api/companion/figures/{figure_id}",
|
||||
axum::routing::patch(update_figure).delete(delete_figure),
|
||||
)
|
||||
.route(
|
||||
"/api/companion/companions/{companion_id}/companion/threads",
|
||||
post(create_thread),
|
||||
)
|
||||
.route("/api/companion/companions/{companion_id}/companion/active", get(get_active_thread))
|
||||
.route("/api/companion/memories", get(list_memories).post(add_memory))
|
||||
.route("/api/companion/memories/{id}", axum::routing::put(update_memory).delete(delete_memory))
|
||||
.route("/api/companion/suggestions", get(list_suggestions))
|
||||
.route("/api/companion/suggestions/{id}/decide", post(decide_suggestion))
|
||||
.route("/api/companion/companions/{companion_id}/skills", get(list_companion_skills))
|
||||
.route("/api/companion/companions/{companion_id}/weekly-digest", get(weekly_digest))
|
||||
.route(
|
||||
"/api/companion/companions/{companion_id}/skills/{name}",
|
||||
get(get_companion_skill).put(update_companion_skill),
|
||||
)
|
||||
.route(
|
||||
"/api/companion/companions/{companion_id}/skills/{name}/decide",
|
||||
post(decide_companion_skill),
|
||||
)
|
||||
.route(
|
||||
"/api/companion/companions/{companion_id}/skills/from-session",
|
||||
post(draft_skill_from_session),
|
||||
)
|
||||
.route(
|
||||
"/api/companion/companions/{companion_id}/skills/{name}/gift",
|
||||
post(gift_companion_skill),
|
||||
)
|
||||
.route("/api/companion/learn/run", post(run_learn))
|
||||
.route("/api/companion/learn/runs", get(list_learn_runs))
|
||||
.route("/api/companion/events/stats", get(event_stats))
|
||||
.route("/api/companion/events/recent", get(recent_events))
|
||||
.route("/api/companion/events", delete(clear_events))
|
||||
.route("/api/companion/consent", post(apply_consent))
|
||||
.route("/api/companion/disable-all", post(disable_all))
|
||||
.route("/api/companion/export/memory", post(export_memory))
|
||||
.route("/api/companion/export/companions/{companion_id}", post(export_companion))
|
||||
.route("/api/companion/import", post(import_package))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// Public (auth-exempt) figure-image serving.
|
||||
///
|
||||
/// `<img>` / `new Image()` are browser-native subresource loads with no
|
||||
/// custom-header API, so under the desktop's `TrustLocalToken` policy they
|
||||
/// cannot present the `x-nomi-local-trust` header — the authenticated router
|
||||
/// would 403 every figure thumbnail (broken library image + blank desktop
|
||||
/// companion mesh). This GET-only route therefore lives outside auth, exactly
|
||||
/// like `asset_routes` (logos) and the office proxy. Figure ids are unguessable
|
||||
/// (`figure_<uuidv7>`) and listing/creation/rename/delete stay authenticated,
|
||||
/// so this only serves opaque-id image bytes — a capability URL, not an
|
||||
/// enumeration surface.
|
||||
pub fn companion_public_routes(state: CompanionRouterState) -> Router {
|
||||
Router::new()
|
||||
.route("/api/companion/figures/{figure_id}/image", get(get_figure_image))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn get_config(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
) -> Result<Json<ApiResponse<SharedCompanionConfig>>, AppError> {
|
||||
Ok(Json(ApiResponse::ok(state.service.get_config().await)))
|
||||
}
|
||||
|
||||
async fn update_config(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
body: Result<Json<SharedCompanionConfig>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<SharedCompanionConfig>>, AppError> {
|
||||
let Json(config) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
Ok(Json(ApiResponse::ok(state.service.update_config(config).await?)))
|
||||
}
|
||||
|
||||
async fn patch_config(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
body: Result<Json<serde_json::Value>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<SharedCompanionConfig>>, AppError> {
|
||||
let Json(patch) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
Ok(Json(ApiResponse::ok(state.service.patch_config(patch).await?)))
|
||||
}
|
||||
|
||||
async fn status(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
) -> Result<Json<ApiResponse<CompanionStatus>>, AppError> {
|
||||
Ok(Json(ApiResponse::ok(state.service.status().await?)))
|
||||
}
|
||||
|
||||
/// Build an optional [`MemoryScope`] from wire parts.
|
||||
/// - `scope_kind = Some("companion")` with a non-empty id → private to it.
|
||||
/// - `scope_kind = Some(_other)` → Shared.
|
||||
/// - `scope_kind = None` → `None` (leave unchanged on update / default on add).
|
||||
fn scope_from_parts(scope_kind: Option<&str>, scope_companion_id: Option<&str>) -> Option<MemoryScope> {
|
||||
let kind = scope_kind?;
|
||||
let cid = scope_companion_id.unwrap_or("").trim();
|
||||
if kind == "companion" && !cid.is_empty() {
|
||||
Some(MemoryScope::Companion(cid.to_owned()))
|
||||
} else {
|
||||
Some(MemoryScope::Shared)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ListMemoriesQuery {
|
||||
kind: Option<String>,
|
||||
q: Option<String>,
|
||||
status: Option<String>,
|
||||
/// When set, scope the list to memories visible to this companion (shared +
|
||||
/// its own private). Empty/absent = cross-companion "all" view.
|
||||
scope_companion_id: Option<String>,
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
}
|
||||
|
||||
async fn list_memories(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Query(query): Query<ListMemoriesQuery>,
|
||||
) -> Result<Json<ApiResponse<Vec<CompanionMemory>>>, AppError> {
|
||||
let filter = MemoryFilter {
|
||||
kind: query.kind.filter(|k| !k.is_empty()),
|
||||
q: query.q.filter(|q| !q.is_empty()),
|
||||
status: Some(query.status.filter(|s| !s.is_empty()).unwrap_or_else(|| "active".into())),
|
||||
scope_companion_id: query.scope_companion_id.filter(|s| !s.is_empty()),
|
||||
limit: query.limit.unwrap_or(100),
|
||||
offset: query.offset.unwrap_or(0),
|
||||
};
|
||||
Ok(Json(ApiResponse::ok(state.service.list_memories(&filter).await?)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AddMemoryRequest {
|
||||
kind: String,
|
||||
content: String,
|
||||
#[serde(default)]
|
||||
tags: Vec<String>,
|
||||
/// Owning companion for a private memory; empty/absent = shared.
|
||||
#[serde(default)]
|
||||
scope_companion_id: Option<String>,
|
||||
}
|
||||
|
||||
async fn add_memory(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
body: Result<Json<AddMemoryRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<CompanionMemory>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let scope = scope_from_parts(Some("companion"), req.scope_companion_id.as_deref()).unwrap_or(MemoryScope::Shared);
|
||||
Ok(Json(ApiResponse::ok(
|
||||
state.service.add_memory(&req.kind, &req.content, &req.tags, scope).await?,
|
||||
)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UpdateMemoryRequest {
|
||||
content: Option<String>,
|
||||
pinned: Option<bool>,
|
||||
status: Option<String>,
|
||||
/// `'user'` (shared) or `'companion'` (private). Present together with
|
||||
/// `scope_companion_id` to re-home a memory; both absent = scope unchanged.
|
||||
scope_kind: Option<String>,
|
||||
scope_companion_id: Option<String>,
|
||||
}
|
||||
|
||||
async fn update_memory(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
body: Result<Json<UpdateMemoryRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let scope = scope_from_parts(req.scope_kind.as_deref(), req.scope_companion_id.as_deref());
|
||||
state
|
||||
.service
|
||||
.update_memory(&id, req.content.as_deref(), req.pinned, req.status.as_deref(), scope)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
async fn delete_memory(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
state.service.delete_memory(&id).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ListSuggestionsQuery {
|
||||
status: Option<String>,
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
async fn list_suggestions(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Query(query): Query<ListSuggestionsQuery>,
|
||||
) -> Result<Json<ApiResponse<Vec<CompanionSuggestion>>>, AppError> {
|
||||
let status = query.status.filter(|s| !s.is_empty());
|
||||
Ok(Json(ApiResponse::ok(
|
||||
state
|
||||
.service
|
||||
.list_suggestions(status.as_deref(), query.limit.unwrap_or(100))
|
||||
.await?,
|
||||
)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DecideSuggestionRequest {
|
||||
accept: bool,
|
||||
}
|
||||
|
||||
async fn decide_suggestion(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
body: Result<Json<DecideSuggestionRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<CompanionSuggestion>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
Ok(Json(ApiResponse::ok(state.service.decide_suggestion(&id, req.accept).await?)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ListSkillsQuery {
|
||||
include_shared: Option<bool>,
|
||||
}
|
||||
|
||||
async fn list_companion_skills(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(companion_id): Path<String>,
|
||||
Query(q): Query<ListSkillsQuery>,
|
||||
) -> Result<Json<ApiResponse<Vec<CompanionSkillView>>>, AppError> {
|
||||
let views = state
|
||||
.service
|
||||
.list_companion_skills(&companion_id, q.include_shared.unwrap_or(true))
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(views)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DigestQuery {
|
||||
days: Option<i64>,
|
||||
}
|
||||
|
||||
async fn weekly_digest(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(companion_id): Path<String>,
|
||||
Query(q): Query<DigestQuery>,
|
||||
) -> Result<Json<ApiResponse<CompanionWeeklyDigest>>, AppError> {
|
||||
let days = q.days.unwrap_or(7).clamp(1, 90);
|
||||
let since_ms = nomifun_common::now_ms() - days * 86_400_000;
|
||||
Ok(Json(ApiResponse::ok(state.service.weekly_digest(&companion_id, since_ms).await?)))
|
||||
}
|
||||
|
||||
async fn get_companion_skill(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path((companion_id, name)): Path<(String, String)>,
|
||||
) -> Result<Json<ApiResponse<CompanionSkillContent>>, AppError> {
|
||||
Ok(Json(ApiResponse::ok(state.service.get_companion_skill_content(&companion_id, &name).await?)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UpdateSkillRequest {
|
||||
content: String,
|
||||
}
|
||||
|
||||
async fn update_companion_skill(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path((companion_id, name)): Path<(String, String)>,
|
||||
body: Result<Json<UpdateSkillRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
state.service.write_companion_skill_content(&companion_id, &name, &req.content).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DecideSkillRequest {
|
||||
accept: bool,
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
||||
async fn decide_companion_skill(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path((companion_id, name)): Path<(String, String)>,
|
||||
body: Result<Json<DecideSkillRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<CompanionSkill>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
Ok(Json(ApiResponse::ok(
|
||||
state
|
||||
.service
|
||||
.decide_companion_skill(&companion_id, &name, req.accept, req.reason.as_deref())
|
||||
.await?,
|
||||
)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FromSessionRequest {
|
||||
conversation_id: String,
|
||||
}
|
||||
|
||||
async fn draft_skill_from_session(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(companion_id): Path<String>,
|
||||
body: Result<Json<FromSessionRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<Option<String>>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
Ok(Json(ApiResponse::ok(
|
||||
state.service.draft_skill_from_session(&companion_id, &req.conversation_id).await?,
|
||||
)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GiftSkillRequest {
|
||||
to_companion_id: String,
|
||||
}
|
||||
|
||||
async fn gift_companion_skill(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path((companion_id, name)): Path<(String, String)>,
|
||||
body: Result<Json<GiftSkillRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<CompanionSkill>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
Ok(Json(ApiResponse::ok(
|
||||
state.service.gift_companion_skill(&companion_id, &name, &req.to_companion_id).await?,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn run_learn(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
) -> Result<Json<ApiResponse<CompanionLearnRun>>, AppError> {
|
||||
Ok(Json(ApiResponse::ok(state.service.run_learn_now().await?)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LimitQuery {
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
async fn list_learn_runs(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Query(query): Query<LimitQuery>,
|
||||
) -> Result<Json<ApiResponse<Vec<CompanionLearnRun>>>, AppError> {
|
||||
Ok(Json(ApiResponse::ok(
|
||||
state.service.list_learn_runs(query.limit.unwrap_or(30)).await?,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn event_stats(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
) -> Result<Json<ApiResponse<Vec<SourceStats>>>, AppError> {
|
||||
Ok(Json(ApiResponse::ok(state.service.event_stats())))
|
||||
}
|
||||
|
||||
async fn recent_events(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Query(query): Query<LimitQuery>,
|
||||
) -> Result<Json<ApiResponse<Vec<crate::collector::CollectedEvent>>>, AppError> {
|
||||
let limit = query.limit.unwrap_or(100).clamp(1, 500) as usize;
|
||||
Ok(Json(ApiResponse::ok(state.service.recent_events(limit))))
|
||||
}
|
||||
|
||||
async fn apply_consent(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
) -> Result<Json<ApiResponse<SharedCompanionConfig>>, AppError> {
|
||||
Ok(Json(ApiResponse::ok(state.service.apply_default_on_consent().await?)))
|
||||
}
|
||||
|
||||
async fn disable_all(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
) -> Result<Json<ApiResponse<SharedCompanionConfig>>, AppError> {
|
||||
Ok(Json(ApiResponse::ok(state.service.disable_all().await?)))
|
||||
}
|
||||
|
||||
// ----- companions -----
|
||||
|
||||
/// One companion card: profile fields flattened at the top level plus that companion's
|
||||
/// live status — list/detail fetch everything for a card in one round trip.
|
||||
#[derive(serde::Serialize)]
|
||||
struct CompanionWithStatus {
|
||||
#[serde(flatten)]
|
||||
profile: CompanionProfileConfig,
|
||||
status: CompanionStatus,
|
||||
}
|
||||
|
||||
async fn list_companions(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
) -> Result<Json<ApiResponse<Vec<CompanionWithStatus>>>, AppError> {
|
||||
let mut companions = Vec::new();
|
||||
for profile in state.service.list_companions().await {
|
||||
match state.service.companion_status(&profile.id).await {
|
||||
Ok(status) => companions.push(CompanionWithStatus { profile, status }),
|
||||
// The companion vanished between list and status (concurrent delete):
|
||||
// drop the card rather than failing the whole list.
|
||||
Err(AppError::NotFound(_)) => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok(Json(ApiResponse::ok(companions)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateCompanionRequest {
|
||||
name: String,
|
||||
/// Empty/missing falls back to the default roster character.
|
||||
#[serde(default)]
|
||||
character: String,
|
||||
}
|
||||
|
||||
async fn create_companion(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
body: Result<Json<CreateCompanionRequest>, JsonRejection>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let profile = state.service.create_companion(&req.name, &req.character).await?;
|
||||
Ok((StatusCode::CREATED, Json(ApiResponse::ok(profile))))
|
||||
}
|
||||
|
||||
async fn get_companion(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(companion_id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<CompanionWithStatus>>, AppError> {
|
||||
let profile = state.service.get_companion(&companion_id).await?;
|
||||
let status = state.service.companion_status(&companion_id).await?;
|
||||
Ok(Json(ApiResponse::ok(CompanionWithStatus { profile, status })))
|
||||
}
|
||||
|
||||
/// RFC 7396 merge patch over one companion's profile.
|
||||
async fn patch_companion(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(companion_id): Path<String>,
|
||||
body: Result<Json<serde_json::Value>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<CompanionProfileConfig>>, AppError> {
|
||||
let Json(patch) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
Ok(Json(ApiResponse::ok(state.service.patch_companion(&companion_id, patch).await?)))
|
||||
}
|
||||
|
||||
async fn delete_companion(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(companion_id): Path<String>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
state.service.delete_companion(&companion_id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn companion_status(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(companion_id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<CompanionStatus>>, AppError> {
|
||||
Ok(Json(ApiResponse::ok(state.service.companion_status(&companion_id).await?)))
|
||||
}
|
||||
|
||||
// ----- DIY custom figure (spec §3 存储与回显) -----
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UploadFigureRequest {
|
||||
/// Temp path returned by `POST /api/fs/upload` (two-phase upload).
|
||||
source_path: String,
|
||||
}
|
||||
|
||||
async fn upload_figure(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(companion_id): Path<String>,
|
||||
body: Result<Json<UploadFigureRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
state.service.ingest_figure(&companion_id, &req.source_path).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
/// Binary serve of one companion's figure (the nomifun-assets Response template,
|
||||
/// disk-backed). `Cache-Control: no-cache` + a `"{mtime}-{len}"` ETag: the
|
||||
/// browser revalidates every time and gets a cheap 304 until re-upload.
|
||||
async fn get_figure(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(companion_id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, AppError> {
|
||||
let (bytes, mtime) = state.service.read_figure(&companion_id).await?;
|
||||
let etag = format!("\"{}-{}\"", mtime, bytes.len());
|
||||
|
||||
let if_none_match_hits = headers
|
||||
.get(header::IF_NONE_MATCH)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| v.split(',').map(str::trim).any(|c| c == etag || c == "*"));
|
||||
if if_none_match_hits {
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_MODIFIED)
|
||||
.header(header::CACHE_CONTROL, "no-cache")
|
||||
.header(header::ETAG, etag)
|
||||
.body(Body::empty())
|
||||
.map_err(|e| AppError::Internal(e.to_string()));
|
||||
}
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, crate::figure::content_type_of(&bytes))
|
||||
.header(header::CACHE_CONTROL, "no-cache")
|
||||
.header(header::ETAG, etag)
|
||||
.body(Body::from(bytes))
|
||||
.map_err(|e| AppError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// Binary serve of the cached MODNet matting model, downloading it from a
|
||||
/// mirror on first use (see [`crate::matting_model`]). The renderer fetches
|
||||
/// this from `127.0.0.1` and mirrors it into Cache Storage, so the matting
|
||||
/// Web Worker reads a local copy instead of hitting huggingface behind a 30 s
|
||||
/// timeout. Immutable + long-lived: the filename is versioned, so the browser
|
||||
/// may cache it forever.
|
||||
async fn get_matting_model(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
) -> Result<Response, AppError> {
|
||||
let bytes = state.service.matting_model_bytes().await?;
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
|
||||
.body(Body::from(bytes))
|
||||
.map_err(|e| AppError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
// ----- custom-figure library (decoupled from companions) -----
|
||||
|
||||
async fn list_figures(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
) -> Result<Json<ApiResponse<Vec<crate::figures::FigureMeta>>>, AppError> {
|
||||
Ok(Json(ApiResponse::ok(state.service.list_figures().await)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateFigureRequest {
|
||||
/// Temp path returned by `POST /api/fs/upload` (two-phase upload).
|
||||
source_path: String,
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
aspect: f32,
|
||||
head_box: HeadBox,
|
||||
#[serde(default)]
|
||||
size_tier: String,
|
||||
}
|
||||
|
||||
async fn create_figure(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
body: Result<Json<CreateFigureRequest>, JsonRejection>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let figure = state
|
||||
.service
|
||||
.create_figure(&req.source_path, &req.name, req.aspect, req.head_box, &req.size_tier)
|
||||
.await?;
|
||||
Ok((StatusCode::CREATED, Json(ApiResponse::ok(figure))))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UpdateFigureRequest {
|
||||
name: Option<String>,
|
||||
head_box: Option<HeadBox>,
|
||||
size_tier: Option<String>,
|
||||
}
|
||||
|
||||
async fn update_figure(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(figure_id): Path<String>,
|
||||
body: Result<Json<UpdateFigureRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<crate::figures::FigureMeta>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
Ok(Json(ApiResponse::ok(state.service.update_figure(
|
||||
&figure_id,
|
||||
crate::figures::FigureUpdate { name: req.name, head_box: req.head_box, size_tier: req.size_tier },
|
||||
).await?)))
|
||||
}
|
||||
|
||||
async fn delete_figure(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(figure_id): Path<String>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
state.service.delete_figure(&figure_id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Binary serve of one library figure's image (same ETag/no-cache template as
|
||||
/// the per-companion `get_figure`).
|
||||
///
|
||||
/// AUTH-EXEMPT route (see `companion_public_routes`): native `<img>` loads carry
|
||||
/// no trust header, so `trust_resolve_middleware` injects NO `CurrentUser` for
|
||||
/// them. This handler therefore MUST NOT extract `Extension<CurrentUser>` — that
|
||||
/// extractor would 500 on the very (untrusted-header) requests this route exists
|
||||
/// to serve. The figure id is the opaque capability; no user identity is needed.
|
||||
async fn get_figure_image(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Path(figure_id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, AppError> {
|
||||
let (bytes, mtime) = state.service.read_figure_image(&figure_id).await?;
|
||||
let etag = format!("\"{}-{}\"", mtime, bytes.len());
|
||||
|
||||
let if_none_match_hits = headers
|
||||
.get(header::IF_NONE_MATCH)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| v.split(',').map(str::trim).any(|c| c == etag || c == "*"));
|
||||
if if_none_match_hits {
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_MODIFIED)
|
||||
.header(header::CACHE_CONTROL, "no-cache")
|
||||
.header(header::ETAG, etag)
|
||||
.body(Body::empty())
|
||||
.map_err(|e| AppError::Internal(e.to_string()));
|
||||
}
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, crate::figure::content_type_of(&bytes))
|
||||
.header(header::CACHE_CONTROL, "no-cache")
|
||||
.header(header::ETAG, etag)
|
||||
.body(Body::from(bytes))
|
||||
.map_err(|e| AppError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
// ----- companion thread (per companion, single session) -----
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateThreadRequest {
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
}
|
||||
|
||||
/// Idempotent ensure of the companion's single companion session: returns the
|
||||
/// existing one, or creates it (requires the companion's model to be configured).
|
||||
async fn create_thread(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(companion_id): Path<String>,
|
||||
body: Result<Json<CreateThreadRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<crate::store::CompanionThread>>, AppError> {
|
||||
let title = body.map(|Json(b)| b.title).unwrap_or_default();
|
||||
Ok(Json(ApiResponse::ok(
|
||||
state.service.create_companion_thread(&companion_id, title).await?,
|
||||
)))
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct ActiveThreadResponse {
|
||||
conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
/// The companion's single companion session id (or null when none exists yet).
|
||||
async fn get_active_thread(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(companion_id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<ActiveThreadResponse>>, AppError> {
|
||||
// Existence gate: an unknown companion must 404, not read as "no active thread".
|
||||
state.service.get_companion(&companion_id).await?;
|
||||
Ok(Json(ApiResponse::ok(ActiveThreadResponse {
|
||||
conversation_id: state.service.companion_active_thread(&companion_id).await?,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn clear_events(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
state.service.clear_events()?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
// ----- export / import (§4.8 migration) -----
|
||||
|
||||
/// The live shared store + shared dir for export/import. `CompanionService` keeps
|
||||
/// its store private, so the boot-time registration in `crate::store` is the
|
||||
/// only crate-visible handle. `None` means boot fell back to the in-memory
|
||||
/// store (corrupt/locked memory.db) — exporting that throwaway snapshot would
|
||||
/// silently lose the on-disk data, so the endpoints refuse instead.
|
||||
fn live_store() -> Result<(&'static std::path::Path, &'static crate::store::CompanionStore), AppError> {
|
||||
crate::store::live_store()
|
||||
.ok_or_else(|| AppError::Internal("伙伴存储当前处于内存降级模式,无法导入导出".into()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ExportMemoryRequest {
|
||||
dest_path: String,
|
||||
#[serde(default)]
|
||||
include_events: bool,
|
||||
}
|
||||
|
||||
async fn export_memory(
|
||||
State(_state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
body: Result<Json<ExportMemoryRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<crate::export::ExportSummary>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let (shared_dir, store) = live_store()?;
|
||||
let summary = crate::export::export_memory_bundle(
|
||||
store,
|
||||
shared_dir,
|
||||
std::path::Path::new(&req.dest_path),
|
||||
req.include_events,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(summary)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ExportCompanionRequest {
|
||||
dest_path: String,
|
||||
/// Names of the knowledge bases bound to this companion, collected by the
|
||||
/// frontend (the companion crate never reaches into the knowledge domain).
|
||||
#[serde(default)]
|
||||
knowledge_names: Vec<String>,
|
||||
}
|
||||
|
||||
async fn export_companion(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(companion_id): Path<String>,
|
||||
body: Result<Json<ExportCompanionRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<crate::export::ExportSummary>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
// Existence gate: an unknown companion must 404 before any file is written.
|
||||
let profile = state.service.get_companion(&companion_id).await?;
|
||||
let (_, store) = live_store()?;
|
||||
let summary = crate::export::export_companion_bundle(
|
||||
store,
|
||||
&profile,
|
||||
std::path::Path::new(&req.dest_path),
|
||||
&req.knowledge_names,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(summary)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ImportPackageRequest {
|
||||
src_path: String,
|
||||
}
|
||||
|
||||
async fn import_package(
|
||||
State(state): State<CompanionRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
body: Result<Json<ImportPackageRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<crate::export::ImportOutcome>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let (shared_dir, store) = live_store()?;
|
||||
let outcome = crate::export::import_bundle(
|
||||
store,
|
||||
state.service.as_ref(),
|
||||
shared_dir,
|
||||
std::path::Path::new(&req.src_path),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(outcome)))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
//! `CompanionSkillStoreSink` — bridges the companion's skill registry + on-disk
|
||||
//! SKILL.md bodies to the `nomifun_ai_agent::CompanionSkillSink` trait the agent
|
||||
//! engine consumes for skill auto-use (design §7).
|
||||
//!
|
||||
//! `active_skills` feeds the per-turn `when_to_use` index (the `CompanionSkillContributor`);
|
||||
//! `load_skill_body` resolves a named skill's SKILL.md on demand (the `companion_skill` tool).
|
||||
//! Both scope to the default companion (the owner of mined skills) plus shared skills.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nomifun_ai_agent::{CompanionSkillSink, SkillListing};
|
||||
use nomifun_extension::constants::SKILL_MANIFEST_FILE;
|
||||
use nomifun_extension::skill_service::{self, SkillPaths, SkillScope};
|
||||
|
||||
use crate::collector::SharedConfig;
|
||||
use crate::store::CompanionStore;
|
||||
|
||||
pub struct CompanionSkillStoreSink {
|
||||
pub store: CompanionStore,
|
||||
pub config: SharedConfig,
|
||||
pub skill_paths: Arc<SkillPaths>,
|
||||
}
|
||||
|
||||
impl CompanionSkillStoreSink {
|
||||
/// The companion that owns mined skills (default companion).
|
||||
async fn owner(&self) -> String {
|
||||
self.config.read().await.default_companion_id.clone()
|
||||
}
|
||||
|
||||
fn scope_of(companion_id: &str) -> SkillScope {
|
||||
if companion_id.is_empty() {
|
||||
SkillScope::Shared
|
||||
} else {
|
||||
SkillScope::Companion(companion_id.to_owned())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CompanionSkillSink for CompanionSkillStoreSink {
|
||||
async fn active_skills(&self) -> Vec<SkillListing> {
|
||||
let owner = self.owner().await;
|
||||
if owner.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let skills = self.store.list_skills(&owner, true).await.unwrap_or_default();
|
||||
let mut out = Vec::new();
|
||||
for s in skills.into_iter().filter(|s| s.status == "active") {
|
||||
let scope = Self::scope_of(&s.scope_companion_id);
|
||||
// when_to_use index uses the SKILL.md description (what the skill does).
|
||||
if let Ok(dir) = skill_service::skill_dir_for(&self.skill_paths, &scope, &s.skill_name, false) {
|
||||
let desc = skill_service::read_skill_info(&dir).await.map(|(_, d)| d).unwrap_or_default();
|
||||
out.push(SkillListing { name: s.skill_name, when_to_use: desc });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
async fn load_skill_body(&self, name: &str) -> Option<String> {
|
||||
let owner = self.owner().await;
|
||||
// Prefer the owner's companion-scoped skill (record usage against the owner),
|
||||
// then fall back to shared (recorded against the shared "" scope).
|
||||
if !owner.is_empty() {
|
||||
if let Ok(dir) = skill_service::skill_dir_for(&self.skill_paths, &SkillScope::Companion(owner.clone()), name, false) {
|
||||
if let Ok(body) = tokio::fs::read_to_string(dir.join(SKILL_MANIFEST_FILE)).await {
|
||||
let _ = self.store.record_skill_usage(&owner, name, nomifun_common::now_ms()).await;
|
||||
return Some(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(dir) = skill_service::skill_dir_for(&self.skill_paths, &SkillScope::Shared, name, false) {
|
||||
if let Ok(body) = tokio::fs::read_to_string(dir.join(SKILL_MANIFEST_FILE)).await {
|
||||
let _ = self.store.record_skill_usage("", name, nomifun_common::now_ms()).await;
|
||||
return Some(body);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Router state for the companion domain. Holds the `Arc`-wrapped service.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::service::CompanionService;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CompanionRouterState {
|
||||
pub service: Arc<CompanionService>,
|
||||
}
|
||||
|
||||
impl CompanionRouterState {
|
||||
pub fn new(service: Arc<CompanionService>) -> Self {
|
||||
Self { service }
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user