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
16 changes: 16 additions & 0 deletions openless-all/app/crates/openless-core/src/prompts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,22 @@ pub fn polish_injection_defense() -> &'static str {
///
/// The instruction is executable user intent, but it cannot redefine the
/// system contract or turn the selected text into another instruction source.
/// 圈選潤色的 user message:選區專用框架(`<selected_text>` 信封)。
///
/// 蜘蛛故事事故(2026-09-11/12):圈選路徑曾複用 `user_prompt`(語音輸入框架——
/// 「语音输入的原始转写 / 当前 mode 的任务描述 / 插入到光标位置」),小模型把整套
/// 語音脚手架照抄進輸出。選區沒有「語音輸入」「mode」「游標」,必須用選區框架。
pub fn selection_user_prompt(selected_text: &str) -> String {
let escaped = sanitize_for_xml_envelope(selected_text, "selected_text");
format!(
"下面是用户选中的文本。请按 system prompt 中的任务要求处理这段文本,\
输出处理后的正文,它会被原样替换选区。\n\n\
<selected_text>\n{}\n</selected_text>\n\n\
只输出处理后的文本正文。",
escaped
)
}

pub fn selection_instruction_block(instruction: &str) -> Option<String> {
let instruction = instruction.trim();
if instruction.is_empty() {
Expand Down
84 changes: 81 additions & 3 deletions openless-all/app/crates/openless-core/src/selection_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -479,8 +479,16 @@ impl SelectionServiceInner {

fn fail_if_active(&self, session_id: SessionId) -> bool {
let mut state = self.state.write().expect("selection state lock poisoned");
// 只对「还在进行中」的 session 结算:Cancelled 是用户主动结束,
// Completed 是已粘贴成功(race:complete 与 fail 判断之间的窄窗口,
// 若误标 Failed 会把成功状态覆盖掉)。
if state.snapshot.session_id == Some(session_id)
&& !matches!(state.snapshot.phase, SelectionPhase::Cancelled)
&& matches!(
state.snapshot.phase,
SelectionPhase::Capturing
| SelectionPhase::Preview
| SelectionPhase::Applying
)
{
state.snapshot.phase = SelectionPhase::Failed;
let snapshot = state.snapshot.clone();
Expand All @@ -495,6 +503,38 @@ impl SelectionServiceInner {
}
}

/// confirm 失败结算:session 已失效(stale / 目标变更 / 并发占用)结算为
/// Failed 并隐藏预览;瞬时的平台错误(焦点恢复 / 目标复核抖动)回退到
/// Preview 保持可重试——直接失败掉会让预览窗被隐藏、编辑内容丢失,
/// 用户看到的只是「点确认没反应」。
fn settle_confirm_failure(&self, session_id: SessionId, error: &BackendError) -> bool {
let settled = matches!(
error.code,
BackendErrorCode::Cancelled
| BackendErrorCode::InvalidState
| BackendErrorCode::InvalidArgument
| BackendErrorCode::Busy
);
let mut state = self.state.write().expect("selection state lock poisoned");
let active = state.snapshot.session_id == Some(session_id)
&& !matches!(state.snapshot.phase, SelectionPhase::Cancelled);
if !active {
return false;
}
state.snapshot.phase = if settled {
SelectionPhase::Failed
} else {
SelectionPhase::Preview
};
let snapshot = state.snapshot.clone();
drop(state);
self.events.publish(
Some(session_id),
BackendEventKind::SelectionStateChanged(snapshot),
);
settled
}

fn begin_revert(&self, session_id: SessionId) -> Result<(), BackendError> {
let mut state = self.state.write().expect("selection state lock poisoned");
if state.snapshot.session_id != Some(session_id) {
Expand Down Expand Up @@ -575,7 +615,13 @@ impl SelectionApi for SelectionService {
inner.set_context(session_id, Arc::clone(&context))?;
let (output, polish_ms) = if uses_llm {
let polish_started = std::time::Instant::now();
let output = inner
// C 案:圈選潤色此前漏接簡繁偏好(語音輸入路徑在 finish 時已套用
// apply_chinese_script_preference)。這裡對齊——LLM 輸出依用戶
// 設定做確定性簡繁轉換,與 prompt 無關,避免小模型簡體漂移直接
// 進預覽/替換。非 LLM 分支只回顯原始選區,不轉換。
// `context` 稍後被 move 進 polish(),先把 Copy 的偏好抓成局部。
let script_pref = context.polish.chinese_script_preference;
let mut output = inner
.polisher
.polish(
session_id,
Expand All @@ -584,6 +630,35 @@ impl SelectionApi for SelectionService {
Arc::new(DiscardTextStreamSink),
)
.await?;
// 脚手架剥离(2026-09-11 蜘蛛故事事故):小模型间歇性把 user
// message 的模板句与 <raw_transcript> 信封连同正文一起回显。
// prompt 层禁令对 35B 小模型只有部分效果,这里做确定性后处理
// (模型无关):活标签必然来自回显——用户正文进 LLM 前标签已被
// sanitize 中和,正规输出不可能含活标签,取标签内正文零误伤。
let before_strip = output.text.clone();
let stripped =
crate::streaming_insert::strip_echoed_scaffolding(&output.text);
if stripped != before_strip {
log::info!(
"[selection-polish] stripped echoed scaffolding: {} -> {} chars",
before_strip.chars().count(),
stripped.chars().count()
);
output.text = stripped;
}
let before = output.text.clone();
output.text = crate::streaming_insert::apply_chinese_script_preference(
&output.text,
script_pref,
);
if output.text != before {
log::info!(
"[selection-polish] script preference applied: {:?} {} -> {} chars",
script_pref,
before.chars().count(),
output.text.chars().count(),
);
}
(
output,
Some(
Expand Down Expand Up @@ -650,7 +725,10 @@ impl SelectionApi for SelectionService {
Ok(())
}
Err(error) => {
if inner.fail_if_active(session_id) {
// 分流:session 已失效(stale / 并发 confirm)必须结算;瞬时的
// 平台错误(焦点恢复 / 目标复核抖动)保持 preview 可重试——
// 否则窗口被隐藏、busy 卡死,表现为「点确认没反应」。
if inner.settle_confirm_failure(session_id, &error) {
let _ = inner.polisher.cancel(session_id).await;
let _ = inner.runtime.cancel(session_id).await;
inner.hide_preview();
Expand Down
123 changes: 123 additions & 0 deletions openless-all/app/crates/openless-core/src/streaming_insert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,69 @@ pub fn apply_chinese_script_preference(text: &str, preference: ChineseScriptPref
.map_or_else(|| text.to_string(), |converter| converter.convert(text))
}

/// 剝離模型完整回顯的 user-message 腳手架。
///
/// 以真正的 prompt builder 產生 canonical 模板,再比對標籤前後的完整內容;不手抄
/// 導語,避免 prompt 改字後判定漂移。另接受整套模板被簡繁轉換後的版本。只有完整
/// 模板命中才取信封正文;單純 XML、文件範例或前後另有正文一律保留原輸出。
fn strip_complete_template(text: &str, template: &str, open: &str, close: &str) -> Option<String> {
let template_open = template.find(open)?;
let template_inner_start = template_open + open.len();
let template_close = template[template_inner_start..].find(close)? + template_inner_start;
let expected_before = normalize_scaffold_prose(template[..template_open].trim());
let expected_after = normalize_scaffold_prose(template[template_close + close.len()..].trim());

let trimmed = text.trim();
let open_pos = trimmed.find(open)?;
let inner_start = open_pos + open.len();
let inner_end = trimmed[inner_start..].find(close)? + inner_start;
let before = normalize_scaffold_prose(trimmed[..open_pos].trim());
let after = normalize_scaffold_prose(trimmed[inner_end + close.len()..].trim());
if before != expected_before || after != expected_after {
return None;
}
let inner = trimmed[inner_start..inner_end].trim();
(!inner.is_empty()).then(|| inner.to_string())
}

fn normalize_scaffold_prose(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}

pub fn strip_echoed_scaffolding(text: &str) -> String {
const MARKER: &str = "__OPENLESS_SCAFFOLD_BODY__";
let raw = crate::prompts::user_prompt(MARKER);
let selection = crate::prompts::selection_user_prompt(MARKER);
let raw_traditional = apply_chinese_script_preference(
&raw,
ChineseScriptPreference::Traditional,
);
let selection_traditional = apply_chinese_script_preference(
&selection,
ChineseScriptPreference::Traditional,
);

let stripped = [raw.as_str(), raw_traditional.as_str()]
.into_iter()
.find_map(|template| {
strip_complete_template(text, template, "<raw_transcript>", "</raw_transcript>")
})
.or_else(|| {
[selection.as_str(), selection_traditional.as_str()]
.into_iter()
.find_map(|template| {
strip_complete_template(
text,
template,
"<selected_text>",
"</selected_text>",
)
})
})
.unwrap_or_else(|| text.to_string());
stripped
}

pub fn append_typed_prefix(target: &mut String, delta: &str, typed_chars: usize) -> usize {
let prefix: String = delta.chars().take(typed_chars).collect();
let count = prefix.chars().count();
Expand All @@ -155,6 +218,66 @@ pub fn streaming_insert_eligible(

#[cfg(test)]
mod tests {
#[test]
fn strip_echoed_scaffolding_none_passes_through() {
assert_eq!(strip_echoed_scaffolding("純正文,沒有標籤。"), "純正文,沒有標籤。");
}

#[test]
fn strip_echoed_scaffolding_unescaped_user_content_not_touched() {
// 用戶正文裡的標籤經 sanitize 後是 &lt;raw_transcript,不是活標籤——不得剝。
let text = "文中提到 &lt;raw_transcript 的用法。";
assert_eq!(strip_echoed_scaffolding(text), text);
}

#[test]
fn strip_echoed_scaffolding_drops_echoed_template_and_tags() {
// 蜘蛛故事事故形態:模型照抄整套 raw user prompt,並被轉成繁體。
let text = crate::prompts::user_prompt("從前,有一隻蜘蛛。");
let text = apply_chinese_script_preference(&text, ChineseScriptPreference::Traditional);
assert_eq!(strip_echoed_scaffolding(&text), "從前,有一隻蜘蛛。");
}

#[test]
fn strip_echoed_scaffolding_empty_inner_keeps_original() {
// 標籤內為空(模型只回顯了空信封)——保守不剝,避免把正文吃掉。
let text = "脚手架\n<raw_transcript>\n</raw_transcript>\n正文在這裡。";
assert_eq!(strip_echoed_scaffolding(text), text);
}

#[test]
fn strip_echoed_scaffolding_missing_close_keeps_original() {
// 流被截斷、只回顯了開標籤——保守不剝。
let text = "<raw_transcript>\n只有開標籤,流斷了。";
assert_eq!(strip_echoed_scaffolding(text), text);
}

#[test]
fn strip_echoed_scaffolding_legitimate_xml_is_not_touched() {
let text = "請輸出以下 XML 範例:<selected_text>保留我</selected_text>,並補充說明。";
assert_eq!(strip_echoed_scaffolding(text), text);
}

#[test]
fn strip_echoed_scaffolding_selected_text_echo_stripped() {
// 圈選路徑:模型完整回顯真實 selection user prompt。
let text = crate::prompts::selection_user_prompt("从前,有一只蜘蛛。");
assert_eq!(strip_echoed_scaffolding(&text), "从前,有一只蜘蛛。");
}

#[test]
fn strip_echoed_scaffolding_selected_text_empty_inner_keeps_original() {
let text = "脚手架\n<selected_text>\n</selected_text>\n正文在这里。";
assert_eq!(strip_echoed_scaffolding(text), text);
}

#[test]
fn strip_echoed_scaffolding_escaped_selected_text_not_touched() {
// 用戶正文裡的 <selected_text> 經 sanitize 後是 &lt;selected_text,不是活標籤。
let text = "文中提到 &lt;selected_text 的用法。";
assert_eq!(strip_echoed_scaffolding(text), text);
}

use super::*;
use crate::shared_types::MacosNewlineMode;

Expand Down
Loading