hololake-system-architecture/product-source/hololake-clean-desktop/src-tauri/src/persona_inference.rs

598 lines
21 KiB
Rust

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<String>,
pub provider_kind: Option<String>,
pub display_name: Option<String>,
pub endpoint_url: Option<String>,
pub model: Option<String>,
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<TimelineEvent>,
pub system_receipt: Option<TimelineEvent>,
}
fn config_path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
Ok(storage::root(app)?.join("persona-inference/model-binding.json"))
}
fn load_config(app: &AppHandle) -> Result<Option<PersonaModelConfig>, 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<Option<Vec<u8>>, 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<Option<Vec<u8>>, 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<String, String> {
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::<IpAddr>()
.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<String, String> {
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<String>,
) -> Result<PersonaInferenceSnapshot, String> {
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<String>,
) -> Result<PersonaInferenceSnapshot, String> {
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::<IpAddr>()
.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<PersonaInferenceSnapshot, String> {
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::<Vec<_>>();
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<String, String> {
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<String, String> {
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<PersonaInteractionReceipt, String> {
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<PersonaInteractionReceipt, String> {
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<PersonaInteractionReceipt, String> {
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();
}
}