Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/acmembers_agents.json
T

1 line
48 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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"}}}}