Fix online one-shot AI compatibility.
This commit is contained in:
@@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::util::now_unix_ms;
|
||||
|
||||
const KEYRING_SERVICE: &str = "RuDS";
|
||||
const KEYRING_SETTING_PREFIX: &str = "ai.endpoint";
|
||||
const DEFAULT_MAX_OUTPUT_TOKENS: u64 = 16_384;
|
||||
static TEST_API_KEYS: LazyLock<Mutex<BTreeMap<String, String>>> =
|
||||
LazyLock::new(|| Mutex::new(BTreeMap::new()));
|
||||
|
||||
@@ -514,7 +515,6 @@ pub fn run_one_shot(
|
||||
let endpoint = active_endpoint(conn, offline_mode)?;
|
||||
let model = select_model(&settings, &endpoint, &request.operation)?;
|
||||
let user_content = build_one_shot_user_content(request)?;
|
||||
let schema = response_schema(&request.operation);
|
||||
let payload = json!({
|
||||
"model": model,
|
||||
"messages": [
|
||||
@@ -527,14 +527,8 @@ pub fn run_one_shot(
|
||||
"content": user_content,
|
||||
}
|
||||
],
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": schema.0,
|
||||
"schema": schema.1,
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
"max_tokens": DEFAULT_MAX_OUTPUT_TOKENS,
|
||||
"stream": false,
|
||||
});
|
||||
let client = build_http_client_for_endpoint(&endpoint.url)?;
|
||||
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> {
|
||||
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) {
|
||||
builder.no_proxy()
|
||||
} 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(
|
||||
request: &OneShotRequest,
|
||||
content: &str,
|
||||
) -> EngineResult<OneShotResponse> {
|
||||
let content = decode_json_content(content)?;
|
||||
Ok(match request.operation {
|
||||
OneShotOperation::AnalyzeTaxonomy => {
|
||||
OneShotResponse::Taxonomy(serde_json::from_str(content)?)
|
||||
OneShotResponse::Taxonomy(serde_json::from_value(content)?)
|
||||
}
|
||||
OneShotOperation::MapImportTaxonomy => {
|
||||
OneShotResponse::ImportTaxonomyMapping(serde_json::from_str(content)?)
|
||||
OneShotResponse::ImportTaxonomyMapping(serde_json::from_value(content)?)
|
||||
}
|
||||
OneShotOperation::AnalyzePost => {
|
||||
OneShotResponse::PostAnalysis(serde_json::from_str(content)?)
|
||||
OneShotResponse::PostAnalysis(serde_json::from_value(content)?)
|
||||
}
|
||||
OneShotOperation::DetectLanguage => {
|
||||
OneShotResponse::LanguageDetection(serde_json::from_str(content)?)
|
||||
OneShotResponse::LanguageDetection(serde_json::from_value(content)?)
|
||||
}
|
||||
OneShotOperation::TranslatePost { .. } => {
|
||||
OneShotResponse::Translation(serde_json::from_str(content)?)
|
||||
OneShotResponse::Translation(serde_json::from_value(content)?)
|
||||
}
|
||||
OneShotOperation::AnalyzeImage => {
|
||||
OneShotResponse::ImageAnalysis(serde_json::from_str(content)?)
|
||||
OneShotResponse::ImageAnalysis(serde_json::from_value(content)?)
|
||||
}
|
||||
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 {
|
||||
format!("{}.{}", kind.settings_prefix(), suffix)
|
||||
}
|
||||
@@ -1335,6 +1271,8 @@ mod tests {
|
||||
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(
|
||||
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]
|
||||
fn run_one_shot_supports_taxonomy_analysis_via_airplane_endpoint() {
|
||||
let response = run_airplane_one_shot(
|
||||
|
||||
Reference in New Issue
Block a user