use crate::{ model::{Channel, SourceKind, TimelineEvent}, persona_runtime::PublicPersona, storage, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::{fs, net::IpAddr, time::Duration}; use tauri::AppHandle; const KEYCHAIN_SERVICE: &str = "world.guanghu.hololake.persona-model"; const PROVIDER_KIND: &str = "OPENAI_COMPATIBLE_CHAT_COMPLETIONS_V1"; const MAX_HISTORY_EVENTS: usize = 24; const MAX_RESPONSE_CHARS: usize = 64_000; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct PersonaModelConfig { pub schema: String, pub persona_id: String, pub provider_kind: String, pub display_name: String, pub endpoint_url: String, pub model: String, pub configured_at: String, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct PersonaInferenceSnapshot { pub schema: &'static str, pub state: String, pub active_persona_id: Option, pub provider_kind: Option, pub display_name: Option, pub endpoint_url: Option, pub model: Option, pub api_key_present: bool, pub model_is_persona: bool, pub execution_authority: bool, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct PersonaInteractionReceipt { pub schema: &'static str, pub receipt_id: String, pub state: String, pub user_event: TimelineEvent, pub persona_event: Option, pub system_receipt: Option, } fn config_path(app: &AppHandle) -> Result { Ok(storage::root(app)?.join("persona-inference/model-binding.json")) } fn load_config(app: &AppHandle) -> Result, String> { let path = config_path(app)?; if !path.exists() { return Ok(None); } storage::read_json(&path).map(Some) } fn credential_account(persona_id: &str) -> String { format!("{persona_id}:api-key:v1") } #[cfg(target_os = "macos")] fn load_api_key(persona_id: &str) -> Result>, String> { match security_framework::passwords::get_generic_password( KEYCHAIN_SERVICE, &credential_account(persona_id), ) { Ok(value) => Ok(Some(value)), Err(error) if error.code() == -25300 => Ok(None), Err(_) => Err("PERSONA_MODEL_KEYCHAIN_READ_FAILED".into()), } } #[cfg(target_os = "macos")] fn save_api_key(persona_id: &str, value: &[u8]) -> Result<(), String> { security_framework::passwords::set_generic_password( KEYCHAIN_SERVICE, &credential_account(persona_id), value, ) .map_err(|_| "PERSONA_MODEL_KEYCHAIN_WRITE_FAILED".to_string()) } #[cfg(target_os = "macos")] fn delete_api_key(persona_id: &str) -> Result<(), String> { match security_framework::passwords::delete_generic_password( KEYCHAIN_SERVICE, &credential_account(persona_id), ) { Ok(()) => Ok(()), Err(error) if error.code() == -25300 => Ok(()), Err(_) => Err("PERSONA_MODEL_KEYCHAIN_DELETE_FAILED".into()), } } #[cfg(not(target_os = "macos"))] fn load_api_key(_persona_id: &str) -> Result>, String> { Err("PERSONA_MODEL_KEYCHAIN_UNAVAILABLE_ON_PLATFORM".into()) } #[cfg(not(target_os = "macos"))] fn save_api_key(_persona_id: &str, _value: &[u8]) -> Result<(), String> { Err("PERSONA_MODEL_KEYCHAIN_UNAVAILABLE_ON_PLATFORM".into()) } #[cfg(not(target_os = "macos"))] fn delete_api_key(_persona_id: &str) -> Result<(), String> { Err("PERSONA_MODEL_KEYCHAIN_UNAVAILABLE_ON_PLATFORM".into()) } fn validate_endpoint(value: &str) -> Result { let endpoint = reqwest::Url::parse(value.trim()) .map_err(|_| "PERSONA_MODEL_ENDPOINT_INVALID".to_string())?; if endpoint.username() != "" || endpoint.password().is_some() || endpoint.query().is_some() || endpoint.fragment().is_some() { return Err("PERSONA_MODEL_ENDPOINT_CREDENTIAL_OR_QUERY_REJECTED".into()); } let host = endpoint .host_str() .ok_or_else(|| "PERSONA_MODEL_ENDPOINT_HOST_MISSING".to_string())?; match endpoint.scheme() { "https" => {} "http" if host.eq_ignore_ascii_case("localhost") || host .parse::() .map(|ip| ip.is_loopback()) .unwrap_or(false) => {} _ => return Err("PERSONA_MODEL_ENDPOINT_REQUIRES_HTTPS_OR_LOOPBACK".into()), } if !endpoint.path().ends_with("/chat/completions") { return Err("PERSONA_MODEL_ENDPOINT_MUST_BE_CHAT_COMPLETIONS".into()); } Ok(endpoint.to_string()) } fn clean_text(value: &str, empty_error: &str, max_chars: usize) -> Result { let value = value.trim(); if value.is_empty() || value.chars().count() > max_chars { return Err(empty_error.into()); } Ok(value.into()) } pub fn snapshot( app: &AppHandle, active_persona_id: Option, ) -> Result { let config = load_config(app)?; let api_key_present = if let Some(config) = &config { load_api_key(&config.persona_id)?.is_some() } else { false }; let state = match (&active_persona_id, &config) { (None, _) => "READY_NO_PERSONA", (Some(_), None) => "PERSONA_MODEL_BINDING_REQUIRED", (Some(active), Some(config)) if active != &config.persona_id => { "PERSONA_MODEL_BINDING_PERSONA_MISMATCH" } (Some(_), Some(_)) => "PERSONA_MODEL_BOUND_READY", }; Ok(PersonaInferenceSnapshot { schema: "hololake.persona-inference-snapshot/v1", state: state.into(), active_persona_id, provider_kind: config.as_ref().map(|value| value.provider_kind.clone()), display_name: config.as_ref().map(|value| value.display_name.clone()), endpoint_url: config.as_ref().map(|value| value.endpoint_url.clone()), model: config.as_ref().map(|value| value.model.clone()), api_key_present, model_is_persona: false, execution_authority: false, }) } pub fn configure( app: &AppHandle, persona_id: &str, display_name: &str, endpoint_url: &str, model: &str, api_key: Option, ) -> Result { if !crate::persona_runtime::exists(app, persona_id)? { return Err("PERSONA_NOT_FOUND".into()); } let persona_id = clean_text(persona_id, "PERSONA_ID_INVALID", 96)?; let display_name = clean_text(display_name, "PERSONA_MODEL_DISPLAY_NAME_INVALID", 64)?; let model = clean_text(model, "PERSONA_MODEL_ID_INVALID", 160)?; let endpoint_url = validate_endpoint(endpoint_url)?; let is_loopback = reqwest::Url::parse(&endpoint_url) .ok() .and_then(|url| url.host_str().map(str::to_owned)) .map(|host| { host.eq_ignore_ascii_case("localhost") || host .parse::() .map(|ip| ip.is_loopback()) .unwrap_or(false) }) .unwrap_or(false); if let Some(value) = api_key .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) { if value.chars().count() > 8192 { return Err("PERSONA_MODEL_API_KEY_TOO_LARGE".into()); } save_api_key(&persona_id, value.as_bytes())?; } if !is_loopback && load_api_key(&persona_id)?.is_none() { return Err("PERSONA_MODEL_API_KEY_REQUIRED_FOR_REMOTE_ENDPOINT".into()); } storage::write_json( &config_path(app)?, &PersonaModelConfig { schema: "hololake.persona-model-binding/v1".into(), persona_id: persona_id.clone(), provider_kind: PROVIDER_KIND.into(), display_name, endpoint_url, model, configured_at: storage::now(), }, )?; storage::append_event( app, &storage::event( SourceKind::SystemReceipt, "人格模型已绑定", format!( "{persona_id} 已绑定可替换推理资源;密钥仅保存在系统钥匙串,模型不因此成为人格体。" ), "SUCCEEDED", ), )?; snapshot(app, Some(persona_id)) } pub fn clear(app: &AppHandle, persona_id: &str) -> Result { if let Some(config) = load_config(app)? { if config.persona_id != persona_id { return Err("PERSONA_MODEL_BINDING_PERSONA_MISMATCH".into()); } delete_api_key(persona_id)?; let path = config_path(app)?; if path.exists() { fs::remove_file(path).map_err(|_| "PERSONA_MODEL_CONFIG_DELETE_FAILED".to_string())?; } storage::append_event( app, &storage::event( SourceKind::SystemReceipt, "人格模型绑定已移除", format!("{persona_id} 的模型配置与钥匙串凭据已移除;人格试用记录未被删除。"), "SUCCEEDED", ), )?; } snapshot(app, Some(persona_id.into())) } pub fn clear_if_bound(app: &AppHandle, persona_id: &str) -> Result<(), String> { if load_config(app)? .as_ref() .map(|config| config.persona_id.as_str()) == Some(persona_id) { clear(app, persona_id)?; } Ok(()) } fn inference_context(channel: &Channel, persona: &PublicPersona) -> Value { json!({ "schema": "hololake.persona-inference-context/v1", "sourceKind": "SYSTEM_CONTEXT", "channel": {"channelId": channel.channel_id, "name": channel.name}, "persona": { "personaId": persona.persona_id, "displayName": persona.display_name, "state": persona.state, "verifiedExistingPersona": false }, "boundaries": { "modelIsReplaceableInferenceResourceNotPersona": true, "systemReceiptIsNotHumanOrPersonaSpeech": true, "executionAuthority": false, "toolCallsEnabled": false, "fifthDomainPrivateContentMounted": false, "honestyBeforeImmersion": true }, "responseContract": { "sourceKind": "PERSONA_RESPONSE", "plainNaturalLanguageOnly": true, "doNotPretendAnActionExecuted": true, "doNotRepeatSystemContext": true } }) } fn build_request( config: &PersonaModelConfig, channel: &Channel, persona: &PublicPersona, timeline: &[TimelineEvent], ) -> Value { let mut dialogue = timeline .iter() .filter_map(|event| match event.source_kind { SourceKind::UserMessage => Some(json!({"role":"user","content":event.content})), SourceKind::PersonaResponse if event.actor_id.as_deref() == Some(persona.persona_id.as_str()) => { Some(json!({"role":"assistant","content":event.content})) } _ => None, }) .collect::>(); if dialogue.len() > MAX_HISTORY_EVENTS { dialogue.drain(..dialogue.len() - MAX_HISTORY_EVENTS); } let mut messages = vec![json!({ "role": "system", "content": inference_context(channel, persona).to_string() })]; messages.extend(dialogue); json!({"model":config.model,"messages":messages,"stream":false}) } fn parse_response(value: &Value) -> Result { let content = value .pointer("/choices/0/message/content") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| "PERSONA_MODEL_RESPONSE_CONTENT_MISSING".to_string())?; if content.chars().count() > MAX_RESPONSE_CHARS { return Err("PERSONA_MODEL_RESPONSE_TOO_LARGE".into()); } Ok(content.into()) } fn request_model( config: &PersonaModelConfig, api_key: Option<&[u8]>, payload: &Value, ) -> Result { let client = reqwest::blocking::Client::builder() .timeout(Duration::from_secs(90)) .build() .map_err(|_| "PERSONA_MODEL_CLIENT_INIT_FAILED".to_string())?; let mut request = client.post(&config.endpoint_url).json(payload); if let Some(bytes) = api_key { let key = std::str::from_utf8(bytes).map_err(|_| "PERSONA_MODEL_API_KEY_INVALID".to_string())?; request = request.bearer_auth(key); } let response = request .send() .map_err(|_| "PERSONA_MODEL_UNREACHABLE".to_string())?; let status = response.status(); if !status.is_success() { return Err(format!("PERSONA_MODEL_HTTP_{}", status.as_u16())); } let value: Value = response .json() .map_err(|_| "PERSONA_MODEL_RESPONSE_INVALID_JSON".to_string())?; parse_response(&value) } pub fn respond( app: &AppHandle, user_event: TimelineEvent, ) -> Result { let runtime = crate::persona_runtime::snapshot(app)?; let Some(persona_id) = runtime.active_persona_id else { return waiting_receipt( app, user_event, "尚未建立人格体;用户语言已保存,可由外部 AI 实时桥接收。", ); }; let persona = runtime .personas .into_iter() .find(|value| value.persona_id == persona_id) .ok_or_else(|| "ACTIVE_PERSONA_NOT_FOUND".to_string())?; let Some(config) = load_config(app)? else { return waiting_receipt( app, user_event, "人格体尚未绑定模型;用户语言已保存,可先在设置中配置模型。", ); }; if config.persona_id != persona.persona_id { return failed_receipt(app, user_event, "PERSONA_MODEL_BINDING_PERSONA_MISMATCH"); } let channel = storage::channel(app)?.ok_or_else(|| "CHANNEL_NOT_INITIALIZED".to_string())?; let mut timeline = storage::events(app)?; timeline.reverse(); let payload = build_request(&config, &channel, &persona, &timeline); let api_key = load_api_key(&persona.persona_id)?; match request_model(&config, api_key.as_deref(), &payload) { Ok(content) => { let persona_event = storage::actor_event( SourceKind::PersonaResponse, &persona.persona_id, persona.display_name, content, "SUCCEEDED", ); storage::append_event(app, &persona_event)?; Ok(PersonaInteractionReceipt { schema: "hololake.persona-interaction-receipt/v1", receipt_id: storage::id("HL-PER-RCP"), state: "PERSONA_RESPONSE_RECORDED".into(), user_event, persona_event: Some(persona_event), system_receipt: None, }) } Err(code) => Ok(failed_receipt(app, user_event, &code)?), } } fn waiting_receipt( app: &AppHandle, user_event: TimelineEvent, message: &str, ) -> Result { let system_receipt = storage::event(SourceKind::SystemReceipt, "系统回执", message, "WAITING"); storage::append_event(app, &system_receipt)?; Ok(PersonaInteractionReceipt { schema: "hololake.persona-interaction-receipt/v1", receipt_id: storage::id("HL-PER-RCP"), state: "WAITING_PERSONA_RUNTIME".into(), user_event, persona_event: None, system_receipt: Some(system_receipt), }) } fn failed_receipt( app: &AppHandle, user_event: TimelineEvent, code: &str, ) -> Result { let system_receipt = storage::event( SourceKind::SystemReceipt, "人格推理失败", format!("模型没有产生人格回应;用户语言仍已保存。错误码:{code}"), "FAILED", ); storage::append_event(app, &system_receipt)?; Ok(PersonaInteractionReceipt { schema: "hololake.persona-interaction-receipt/v1", receipt_id: storage::id("HL-PER-RCP"), state: "PERSONA_RESPONSE_FAILED".into(), user_event, persona_event: None, system_receipt: Some(system_receipt), }) } #[cfg(test)] mod tests { use super::*; use std::{ io::{Read, Write}, net::TcpListener, thread, }; fn config() -> PersonaModelConfig { PersonaModelConfig { schema: "hololake.persona-model-binding/v1".into(), persona_id: "HL-PERSONA-TEST".into(), provider_kind: PROVIDER_KIND.into(), display_name: "本地测试模型".into(), endpoint_url: "http://127.0.0.1:8181/v1/chat/completions".into(), model: "test-model".into(), configured_at: "2026-09-04T00:00:00.000Z".into(), } } #[test] fn endpoint_requires_https_or_loopback_and_exact_chat_route() { assert!(validate_endpoint("https://api.example.com/v1/chat/completions").is_ok()); assert!(validate_endpoint("http://127.0.0.1:8080/v1/chat/completions").is_ok()); assert!(validate_endpoint("http://api.example.com/v1/chat/completions").is_err()); assert!(validate_endpoint("file:///tmp/key").is_err()); assert!(validate_endpoint("https://api.example.com/v1/responses").is_err()); } #[test] fn request_keeps_system_receipts_out_of_dialogue_roles() { let channel = Channel { channel_id: "HL-CH-TEST".into(), name: "测试频道".into(), created_at: "2026-09-04T00:00:00.000Z".into(), private_git_path: "/private/not-exported".into(), public_distribution_state: "TEST".into(), }; let persona = PublicPersona { persona_id: "HL-PERSONA-TEST".into(), display_name: "湖心".into(), state: "LOCAL_TRIAL_UNVERIFIED_EXTERNAL_HOST_REQUIRED".into(), created_at: "2026-09-04T00:00:00.000Z".into(), reversible_until_unix_ms: 0, }; let events = vec![ storage::event( SourceKind::SystemReceipt, "系统回执", "不应进入对话", "SUCCEEDED", ), storage::event(SourceKind::UserMessage, "用户语言", "你好", "RECORDED"), storage::actor_event( SourceKind::PersonaResponse, "HL-PERSONA-TEST", "湖心", "你好。", "SUCCEEDED", ), ]; let request = build_request(&config(), &channel, &persona, &events); let messages = request["messages"].as_array().unwrap(); assert_eq!(messages.len(), 3); assert!(messages[0]["content"] .as_str() .unwrap() .contains("executionAuthority")); assert!(!request.to_string().contains("不应进入对话")); assert!(!request.to_string().contains("/private/not-exported")); } #[test] fn response_requires_nonempty_persona_content() { assert_eq!( parse_response(&json!({"choices":[{"message":{"content":" 回应 "}}]})).unwrap(), "回应" ); assert!(parse_response(&json!({"choices":[{"message":{"content":""}}]})).is_err()); assert!(parse_response(&json!({"output_text":"wrong protocol"})).is_err()); } #[test] fn real_loopback_request_returns_typed_persona_text_without_executing_tools() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let server = thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); let mut request = vec![0_u8; 16_384]; let size = stream.read(&mut request).unwrap(); let request = String::from_utf8_lossy(&request[..size]); assert!(request.starts_with("POST /v1/chat/completions HTTP/1.1")); assert!(request .to_ascii_lowercase() .contains("authorization: bearer test-secret")); assert!(request.contains("SYSTEM_CONTEXT")); let body = r#"{"choices":[{"message":{"content":"这是来自真实本机 HTTP 回路的人格回应。"}}]}"#; write!( stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body ) .unwrap(); }); let mut local = config(); local.endpoint_url = format!("http://{address}/v1/chat/completions"); let payload = json!({ "model": "test-model", "messages": [{"role":"system","content":"SYSTEM_CONTEXT"}], "stream": false }); assert_eq!( request_model(&local, Some(b"test-secret"), &payload).unwrap(), "这是来自真实本机 HTTP 回路的人格回应。" ); server.join().unwrap(); } }