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()