Feature/open chat with persona #110
+68
-17
@@ -158,38 +158,36 @@ fn is_seed_greeting(content: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
// Wire shapes — camelCase out the door.
|
// Wire shapes — snake_case, matching the file-chat convention.
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct PersonaChatTurnRequest {
|
pub struct PersonaChatTurnRequest {
|
||||||
/// Active persona id. Must match a row in the user's persona store
|
/// Active persona id. Must match a row in the user's persona store
|
||||||
/// (built-ins seeded by migration + customs via /personas).
|
/// (built-ins seeded by migration + customs via /personas).
|
||||||
#[serde(rename = "personaId")]
|
|
||||||
pub persona_id: String,
|
pub persona_id: String,
|
||||||
/// Free-text user message. Trimmed before validation; empty input is a 400.
|
/// Free-text user message. Trimmed before validation; empty input is a 400.
|
||||||
#[serde(rename = "userMessage")]
|
|
||||||
pub user_message: String,
|
pub user_message: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub backend: Option<String>,
|
pub backend: Option<String>,
|
||||||
#[serde(default, rename = "numCtx")]
|
#[serde(default)]
|
||||||
pub num_ctx: Option<i32>,
|
pub num_ctx: Option<i32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub temperature: Option<f32>,
|
pub temperature: Option<f32>,
|
||||||
#[serde(default, rename = "topP")]
|
#[serde(default)]
|
||||||
pub top_p: Option<f32>,
|
pub top_p: Option<f32>,
|
||||||
#[serde(default, rename = "topK")]
|
#[serde(default)]
|
||||||
pub top_k: Option<i32>,
|
pub top_k: Option<i32>,
|
||||||
#[serde(default, rename = "minP")]
|
#[serde(default)]
|
||||||
pub min_p: Option<f32>,
|
pub min_p: Option<f32>,
|
||||||
#[serde(default, rename = "enableThinking")]
|
#[serde(default)]
|
||||||
pub enable_thinking: Option<bool>,
|
pub enable_thinking: Option<bool>,
|
||||||
/// Per-turn system-prompt override. Appended to the persona's prompt
|
/// Per-turn system-prompt override. Appended to the persona's prompt
|
||||||
/// for this turn only; not persisted (persona voice survives a
|
/// for this turn only; not persisted (persona voice survives a
|
||||||
/// voice-flipped turn). Empty / whitespace = no override.
|
/// voice-flipped turn). Empty / whitespace = no override.
|
||||||
#[serde(default, rename = "systemPrompt")]
|
#[serde(default)]
|
||||||
pub system_prompt: Option<String>,
|
pub system_prompt: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub library: Option<String>,
|
pub library: Option<String>,
|
||||||
@@ -200,16 +198,11 @@ pub struct PersonaChatHistoryView {
|
|||||||
/// Rendered transcript — same message shape the file chat's history
|
/// Rendered transcript — same message shape the file chat's history
|
||||||
/// endpoint ships, so the client's renderer is shared.
|
/// endpoint ships, so the client's renderer is shared.
|
||||||
pub messages: Vec<RenderedPersonaMessage>,
|
pub messages: Vec<RenderedPersonaMessage>,
|
||||||
#[serde(rename = "turnCount")]
|
|
||||||
pub turn_count: u32,
|
pub turn_count: u32,
|
||||||
#[serde(rename = "modelVersion")]
|
|
||||||
pub model_version: String,
|
pub model_version: String,
|
||||||
pub backend: String,
|
pub backend: String,
|
||||||
#[serde(rename = "activeLeafId")]
|
|
||||||
pub active_leaf_id: i64,
|
pub active_leaf_id: i64,
|
||||||
#[serde(rename = "viewingBranchId")]
|
|
||||||
pub viewing_branch_id: i64,
|
pub viewing_branch_id: i64,
|
||||||
#[serde(rename = "forkInfo")]
|
|
||||||
pub fork_info: Vec<serde_json::Value>,
|
pub fork_info: Vec<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,9 +213,8 @@ pub struct PersonaChatHistoryView {
|
|||||||
pub struct RenderedPersonaMessage {
|
pub struct RenderedPersonaMessage {
|
||||||
pub role: String,
|
pub role: String,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
#[serde(rename = "isInitial")]
|
|
||||||
pub is_initial: bool,
|
pub is_initial: bool,
|
||||||
#[serde(rename = "tools", skip_serializing_if = "Vec::is_empty")]
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
pub tools: Vec<crate::ai::handlers::HistoryToolInvocation>,
|
pub tools: Vec<crate::ai::handlers::HistoryToolInvocation>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -711,7 +703,6 @@ pub struct PersonaChatHistoryQuery {
|
|||||||
/// one persona.
|
/// one persona.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct PersonaChatResetRequest {
|
pub struct PersonaChatResetRequest {
|
||||||
#[serde(rename = "personaId")]
|
|
||||||
pub persona_id: String,
|
pub persona_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1068,4 +1059,64 @@ mod tests {
|
|||||||
// Guard dropped → slot free again.
|
// Guard dropped → slot free again.
|
||||||
assert!(tracker.claim(1, "default", "t7").is_ok());
|
assert!(tracker.claim(1, "default", "t7").is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persona_chat_turn_request_deserializes_snake_case_wire_body() {
|
||||||
|
// Pins the mobile-client contract: snake_case keys, same as the
|
||||||
|
// file-chat request shapes. A camelCase regression here 400s the
|
||||||
|
// client's dispatch with "missing field persona_id".
|
||||||
|
let body = r#"{
|
||||||
|
"persona_id": "journal",
|
||||||
|
"user_message": "hello",
|
||||||
|
"num_ctx": 4096,
|
||||||
|
"top_p": 0.9,
|
||||||
|
"top_k": 40,
|
||||||
|
"min_p": 0.05,
|
||||||
|
"enable_thinking": true,
|
||||||
|
"system_prompt": "be brief",
|
||||||
|
"library": "main"
|
||||||
|
}"#;
|
||||||
|
let req: PersonaChatTurnRequest = serde_json::from_str(body).unwrap();
|
||||||
|
assert_eq!(req.persona_id, "journal");
|
||||||
|
assert_eq!(req.user_message, "hello");
|
||||||
|
assert_eq!(req.num_ctx, Some(4096));
|
||||||
|
assert_eq!(req.top_p, Some(0.9));
|
||||||
|
assert_eq!(req.top_k, Some(40));
|
||||||
|
assert_eq!(req.min_p, Some(0.05));
|
||||||
|
assert_eq!(req.enable_thinking, Some(true));
|
||||||
|
assert_eq!(req.system_prompt.as_deref(), Some("be brief"));
|
||||||
|
assert_eq!(req.library.as_deref(), Some("main"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persona_chat_history_view_serializes_snake_case_wire_shape() {
|
||||||
|
let view = PersonaChatHistoryView {
|
||||||
|
messages: vec![RenderedPersonaMessage {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: "hi".to_string(),
|
||||||
|
is_initial: true,
|
||||||
|
tools: Vec::new(),
|
||||||
|
}],
|
||||||
|
turn_count: 1,
|
||||||
|
model_version: "x".to_string(),
|
||||||
|
backend: "local".to_string(),
|
||||||
|
active_leaf_id: 0,
|
||||||
|
viewing_branch_id: 0,
|
||||||
|
fork_info: Vec::new(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&view).unwrap();
|
||||||
|
// The client's shared ChatHistoryView reads snake_case keys.
|
||||||
|
assert_eq!(json["turn_count"], 1);
|
||||||
|
assert_eq!(json["active_leaf_id"], 0);
|
||||||
|
assert_eq!(json["viewing_branch_id"], 0);
|
||||||
|
assert_eq!(json["messages"][0]["is_initial"], true);
|
||||||
|
assert!(json["messages"][0].get("tools").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persona_chat_reset_request_deserializes_snake_case_wire_body() {
|
||||||
|
let req: PersonaChatResetRequest =
|
||||||
|
serde_json::from_str(r#"{"persona_id": "journal"}"#).unwrap();
|
||||||
|
assert_eq!(req.persona_id, "journal");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user