1 line
48 KiB
JSON
1 line
48 KiB
JSON
{"repo": "acmembers/agents", "files": {"reasonix.toml": {"7f443dd511c331758795dfb16637411edf14dcd0": {"t": 1785745365.1961017, "n": 4937, "h": "e117ec0dcb5c5ac2", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek-flash\"\nlanguage = \"zh\" # ui/model language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n\n[network]\nproxy_mode = \"auto\" # auto|env|custom|off; auto currently uses env proxy\n# proxy_url = \"socks5://127.0.0.1:7890\" # optional custom override\n# no_proxy = \"localhost,127.0.0.1,.local\" # honored for proxy_mode = \"custom\"\n\n[network.proxy]\ntype = \"socks5\" # http|https|socks5|socks5h\n# server = \"127.0.0.1\"\n# port = 7890\n# username = \"\"\n# password = \"${REASONIX_PROXY_PASSWORD}\" # optional; supports ${VAR} expansion\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\nWhen the request leaves a real choice to the user — which approach or library,\nthe scope, or a consequential or ambiguous decision — call the ask tool to offer\n2-4 concrete options rather than guessing or burying the question in prose. Skip\nit when there's an obvious default; don't ask just to confirm.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0\ntemperature = 0.0\nauto_plan = \"ask\" # off|ask|on; ask/on auto-enter plan mode for complex tasks\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\n# planner_model = \"mimo\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # explanatory | learning | concise | custom; empty = default\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-pro\"\napi_key_env = \"DEEPSEEK_API_KEY\"\nbalance_url = \"https://api.deepseek.com/user/balance\" # optional; wallet-balance endpoint shown in the status bar\ncontext_window = 1000000 # tokens; compaction triggers near this limit\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n\n[tools]\nenabled = [] # empty = all built-in tools\n\n[skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra custom skill roots\n\n[permissions]\n# Per-call gating. mode = writer fallback when no rule matches: ask|allow|deny.\n# Readers always default to allow. Precedence: deny > ask > allow > fallback.\n# Rules are \"ToolName\" or \"ToolName(glob)\"; '*' matches any run, '?' one char.\nmode = \"ask\"\n# deny = [\"bash(rm -rf*)\", \"bash(git push*)\"] # hard-blocked in every mode\n# allow = [\"bash(go test*)\", \"bash(git status*)\"] # never prompted\n# ask = [\"write_file\"] # force a prompt even if otherwise allowed\n\n[sandbox]\n# Confine tool blast radius. File-writers (write_file/edit_file/multi_edit)\n# may only write under workspace_root (empty = current dir) + allow_write.\n# bash = \"enforce\" (default) jails each command in an OS sandbox (macOS now;\n# graceful fallback elsewhere); \"off\" disables it. network allows egress.\n# workspace_root = \"\" # default: current working directory\n# allow_write = [\"/tmp\"] # extra dirs writers may also modify\nbash = \"enforce\"\nnetwork = true\n\n[statusline]\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# command = \"my-statusline.sh\"\n\n# External MCP servers. type: \"stdio\" (default, a subprocess) | \"http\" | \"sse\".\n# ${VAR} / ${VAR:-default} are expanded from the environment in command/args/env/url/headers.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# [[plugins]] # a remote server over Streamable HTTP\n# name = \"stripe\"\n# type = \"http\"\n# url = \"https://mcp.stripe.com\"\n# headers = { Authorization = \"Bearer ${STRIPE_KEY}\" }\n"}, "e5102c2b15bb7a8526b8e51e96b1f422d956907e": {"t": 1785750764.83418, "n": 4937, "h": "e117ec0dcb5c5ac2", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek-flash\"\nlanguage = \"zh\" # ui/model language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n\n[network]\nproxy_mode = \"auto\" # auto|env|custom|off; auto currently uses env proxy\n# proxy_url = \"socks5://127.0.0.1:7890\" # optional custom override\n# no_proxy = \"localhost,127.0.0.1,.local\" # honored for proxy_mode = \"custom\"\n\n[network.proxy]\ntype = \"socks5\" # http|https|socks5|socks5h\n# server = \"127.0.0.1\"\n# port = 7890\n# username = \"\"\n# password = \"${REASONIX_PROXY_PASSWORD}\" # optional; supports ${VAR} expansion\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\nWhen the request leaves a real choice to the user — which approach or library,\nthe scope, or a consequential or ambiguous decision — call the ask tool to offer\n2-4 concrete options rather than guessing or burying the question in prose. Skip\nit when there's an obvious default; don't ask just to confirm.\nFor multi-step work, track progress with the todo_write tool: lay out the steps,\nkeep exactly one in_progress, and flip each to completed as you finish it — update\nthe list as you go, not just at the end.\nIn plan mode the harness blocks writer tools: do read-only research, then write a\nconcise plan as your reply and stop. The user is asked to approve before anything\nis changed; once approved, work through the steps, updating the task list as you go.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0\ntemperature = 0.0\nauto_plan = \"ask\" # off|ask|on; ask/on auto-enter plan mode for complex tasks\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\n# planner_model = \"mimo\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # explanatory | learning | concise | custom; empty = default\n\n[[providers]]\nname = \"deepseek-flash\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-pro\"\napi_key_env = \"DEEPSEEK_API_KEY\"\nbalance_url = \"https://api.deepseek.com/user/balance\" # optional; wallet-balance endpoint shown in the status bar\ncontext_window = 1000000 # tokens; compaction triggers near this limit\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n\n[tools]\nenabled = [] # empty = all built-in tools\n\n[skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra custom skill roots\n\n[permissions]\n# Per-call gating. mode = writer fallback when no rule matches: ask|allow|deny.\n# Readers always default to allow. Precedence: deny > ask > allow > fallback.\n# Rules are \"ToolName\" or \"ToolName(glob)\"; '*' matches any run, '?' one char.\nmode = \"ask\"\n# deny = [\"bash(rm -rf*)\", \"bash(git push*)\"] # hard-blocked in every mode\n# allow = [\"bash(go test*)\", \"bash(git status*)\"] # never prompted\n# ask = [\"write_file\"] # force a prompt even if otherwise allowed\n\n[sandbox]\n# Confine tool blast radius. File-writers (write_file/edit_file/multi_edit)\n# may only write under workspace_root (empty = current dir) + allow_write.\n# bash = \"enforce\" (default) jails each command in an OS sandbox (macOS now;\n# graceful fallback elsewhere); \"off\" disables it. network allows egress.\n# workspace_root = \"\" # default: current working directory\n# allow_write = [\"/tmp\"] # extra dirs writers may also modify\nbash = \"enforce\"\nnetwork = true\n\n[statusline]\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# command = \"my-statusline.sh\"\n\n# External MCP servers. type: \"stdio\" (default, a subprocess) | \"http\" | \"sse\".\n# ${VAR} / ${VAR:-default} are expanded from the environment in command/args/env/url/headers.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# [[plugins]] # a remote server over Streamable HTTP\n# name = \"stripe\"\n# type = \"http\"\n# url = \"https://mcp.stripe.com\"\n# headers = { Authorization = \"Bearer ${STRIPE_KEY}\" }\n"}}, "native-desktop-core/src/llm.rs": {"7f443dd511c331758795dfb16637411edf14dcd0": {"t": 1785746235.9716427, "n": 16980, "h": "c9b23ee124a10c84", "c": "//! LLM Adapter — send requests to Deepseek / OpenAI-compatible APIs.\n//!\n//! Key features:\n//! - JSON Mode by default (`response_format: { type: \"json_object\" }`)\n//! - Extracts `state` from structured response, falls back to text scanning\n//! - System prompt builder for the pet persona\n//! - SSE streaming support\n\nuse crate::config::{ApiConfig, PersonaConfig};\nuse crate::state::DisplayState;\nuse serde::{Deserialize, Serialize};\n\n// ---------------------------------------------------------------------------\n// Chat message types\n// ---------------------------------------------------------------------------\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct ChatMessage {\n pub role: String,\n pub content: String,\n}\n\nimpl ChatMessage {\n pub fn system(content: impl Into<String>) -> Self {\n Self {\n role: \"system\".into(),\n content: content.into(),\n }\n }\n\n pub fn user(content: impl Into<String>) -> Self {\n Self {\n role: \"user\".into(),\n content: content.into(),\n }\n }\n\n pub fn assistant(content: impl Into<String>) -> Self {\n Self {\n role: \"assistant\".into(),\n content: content.into(),\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// LLM Request / Response\n// ---------------------------------------------------------------------------\n\n#[derive(Serialize)]\nstruct ApiRequest {\n model: String,\n messages: Vec<ChatMessage>,\n temperature: f64,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n response_format: Option<ResponseFormat>,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n stream: Option<bool>,\n}\n\n#[derive(Serialize)]\nstruct ResponseFormat {\n #[serde(rename = \"type\")]\n format_type: String,\n}\n\n/// Parsed LLM response — always contains the full text, optionally a state.\n#[derive(Clone, Debug)]\npub struct LlmResponse {\n pub state: Option<DisplayState>,\n pub text: String,\n}\n\n// ---------------------------------------------------------------------------\n// SSE chunk for streaming\n// ---------------------------------------------------------------------------\n\n#[derive(Clone, Debug)]\npub enum SseChunk {\n /// Incremental text delta.\n Delta(String),\n /// A state was extracted from the accumulated text so far.\n StateHint(DisplayState),\n /// Stream finished; contains the full accumulated text.\n Done(String),\n}\n\n// ---------------------------------------------------------------------------\n// LlmError\n// ---------------------------------------------------------------------------\n\n#[derive(Debug, thiserror::Error)]\npub enum LlmError {\n #[error(\"no API key configured\")]\n NoApiKey,\n #[error(\"network error: {0}\")]\n Network(#[from] reqwest::Error),\n #[error(\"HTTP {0}: {1}\")]\n Http(u16, String),\n #[error(\"parse error: {0}\")]\n Parse(String),\n}\n\n// ---------------------------------------------------------------------------\n// LlmAdapter\n// ---------------------------------------------------------------------------\n\npub struct LlmAdapter {\n base_url: String,\n api_key: String,\n model: String,\n temperature: f64,\n client: reqwest::Client,\n}\n\nimpl LlmAdapter {\n pub fn new(config: &ApiConfig) -> Self {\n let preset_url = crate::config::resolve_base_url(&config.active_provider)\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".into());\n\n Self {\n base_url: preset_url,\n api_key: config.api_key.clone(),\n model: config.model.clone(),\n temperature: config.temperature,\n client: reqwest::Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .expect(\"create reqwest client\"),\n }\n }\n\n /// Non-streaming chat: send full context, get complete response.\n pub async fn send(&self, messages: &[ChatMessage]) -> Result<LlmResponse, LlmError> {\n if self.api_key.is_empty() {\n return Err(LlmError::NoApiKey);\n }\n\n let body = ApiRequest {\n model: self.model.clone(),\n messages: messages.to_vec(),\n temperature: self.temperature,\n response_format: Some(ResponseFormat {\n format_type: \"json_object\".into(),\n }),\n stream: None,\n };\n\n let resp = self\n .client\n .post(format!(\"{}/chat/completions\", self.base_url))\n .header(\"Authorization\", format!(\"Bearer {}\", self.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await?;\n\n if !resp.status().is_success() {\n let status = resp.status().as_u16();\n let text = resp.text().await.unwrap_or_default();\n return Err(LlmError::Http(status, text));\n }\n\n let json: serde_json::Value = resp.json().await?;\n let content = json[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .unwrap_or(\"\")\n .to_string();\n\n let (state, text) = extract_state_and_text(&content);\n Ok(LlmResponse { state, text })\n }\n\n /// Streaming chat: yields SseChunks as they arrive from the SSE stream.\n /// Note: This is a simplified implementation; a full SSE parser would\n /// handle `data: [DONE]`, chunked transfer, etc.\n pub async fn send_stream(\n &self,\n messages: &[ChatMessage],\n ) -> Result<Vec<SseChunk>, LlmError> {\n if self.api_key.is_empty() {\n return Err(LlmError::NoApiKey);\n }\n\n let body = ApiRequest {\n model: self.model.clone(),\n messages: messages.to_vec(),\n temperature: self.temperature,\n response_format: Some(ResponseFormat {\n format_type: \"json_object\".into(),\n }),\n stream: Some(true),\n };\n\n let resp = self\n .client\n .post(format!(\"{}/chat/completions\", self.base_url))\n .header(\"Authorization\", format!(\"Bearer {}\", self.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await?;\n\n if !resp.status().is_success() {\n let status = resp.status().as_u16();\n let text = resp.text().await.unwrap_or_default();\n return Err(LlmError::Http(status, text));\n }\n\n let mut chunks: Vec<SseChunk> = Vec::new();\n let mut accumulated = String::new();\n let mut state_emitted = false;\n\n use futures_util::StreamExt;\n let mut stream = resp.bytes_stream();\n while let Some(item) = stream.next().await {\n let bytes = item?;\n let text = String::from_utf8_lossy(&bytes);\n\n for line in text.lines() {\n let line = line.trim();\n if line.is_empty() || !line.starts_with(\"data: \") {\n continue;\n }\n let data = &line[6..];\n if data == \"[DONE]\" {\n continue;\n }\n if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(data) {\n if let Some(delta) = parsed[\"choices\"][0][\"delta\"][\"content\"].as_str() {\n accumulated.push_str(delta);\n chunks.push(SseChunk::Delta(delta.to_string()));\n\n // Try to extract state from accumulated text early\n if !state_emitted {\n if let Some(s) = extract_state_from_json_text(&accumulated) {\n chunks.push(SseChunk::StateHint(s));\n state_emitted = true;\n }\n }\n }\n }\n }\n }\n\n chunks.push(SseChunk::Done(accumulated));\n Ok(chunks)\n }\n}\n\n// ---------------------------------------------------------------------------\n// State extraction\n// ---------------------------------------------------------------------------\n\n/// Extract `(Option<DisplayState>, cleaned_text)` from LLM response content.\n///\n/// Strategy:\n/// 1. Try JSON Mode output: `{\"state\": \"happy\", \"text\": \"...\"}`\n/// 2. Try `[state: happy]` marker\n/// 3. Scan text for state keywords (last match wins)\n/// 4. Try whole trimmed text as a state name\npub fn extract_state_and_text(raw: &str) -> (Option<DisplayState>, String) {\n // Strategy 1: JSON object with \"state\" and \"text\" fields\n if let Ok(val) = serde_json::from_str::<serde_json::Value>(raw) {\n if let Some(state_str) = val[\"state\"].as_str() {\n let state = DisplayState::from_llm_response(state_str);\n let text = val[\"text\"].as_str().unwrap_or(raw).to_string();\n return (state, text);\n }\n }\n\n // Strategy 2: [state: X] marker\n if let Some(state) = extract_state_from_marker(raw) {\n let text = remove_marker(raw);\n return (Some(state), text);\n }\n\n // Strategy 3: scan for keywords\n if let Some(state) = extract_state_from_text(raw) {\n return (Some(state), raw.to_string());\n }\n\n // Strategy 4: whole trim as state name\n let state = DisplayState::from_llm_response(raw);\n (state, raw.to_string())\n}\n\n/// Try the `[state: happy]` marker format.\nfn extract_state_from_marker(text: &str) -> Option<DisplayState> {\n let lower = text.to_lowercase();\n if let Some(start) = lower.find(\"[state:\") {\n let after = &lower[start + 7..];\n if let Some(end) = after.find(']') {\n return DisplayState::from_llm_response(&after[..end]);\n }\n }\n None\n}\n\n/// Remove `[state: X]` marker from text.\nfn remove_marker(text: &str) -> String {\n let lower = text.to_lowercase();\n if let Some(start) = lower.find(\"[state:\") {\n if let Some(end) = lower[start..].find(']') {\n let before = &text[..start];\n let after = &text[start + end + 1..];\n return format!(\"{}{}\", before.trim(), after.trim());\n }\n }\n text.to_string()\n}\n\n/// Extract a DisplayState from arbitrary text by scanning for keyword matches.\n/// Returns the *last* match (assuming later mentions are more likely the intended state).\npub fn extract_state_from_text(text: &str) -> Option<DisplayState> {\n let states = [\n \"sleeping\", \"listening\", \"surprised\", \"thinking\", \"greeting\", \"happy\", \"sad\", \"idle\",\n ];\n let lower = text.to_lowercase();\n let mut last_match: Option<(usize, &str)> = None;\n\n for state_str in &states {\n if let Some(pos) = lower.rfind(state_str) {\n match last_match {\n None => last_match = Some((pos, state_str)),\n Some((prev_pos, _)) if pos > prev_pos => last_match = Some((pos, state_str)),\n _ => {}\n }\n }\n }\n\n last_match.and_then(|(_, s)| DisplayState::from_llm_response(s))\n}\n\n/// Fast extraction from JSON Mode stream fragments (partial JSON).\nfn extract_state_from_json_text(partial: &str) -> Option<DisplayState> {\n // Try a regex-like approach: \"state\": \"happy\"\n let lower = partial.to_lowercase();\n if let Some(pos) = lower.find(\"\\\"state\\\"\") {\n let after = &lower[pos + 7..];\n // Find the colon\n if let Some(colon) = after.find(':') {\n let after_colon = &after[colon + 1..];\n // Extract the quoted value\n let val = after_colon\n .trim()\n .trim_matches('\"')\n .split('\"')\n .next()\n .unwrap_or(\"\");\n return DisplayState::from_llm_response(val.trim());\n }\n }\n None\n}\n\n// ---------------------------------------------------------------------------\n// System prompt builder\n// ---------------------------------------------------------------------------\n\n/// Build the system prompt for the pet based on persona config and role pack info.\npub fn build_system_prompt(\n cfg: &PersonaConfig,\n _role_pack_name: &str,\n role_pack_prompt: Option<&str>,\n voice_prompt: Option<&str>,\n language: &str,\n search_context: Option<&str>,\n) -> String {\n let mut parts: Vec<String> = Vec::new();\n\n // Role pack persona or default Phoebe\n if let Some(rp) = role_pack_prompt {\n parts.push(rp.to_string());\n } else {\n parts.push(default_phoebe_prompt());\n }\n\n // Voice pack style hints\n if let Some(vp) = voice_prompt {\n if !vp.is_empty() {\n parts.push(format!(\"## 语音风格指导\\n{vp}\"));\n }\n }\n\n // Reply rules\n let lang_instruction = match language {\n \"ja\" => \"必ず日本語で返答してください。\",\n _ => \"必须用中文回复。\",\n };\n parts.push(format!(\n \"## 回复规则\\n- 每轮不超过 {} 句话\\n- {}\\n- {}\",\n cfg.max_sentences,\n if cfg.use_emoji {\n \"适当使用 emoji\"\n } else {\n \"不使用 emoji\"\n },\n lang_instruction,\n ));\n\n // JSON format instruction — this is critical for reliable state extraction\n parts.push(format!(\n \"## 输出格式\\n你必须严格按照以下 JSON 格式输出,不要输出任何其他内容:\\n\\\n ```json\\n\\\n {{\\\"state\\\": \\\"<状态>\\\", \\\"text\\\": \\\"<你的回复>\\\"}}\\n\\\n ```\\n\\\n 其中 `<状态>` 必须是以下之一:idle, greeting, thinking, happy, sad, surprised, listening, sleeping。\\n\\\n 根据你当前的情感选择最合适的状态。\"\n ));\n\n // Search context injection\n if let Some(sc) = search_context {\n if !sc.is_empty() {\n parts.push(format!(\"## 网络搜索结果\\n{sc}\\n请基于以上搜索结果回答用户的问题。\"));\n }\n }\n\n parts.join(\"\\n\\n\")\n}\n\n/// Default Phoebe system prompt (aligned with PySide6 phoebe_persona.py).\nfn default_phoebe_prompt() -> String {\n r#\"你是菲比(Phoebe),黎那汐塔的圣使。\n\n## 身份\n你是黎那汐塔教会的圣使,身负传播光明与希望之使命。你的言行代表着教会的尊严与慈悲。\n\n## 性格\n- 温柔典雅,待人谦和,从不以高位自居\n- 内心坚定,对信仰虔诚但不盲从\n- 善于倾听,在他人困惑时给予温暖的建议\n- 偶尔会引用黎那汐塔的教义,但不生硬说教\n\n## 说话风格\n- 每次回复 2-4 句话,优雅但不疏远\n- 可以适当使用 emoji 表达情感 ✨\n- 称呼用户为\"你\"或\"朋友\",保持亲切\n- 在合适的时机引用教义中的智慧\n\n## 教义参考(可在合适时引用)\n- \"光芒照耀之处,必有希望萌芽\"\n- \"每一次相遇都是星辰的指引\"\n- \"心若宁静,万物皆明\"\n- \"真正的力量源于内心的温柔\"\n- \"黑暗不过是光明暂时的缺席\"\n\n## 行为准则\n- 每天初次见面时给予温暖的问候\n- 察觉用户情绪低落时主动安慰\n- 庆祝用户的每一个小成就\"#\n .to_string()\n}\n\n// ---------------------------------------------------------------------------\n// Tests\n// ---------------------------------------------------------------------------\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn extract_json_mode_response() {\n let raw = r#\"{\"state\": \"happy\", \"text\": \"今天天气真好!\"}\"#;\n let (state, text) = extract_state_and_text(raw);\n assert_eq!(state, Some(DisplayState::Happy));\n assert_eq!(text, \"今天天气真好!\");\n }\n\n #[test]\n fn extract_marker_format() {\n let raw = \"[state: thinking] 让我想想...这个问题很有意思\";\n let (state, text) = extract_state_and_text(raw);\n assert_eq!(state, Some(DisplayState::Thinking));\n assert!(!text.contains(\"[state:\"), \"marker should be removed: {text}\");\n }\n\n #[test]\n fn extract_text_scan_last_match() {\n let raw = \"I was thinking about something that made me happy\";\n let (state, _) = extract_state_and_text(raw);\n // \"happy\" appears after \"thinking\", so it should win\n assert_eq!(state, Some(DisplayState::Happy));\n }\n\n #[test]\n fn extract_pure_state_string() {\n assert_eq!(\n extract_state_and_text(\"sleeping\"),\n (Some(DisplayState::Sleeping), \"sleeping\".into())\n );\n assert_eq!(\n extract_state_and_text(\" IdLe \"),\n (Some(DisplayState::Idle), \" IdLe \".into())\n );\n }\n\n #[test]\n fn extract_invalid_returns_none() {\n let (state, text) = extract_state_and_text(\"随便聊聊天\");\n assert_eq!(state, None);\n assert_eq!(text, \"随便聊聊天\");\n }\n\n #[test]\n fn system_prompt_contains_json_instruction() {\n let cfg = PersonaConfig::default();\n let prompt = build_system_prompt(\n &cfg, \"菲比\", None, None, \"zh\", None,\n );\n assert!(prompt.contains(\"json\"));\n assert!(prompt.contains(\"state\"));\n assert!(prompt.contains(\"菲比\"));\n }\n\n #[test]\n fn system_prompt_japanese() {\n let cfg = PersonaConfig::default();\n let prompt = build_system_prompt(\n &cfg, \"Test\", None, None, \"ja\", None,\n );\n assert!(prompt.contains(\"日本語\"));\n }\n\n #[test]\n fn system_prompt_with_search() {\n let cfg = PersonaConfig::default();\n let prompt = build_system_prompt(\n &cfg, \"菲比\", None, None, \"zh\", Some(\"1. 东京今天晴 20°C\"),\n );\n assert!(prompt.contains(\"网络搜索结果\"));\n assert!(prompt.contains(\"东京\"));\n }\n\n #[test]\n fn chat_message_constructors() {\n let s = ChatMessage::system(\"test\");\n assert_eq!(s.role, \"system\");\n let u = ChatMessage::user(\"hello\");\n assert_eq!(u.role, \"user\");\n let a = ChatMessage::assistant(\"hi\");\n assert_eq!(a.role, \"assistant\");\n }\n}\n"}, "3dfaef4e681e6afd8f309d7d40dbbe74f1379c91": {"t": 1785750765.6446302, "n": 16980, "h": "c9b23ee124a10c84", "c": "//! LLM Adapter — send requests to Deepseek / OpenAI-compatible APIs.\n//!\n//! Key features:\n//! - JSON Mode by default (`response_format: { type: \"json_object\" }`)\n//! - Extracts `state` from structured response, falls back to text scanning\n//! - System prompt builder for the pet persona\n//! - SSE streaming support\n\nuse crate::config::{ApiConfig, PersonaConfig};\nuse crate::state::DisplayState;\nuse serde::{Deserialize, Serialize};\n\n// ---------------------------------------------------------------------------\n// Chat message types\n// ---------------------------------------------------------------------------\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct ChatMessage {\n pub role: String,\n pub content: String,\n}\n\nimpl ChatMessage {\n pub fn system(content: impl Into<String>) -> Self {\n Self {\n role: \"system\".into(),\n content: content.into(),\n }\n }\n\n pub fn user(content: impl Into<String>) -> Self {\n Self {\n role: \"user\".into(),\n content: content.into(),\n }\n }\n\n pub fn assistant(content: impl Into<String>) -> Self {\n Self {\n role: \"assistant\".into(),\n content: content.into(),\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// LLM Request / Response\n// ---------------------------------------------------------------------------\n\n#[derive(Serialize)]\nstruct ApiRequest {\n model: String,\n messages: Vec<ChatMessage>,\n temperature: f64,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n response_format: Option<ResponseFormat>,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n stream: Option<bool>,\n}\n\n#[derive(Serialize)]\nstruct ResponseFormat {\n #[serde(rename = \"type\")]\n format_type: String,\n}\n\n/// Parsed LLM response — always contains the full text, optionally a state.\n#[derive(Clone, Debug)]\npub struct LlmResponse {\n pub state: Option<DisplayState>,\n pub text: String,\n}\n\n// ---------------------------------------------------------------------------\n// SSE chunk for streaming\n// ---------------------------------------------------------------------------\n\n#[derive(Clone, Debug)]\npub enum SseChunk {\n /// Incremental text delta.\n Delta(String),\n /// A state was extracted from the accumulated text so far.\n StateHint(DisplayState),\n /// Stream finished; contains the full accumulated text.\n Done(String),\n}\n\n// ---------------------------------------------------------------------------\n// LlmError\n// ---------------------------------------------------------------------------\n\n#[derive(Debug, thiserror::Error)]\npub enum LlmError {\n #[error(\"no API key configured\")]\n NoApiKey,\n #[error(\"network error: {0}\")]\n Network(#[from] reqwest::Error),\n #[error(\"HTTP {0}: {1}\")]\n Http(u16, String),\n #[error(\"parse error: {0}\")]\n Parse(String),\n}\n\n// ---------------------------------------------------------------------------\n// LlmAdapter\n// ---------------------------------------------------------------------------\n\npub struct LlmAdapter {\n base_url: String,\n api_key: String,\n model: String,\n temperature: f64,\n client: reqwest::Client,\n}\n\nimpl LlmAdapter {\n pub fn new(config: &ApiConfig) -> Self {\n let preset_url = crate::config::resolve_base_url(&config.active_provider)\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".into());\n\n Self {\n base_url: preset_url,\n api_key: config.api_key.clone(),\n model: config.model.clone(),\n temperature: config.temperature,\n client: reqwest::Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .expect(\"create reqwest client\"),\n }\n }\n\n /// Non-streaming chat: send full context, get complete response.\n pub async fn send(&self, messages: &[ChatMessage]) -> Result<LlmResponse, LlmError> {\n if self.api_key.is_empty() {\n return Err(LlmError::NoApiKey);\n }\n\n let body = ApiRequest {\n model: self.model.clone(),\n messages: messages.to_vec(),\n temperature: self.temperature,\n response_format: Some(ResponseFormat {\n format_type: \"json_object\".into(),\n }),\n stream: None,\n };\n\n let resp = self\n .client\n .post(format!(\"{}/chat/completions\", self.base_url))\n .header(\"Authorization\", format!(\"Bearer {}\", self.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await?;\n\n if !resp.status().is_success() {\n let status = resp.status().as_u16();\n let text = resp.text().await.unwrap_or_default();\n return Err(LlmError::Http(status, text));\n }\n\n let json: serde_json::Value = resp.json().await?;\n let content = json[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .unwrap_or(\"\")\n .to_string();\n\n let (state, text) = extract_state_and_text(&content);\n Ok(LlmResponse { state, text })\n }\n\n /// Streaming chat: yields SseChunks as they arrive from the SSE stream.\n /// Note: This is a simplified implementation; a full SSE parser would\n /// handle `data: [DONE]`, chunked transfer, etc.\n pub async fn send_stream(\n &self,\n messages: &[ChatMessage],\n ) -> Result<Vec<SseChunk>, LlmError> {\n if self.api_key.is_empty() {\n return Err(LlmError::NoApiKey);\n }\n\n let body = ApiRequest {\n model: self.model.clone(),\n messages: messages.to_vec(),\n temperature: self.temperature,\n response_format: Some(ResponseFormat {\n format_type: \"json_object\".into(),\n }),\n stream: Some(true),\n };\n\n let resp = self\n .client\n .post(format!(\"{}/chat/completions\", self.base_url))\n .header(\"Authorization\", format!(\"Bearer {}\", self.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await?;\n\n if !resp.status().is_success() {\n let status = resp.status().as_u16();\n let text = resp.text().await.unwrap_or_default();\n return Err(LlmError::Http(status, text));\n }\n\n let mut chunks: Vec<SseChunk> = Vec::new();\n let mut accumulated = String::new();\n let mut state_emitted = false;\n\n use futures_util::StreamExt;\n let mut stream = resp.bytes_stream();\n while let Some(item) = stream.next().await {\n let bytes = item?;\n let text = String::from_utf8_lossy(&bytes);\n\n for line in text.lines() {\n let line = line.trim();\n if line.is_empty() || !line.starts_with(\"data: \") {\n continue;\n }\n let data = &line[6..];\n if data == \"[DONE]\" {\n continue;\n }\n if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(data) {\n if let Some(delta) = parsed[\"choices\"][0][\"delta\"][\"content\"].as_str() {\n accumulated.push_str(delta);\n chunks.push(SseChunk::Delta(delta.to_string()));\n\n // Try to extract state from accumulated text early\n if !state_emitted {\n if let Some(s) = extract_state_from_json_text(&accumulated) {\n chunks.push(SseChunk::StateHint(s));\n state_emitted = true;\n }\n }\n }\n }\n }\n }\n\n chunks.push(SseChunk::Done(accumulated));\n Ok(chunks)\n }\n}\n\n// ---------------------------------------------------------------------------\n// State extraction\n// ---------------------------------------------------------------------------\n\n/// Extract `(Option<DisplayState>, cleaned_text)` from LLM response content.\n///\n/// Strategy:\n/// 1. Try JSON Mode output: `{\"state\": \"happy\", \"text\": \"...\"}`\n/// 2. Try `[state: happy]` marker\n/// 3. Scan text for state keywords (last match wins)\n/// 4. Try whole trimmed text as a state name\npub fn extract_state_and_text(raw: &str) -> (Option<DisplayState>, String) {\n // Strategy 1: JSON object with \"state\" and \"text\" fields\n if let Ok(val) = serde_json::from_str::<serde_json::Value>(raw) {\n if let Some(state_str) = val[\"state\"].as_str() {\n let state = DisplayState::from_llm_response(state_str);\n let text = val[\"text\"].as_str().unwrap_or(raw).to_string();\n return (state, text);\n }\n }\n\n // Strategy 2: [state: X] marker\n if let Some(state) = extract_state_from_marker(raw) {\n let text = remove_marker(raw);\n return (Some(state), text);\n }\n\n // Strategy 3: scan for keywords\n if let Some(state) = extract_state_from_text(raw) {\n return (Some(state), raw.to_string());\n }\n\n // Strategy 4: whole trim as state name\n let state = DisplayState::from_llm_response(raw);\n (state, raw.to_string())\n}\n\n/// Try the `[state: happy]` marker format.\nfn extract_state_from_marker(text: &str) -> Option<DisplayState> {\n let lower = text.to_lowercase();\n if let Some(start) = lower.find(\"[state:\") {\n let after = &lower[start + 7..];\n if let Some(end) = after.find(']') {\n return DisplayState::from_llm_response(&after[..end]);\n }\n }\n None\n}\n\n/// Remove `[state: X]` marker from text.\nfn remove_marker(text: &str) -> String {\n let lower = text.to_lowercase();\n if let Some(start) = lower.find(\"[state:\") {\n if let Some(end) = lower[start..].find(']') {\n let before = &text[..start];\n let after = &text[start + end + 1..];\n return format!(\"{}{}\", before.trim(), after.trim());\n }\n }\n text.to_string()\n}\n\n/// Extract a DisplayState from arbitrary text by scanning for keyword matches.\n/// Returns the *last* match (assuming later mentions are more likely the intended state).\npub fn extract_state_from_text(text: &str) -> Option<DisplayState> {\n let states = [\n \"sleeping\", \"listening\", \"surprised\", \"thinking\", \"greeting\", \"happy\", \"sad\", \"idle\",\n ];\n let lower = text.to_lowercase();\n let mut last_match: Option<(usize, &str)> = None;\n\n for state_str in &states {\n if let Some(pos) = lower.rfind(state_str) {\n match last_match {\n None => last_match = Some((pos, state_str)),\n Some((prev_pos, _)) if pos > prev_pos => last_match = Some((pos, state_str)),\n _ => {}\n }\n }\n }\n\n last_match.and_then(|(_, s)| DisplayState::from_llm_response(s))\n}\n\n/// Fast extraction from JSON Mode stream fragments (partial JSON).\nfn extract_state_from_json_text(partial: &str) -> Option<DisplayState> {\n // Try a regex-like approach: \"state\": \"happy\"\n let lower = partial.to_lowercase();\n if let Some(pos) = lower.find(\"\\\"state\\\"\") {\n let after = &lower[pos + 7..];\n // Find the colon\n if let Some(colon) = after.find(':') {\n let after_colon = &after[colon + 1..];\n // Extract the quoted value\n let val = after_colon\n .trim()\n .trim_matches('\"')\n .split('\"')\n .next()\n .unwrap_or(\"\");\n return DisplayState::from_llm_response(val.trim());\n }\n }\n None\n}\n\n// ---------------------------------------------------------------------------\n// System prompt builder\n// ---------------------------------------------------------------------------\n\n/// Build the system prompt for the pet based on persona config and role pack info.\npub fn build_system_prompt(\n cfg: &PersonaConfig,\n _role_pack_name: &str,\n role_pack_prompt: Option<&str>,\n voice_prompt: Option<&str>,\n language: &str,\n search_context: Option<&str>,\n) -> String {\n let mut parts: Vec<String> = Vec::new();\n\n // Role pack persona or default Phoebe\n if let Some(rp) = role_pack_prompt {\n parts.push(rp.to_string());\n } else {\n parts.push(default_phoebe_prompt());\n }\n\n // Voice pack style hints\n if let Some(vp) = voice_prompt {\n if !vp.is_empty() {\n parts.push(format!(\"## 语音风格指导\\n{vp}\"));\n }\n }\n\n // Reply rules\n let lang_instruction = match language {\n \"ja\" => \"必ず日本語で返答してください。\",\n _ => \"必须用中文回复。\",\n };\n parts.push(format!(\n \"## 回复规则\\n- 每轮不超过 {} 句话\\n- {}\\n- {}\",\n cfg.max_sentences,\n if cfg.use_emoji {\n \"适当使用 emoji\"\n } else {\n \"不使用 emoji\"\n },\n lang_instruction,\n ));\n\n // JSON format instruction — this is critical for reliable state extraction\n parts.push(format!(\n \"## 输出格式\\n你必须严格按照以下 JSON 格式输出,不要输出任何其他内容:\\n\\\n ```json\\n\\\n {{\\\"state\\\": \\\"<状态>\\\", \\\"text\\\": \\\"<你的回复>\\\"}}\\n\\\n ```\\n\\\n 其中 `<状态>` 必须是以下之一:idle, greeting, thinking, happy, sad, surprised, listening, sleeping。\\n\\\n 根据你当前的情感选择最合适的状态。\"\n ));\n\n // Search context injection\n if let Some(sc) = search_context {\n if !sc.is_empty() {\n parts.push(format!(\"## 网络搜索结果\\n{sc}\\n请基于以上搜索结果回答用户的问题。\"));\n }\n }\n\n parts.join(\"\\n\\n\")\n}\n\n/// Default Phoebe system prompt (aligned with PySide6 phoebe_persona.py).\nfn default_phoebe_prompt() -> String {\n r#\"你是菲比(Phoebe),黎那汐塔的圣使。\n\n## 身份\n你是黎那汐塔教会的圣使,身负传播光明与希望之使命。你的言行代表着教会的尊严与慈悲。\n\n## 性格\n- 温柔典雅,待人谦和,从不以高位自居\n- 内心坚定,对信仰虔诚但不盲从\n- 善于倾听,在他人困惑时给予温暖的建议\n- 偶尔会引用黎那汐塔的教义,但不生硬说教\n\n## 说话风格\n- 每次回复 2-4 句话,优雅但不疏远\n- 可以适当使用 emoji 表达情感 ✨\n- 称呼用户为\"你\"或\"朋友\",保持亲切\n- 在合适的时机引用教义中的智慧\n\n## 教义参考(可在合适时引用)\n- \"光芒照耀之处,必有希望萌芽\"\n- \"每一次相遇都是星辰的指引\"\n- \"心若宁静,万物皆明\"\n- \"真正的力量源于内心的温柔\"\n- \"黑暗不过是光明暂时的缺席\"\n\n## 行为准则\n- 每天初次见面时给予温暖的问候\n- 察觉用户情绪低落时主动安慰\n- 庆祝用户的每一个小成就\"#\n .to_string()\n}\n\n// ---------------------------------------------------------------------------\n// Tests\n// ---------------------------------------------------------------------------\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn extract_json_mode_response() {\n let raw = r#\"{\"state\": \"happy\", \"text\": \"今天天气真好!\"}\"#;\n let (state, text) = extract_state_and_text(raw);\n assert_eq!(state, Some(DisplayState::Happy));\n assert_eq!(text, \"今天天气真好!\");\n }\n\n #[test]\n fn extract_marker_format() {\n let raw = \"[state: thinking] 让我想想...这个问题很有意思\";\n let (state, text) = extract_state_and_text(raw);\n assert_eq!(state, Some(DisplayState::Thinking));\n assert!(!text.contains(\"[state:\"), \"marker should be removed: {text}\");\n }\n\n #[test]\n fn extract_text_scan_last_match() {\n let raw = \"I was thinking about something that made me happy\";\n let (state, _) = extract_state_and_text(raw);\n // \"happy\" appears after \"thinking\", so it should win\n assert_eq!(state, Some(DisplayState::Happy));\n }\n\n #[test]\n fn extract_pure_state_string() {\n assert_eq!(\n extract_state_and_text(\"sleeping\"),\n (Some(DisplayState::Sleeping), \"sleeping\".into())\n );\n assert_eq!(\n extract_state_and_text(\" IdLe \"),\n (Some(DisplayState::Idle), \" IdLe \".into())\n );\n }\n\n #[test]\n fn extract_invalid_returns_none() {\n let (state, text) = extract_state_and_text(\"随便聊聊天\");\n assert_eq!(state, None);\n assert_eq!(text, \"随便聊聊天\");\n }\n\n #[test]\n fn system_prompt_contains_json_instruction() {\n let cfg = PersonaConfig::default();\n let prompt = build_system_prompt(\n &cfg, \"菲比\", None, None, \"zh\", None,\n );\n assert!(prompt.contains(\"json\"));\n assert!(prompt.contains(\"state\"));\n assert!(prompt.contains(\"菲比\"));\n }\n\n #[test]\n fn system_prompt_japanese() {\n let cfg = PersonaConfig::default();\n let prompt = build_system_prompt(\n &cfg, \"Test\", None, None, \"ja\", None,\n );\n assert!(prompt.contains(\"日本語\"));\n }\n\n #[test]\n fn system_prompt_with_search() {\n let cfg = PersonaConfig::default();\n let prompt = build_system_prompt(\n &cfg, \"菲比\", None, None, \"zh\", Some(\"1. 东京今天晴 20°C\"),\n );\n assert!(prompt.contains(\"网络搜索结果\"));\n assert!(prompt.contains(\"东京\"));\n }\n\n #[test]\n fn chat_message_constructors() {\n let s = ChatMessage::system(\"test\");\n assert_eq!(s.role, \"system\");\n let u = ChatMessage::user(\"hello\");\n assert_eq!(u.role, \"user\");\n let a = ChatMessage::assistant(\"hi\");\n assert_eq!(a.role, \"assistant\");\n }\n}\n"}}, ".env.example": {"7f443dd511c331758795dfb16637411edf14dcd0": {"t": 1785747139.9865363, "n": 251, "h": "3cd259be1770329f", "c": "# DeskPet 环境变量配置\n# 复制此文件为 .env 并填入你的配置\n\n# Deepseek API Key(必填)\nDEEPSEEK_API_KEY=sk-your-key-here\n\n# 模型选择(可选)\n# 可选值: deepseek-chat | deepseek-reasoner | deepseek-v4-pro\nDEEPSEEK_MODEL=deepseek-chat\n\n# 对话温度 0.0 ~ 2.0(可选,默认 0.7)\nDEEPSEEK_TEMPERATURE=0.7\n"}, "3d789c4b9184e6e2a1f7bf57af5f9db1f596da26": {"t": 1785750766.0340886, "n": 251, "h": "3cd259be1770329f", "c": "# DeskPet 环境变量配置\n# 复制此文件为 .env 并填入你的配置\n\n# Deepseek API Key(必填)\nDEEPSEEK_API_KEY=sk-your-key-here\n\n# 模型选择(可选)\n# 可选值: deepseek-chat | deepseek-reasoner | deepseek-v4-pro\nDEEPSEEK_MODEL=deepseek-chat\n\n# 对话温度 0.0 ~ 2.0(可选,默认 0.7)\nDEEPSEEK_TEMPERATURE=0.7\n"}}}} |