Fix online one-shot AI compatibility.

This commit is contained in:
2026-07-21 20:03:26 +02:00
parent 812de13f62
commit 2f7d752e73
2 changed files with 67 additions and 116 deletions

View File

@@ -22,7 +22,7 @@ The project is under active development. Core blogging workflows are broadly ava
- `bds-cli server` hosting the shared application engines over a loopback-by-default, public-key-only SSH service, with restrictive private key material, live authorization updates, terminal-session transport, CLI-change synchronization, ordered domain/task events, and native desktop remote-project selection. - `bds-cli server` hosting the shared application engines over a loopback-by-default, public-key-only SSH service, with restrictive private key material, live authorization updates, terminal-session transport, CLI-change synchronization, ordered domain/task events, and native desktop remote-project selection.
- bDS2-compatible Markdown/Liquid rendering with native macros, descriptive category archive titles, canonical multilingual and flat page routes, recursive menus, calendar archives, feeds, a root hreflang sitemap, Pagefind, and incremental site generation through cancellable section task groups. - bDS2-compatible Markdown/Liquid rendering with native macros, descriptive category archive titles, canonical multilingual and flat page routes, recursive menus, calendar archives, feeds, a root hreflang sitemap, Pagefind, and incremental site generation through cancellable section task groups.
- Navigable generated-route preview in the app or system browser, with draft database overlays and published filesystem content. - Navigable generated-route preview in the app or system browser, with draft database overlays and published filesystem content.
- Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations run in background tasks with editor-level waiting indicators, using independent online and local OpenAI-compatible profiles. Each profile has secure credentials, persistently discovered chat/title/image model selections, explicit tool/vision overrides, chat testing, and status-bar airplane-mode routing. - Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations run in background tasks with editor-level waiting indicators, using provider-portable JSON-only requests through independent online and local OpenAI-compatible profiles. Each profile has secure credentials, persistently discovered chat/title/image model selections, explicit tool/vision overrides, chat testing, and status-bar airplane-mode routing.
- Persistent conversational AI with safe Markdown, streamed and cancellable responses, model/session/token tracking, bounded project-aware blog tools, and localized conversation management in the Chat workspace. Allowlisted render tools add persistent native cards, charts, forms, lists, metrics, mind maps, tables, and tabs without executing assistant-provided HTML or JavaScript. - Persistent conversational AI with safe Markdown, streamed and cancellable responses, model/session/token tracking, bounded project-aware blog tools, and localized conversation management in the Chat workspace. Allowlisted render tools add persistent native cards, charts, forms, lists, metrics, mind maps, tables, and tabs without executing assistant-provided HTML or JavaScript.
- SSH-agent-based SCP or rsync publishing. - SSH-agent-based SCP or rsync publishing.
- Integrated Git workflow with repository initialization, Git LFS image tracking, status and diffs, branch/file history, commits, remotes, cancellable fetch/pull/push, and post-pull filesystem reconciliation; network actions respect airplane mode. - Integrated Git workflow with repository initialization, Git LFS image tracking, status and diffs, branch/file history, commits, remotes, cancellable fetch/pull/push, and post-pull filesystem reconciliation; network actions respect airplane mode.

View File

