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.
|
- `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.
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user