Skip to content
Merged
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/crates/openless-core/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2046,6 +2046,7 @@ impl OpenLessBackend {
Arc::clone(&repositories.correction_rules),
Arc::clone(&repositories.activity),
Arc::clone(&deps.credential_store),
Arc::clone(&repositories.style_packs),
deps.selection_polisher.clone(),
Arc::clone(&voice_sessions),
));
Expand Down
4 changes: 4 additions & 0 deletions openless-all/app/crates/openless-core/src/cloud_providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1031,6 +1031,7 @@ async fn run_cloud_polish(
context.polish.front_app.as_deref(),
context.polish.cursor_context.as_deref(),
&prior_turns,
context.polish.edit_plan_input,
)
.await
}
Expand All @@ -1047,6 +1048,7 @@ async fn run_cloud_polish(
context.polish.front_app.as_deref(),
context.polish.cursor_context.as_deref(),
&prior_turns,
context.polish.edit_plan_input,
on_delta,
should_cancel,
)
Expand All @@ -1065,6 +1067,7 @@ async fn run_cloud_polish(
context.polish.front_app.as_deref(),
context.polish.cursor_context.as_deref(),
&prior_turns,
context.polish.edit_plan_input,
)
.await
}
Expand All @@ -1081,6 +1084,7 @@ async fn run_cloud_polish(
context.polish.front_app.as_deref(),
context.polish.cursor_context.as_deref(),
&prior_turns,
context.polish.edit_plan_input,
on_delta,
should_cancel,
)
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/crates/openless-core/src/cloud_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,7 @@ fn validated_native_packs(payload: &CloudSyncPayload) -> Result<Vec<StylePack>,
kind: pack.kind,
base_mode: pack.base_mode,
selection_prompt: pack.selection_prompt.clone(),
voice_edit_prompt: pack.voice_edit_prompt.clone(),
prompt: pack.prompt.clone(),
examples: pack
.examples
Expand Down Expand Up @@ -490,6 +491,7 @@ fn to_wire_pack(pack: &StylePack, icon_png_base64: Option<String>) -> SyncStyleP
kind: pack.kind,
base_mode: pack.base_mode,
selection_prompt: pack.selection_prompt.clone(),
voice_edit_prompt: pack.voice_edit_prompt.clone(),
prompt: pack.prompt.clone(),
examples: pack
.examples
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/crates/openless-core/src/cloud_sync_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ pub struct SyncStylePack {
pub kind: SyncStylePackKind,
pub base_mode: PolishMode,
pub selection_prompt: String,
#[serde(default)]
pub voice_edit_prompt: String,
pub prompt: String,
pub examples: Vec<SyncStylePackExample>,
pub tags: Vec<String>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ pub struct DictationPolishContext {
pub working_languages: Vec<String>,
pub translation_target_language: String,
pub translation_active: bool,
pub edit_plan_input: bool,
pub chinese_script_preference: ChineseScriptPreference,
pub output_language_preference: OutputLanguagePreference,
pub llm_thinking_enabled: bool,
Expand Down Expand Up @@ -273,6 +274,7 @@ impl DictationContext {
working_languages: preferences.working_languages.clone(),
translation_target_language,
translation_active,
edit_plan_input: false,
chinese_script_preference: preferences.chinese_script_preference,
output_language_preference: preferences.output_language_preference,
llm_thinking_enabled: preferences.llm_thinking_enabled,
Expand Down Expand Up @@ -309,7 +311,7 @@ impl DictationContext {
} else {
self.polish.style_system_prompt.clone()
};
crate::prompt_compose::compose_polish_prompts(
crate::prompt_compose::compose_polish_prompts_for_input(
raw_text,
self.polish.mode,
&self.polish.hotwords,
Expand All @@ -320,6 +322,7 @@ impl DictationContext {
self.polish.front_app.as_deref(),
self.polish.cursor_context.as_deref(),
!self.polish.prior_turns.is_empty(),
self.polish.edit_plan_input,
)
}

Expand Down
168 changes: 149 additions & 19 deletions openless-all/app/crates/openless-core/src/edit_plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ const MAX_OP_STRING_LEN: usize = 8_192;
const MAX_PATTERN_LEN: usize = 512;
const REGEX_TIMEOUT_MS: u64 = 50;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum EditPlanFormat {
#[default]
Xml,
Json,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditPlan {
Expand Down Expand Up @@ -90,23 +98,34 @@ const EDIT_OPERATION_TAGS: &[&str] = &[
"full_rewrite",
];

/// Parse LLM edit-plan output (XML primary, JSON legacy fallback).
/// Parse LLM edit-plan output with XML preferred (backward compatible).
pub fn parse_edit_plan(raw: &str) -> Result<EditPlan, String> {
parse_edit_plan_with_priority(raw, EditPlanFormat::Xml)
}

/// Try `preferred` format first, then fall back to the other.
pub fn parse_edit_plan_with_priority(
raw: &str,
preferred: EditPlanFormat,
) -> Result<EditPlan, String> {
let trimmed = raw.trim();
if trimmed.contains('<') {
match parse_edit_plan_xml(trimmed) {
Ok(plan) => return Ok(plan),
Err(xml_error) => {
if trimmed.contains('{') {
return parse_edit_plan_json(trimmed).map_err(|json_error| {
format!("invalid EditPlan XML: {xml_error}; JSON fallback: {json_error}")
});
}
return Err(format!("invalid EditPlan XML: {xml_error}"));
}
}
let (primary, fallback) = match preferred {
EditPlanFormat::Xml => (
parse_edit_plan_xml(trimmed).map_err(|e| format!("invalid EditPlan XML: {e}")),
parse_edit_plan_json(trimmed),
),
EditPlanFormat::Json => (
parse_edit_plan_json(trimmed),
parse_edit_plan_xml(trimmed).map_err(|e| format!("invalid EditPlan XML: {e}")),
),
};
match primary {
Ok(plan) => Ok(plan),
Err(primary_error) => match fallback {
Ok(plan) => Ok(plan),
Err(fallback_error) => Err(format!("{primary_error}; fallback: {fallback_error}")),
},
}
parse_edit_plan_json(trimmed)
}

pub fn parse_edit_plan_xml(raw: &str) -> Result<EditPlan, String> {
Expand Down Expand Up @@ -397,7 +416,17 @@ pub fn parse_edit_plan_json(raw: &str) -> Result<EditPlan, String> {
}

fn parse_edit_plan_json_candidate(raw: &str) -> Result<EditPlan, String> {
let json = extract_json_object(raw).unwrap_or(raw);
let mut last_error = None;
for json in extract_json_object_candidates(raw) {
match try_parse_edit_plan_json_str(json) {
Ok(plan) => return Ok(plan),
Err(error) => last_error = Some(error),
}
}
Err(last_error.unwrap_or_else(|| "invalid EditPlan JSON: no JSON object found".into()))
}

fn try_parse_edit_plan_json_str(json: &str) -> Result<EditPlan, String> {
let mut value: Value =
serde_json::from_str(json).map_err(|error| format!("invalid EditPlan JSON: {error}"))?;
normalize_edit_plan_value(&mut value);
Expand Down Expand Up @@ -481,10 +510,62 @@ fn promote_alias_field(
}
}

fn extract_json_object(raw: &str) -> Option<&str> {
let start = raw.find('{')?;
let end = raw.rfind('}')?;
(start <= end).then(|| &raw[start..=end])
fn extract_json_object_candidates(raw: &str) -> Vec<&str> {
let mut candidates = Vec::new();
let bytes = raw.as_bytes();
let mut i = 0usize;
while i < bytes.len() {
if bytes[i] == b'{' {
if let Some(end) = find_balanced_json_object_end(raw, i) {
candidates.push(&raw[i..=end]);
i = end + 1;
continue;
}
}
i += 1;
}
if candidates.is_empty() {
let trimmed = raw.trim();
if !trimmed.is_empty() {
candidates.push(trimmed);
}
}
candidates
}

fn find_balanced_json_object_end(raw: &str, start: usize) -> Option<usize> {
let bytes = raw.as_bytes();
if start >= bytes.len() || bytes[start] != b'{' {
return None;
}
let mut depth = 0i32;
let mut in_string = false;
let mut escape = false;
for (offset, &byte) in bytes[start..].iter().enumerate() {
let index = start + offset;
if in_string {
if escape {
escape = false;
} else if byte == b'\\' {
escape = true;
} else if byte == b'"' {
in_string = false;
}
continue;
}
match byte {
b'"' => in_string = true,
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
return Some(index);
}
}
_ => {}
}
}
None
}

pub fn apply_edit_plan(draft: &str, plan: &EditPlan) -> Result<String, EditApplyError> {
Expand Down Expand Up @@ -813,4 +894,53 @@ Line two</text>
assert_eq!(plan.operations.len(), 1);
assert_eq!(plan.summary.as_deref(), Some("ok"));
}

#[test]
fn json_priority_prefers_json_when_both_present() {
let raw = r#"{"operations":[{"type":"full_rewrite","text":"from-json"}]}
<edit_plan><full_rewrite><text>from-xml</text></full_rewrite></edit_plan>"#;
let plan = parse_edit_plan_with_priority(raw, EditPlanFormat::Json).unwrap();
assert_eq!(
plan.operations[0],
EditOperation::FullRewrite {
text: "from-json".into()
}
);
}

#[test]
fn xml_priority_prefers_xml_when_both_present() {
let raw = r#"<edit_plan><full_rewrite><text>from-xml</text></full_rewrite></edit_plan>
{"operations":[{"type":"full_rewrite","text":"from-json"}]}"#;
let plan = parse_edit_plan_with_priority(raw, EditPlanFormat::Xml).unwrap();
assert_eq!(
plan.operations[0],
EditOperation::FullRewrite {
text: "from-xml".into()
}
);
}

#[test]
fn balanced_json_extract_ignores_trailing_brace_noise() {
let raw = r#"prefix {"operations":[{"type":"literal_replace","find":"a","replace":"b}"}]} trailing } noise"#;
let plan = parse_edit_plan_json(raw).unwrap();
assert_eq!(
plan.operations[0],
EditOperation::LiteralReplace {
find: "a".into(),
replace: "b}".into(),
}
);
}

#[test]
fn parses_fenced_json_via_priority() {
let raw = "```json\n{\"operations\":[{\"type\":\"full_rewrite\",\"text\":\"ok\"}]}\n```";
let plan = parse_edit_plan_with_priority(raw, EditPlanFormat::Json).unwrap();
assert_eq!(
plan.operations[0],
EditOperation::FullRewrite { text: "ok".into() }
);
}
}
4 changes: 2 additions & 2 deletions openless-all/app/crates/openless-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,8 @@ pub use dictation_context::{
pub use dictation_engine::{PipelineDictationEngine, PolishFailurePolicy};
pub use domains::*;
pub use edit_plan::{
apply_edit_plan, parse_edit_plan, parse_edit_plan_json, parse_edit_plan_xml, EditApplyError,
EditOperation, EditPlan, RegexFlags,
apply_edit_plan, parse_edit_plan, parse_edit_plan_json, parse_edit_plan_with_priority,
parse_edit_plan_xml, EditApplyError, EditOperation, EditPlan, EditPlanFormat, RegexFlags,
};
pub use errors::{BackendError, BackendErrorCode};
pub use events::{
Expand Down
12 changes: 8 additions & 4 deletions openless-all/app/crates/openless-core/src/llm_gemini.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ use base64::Engine;
use serde_json::{json, Value};

use crate::polish::{
clean_polish_output, compose_polish_prompts, compose_qa_system_prompt,
compose_translate_prompts, llm_error_from_reqwest, safe_str_slice, LLMError,
clean_polish_output, compose_qa_system_prompt, compose_translate_prompts,
llm_error_from_reqwest, safe_str_slice, LLMError,
};
use crate::shared_types::{ChineseScriptPreference, OutputLanguagePreference, QaChatMessage};
use crate::types::PolishMode;
Expand Down Expand Up @@ -102,8 +102,9 @@ impl GeminiProvider {
front_app: Option<&str>,
cursor_context: Option<&str>,
prior_turns: &[(String, String)],
edit_plan_input: bool,
) -> Result<String, LLMError> {
let (system_prompt, user_prompt) = compose_polish_prompts(
let (system_prompt, user_prompt) = crate::prompt_compose::compose_polish_prompts_for_input(
raw_text,
mode,
hotwords,
Expand All @@ -114,6 +115,7 @@ impl GeminiProvider {
front_app,
cursor_context,
!prior_turns.is_empty(),
edit_plan_input,
);

let contents = build_polish_history_contents(prior_turns, &user_prompt);
Expand Down Expand Up @@ -176,14 +178,15 @@ impl GeminiProvider {
front_app: Option<&str>,
cursor_context: Option<&str>,
prior_turns: &[(String, String)],
edit_plan_input: bool,
on_delta: F,
should_cancel: C,
) -> Result<String, LLMError>
where
F: Fn(&str) + Send + Sync,
C: Fn() -> bool + Send + Sync,
{
let (system_prompt, user_prompt) = compose_polish_prompts(
let (system_prompt, user_prompt) = crate::prompt_compose::compose_polish_prompts_for_input(
raw_text,
mode,
hotwords,
Expand All @@ -194,6 +197,7 @@ impl GeminiProvider {
front_app,
cursor_context,
!prior_turns.is_empty(),
edit_plan_input,
);
let body = self.build_generate_body(
&system_prompt,
Expand Down
Loading
Loading