diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2308473..0dddce9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,13 @@
---
+## [0.1.0] - 未发布
+
+### 新增
+- 快捷键支持鼠标侧键:「按住说话」与「免提模式」均可绑定鼠标侧键(后退键 / 前进键)。在设置里点录制、按一下侧键即可绑定。
+
+---
+
## [0.0.9] - 2026-07-16
### 新增
diff --git a/client/src-tauri/src/providers/ai_ollama.rs b/client/src-tauri/src/providers/ai_ollama.rs
index 1881824..34d1d5b 100644
--- a/client/src-tauri/src/providers/ai_ollama.rs
+++ b/client/src-tauri/src/providers/ai_ollama.rs
@@ -1,6 +1,7 @@
// Ollama AI 供应商
// 调用本地 Ollama 的 /api/generate 接口
+use super::prompt::wrap_user_text;
use super::types::{AiProviderConfig, AiResult, TestResult};
use std::time::Instant;
@@ -19,7 +20,9 @@ pub async fn polish(
let url = normalize_url(&config.api_url);
let sys_prompt = system_prompt.unwrap_or("你是语音转文本的校对助手。");
- let combined = format!("{}\n\n请处理以下语音转写文本:\n\n{}", sys_prompt, text);
+ // Ollama /api/generate 是单一 prompt 字符串,没有 system/user 角色区分,
+ // 这里手动拼接:system prompt 在前,user 消息(中性标签包裹)在后。
+ let combined = format!("{}\n\n{}", sys_prompt, wrap_user_text(text));
let model = if config.model.is_empty() {
"qwen2.5:7b"
diff --git a/client/src-tauri/src/providers/ai_openai_compat.rs b/client/src-tauri/src/providers/ai_openai_compat.rs
index f1b93a7..9c6b29a 100644
--- a/client/src-tauri/src/providers/ai_openai_compat.rs
+++ b/client/src-tauri/src/providers/ai_openai_compat.rs
@@ -1,6 +1,7 @@
// OpenAI 兼容 AI 供应商
// 覆盖所有支持 /v1/chat/completions 的服务:DeepSeek、通义、豆包(火山方舟)等
+use super::prompt::wrap_user_text;
use super::types::{AiProviderConfig, AiResult, TestResult};
use std::time::Instant;
@@ -21,7 +22,7 @@ pub async fn polish(
let url = format!("{}/chat/completions", base_url);
let sys_prompt = system_prompt.unwrap_or("你是语音转文本的校对助手。");
- let user_content = format!("请处理以下语音转写文本:\n\n{}", text);
+ let user_content = wrap_user_text(text);
let mut body = serde_json::json!({
"model": config.model,
diff --git a/client/src-tauri/src/providers/mod.rs b/client/src-tauri/src/providers/mod.rs
index dd1f3df..2bdf2a7 100644
--- a/client/src-tauri/src/providers/mod.rs
+++ b/client/src-tauri/src/providers/mod.rs
@@ -1,6 +1,7 @@
// 转写供应商模块 — ASR 和 AI 的 trait 定义及具体实现
pub mod types;
+pub mod prompt;
pub mod ai_openai_compat;
pub mod ai_ollama;
pub mod asr_doubao;
diff --git a/client/src-tauri/src/providers/prompt.rs b/client/src-tauri/src/providers/prompt.rs
new file mode 100644
index 0000000..6aadbcf
--- /dev/null
+++ b/client/src-tauri/src/providers/prompt.rs
@@ -0,0 +1,14 @@
+// AI 校对请求的 user 消息统一构造 —— 云 API / 本地 Ollama 共用。
+//
+// 背景:早期这里的 user 消息外壳是一句祈使句("请处理以下语音转写文本:"),
+// 与服务器模式此前的做法(server/backend/app/llm.py 里 "请校对以下…")是同一类
+// 问题:当 ASR 识别出的文本本身也是祈使句(如用户说的是"请执行")时,外壳的
+// "请…" 和文本内容的"请…"首尾相连,容易被模型误读成一句连续指令,而不是
+// "这是一段待清洗的数据",导致模型反问"请提供文本"而不是正常输出清洗结果。
+//
+// 现在改为与服务器模式完全一致的中性标签包裹,不含任何动词/祈使句,只标出
+// 文本边界,prompt 的全部措辞(风格、约束)仍 100% 由 system_prompt(用户的
+// 预设)决定,这里不再"额外说话"。三种模式(服务器 / 云 API / 本地)保持统一。
+pub fn wrap_user_text(text: &str) -> String {
+ format!("\n{}\n", text)
+}
diff --git a/server/backend/app/llm.py b/server/backend/app/llm.py
index ebe7bfc..232b7b2 100644
--- a/server/backend/app/llm.py
+++ b/server/backend/app/llm.py
@@ -40,6 +40,13 @@ _DEFAULT_SYSTEM_PROMPT = """\
_DEFAULT_USER_PREFIX = "请校对以下 标签内的语音转写文本:\n\n"
+# 桌面客户端模式下用户消息的外壳:不含任何动词/祈使句,只用一对标签给待清洗文本
+# 标出边界(模型输出后会被 _strip_thinking 清掉,不会混进结果)。这条壳文字固定、
+# 不可配置——桌面模式的全部 prompt 措辞(风格、约束)都由客户端的 system_prompt
+# 决定,服务器不再额外"发出"任何指令,避免壳文字与文本内容本身恰好都是祈使句时
+# (如 ASR 说的是"请执行")被模型误当成连续指令。
+_DESKTOP_USER_WRAPPER = "\n{text}\n"
+
class LLMEngine:
def __init__(self, profile: LLMProfile) -> None:
@@ -54,12 +61,20 @@ class LLMEngine:
# ── public API ──
- async def polish(self, text: str, system_prompt: str | None = None) -> tuple[str, int, dict]:
- """Polish text, return (result, elapsed_ms, debug_info)."""
+ async def polish(
+ self, text: str, system_prompt: str | None = None, *, is_web_demo: bool = False,
+ ) -> tuple[str, int, dict]:
+ """Polish text, return (result, elapsed_ms, debug_info).
+
+ is_web_demo=False(桌面客户端,默认):system prompt 完全由调用方(客户端)
+ 提供,用户消息用固定的中性标签包一下,服务器不额外拼任何指令措辞。
+ is_web_demo=True:网页体验版没有客户端,永远使用服务器本地 prompts/ 目录下的
+ system.txt / user.txt(或内置默认值),忽略传入的 system_prompt(即使有)。
+ """
if not text or not self._cfg.enabled:
return text, 0, {"skipped": True}
- messages = self._build_messages(text, system_prompt)
+ messages = self._build_messages(text, system_prompt, is_web_demo=is_web_demo)
provider = self._cfg.provider.lower()
try:
@@ -99,9 +114,22 @@ class LLMEngine:
# ── internals ──
- def _build_messages(self, text: str, system_prompt: str | None = None) -> list[dict]:
+ def _build_messages(
+ self, text: str, system_prompt: str | None = None, *, is_web_demo: bool = False,
+ ) -> list[dict]:
+ if not is_web_demo:
+ # 桌面模式:system prompt 全由客户端决定(服务器本地 system.txt 仅在客户端
+ # 异常未传时兜底);用户消息固定用中性标签包裹,不含任何指令措辞。
+ sys_p = system_prompt or _read_prompt(
+ f"{self._cfg.prompt_dir}/system.txt", _DEFAULT_SYSTEM_PROMPT
+ )
+ user_content = _DESKTOP_USER_WRAPPER.format(text=text)
+ return [{"role": "system", "content": sys_p}, {"role": "user", "content": user_content}]
+
+ # Web demo:没有客户端,永远用服务器本地 prompts/ 目录(或内置默认值)兜底——
+ # 即使调用方误传了 system_prompt 也忽略,双重保险防止客户端预设泄露到公开体验页。
d = self._cfg.prompt_dir
- sys_p = system_prompt or _read_prompt(f"{d}/system.txt", _DEFAULT_SYSTEM_PROMPT)
+ sys_p = _read_prompt(f"{d}/system.txt", _DEFAULT_SYSTEM_PROMPT)
user_prefix = _read_prompt(f"{d}/user.txt", _DEFAULT_USER_PREFIX)
user_content = f"{user_prefix}{text}\n" if "" in user_prefix else f"{user_prefix}{text}"
return [{"role": "system", "content": sys_p}, {"role": "user", "content": user_content}]
diff --git a/server/backend/app/main.py b/server/backend/app/main.py
index 68712f4..2ea3e8f 100644
--- a/server/backend/app/main.py
+++ b/server/backend/app/main.py
@@ -442,7 +442,9 @@ async def _run_asr_llm(
llm_text, llm_ms, llm_debug = asr_text, 0, {}
if asr_text and selected_llm and selected_profile.enabled and not disable_ai:
- llm_text, llm_ms, llm_debug = await selected_llm.polish(asr_text, system_prompt)
+ llm_text, llm_ms, llm_debug = await selected_llm.polish(
+ asr_text, system_prompt, is_web_demo=use_web_demo_profile,
+ )
if cfg.logging.slow_llm_ms and llm_ms >= cfg.logging.slow_llm_ms:
logger.warning("Slow LLM detected duration_sec=%.2f llm_ms=%d", duration, llm_ms)
diff --git a/server/backend/tests/test_llm_prompt.py b/server/backend/tests/test_llm_prompt.py
new file mode 100644
index 0000000..029aa12
--- /dev/null
+++ b/server/backend/tests/test_llm_prompt.py
@@ -0,0 +1,59 @@
+"""Tests for LLMEngine message construction: desktop vs web-demo prompt handling.
+
+桌面模式:服务器不得拼接任何指令措辞,system prompt 完全来自调用方,用户消息只用
+中性标签包裹待清洗文本。Web demo:沿用服务器本地 prompts/ 目录兜底(保持原行为)。
+"""
+from __future__ import annotations
+
+import unittest
+
+from backend.app.config import LLMProfile
+from backend.app.llm import LLMEngine, _DEFAULT_SYSTEM_PROMPT
+
+
+class DesktopModeMessageTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.engine = LLMEngine(LLMProfile(prompt_dir="/nonexistent/prompt/dir"))
+
+ def test_uses_client_system_prompt_verbatim(self) -> None:
+ messages = self.engine._build_messages(
+ "请执行", system_prompt="自定义客户端预设", is_web_demo=False,
+ )
+ self.assertEqual(messages[0]["content"], "自定义客户端预设")
+
+ def test_user_message_has_no_instruction_wording(self) -> None:
+ messages = self.engine._build_messages(
+ "请执行", system_prompt="自定义客户端预设", is_web_demo=False,
+ )
+ user_content = messages[1]["content"]
+ # 不应包含任何祈使句式的服务器指令措辞
+ self.assertNotIn("请校对", user_content)
+ self.assertNotIn("请处理", user_content)
+ # 待清洗文本仍完整出现在标签内
+ self.assertIn("", user_content)
+ self.assertIn("请执行", user_content)
+
+ def test_falls_back_to_local_system_prompt_if_none_given(self) -> None:
+ messages = self.engine._build_messages("你好", system_prompt=None, is_web_demo=False)
+ # 找不到本地 prompts/system.txt(目录不存在)时应回退到内置默认值
+ self.assertEqual(messages[0]["content"], _DEFAULT_SYSTEM_PROMPT)
+
+
+class WebDemoMessageTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.engine = LLMEngine(LLMProfile(prompt_dir="/nonexistent/prompt/dir"))
+
+ def test_ignores_client_system_prompt_and_uses_server_default(self) -> None:
+ messages = self.engine._build_messages(
+ "你好", system_prompt="客户端预设(web demo 应忽略)", is_web_demo=True,
+ )
+ self.assertEqual(messages[0]["content"], _DEFAULT_SYSTEM_PROMPT)
+
+ def test_wraps_user_text_with_default_prefix(self) -> None:
+ messages = self.engine._build_messages("你好", system_prompt=None, is_web_demo=True)
+ self.assertIn("请校对以下", messages[1]["content"])
+ self.assertIn("你好", messages[1]["content"])
+
+
+if __name__ == "__main__":
+ unittest.main()