Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions openless-all/app/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,7 @@ async fn validate_llm_provider() -> Result<(), String> {
"验证连接",
PolishMode::Raw,
&[],
"",
&[],
ChineseScriptPreference::Auto,
OutputLanguagePreference::Auto,
Expand Down
17 changes: 17 additions & 0 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,7 @@ impl Coordinator {
pub async fn repolish(&self, raw_text: String, mode: PolishMode) -> Result<String, String> {
let hotwords = enabled_phrases(&self.inner);
let prefs = self.inner.prefs.get();
let universal_directives = prefs.polish_universal_directives.clone();
let working_languages = prefs.working_languages;
let chinese_script_preference = prefs.chinese_script_preference;
let output_language_preference = prefs.output_language_preference;
Expand All @@ -854,6 +855,7 @@ impl Coordinator {
&raw_text,
mode,
&hotwords,
&universal_directives,
&working_languages,
chinese_script_preference,
output_language_preference,
Expand Down Expand Up @@ -2703,6 +2705,7 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
let prefs = inner.prefs.get();
let mode = prefs.default_mode;
let hotword_strs = enabled_phrases(inner);
let universal_directives = prefs.polish_universal_directives.clone();
let working_languages = prefs.working_languages.clone();
let chinese_script_preference = prefs.chinese_script_preference;
let output_language_preference = prefs.output_language_preference;
Expand Down Expand Up @@ -2747,6 +2750,7 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
translate_or_passthrough(
&raw,
&translation_target,
&universal_directives,
&working_languages,
chinese_script_preference,
output_language_preference,
Expand All @@ -2758,6 +2762,7 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
&raw,
mode,
&hotword_strs,
&universal_directives,
&working_languages,
chinese_script_preference,
output_language_preference,
Expand Down Expand Up @@ -3355,10 +3360,12 @@ fn ensure_qa_volcengine_credentials() -> Result<(), String> {

/// 润色文本;失败时返回原文 + 失败原因,调用方据此弹错误胶囊 + 写历史 error_code。
/// 之前固定返回 String,调用方拿不到失败信号 → 用户感知"为什么风格设置没生效"。issue #57。
#[allow(clippy::too_many_arguments)]
async fn polish_or_passthrough(
raw: &RawTranscript,
mode: PolishMode,
hotwords: &[String],
universal_directives: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
Expand All @@ -3372,6 +3379,7 @@ async fn polish_or_passthrough(
&raw.text,
mode,
hotwords,
universal_directives,
working_languages,
chinese_script_preference,
output_language_preference,
Expand All @@ -3389,10 +3397,12 @@ async fn polish_or_passthrough(
}
}

#[allow(clippy::too_many_arguments)]
async fn polish_text(
raw: &str,
mode: PolishMode,
hotwords: &[String],
universal_directives: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
Expand All @@ -3416,6 +3426,7 @@ async fn polish_text(
raw,
mode,
hotwords,
universal_directives,
working_languages,
chinese_script_preference,
output_language_preference,
Expand All @@ -3426,9 +3437,11 @@ async fn polish_text(
}

/// 翻译路径——和 polish 一样失败时返回原文 + 失败原因,避免"不丢字"约定被违反(CLAUDE.md)。
#[allow(clippy::too_many_arguments)]
async fn translate_or_passthrough(
raw: &RawTranscript,
target_language: &str,
universal_directives: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
Expand All @@ -3437,6 +3450,7 @@ async fn translate_or_passthrough(
match translate_text(
&raw.text,
target_language,
universal_directives,
working_languages,
chinese_script_preference,
output_language_preference,
Expand All @@ -3453,9 +3467,11 @@ async fn translate_or_passthrough(
}
}

#[allow(clippy::too_many_arguments)]
async fn translate_text(
raw: &str,
target_language: &str,
universal_directives: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
Expand All @@ -3477,6 +3493,7 @@ async fn translate_text(
.translate_to(
raw,
target_language,
universal_directives,
working_languages,
chinese_script_preference,
output_language_preference,
Expand Down
102 changes: 96 additions & 6 deletions openless-all/app/src-tauri/src/polish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,18 +84,20 @@ impl OpenAICompatibleLLMProvider {
&self.config
}

#[allow(clippy::too_many_arguments)]
pub async fn polish(
&self,
raw_text: &str,
mode: PolishMode,
hotwords: &[String],
universal_directives: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
front_app: Option<&str>,
prior_turns: &[(String, String)],
) -> Result<String, LLMError> {
let mut system_prompt = compose_system_prompt(mode, hotwords);
let mut system_prompt = compose_system_prompt(mode, hotwords, universal_directives);
if let Some(premise) = context_premise(
working_languages,
chinese_script_preference,
Expand Down Expand Up @@ -155,10 +157,12 @@ impl OpenAICompatibleLLMProvider {

/// 把转写翻译成 `target_language`(前端从内置语言列表里选出来的原生名)。
/// `working_languages` 与 `front_app` 作为前提注入头部。详见 issue #4 与 #116。
#[allow(clippy::too_many_arguments)]
pub async fn translate_to(
&self,
raw_text: &str,
target_language: &str,
universal_directives: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
_output_language_preference: OutputLanguagePreference,
Expand All @@ -167,6 +171,15 @@ impl OpenAICompatibleLLMProvider {
let mut system_prompt = prompts::translate_system_prompt(target_language);
// 翻译模式必须以 target_language 为唯一输出语言约束,避免和 UI 驱动的
// output_language_preference 发生冲突(例如 UI=ja, target=en)。
// Universal directives(タイポグラフィ規約等)も翻訳出力に適用する:
// 翻訳された日本語にも「句読点は全角」等のルールを効かせるため。
let trimmed_directives = universal_directives.trim();
if !trimmed_directives.is_empty() {
system_prompt = format!(
"{}\n\n用户全局指令(用户在 Style 设置中编辑,polish/translate 共用):\n{}",
system_prompt, trimmed_directives
);
}
if let Some(premise) = context_premise(
working_languages,
chinese_script_preference,
Expand Down Expand Up @@ -560,15 +573,31 @@ fn context_premise(
Some(lines.join("\n"))
}

fn compose_system_prompt(mode: PolishMode, hotwords: &[String]) -> String {
fn compose_system_prompt(
mode: PolishMode,
hotwords: &[String],
universal_directives: &str,
) -> String {
let base = prompts::system_prompt(mode);
// Universal directives are appended after the mode-specific instructions
// and before the hotword block. Order matters: mode prompt establishes the
// overall voice, then user-defined cross-mode rules (e.g. typography
// conventions) layer on top, and finally the hotword glossary.
let mut composed = base;
let trimmed_directives = universal_directives.trim();
if !trimmed_directives.is_empty() {
composed = format!(
"{}\n\n用户全局指令(用户在 Style 设置中编辑,所有 polish mode 共用,与上述模式指示并存):\n{}",
composed, trimmed_directives
);
}
let cleaned: Vec<String> = hotwords
.iter()
.map(|h| h.trim().to_string())
.filter(|h| !h.is_empty())
.collect();
if cleaned.is_empty() {
return base;
return composed;
}
let bullets = cleaned
.iter()
Expand All @@ -577,7 +606,7 @@ fn compose_system_prompt(mode: PolishMode, hotwords: &[String]) -> String {
.join("\n");
format!(
"{}\n\n热词(用户希望以下写法在输出中保持准确;当转写中出现这些词的同音 / 近形误识别时,优先按上述写法输出,不做无关词的机械替换):\n{}",
base, bullets
composed, bullets
)
}

Expand Down Expand Up @@ -1258,15 +1287,75 @@ mod tests {

#[test]
fn compose_system_prompt_prefers_correct_spelling_for_hotwords() {
let prompt =
compose_system_prompt(PolishMode::Light, &["GitHub".into(), "OpenLess".into()]);
let prompt = compose_system_prompt(
PolishMode::Light,
&["GitHub".into(), "OpenLess".into()],
"",
);

assert!(prompt.contains("用户希望以下写法在输出中保持准确"));
assert!(prompt.contains("同音 / 近形误识别时,优先按上述写法输出"));
assert!(prompt.contains("- GitHub"));
assert!(prompt.contains("- OpenLess"));
}

#[test]
fn compose_system_prompt_no_universal_directives_keeps_legacy_output() {
// 空文字 = 既存挙動と完全一致:用户全局指令ブロックは出ない
// (built-in mode prompt 内の `# 通用规则` セクションとは衝突しないよう、
// ヘッダ文字列は `用户全局指令` で固有化している)
let with_empty = compose_system_prompt(PolishMode::Light, &[], "");
let with_blank = compose_system_prompt(PolishMode::Light, &[], " \n\t ");
assert!(!with_empty.contains("用户全局指令"));
assert!(!with_blank.contains("用户全局指令"));
assert_eq!(with_empty, with_blank);
}

#[test]
fn compose_system_prompt_appends_universal_directives_when_present() {
let directives = "句読点は全角(、。)を使う\n!や?の後に全角スペースを1つ入れる";
let prompt = compose_system_prompt(PolishMode::Light, &[], directives);
assert!(
prompt.contains("用户全局指令"),
"universal directive header should appear"
);
assert!(prompt.contains("句読点は全角"));
assert!(prompt.contains("全角スペースを1つ"));
}

#[test]
fn compose_system_prompt_orders_directives_before_hotwords() {
// 注入順序の契約:mode prompt → 用户全局指令 → 热词。LLM が後半の指示を
// 優先的に重視する性質上、辞書(最も具体)を末尾に配置する。
let directives = "句読点は全角を使う";
let prompt = compose_system_prompt(
PolishMode::Light,
&["梁山泊".into()],
directives,
);
let directives_pos = prompt.find("用户全局指令").expect("directive header present");
let hotwords_pos = prompt.find("热词(").expect("hotword header present");
assert!(
directives_pos < hotwords_pos,
"用户全局指令 must come before 热词 (directives at {directives_pos}, hotwords at {hotwords_pos})"
);
}

#[test]
fn compose_system_prompt_trims_universal_directives() {
// 前後空白は除去して整形する(行内の改行は保持)
let prompt = compose_system_prompt(
PolishMode::Light,
&[],
" \n句読点は全角\nビックリマーク後に全角スペース\n ",
);
// 整形後は trim 済みの本文だけが現れる
assert!(prompt.contains("句読点は全角"));
assert!(prompt.contains("ビックリマーク後に全角スペース"));
// 終端側の余計な空白が残っていない(次の段落に影響しない)
assert!(!prompt.ends_with(" "));
}

#[test]
fn common_rules_include_auto_correction_and_natural_organization() {
// 所有 mode 都要带上"自动纠错"(规则 5)和"按整体意图组织成自然书面表达"
Expand Down Expand Up @@ -1337,6 +1426,7 @@ mod tests {
"原文",
PolishMode::Raw,
&[],
"",
&[],
ChineseScriptPreference::Auto,
OutputLanguagePreference::Auto,
Expand Down
14 changes: 14 additions & 0 deletions openless-all/app/src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,15 @@ pub struct UserPreferences {
/// 0 = 关闭(每次润色独立单轮,跟历史行为一致)。默认 5 分钟。
#[serde(default = "default_polish_context_window_minutes")]
pub polish_context_window_minutes: u32,
/// モード横断で常時適用される追加指示(タイポグラフィ規約等)。
/// polish system prompt の base prompt 直後・hotwords 直前に注入される。
/// 例:日本語ユーザーが「句読点は全角を使う」「!や?の後に全角スペース」と
/// 入れておくと、Light/Formal/Structured どのモードでも反映される。
/// 翻訳パスにも適用:translate_to() が出力する翻訳文も同じ規約に従う。
/// 空文字 = 何も注入しない(既存挙動と完全一致、回帰なし)。
/// Raw モードは LLM を経由しないため対象外。
#[serde(default)]
pub polish_universal_directives: String,
/// 启动时静默运行(不弹主窗口)。开机自启用户用得多——本来想看托盘
/// 而不是被主窗口打扰。开关一开后所有启动路径都不弹窗(包括手动点击),
/// 用户改用托盘菜单访问主窗口。默认 false 跟历史行为一致。
Expand Down Expand Up @@ -321,6 +330,8 @@ struct UserPreferencesWire {
#[serde(default = "default_polish_context_window_minutes")]
polish_context_window_minutes: u32,
#[serde(default)]
polish_universal_directives: String,
#[serde(default)]
start_minimized: bool,
}

Expand Down Expand Up @@ -360,6 +371,7 @@ impl Default for UserPreferencesWire {
update_channel: prefs.update_channel,
history_retention_days: prefs.history_retention_days,
polish_context_window_minutes: prefs.polish_context_window_minutes,
polish_universal_directives: prefs.polish_universal_directives,
start_minimized: prefs.start_minimized,
}
}
Expand Down Expand Up @@ -416,6 +428,7 @@ impl<'de> Deserialize<'de> for UserPreferences {
update_channel: wire.update_channel,
history_retention_days: wire.history_retention_days,
polish_context_window_minutes: wire.polish_context_window_minutes,
polish_universal_directives: wire.polish_universal_directives,
start_minimized: wire.start_minimized,
})
}
Expand Down Expand Up @@ -525,6 +538,7 @@ impl Default for UserPreferences {
update_channel: UpdateChannel::default(),
history_retention_days: default_history_retention_days(),
polish_context_window_minutes: default_polish_context_window_minutes(),
polish_universal_directives: String::new(),
start_minimized: false,
}
}
Expand Down
3 changes: 3 additions & 0 deletions openless-all/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,9 @@ export const en: typeof zhCN = {
currentDefault: 'Current default',
ariaSetDefault: 'Set as default',
saveFailed: 'Save failed: {{error}}',
universalDirectivesLabel: 'Universal directives (all modes)',
universalDirectivesDesc: 'Extra instructions that always apply during polish, regardless of which mode is selected. Use this for typography or stylebook rules — they layer on top of Light / Structured / Formal. Translation output respects them too. Raw mode skips the LLM, so it is unaffected. Leave empty for legacy behavior.',
universalDirectivesPlaceholder: 'e.g.\n- Use the Oxford comma\n- Wrap proper nouns in their official capitalization',
modes: {
raw: { name: 'Raw', desc: 'Only adds punctuation and natural breaks — no rewriting or expansion.', sample: "Keeps spoken cadence; fillers like 'um' or 'you know' get dropped, but sentences stay intact." },
light: { name: 'Light polish', desc: 'Drops fillers, adds punctuation, and produces sendable natural prose.', sample: "Makes the transcript flow well without sounding scripted — your tone and habits remain." },
Expand Down
3 changes: 3 additions & 0 deletions openless-all/app/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,9 @@ export const ja: typeof zhCN = {
currentDefault: '現在のデフォルト',
ariaSetDefault: 'デフォルトに設定',
saveFailed: '保存に失敗しました: {{error}}',
universalDirectivesLabel: '全モード共通の指示',
universalDirectivesDesc: 'どのスタイルを選んでも整文時に必ず適用される追加指示。タイポグラフィ規約や文体ルールをここに書いておくと、Light/Structured/Formal のすべてに反映されます。翻訳出力にも適用。Raw モードは LLM を経由しないため対象外。空欄なら何も追加されません(既存挙動と完全一致)。',
universalDirectivesPlaceholder: '例:\n- 句読点は全角(、。)を使う\n- !や?の後に全角スペースを1つ入れる',
modes: {
raw: { name: '原文', desc: '句読点と必要な区切りのみ補い、書き換えや拡張はしません。', sample: '元の話し言葉を保持。「えー」「あの」などの口癖は除去しますが、文の組み替えはしません。' },
light: { name: '軽い整文', desc: '口癖の除去、句読点の補完、自然な送信可能テキストへの整理。', sample: '原稿読み上げのようにならないよう、語気と表現の癖を残しつつ、文章をなめらかにします。' },
Expand Down
3 changes: 3 additions & 0 deletions openless-all/app/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,9 @@ export const ko: typeof zhCN = {
currentDefault: '현재 기본',
ariaSetDefault: '기본으로 설정',
saveFailed: '저장 실패: {{error}}',
universalDirectivesLabel: '모든 모드에 적용되는 공통 지시',
universalDirectivesDesc: '어떤 스타일을 선택하든 정리(polish) 시에 항상 적용되는 추가 지시입니다. 타이포그래피 규약이나 문체 규칙을 여기에 적어두면 Light / Structured / Formal 모두에 반영됩니다. 번역 출력에도 적용됩니다. Raw 모드는 LLM 을 거치지 않아 대상이 아닙니다. 비워두면 기존 동작과 완전히 일치합니다.',
universalDirectivesPlaceholder: '예:\n- 문장 부호는 전각을 사용\n- 느낌표나 물음표 뒤에 전각 공백을 하나 둠',
modes: {
raw: { name: '원문', desc: '구두점과 필요한 문장 구분만 보충하고 다시 쓰거나 확장하지 않습니다.', sample: '원래 구어체 유지. "음", "그게" 같은 입버릇은 제거하지만 문장을 재구성하지 않습니다.' },
light: { name: '가벼운 정리', desc: '입버릇 제거, 구두점 보충, 자연스럽게 보낼 수 있는 텍스트로 정리합니다.', sample: '원고를 읽는 듯한 느낌이 들지 않도록 어조와 표현 습관은 남기되, 문장이 매끄럽게 흐르도록 합니다.' },
Expand Down
Loading
Loading