@@ -15,6 +15,7 @@ use crate::util::now_unix_ms;
const KEYRING_SERVICE: &str = "RuDS"; const KEYRING_SERVICE: &str = "RuDS";
const KEYRING_SETTING_PREFIX: &str = "ai.endpoint"; const KEYRING_SETTING_PREFIX: &str = "ai.endpoint";
const DEFAULT_MAX_OUTPUT_TOKENS: u64 = 16_384;
static TEST_API_KEYS: LazyLock<Mutex<BTreeMap<String, String>>> = static TEST_API_KEYS: LazyLock<Mutex<BTreeMap<String, String>>> =
LazyLock::new(|| Mutex::new(BTreeMap::new())); LazyLock::new(|| Mutex::new(BTreeMap::new()));
@@ -514,7 +515,6 @@ pub fn run_one_shot(
let endpoint = active_endpoint(conn, offline_mode)?; let endpoint = active_endpoint(conn, offline_mode)?;
let model = select_model(&settings, &endpoint, &request.operation)?; let model = select_model(&settings, &endpoint, &request.operation)?;
let user_content = build_one_shot_user_content(request)?; let user_content = build_one_shot_user_content(request)?;
let schema = response_schema(&request.operation);
let payload = json!({ let payload = json!({
"model": model, "model": model,
"messages": [ "messages": [
@@ -527,14 +527,8 @@ pub fn run_one_shot(
"content": user_content, "content": user_content,
} }
], ],
"response_format": { "max_tokens": DEFAULT_MAX_OUTPUT_TOKENS,
"type": "json_schema", "stream": false,
"json_schema": {
"name": schema.0,
"schema": schema.1,
"strict": true
}
}
}); });
let client = build_http_client_for_endpoint(&endpoint.url)?; let client = build_http_client_for_endpoint(&endpoint.url)?;
let response = with_auth( let response = with_auth(
@@ -577,7 +571,9 @@ fn parse_token_usage(body: &Value) -> TokenUsage {
} }
fn build_http_client_for_endpoint(endpoint_url: &str) -> EngineResult<Client> { fn build_http_client_for_endpoint(endpoint_url: &str) -> EngineResult<Client> {
let builder = Client::builder().timeout(Duration::from_secs(5)); let builder = Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(120));
let builder = if should_bypass_proxy(endpoint_url) { let builder = if should_bypass_proxy(endpoint_url) {
builder.no_proxy() builder.no_proxy()
} else { } else {
@@ -813,133 +809,73 @@ fn build_image_analysis_user_content(content: &Value) -> EngineResult<Value> {
])) ]))
} }
fn response_schema(operation: &OneShotOperation) -> (&'static str, Value) {
match operation {
OneShotOperation::AnalyzeTaxonomy => (
"taxonomy_suggestion",
json!({
"type": "object",
"additionalProperties": false,
"properties": {
"tags": { "type": "array", "items": { "type": "string" } },
"categories": { "type": "array", "items": { "type": "string" } }
},
"required": ["tags", "categories"]
}),
),
OneShotOperation::MapImportTaxonomy => (
"import_taxonomy_mapping",
json!({
"type": "object",
"additionalProperties": false,
"properties": {
"category_mappings": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"tag_mappings": {
"type": "object",
"additionalProperties": { "type": "string" }
}
},
"required": ["category_mappings", "tag_mappings"]
}),
),
OneShotOperation::AnalyzePost => (
"post_analysis",
json!({
"type": "object",
"additionalProperties": false,
"properties": {
"title": { "type": "string" },
"excerpt": { "type": "string" },
"slug": { "type": "string" }
},
"required": ["title", "excerpt", "slug"]
}),
),
OneShotOperation::DetectLanguage => (
"language_detection",
json!({
"type": "object",
"additionalProperties": false,
"properties": {
"language_code": { "type": "string" }
},
"required": ["language_code"]
}),
),
OneShotOperation::TranslatePost { .. } => (
"post_translation",
json!({
"type": "object",
"additionalProperties": false,
"properties": {
"title": { "type": "string" },
"excerpt": { "type": "string" },
"content": { "type": "string" }
},
"required": ["title", "excerpt", "content"]
}),
),
OneShotOperation::AnalyzeImage => (
"image_analysis",
json!({
"type": "object",
"additionalProperties": false,
"properties": {
"title": { "type": "string" },
"alt": { "type": "string" },
"caption": { "type": "string" }
},
"required": ["title", "alt", "caption"]
}),
),
OneShotOperation::TranslateMedia { .. } => (
"media_translation",
json!({
"type": "object",
"additionalProperties": false,
"properties": {
"title": { "type": "string" },
"alt": { "type": "string" },
"caption": { "type": "string" }
},
"required": ["title", "alt", "caption"]
}),
),
}
}
fn parse_one_shot_response( fn parse_one_shot_response(
request: &OneShotRequest, request: &OneShotRequest,
content: &str, content: &str,
) -> EngineResult<OneShotResponse> { ) -> EngineResult<OneShotResponse> {
let content = decode_json_content(content)?;
Ok(match request.operation { Ok(match request.operation {
OneShotOperation::AnalyzeTaxonomy => { OneShotOperation::AnalyzeTaxonomy => {
OneShotResponse::Taxonomy(serde_json::from_str(content)?) OneShotResponse::Taxonomy(serde_json::from_value(content)?)
} }
OneShotOperation::MapImportTaxonomy => { OneShotOperation::MapImportTaxonomy => {
OneShotResponse::ImportTaxonomyMapping(serde_json::from_str(content)?) OneShotResponse::ImportTaxonomyMapping(serde_json::from_value(content)?)
} }
OneShotOperation::AnalyzePost => { OneShotOperation::AnalyzePost => {
OneShotResponse::PostAnalysis(serde_json::from_str(content)?) OneShotResponse::PostAnalysis(serde_json::from_value(content)?)
} }
OneShotOperation::DetectLanguage => { OneShotOperation::DetectLanguage => {
OneShotResponse::LanguageDetection(serde_json::from_str(content)?) OneShotResponse::LanguageDetection(serde_json::from_value(content)?)
} }
OneShotOperation::TranslatePost { .. } => { OneShotOperation::TranslatePost { .. } => {
OneShotResponse::Translation(serde_json::from_str(content)?) OneShotResponse::Translation(serde_json::from_value(content)?)
} }
OneShotOperation::AnalyzeImage => { OneShotOperation::AnalyzeImage => {
OneShotResponse::ImageAnalysis(serde_json::from_str(content)?) OneShotResponse::ImageAnalysis(serde_json::from_value(content)?)
} }
OneShotOperation::TranslateMedia { .. } => { OneShotOperation::TranslateMedia { .. } => {
OneShotResponse::MediaTranslation(serde_json::from_str(content)?) OneShotResponse::MediaTranslation(serde_json::from_value(content)?)
} }
}) })
} }
fn decode_json_content(content: &str) -> EngineResult<Value> {
if let Some(value) = parse_json_object(content) {
return Ok(value);
}
if let Some(fence_start) = content.find("```") {
let fenced = &content[fence_start + 3..];
if let Some(fence_end) = fenced.find("```") {
let fenced = fenced[..fence_end].trim();
let fenced = fenced
.get(..4)
.filter(|prefix| prefix.eq_ignore_ascii_case("json"))
.map_or(fenced, |_| fenced[4..].trim());
if let Some(value) = parse_json_object(fenced).or_else(|| parse_embedded_object(fenced))
{
return Ok(value);
}
}
}
parse_embedded_object(content).ok_or_else(|| {
EngineError::Parse("one-shot response did not contain a JSON object".to_string())
})
}
fn parse_json_object(content: &str) -> Option<Value> {
serde_json::from_str(content.trim())
.ok()
.filter(Value::is_object)
}
fn parse_embedded_object(content: &str) -> Option<Value> {
let start = content.find('{')?;
let end = content.rfind('}')?;
(end > start)
.then(|| &content[start..=end])
.and_then(parse_json_object)
}
fn endpoint_setting_key(kind: AiEndpointKind, suffix: &str) -> String { fn endpoint_setting_key(kind: AiEndpointKind, suffix: &str) -> String {
format!("{}.{}", kind.settings_prefix(), suffix) format!("{}.{}", kind.settings_prefix(), suffix)
} }
@@ -1335,6 +1271,8 @@ mod tests {
request.contains("authorization: Bearer secret-token") request.contains("authorization: Bearer secret-token")
|| request.contains("Authorization: Bearer secret-token") || request.contains("Authorization: Bearer secret-token")
); );
assert!(!request.contains("\"response_format\""));
assert!(request.contains("\"max_tokens\":16384"));
http_ok( http_ok(
r#"{"choices":[{"message":{"content":"{\"title\":\"Better title\",\"excerpt\":\"Short summary\",\"slug\":\"better-title\"}"}}],"usage":{"prompt_tokens":10,"completion_tokens":5}}"#, r#"{"choices":[{"message":{"content":"{\"title\":\"Better title\",\"excerpt\":\"Short summary\",\"slug\":\"better-title\"}"}}],"usage":{"prompt_tokens":10,"completion_tokens":5}}"#,
) )
@@ -1410,6 +1348,19 @@ mod tests {
); );
} }
#[test]
fn decodes_fenced_and_embedded_json_objects() {
assert_eq!(
decode_json_content("```JSON\n{\"language_code\":\"de\"}\n```").unwrap(),
json!({"language_code": "de"})
);
assert_eq!(
decode_json_content("Sure! {\"title\":\"Herbst\"} Hope this helps.").unwrap(),
json!({"title": "Herbst"})
);
assert!(decode_json_content("[1, 2, 3]").is_err());
}
#[test] #[test]
fn run_one_shot_supports_taxonomy_analysis_via_airplane_endpoint() { fn run_one_shot_supports_taxonomy_analysis_via_airplane_endpoint() {
let response = run_airplane_one_shot( let response = run_airplane_one_shot(