Separate online and airplane AI profiles.
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.
|
||||
- Markdown/Liquid rendering with native macros, multilingual routes, feeds, sitemap, Pagefind, and incremental site generation through cancellable section task groups.
|
||||
- Local preview in the app or system browser.
|
||||
- Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations using online or local OpenAI-compatible endpoints with airplane-mode gating.
|
||||
- Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations using independent online and local OpenAI-compatible profiles. Each profile has secure credentials, 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.
|
||||
|
||||
@@ -99,9 +99,9 @@ Open:
|
||||
|
||||
Available:
|
||||
|
||||
- Configurable online and airplane-mode OpenAI-compatible endpoints.
|
||||
- Secure API-key storage through the operating-system keychain.
|
||||
- Model catalog discovery and model selection.
|
||||
- Independent online and airplane-mode OpenAI-compatible profiles, selected by the status-bar airplane switch.
|
||||
- Secure keychain credentials for both profiles, optional for local endpoints.
|
||||
- Model discovery without a preselected model; per-profile chat/title/image selections, explicit tool/vision overrides, and minimal chat tests.
|
||||
- Post translation, media translation, image alt text, post analysis, taxonomy analysis, WordPress-import taxonomy mapping, and language detection.
|
||||
- Explicit offline gating and user-visible errors.
|
||||
- Parsed input, output, cache-read, and cache-write token usage returned from every one-shot operation; persistent chat accounting is tracked in the extension plan.
|
||||
|
||||
@@ -40,7 +40,7 @@ Done:
|
||||
- Project-aware tools over statistics, FTS search, posts, media, templates, scripts, tags/categories, metadata mutation through shared engines, and allowlisted workspace navigation.
|
||||
- Localized Chat sidebar/editor with conversation and model controls, safe GFM text rendering with blocked external images, streaming/tool state, multiline send/stop controls, and status-bar token totals.
|
||||
- Fixed native A2UI cards, charts, forms, lists, metrics, mind maps, tables, and tabs with safe nested fallbacks, persistent stable-ID interaction state, debounced form values, and strictly allowlisted application actions.
|
||||
- Online/airplane endpoint routing uses the shared secure endpoint and model infrastructure; unavailable modes direct the user to the existing localized AI settings.
|
||||
- Online/airplane routing swaps the complete active profile—endpoint, credentials, chat/title/image models, and tool/vision overrides—through the status-bar switch; unavailable modes direct the user to the localized two-profile AI settings.
|
||||
|
||||
### Embeddings, semantic search, and duplicates — Done
|
||||
|
||||
|
||||
@@ -883,6 +883,12 @@ fn config(db: &Database, command: ConfigCommand) -> Result<CommandOutput> {
|
||||
cli_sync::run_cli_mutation(db.conn(), || {
|
||||
if key == engine::settings::ONLINE_API_KEY {
|
||||
engine::ai::save_online_api_key(db.conn(), &value)
|
||||
} else if key == engine::settings::AIRPLANE_API_KEY {
|
||||
engine::ai::save_endpoint_api_key(
|
||||
db.conn(),
|
||||
engine::ai::AiEndpointKind::Airplane,
|
||||
&value,
|
||||
)
|
||||
} else {
|
||||
engine::settings::set(db.conn(), &key, &value)
|
||||
}
|
||||
|
||||
@@ -53,12 +53,28 @@ pub struct StoredAiEndpointConfig {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct AiSettings {
|
||||
pub offline_mode: bool,
|
||||
pub default_model: Option<String>,
|
||||
pub system_prompt: String,
|
||||
pub online: AiModeSettings,
|
||||
pub airplane: AiModeSettings,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct AiModeSettings {
|
||||
pub endpoint: StoredAiEndpointConfig,
|
||||
pub title_model: Option<String>,
|
||||
pub image_model: Option<String>,
|
||||
pub system_prompt: String,
|
||||
pub online_endpoint: StoredAiEndpointConfig,
|
||||
pub airplane_endpoint: StoredAiEndpointConfig,
|
||||
pub chat_supports_tools: Option<bool>,
|
||||
pub image_supports_vision: Option<bool>,
|
||||
}
|
||||
|
||||
impl AiSettings {
|
||||
pub fn active(&self) -> &AiModeSettings {
|
||||
if self.offline_mode {
|
||||
&self.airplane
|
||||
} else {
|
||||
&self.online
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StoredAiEndpointConfig {
|
||||
@@ -78,6 +94,7 @@ pub struct AiModelInfo {
|
||||
pub name: String,
|
||||
pub context_window: Option<u64>,
|
||||
pub max_output_tokens: Option<u64>,
|
||||
pub supports_tools: bool,
|
||||
pub supports_vision: bool,
|
||||
}
|
||||
|
||||
@@ -163,21 +180,28 @@ pub enum OneShotResponse {
|
||||
}
|
||||
|
||||
pub fn load_ai_settings(conn: &Connection, offline_mode: bool) -> EngineResult<AiSettings> {
|
||||
let online_endpoint = load_endpoint(conn, AiEndpointKind::Online)?;
|
||||
let airplane_endpoint = load_endpoint(conn, AiEndpointKind::Airplane)?;
|
||||
let default_model = get_optional_setting(conn, "ai.default_model")?;
|
||||
let title_model = get_optional_setting(conn, "ai.title_model")?;
|
||||
let image_model = get_optional_setting(conn, "ai.image_model")?;
|
||||
let legacy_chat_model = get_optional_setting(conn, "ai.default_model")?;
|
||||
let legacy_title_model = get_optional_setting(conn, "ai.title_model")?;
|
||||
let legacy_image_model = get_optional_setting(conn, "ai.image_model")?;
|
||||
let system_prompt = get_optional_setting(conn, "ai.system_prompt")?.unwrap_or_default();
|
||||
|
||||
Ok(AiSettings {
|
||||
offline_mode,
|
||||
default_model,
|
||||
title_model,
|
||||
image_model,
|
||||
system_prompt,
|
||||
online_endpoint,
|
||||
airplane_endpoint,
|
||||
online: load_mode_settings(
|
||||
conn,
|
||||
AiEndpointKind::Online,
|
||||
legacy_chat_model.as_deref(),
|
||||
legacy_title_model.as_deref(),
|
||||
legacy_image_model.as_deref(),
|
||||
)?,
|
||||
airplane: load_mode_settings(
|
||||
conn,
|
||||
AiEndpointKind::Airplane,
|
||||
legacy_chat_model.as_deref(),
|
||||
legacy_title_model.as_deref(),
|
||||
legacy_image_model.as_deref(),
|
||||
)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -196,20 +220,32 @@ pub fn save_endpoint(conn: &Connection, endpoint: &AiEndpointConfig) -> EngineRe
|
||||
endpoint.model.trim(),
|
||||
checked_at,
|
||||
)?;
|
||||
if endpoint.kind == AiEndpointKind::Online {
|
||||
if let Some(api_key) = &endpoint.api_key {
|
||||
save_online_api_key_at(conn, api_key, checked_at)?;
|
||||
}
|
||||
set_setting(conn, "ai.default_model", "", checked_at)?;
|
||||
if let Some(api_key) = &endpoint.api_key {
|
||||
save_endpoint_api_key_at(conn, endpoint.kind, api_key, checked_at)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save_online_api_key(conn: &Connection, api_key: &str) -> EngineResult<()> {
|
||||
save_online_api_key_at(conn, api_key, now_unix_ms())
|
||||
save_endpoint_api_key(conn, AiEndpointKind::Online, api_key)
|
||||
}
|
||||
|
||||
fn save_online_api_key_at(conn: &Connection, api_key: &str, updated_at: i64) -> EngineResult<()> {
|
||||
let entry = endpoint_keyring_entry(AiEndpointKind::Online)?;
|
||||
pub fn save_endpoint_api_key(
|
||||
conn: &Connection,
|
||||
kind: AiEndpointKind,
|
||||
api_key: &str,
|
||||
) -> EngineResult<()> {
|
||||
save_endpoint_api_key_at(conn, kind, api_key, now_unix_ms())
|
||||
}
|
||||
|
||||
fn save_endpoint_api_key_at(
|
||||
conn: &Connection,
|
||||
kind: AiEndpointKind,
|
||||
api_key: &str,
|
||||
updated_at: i64,
|
||||
) -> EngineResult<()> {
|
||||
let entry = endpoint_keyring_entry(kind)?;
|
||||
let configured = !api_key.trim().is_empty();
|
||||
if configured {
|
||||
entry.set_password(api_key.trim()).map_err(keyring_error)?;
|
||||
@@ -221,7 +257,7 @@ fn save_online_api_key_at(conn: &Connection, api_key: &str, updated_at: i64) ->
|
||||
}
|
||||
set_setting(
|
||||
conn,
|
||||
&endpoint_setting_key(AiEndpointKind::Online, "api_key_configured"),
|
||||
&endpoint_setting_key(kind, "api_key_configured"),
|
||||
if configured { "true" } else { "false" },
|
||||
updated_at,
|
||||
)
|
||||
@@ -229,19 +265,46 @@ fn save_online_api_key_at(conn: &Connection, api_key: &str, updated_at: i64) ->
|
||||
|
||||
pub fn save_model_preferences(
|
||||
conn: &Connection,
|
||||
default_model: Option<&str>,
|
||||
kind: AiEndpointKind,
|
||||
title_model: Option<&str>,
|
||||
image_model: Option<&str>,
|
||||
system_prompt: &str,
|
||||
chat_supports_tools: Option<bool>,
|
||||
image_supports_vision: Option<bool>,
|
||||
) -> EngineResult<()> {
|
||||
let updated_at = now_unix_ms();
|
||||
set_optional_setting(conn, "ai.default_model", default_model, updated_at)?;
|
||||
set_optional_setting(conn, "ai.title_model", title_model, updated_at)?;
|
||||
set_optional_setting(conn, "ai.image_model", image_model, updated_at)?;
|
||||
set_setting(conn, "ai.system_prompt", system_prompt, updated_at)?;
|
||||
set_optional_setting(
|
||||
conn,
|
||||
&endpoint_setting_key(kind, "title_model"),
|
||||
title_model,
|
||||
updated_at,
|
||||
)?;
|
||||
set_optional_setting(
|
||||
conn,
|
||||
&endpoint_setting_key(kind, "image_model"),
|
||||
image_model,
|
||||
updated_at,
|
||||
)?;
|
||||
set_optional_bool_setting(
|
||||
conn,
|
||||
&endpoint_setting_key(kind, "chat_supports_tools"),
|
||||
chat_supports_tools,
|
||||
updated_at,
|
||||
)?;
|
||||
set_optional_bool_setting(
|
||||
conn,
|
||||
&endpoint_setting_key(kind, "image_supports_vision"),
|
||||
image_supports_vision,
|
||||
updated_at,
|
||||
)?;
|
||||
set_setting(conn, "ai.title_model", "", updated_at)?;
|
||||
set_setting(conn, "ai.image_model", "", updated_at)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save_system_prompt(conn: &Connection, system_prompt: &str) -> EngineResult<()> {
|
||||
set_setting(conn, "ai.system_prompt", system_prompt, now_unix_ms())
|
||||
}
|
||||
|
||||
pub fn active_endpoint(conn: &Connection, offline_mode: bool) -> EngineResult<AiEndpointConfig> {
|
||||
let kind = if offline_mode {
|
||||
AiEndpointKind::Airplane
|
||||
@@ -255,13 +318,19 @@ pub fn active_endpoint(conn: &Connection, offline_mode: bool) -> EngineResult<Ai
|
||||
kind.as_str()
|
||||
)));
|
||||
}
|
||||
let api_key = if kind == AiEndpointKind::Online {
|
||||
if kind == AiEndpointKind::Online && !stored.api_key_configured {
|
||||
return Err(EngineError::Validation(
|
||||
"AI unavailable - configure online endpoint in Settings".to_string(),
|
||||
));
|
||||
}
|
||||
let api_key = if stored.api_key_configured {
|
||||
let entry = endpoint_keyring_entry(kind)?;
|
||||
let password = entry.get_password().map_err(keyring_error)?;
|
||||
if password.trim().is_empty() {
|
||||
return Err(EngineError::Validation(
|
||||
"AI unavailable - configure online endpoint in Settings".to_string(),
|
||||
));
|
||||
return Err(EngineError::Validation(format!(
|
||||
"AI unavailable - configure {} endpoint in Settings",
|
||||
kind.as_str()
|
||||
)));
|
||||
}
|
||||
Some(password)
|
||||
} else {
|
||||
@@ -287,7 +356,7 @@ pub fn load_endpoint_api_key(kind: AiEndpointKind) -> EngineResult<Option<String
|
||||
}
|
||||
|
||||
pub fn refresh_model_catalog(endpoint: &AiEndpointConfig) -> EngineResult<Vec<AiModelInfo>> {
|
||||
validate_endpoint_config(endpoint)?;
|
||||
validate_endpoint_access(endpoint)?;
|
||||
let client = build_http_client()?;
|
||||
let request = client.get(models_url(&endpoint.url));
|
||||
let response = with_auth(request, endpoint).send()?.error_for_status()?;
|
||||
@@ -325,11 +394,17 @@ pub fn refresh_model_catalog(endpoint: &AiEndpointConfig) -> EngineResult<Vec<Ai
|
||||
.any(|value| value.as_str() == Some("vision"))
|
||||
})
|
||||
.unwrap_or(false);
|
||||
let supports_tools = model
|
||||
.get("supports_tools")
|
||||
.or_else(|| model.get("supportsTools"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
result.push(AiModelInfo {
|
||||
id,
|
||||
name,
|
||||
context_window,
|
||||
max_output_tokens,
|
||||
supports_tools,
|
||||
supports_vision,
|
||||
});
|
||||
}
|
||||
@@ -337,8 +412,25 @@ pub fn refresh_model_catalog(endpoint: &AiEndpointConfig) -> EngineResult<Vec<Ai
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn test_endpoint(endpoint: &AiEndpointConfig) -> EngineResult<()> {
|
||||
let _ = refresh_model_catalog(endpoint)?;
|
||||
pub fn test_chat(endpoint: &AiEndpointConfig, model: &str) -> EngineResult<()> {
|
||||
validate_endpoint_access(endpoint)?;
|
||||
if model.trim().is_empty() {
|
||||
return Err(EngineError::Validation("model is required".to_string()));
|
||||
}
|
||||
let payload = json!({
|
||||
"model": model.trim(),
|
||||
"messages": [{"role": "user", "content": "Reply with OK."}],
|
||||
"max_tokens": 8,
|
||||
"stream": false,
|
||||
});
|
||||
with_auth(
|
||||
build_http_client()?
|
||||
.post(chat_completions_url(&endpoint.url))
|
||||
.json(&payload),
|
||||
endpoint,
|
||||
)
|
||||
.send()?
|
||||
.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -433,17 +525,50 @@ fn load_endpoint(conn: &Connection, kind: AiEndpointKind) -> EngineResult<Stored
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_endpoint_config(endpoint: &AiEndpointConfig) -> EngineResult<()> {
|
||||
if endpoint.url.trim().is_empty() {
|
||||
return Err(EngineError::Validation(
|
||||
"endpoint url is required".to_string(),
|
||||
));
|
||||
fn load_mode_settings(
|
||||
conn: &Connection,
|
||||
kind: AiEndpointKind,
|
||||
legacy_chat_model: Option<&str>,
|
||||
legacy_title_model: Option<&str>,
|
||||
legacy_image_model: Option<&str>,
|
||||
) -> EngineResult<AiModeSettings> {
|
||||
let mut endpoint = load_endpoint(conn, kind)?;
|
||||
if endpoint.model.trim().is_empty() {
|
||||
endpoint.model = legacy_chat_model.unwrap_or_default().to_string();
|
||||
}
|
||||
Ok(AiModeSettings {
|
||||
endpoint,
|
||||
title_model: get_optional_setting(conn, &endpoint_setting_key(kind, "title_model"))?
|
||||
.or_else(|| legacy_title_model.map(str::to_string)),
|
||||
image_model: get_optional_setting(conn, &endpoint_setting_key(kind, "image_model"))?
|
||||
.or_else(|| legacy_image_model.map(str::to_string)),
|
||||
chat_supports_tools: get_optional_bool_setting(
|
||||
conn,
|
||||
&endpoint_setting_key(kind, "chat_supports_tools"),
|
||||
)?,
|
||||
image_supports_vision: get_optional_bool_setting(
|
||||
conn,
|
||||
&endpoint_setting_key(kind, "image_supports_vision"),
|
||||
)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_endpoint_config(endpoint: &AiEndpointConfig) -> EngineResult<()> {
|
||||
validate_endpoint_access(endpoint)?;
|
||||
if endpoint.model.trim().is_empty() {
|
||||
return Err(EngineError::Validation(
|
||||
"endpoint model is required".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_endpoint_access(endpoint: &AiEndpointConfig) -> EngineResult<()> {
|
||||
if endpoint.url.trim().is_empty() {
|
||||
return Err(EngineError::Validation(
|
||||
"endpoint url is required".to_string(),
|
||||
));
|
||||
}
|
||||
if endpoint.kind == AiEndpointKind::Online
|
||||
&& endpoint
|
||||
.api_key
|
||||
@@ -463,17 +588,23 @@ fn select_model(
|
||||
endpoint: &AiEndpointConfig,
|
||||
operation: &OneShotOperation,
|
||||
) -> EngineResult<String> {
|
||||
let active = settings.active();
|
||||
let selected = match operation {
|
||||
OneShotOperation::AnalyzeImage => settings.image_model.as_ref(),
|
||||
OneShotOperation::AnalyzeImage => {
|
||||
if active.image_supports_vision == Some(false) {
|
||||
return Err(EngineError::Validation(
|
||||
"AI unavailable - selected image model is not configured for vision"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
active.image_model.as_ref()
|
||||
}
|
||||
OneShotOperation::AnalyzeTaxonomy
|
||||
| OneShotOperation::MapImportTaxonomy
|
||||
| OneShotOperation::AnalyzePost
|
||||
| OneShotOperation::DetectLanguage
|
||||
| OneShotOperation::TranslatePost { .. }
|
||||
| OneShotOperation::TranslateMedia { .. } => settings
|
||||
.title_model
|
||||
.as_ref()
|
||||
.or(settings.default_model.as_ref()),
|
||||
| OneShotOperation::TranslateMedia { .. } => active.title_model.as_ref(),
|
||||
}
|
||||
.filter(|model| !model.trim().is_empty())
|
||||
.cloned()
|
||||
@@ -746,6 +877,22 @@ fn set_optional_setting(
|
||||
set_setting(conn, key, value.unwrap_or(""), updated_at)
|
||||
}
|
||||
|
||||
fn set_optional_bool_setting(
|
||||
conn: &Connection,
|
||||
key: &str,
|
||||
value: Option<bool>,
|
||||
updated_at: i64,
|
||||
) -> EngineResult<()> {
|
||||
set_setting(
|
||||
conn,
|
||||
key,
|
||||
value
|
||||
.map(|value| if value { "true" } else { "false" })
|
||||
.unwrap_or(""),
|
||||
updated_at,
|
||||
)
|
||||
}
|
||||
|
||||
fn get_optional_setting(conn: &Connection, key: &str) -> EngineResult<Option<String>> {
|
||||
match setting::get_setting_by_key(conn, key) {
|
||||
Ok(setting) if setting.value.trim().is_empty() => Ok(None),
|
||||
@@ -755,6 +902,10 @@ fn get_optional_setting(conn: &Connection, key: &str) -> EngineResult<Option<Str
|
||||
}
|
||||
}
|
||||
|
||||
fn get_optional_bool_setting(conn: &Connection, key: &str) -> EngineResult<Option<bool>> {
|
||||
Ok(get_optional_setting(conn, key)?.map(|value| value == "true"))
|
||||
}
|
||||
|
||||
fn models_url(base_url: &str) -> String {
|
||||
join_openai_path(base_url, "models")
|
||||
}
|
||||
@@ -808,9 +959,9 @@ mod tests {
|
||||
let db = setup();
|
||||
let settings = load_ai_settings(db.conn(), false).unwrap();
|
||||
assert!(!settings.offline_mode);
|
||||
assert!(settings.online_endpoint.url.is_empty());
|
||||
assert!(settings.airplane_endpoint.url.is_empty());
|
||||
assert!(settings.default_model.is_none());
|
||||
assert!(settings.online.endpoint.url.is_empty());
|
||||
assert!(settings.airplane.endpoint.url.is_empty());
|
||||
assert!(settings.online.title_model.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -859,24 +1010,125 @@ mod tests {
|
||||
let server = spawn_test_server(
|
||||
|request| {
|
||||
assert!(request.starts_with("GET /v1/models HTTP/1.1"));
|
||||
assert!(
|
||||
request.contains("authorization: Bearer dummy")
|
||||
|| request.contains("Authorization: Bearer dummy")
|
||||
);
|
||||
http_ok(
|
||||
r#"{"data":[{"id":"gpt-4.1-mini","name":"GPT 4.1 mini","context_window":128000,"max_output_tokens":8192,"modalities":["text"]},{"id":"gpt-4.1","modalities":["text","vision"]}]}"#,
|
||||
r#"{"data":[{"id":"gpt-4.1-mini","name":"GPT 4.1 mini","context_window":128000,"max_output_tokens":8192,"supports_tools":true,"modalities":["text"]},{"id":"gpt-4.1","modalities":["text","vision"]}]}"#,
|
||||
)
|
||||
},
|
||||
1,
|
||||
);
|
||||
let models = refresh_model_catalog(&AiEndpointConfig {
|
||||
kind: AiEndpointKind::Airplane,
|
||||
kind: AiEndpointKind::Online,
|
||||
url: server,
|
||||
model: "gpt-4.1-mini".to_string(),
|
||||
api_key: None,
|
||||
model: String::new(),
|
||||
api_key: Some("dummy".to_string()),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(models.len(), 2);
|
||||
assert_eq!(models[0].name, "GPT 4.1 mini");
|
||||
assert!(models[0].supports_tools);
|
||||
assert!(models[1].supports_vision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_preferences_are_independent_per_endpoint() {
|
||||
let db = setup();
|
||||
save_model_preferences(
|
||||
db.conn(),
|
||||
AiEndpointKind::Online,
|
||||
Some("online-title"),
|
||||
Some("online-image"),
|
||||
Some(true),
|
||||
Some(true),
|
||||
)
|
||||
.unwrap();
|
||||
save_model_preferences(
|
||||
db.conn(),
|
||||
AiEndpointKind::Airplane,
|
||||
Some("local-title"),
|
||||
Some("local-image"),
|
||||
Some(false),
|
||||
Some(true),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let settings = load_ai_settings(db.conn(), false).unwrap();
|
||||
assert_eq!(settings.online.title_model.as_deref(), Some("online-title"));
|
||||
assert_eq!(
|
||||
settings.airplane.title_model.as_deref(),
|
||||
Some("local-title")
|
||||
);
|
||||
assert_eq!(settings.online.chat_supports_tools, Some(true));
|
||||
assert_eq!(settings.airplane.chat_supports_tools, Some(false));
|
||||
assert_eq!(settings.online.image_supports_vision, Some(true));
|
||||
assert_eq!(settings.airplane.image_supports_vision, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chat_sends_the_selected_model() {
|
||||
let server = spawn_test_server(
|
||||
|request| {
|
||||
assert!(request.starts_with("POST /v1/chat/completions HTTP/1.1"));
|
||||
assert!(request.contains(r#""model":"qwen-9b""#));
|
||||
assert!(
|
||||
request.contains("authorization: Bearer dummy")
|
||||
|| request.contains("Authorization: Bearer dummy")
|
||||
);
|
||||
http_ok(r#"{"choices":[{"message":{"content":"OK"}}]}"#)
|
||||
},
|
||||
1,
|
||||
);
|
||||
|
||||
test_chat(
|
||||
&AiEndpointConfig {
|
||||
kind: AiEndpointKind::Airplane,
|
||||
url: server,
|
||||
model: String::new(),
|
||||
api_key: Some("dummy".to_string()),
|
||||
},
|
||||
"qwen-9b",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_vision_override_blocks_image_requests() {
|
||||
let db = setup();
|
||||
save_endpoint(
|
||||
db.conn(),
|
||||
&AiEndpointConfig {
|
||||
kind: AiEndpointKind::Airplane,
|
||||
url: "http://localhost:11434/v1".to_string(),
|
||||
model: "qwen".to_string(),
|
||||
api_key: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save_model_preferences(
|
||||
db.conn(),
|
||||
AiEndpointKind::Airplane,
|
||||
None,
|
||||
Some("qwen-vl"),
|
||||
Some(false),
|
||||
Some(false),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = run_one_shot(
|
||||
db.conn(),
|
||||
true,
|
||||
&OneShotRequest {
|
||||
operation: OneShotOperation::AnalyzeImage,
|
||||
content: json!({"image_data_url": "data:image/jpeg;base64,abc123"}),
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("not configured for vision"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "touches system keychain; run explicitly when validating keychain integration"]
|
||||
fn run_one_shot_uses_active_endpoint_and_parses_response() {
|
||||
@@ -909,7 +1161,15 @@ mod tests {
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save_model_preferences(db.conn(), None, Some("gpt-4.1-mini"), None, "").unwrap();
|
||||
save_model_preferences(
|
||||
db.conn(),
|
||||
AiEndpointKind::Online,
|
||||
Some("gpt-4.1-mini"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (response, usage) = run_one_shot(
|
||||
db.conn(),
|
||||
|
||||
@@ -84,6 +84,7 @@ pub struct ChatSendOptions {
|
||||
pub max_output_tokens: u64,
|
||||
pub max_tool_rounds: usize,
|
||||
pub enable_tools: bool,
|
||||
pub model_supports_tools: Option<bool>,
|
||||
pub event_handler: Option<Arc<dyn Fn(ChatEvent) + Send + Sync>>,
|
||||
}
|
||||
|
||||
@@ -96,6 +97,7 @@ impl Default for ChatSendOptions {
|
||||
max_output_tokens: DEFAULT_OUTPUT_TOKENS,
|
||||
max_tool_rounds: MAX_TOOL_ROUNDS,
|
||||
enable_tools: true,
|
||||
model_supports_tools: None,
|
||||
event_handler: None,
|
||||
}
|
||||
}
|
||||
@@ -467,7 +469,7 @@ pub fn send_chat_message(
|
||||
offline_mode: bool,
|
||||
conversation_id: &str,
|
||||
content: &str,
|
||||
options: ChatSendOptions,
|
||||
mut options: ChatSendOptions,
|
||||
) -> EngineResult<ChatTurnResult> {
|
||||
let content = content.trim();
|
||||
if content.is_empty() {
|
||||
@@ -480,6 +482,11 @@ pub fn send_chat_message(
|
||||
Some(endpoint) => validate_runtime_endpoint(endpoint)?,
|
||||
None => ai::active_endpoint(conn, offline_mode)?,
|
||||
};
|
||||
if options.model_supports_tools.is_none() {
|
||||
options.model_supports_tools = ai::load_ai_settings(conn, offline_mode)?
|
||||
.active()
|
||||
.chat_supports_tools;
|
||||
}
|
||||
let model = options
|
||||
.model
|
||||
.clone()
|
||||
@@ -568,7 +575,11 @@ fn run_turns(
|
||||
) -> EngineResult<ChatTurnResult> {
|
||||
let mut total_usage = TokenUsage::default();
|
||||
let max_rounds = options.max_tool_rounds.min(MAX_TOOL_ROUNDS);
|
||||
let supports_tools = options.enable_tools && chat_tools::model_supports_tools(conn, model)?;
|
||||
let supports_tools = options.enable_tools
|
||||
&& match options.model_supports_tools {
|
||||
Some(supports_tools) => supports_tools,
|
||||
None => chat_tools::model_supports_tools(conn, model)?,
|
||||
};
|
||||
let catalog_model = list_models(conn)?
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.id == model);
|
||||
@@ -841,7 +852,28 @@ fn request_completion(
|
||||
if let Some(api_key) = endpoint.api_key.as_deref() {
|
||||
request = request.bearer_auth(api_key);
|
||||
}
|
||||
let mut response = request.send()?.error_for_status()?;
|
||||
let mut response = request.send()?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().unwrap_or_default();
|
||||
let detail = serde_json::from_str::<Value>(&body)
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
value
|
||||
.pointer("/error/message")
|
||||
.or_else(|| value.get("error"))
|
||||
.or_else(|| value.get("message"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
})
|
||||
.unwrap_or_else(|| body.trim().to_string());
|
||||
let detail = (!detail.is_empty())
|
||||
.then_some(format!(": {detail}"))
|
||||
.unwrap_or_default();
|
||||
return Err(EngineError::Parse(format!(
|
||||
"AI provider returned {status}{detail}"
|
||||
)));
|
||||
}
|
||||
let is_stream = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
|
||||
@@ -7,7 +7,9 @@ use crate::util::now_unix_ms;
|
||||
|
||||
pub const UI_LANGUAGE_KEY: &str = "ui.language";
|
||||
pub const ONLINE_API_KEY: &str = "ai.endpoint.online.api_key";
|
||||
pub const AIRPLANE_API_KEY: &str = "ai.endpoint.airplane.api_key";
|
||||
const ONLINE_API_KEY_CONFIGURED: &str = "ai.endpoint.online.api_key_configured";
|
||||
const AIRPLANE_API_KEY_CONFIGURED: &str = "ai.endpoint.airplane.api_key_configured";
|
||||
const DEFAULTS: &[(&str, &str)] = &[
|
||||
("editor.default_mode", "markdown"),
|
||||
("editor.diff_view_style", "inline"),
|
||||
@@ -15,9 +17,18 @@ const DEFAULTS: &[(&str, &str)] = &[
|
||||
("editor.hide_unchanged_regions", "false"),
|
||||
("ai.endpoint.online.url", ""),
|
||||
("ai.endpoint.online.model", ""),
|
||||
("ai.endpoint.online.title_model", ""),
|
||||
("ai.endpoint.online.image_model", ""),
|
||||
("ai.endpoint.online.chat_supports_tools", ""),
|
||||
("ai.endpoint.online.image_supports_vision", ""),
|
||||
(ONLINE_API_KEY, ""),
|
||||
("ai.endpoint.airplane.url", ""),
|
||||
("ai.endpoint.airplane.model", ""),
|
||||
("ai.endpoint.airplane.title_model", ""),
|
||||
("ai.endpoint.airplane.image_model", ""),
|
||||
("ai.endpoint.airplane.chat_supports_tools", ""),
|
||||
("ai.endpoint.airplane.image_supports_vision", ""),
|
||||
(AIRPLANE_API_KEY, ""),
|
||||
("ai.default_model", ""),
|
||||
("ai.title_model", ""),
|
||||
("ai.image_model", ""),
|
||||
@@ -37,9 +48,14 @@ pub fn get(conn: &Connection, key: &str) -> EngineResult<Option<String>> {
|
||||
}
|
||||
|
||||
pub fn get_effective(conn: &Connection, key: &str) -> EngineResult<Option<String>> {
|
||||
if key == ONLINE_API_KEY {
|
||||
if key == ONLINE_API_KEY || key == AIRPLANE_API_KEY {
|
||||
let configured_key = if key == ONLINE_API_KEY {
|
||||
ONLINE_API_KEY_CONFIGURED
|
||||
} else {
|
||||
AIRPLANE_API_KEY_CONFIGURED
|
||||
};
|
||||
return Ok(Some(
|
||||
get(conn, ONLINE_API_KEY_CONFIGURED)?
|
||||
get(conn, configured_key)?
|
||||
.filter(|value| value == "true")
|
||||
.map_or_else(String::new, |_| "configured".to_string()),
|
||||
));
|
||||
@@ -69,6 +85,8 @@ pub fn list_effective(conn: &Connection) -> EngineResult<BTreeMap<String, String
|
||||
if !value.key.starts_with("app.")
|
||||
&& value.key != ONLINE_API_KEY
|
||||
&& value.key != ONLINE_API_KEY_CONFIGURED
|
||||
&& value.key != AIRPLANE_API_KEY
|
||||
&& value.key != AIRPLANE_API_KEY_CONFIGURED
|
||||
{
|
||||
values.insert(value.key, value.value);
|
||||
}
|
||||
@@ -76,6 +94,9 @@ pub fn list_effective(conn: &Connection) -> EngineResult<BTreeMap<String, String
|
||||
if get(conn, ONLINE_API_KEY_CONFIGURED)?.as_deref() == Some("true") {
|
||||
values.insert(ONLINE_API_KEY.to_string(), "configured".to_string());
|
||||
}
|
||||
if get(conn, AIRPLANE_API_KEY_CONFIGURED)?.as_deref() == Some("true") {
|
||||
values.insert(AIRPLANE_API_KEY.to_string(), "configured".to_string());
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
|
||||
@@ -233,6 +233,33 @@ fn malformed_stream_and_provider_error_keep_reopenable_user_turn() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_success_response_preserves_provider_error_detail() {
|
||||
let (_root, db, project_id, data_dir) = setup();
|
||||
let conversation = chat::create_conversation(db.conn(), Some("plain-model")).unwrap();
|
||||
let (url, server) = serve(vec![MockResponse::status(
|
||||
400,
|
||||
"application/json",
|
||||
"{\"error\":{\"message\":\"tokenizer.chat_template is not set\"}}",
|
||||
)]);
|
||||
|
||||
let error = chat::send_chat_message(
|
||||
db.conn(),
|
||||
&data_dir,
|
||||
&project_id,
|
||||
false,
|
||||
&conversation.id,
|
||||
"Hello",
|
||||
options(url, |_| {}),
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
server.join().unwrap();
|
||||
|
||||
assert!(error.contains("400"));
|
||||
assert!(error.contains("tokenizer.chat_template is not set"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_persists_only_received_content_and_never_runs_later_work() {
|
||||
let (_root, db, project_id, data_dir) = setup();
|
||||
|
||||
@@ -1871,63 +1871,100 @@ impl TuiApp {
|
||||
),
|
||||
],
|
||||
SettingSection::Ai => vec![
|
||||
(
|
||||
"Airplane mode",
|
||||
"ai.airplane_mode",
|
||||
FieldKind::Bool,
|
||||
self.airplane.to_string(),
|
||||
),
|
||||
(
|
||||
"Online endpoint URL",
|
||||
"ai.endpoint.online.url",
|
||||
FieldKind::Text,
|
||||
ai.online_endpoint.url,
|
||||
ai.online.endpoint.url,
|
||||
),
|
||||
(
|
||||
"Online endpoint model",
|
||||
"Online chat model",
|
||||
"ai.endpoint.online.model",
|
||||
FieldKind::Text,
|
||||
ai.online_endpoint.model,
|
||||
ai.online.endpoint.model,
|
||||
),
|
||||
(
|
||||
"Online API key",
|
||||
"ai.endpoint.online.api_key",
|
||||
FieldKind::ReadOnly,
|
||||
if ai.online_endpoint.api_key_configured {
|
||||
if ai.online.endpoint.api_key_configured {
|
||||
"Configured in the system keychain".into()
|
||||
} else {
|
||||
"Configure in the desktop application".into()
|
||||
},
|
||||
),
|
||||
(
|
||||
"Online title model",
|
||||
"ai.endpoint.online.title_model",
|
||||
FieldKind::Text,
|
||||
ai.online.title_model.unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"Online image model",
|
||||
"ai.endpoint.online.image_model",
|
||||
FieldKind::Text,
|
||||
ai.online.image_model.unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"Online chat supports tools",
|
||||
"ai.endpoint.online.chat_supports_tools",
|
||||
FieldKind::Bool,
|
||||
ai.online.chat_supports_tools.unwrap_or(false).to_string(),
|
||||
),
|
||||
(
|
||||
"Online image supports vision",
|
||||
"ai.endpoint.online.image_supports_vision",
|
||||
FieldKind::Bool,
|
||||
ai.online.image_supports_vision.unwrap_or(false).to_string(),
|
||||
),
|
||||
(
|
||||
"Airplane endpoint URL",
|
||||
"ai.endpoint.airplane.url",
|
||||
FieldKind::Text,
|
||||
ai.airplane_endpoint.url,
|
||||
ai.airplane.endpoint.url,
|
||||
),
|
||||
(
|
||||
"Airplane endpoint model",
|
||||
"Airplane chat model",
|
||||
"ai.endpoint.airplane.model",
|
||||
FieldKind::Text,
|
||||
ai.airplane_endpoint.model,
|
||||
ai.airplane.endpoint.model,
|
||||
),
|
||||
(
|
||||
"Default model",
|
||||
"ai.default_model",
|
||||
FieldKind::Text,
|
||||
ai.default_model.unwrap_or_default(),
|
||||
"Airplane API key",
|
||||
"ai.endpoint.airplane.api_key",
|
||||
FieldKind::ReadOnly,
|
||||
if ai.airplane.endpoint.api_key_configured {
|
||||
"Configured in the system keychain".into()
|
||||
} else {
|
||||
"Configure in the desktop application".into()
|
||||
},
|
||||
),
|
||||
(
|
||||
"Title model",
|
||||
"ai.title_model",
|
||||
"Airplane title model",
|
||||
"ai.endpoint.airplane.title_model",
|
||||
FieldKind::Text,
|
||||
ai.title_model.unwrap_or_default(),
|
||||
ai.airplane.title_model.unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"Image model",
|
||||
"ai.image_model",
|
||||
"Airplane image model",
|
||||
"ai.endpoint.airplane.image_model",
|
||||
FieldKind::Text,
|
||||
ai.image_model.unwrap_or_default(),
|
||||
ai.airplane.image_model.unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"Airplane chat supports tools",
|
||||
"ai.endpoint.airplane.chat_supports_tools",
|
||||
FieldKind::Bool,
|
||||
ai.airplane.chat_supports_tools.unwrap_or(false).to_string(),
|
||||
),
|
||||
(
|
||||
"Airplane image supports vision",
|
||||
"ai.endpoint.airplane.image_supports_vision",
|
||||
FieldKind::Bool,
|
||||
ai.airplane
|
||||
.image_supports_vision
|
||||
.unwrap_or(false)
|
||||
.to_string(),
|
||||
),
|
||||
(
|
||||
"System prompt",
|
||||
@@ -2056,14 +2093,8 @@ impl TuiApp {
|
||||
|| key.starts_with("publishing.")
|
||||
|| key.starts_with("ai.endpoint.")
|
||||
|| key.starts_with("mcp.agent.")
|
||||
|| matches!(
|
||||
key,
|
||||
"ai.airplane_mode"
|
||||
| "ai.default_model"
|
||||
| "ai.title_model"
|
||||
| "ai.image_model"
|
||||
| "ai.system_prompt"
|
||||
) {
|
||||
|| key == "ai.system_prompt"
|
||||
{
|
||||
fallback
|
||||
} else {
|
||||
engine::settings::get_effective(db.conn(), key)?.unwrap_or(fallback)
|
||||
@@ -2304,25 +2335,27 @@ impl TuiApp {
|
||||
kind,
|
||||
url: url.into(),
|
||||
model: model.into(),
|
||||
api_key: if kind == AiEndpointKind::Online {
|
||||
engine::ai::load_endpoint_api_key(kind)?
|
||||
} else {
|
||||
None
|
||||
},
|
||||
api_key: engine::ai::load_endpoint_api_key(kind)?,
|
||||
},
|
||||
)?;
|
||||
}
|
||||
let prefix = format!("ai.endpoint.{}", kind.as_str());
|
||||
engine::ai::save_model_preferences(
|
||||
db.conn(),
|
||||
kind,
|
||||
optional_str(self.setting_value(&format!("{prefix}.title_model"))),
|
||||
optional_str(self.setting_value(&format!("{prefix}.image_model"))),
|
||||
Some(
|
||||
self.setting_value(&format!("{prefix}.chat_supports_tools"))
|
||||
.eq_ignore_ascii_case("true"),
|
||||
),
|
||||
Some(
|
||||
self.setting_value(&format!("{prefix}.image_supports_vision"))
|
||||
.eq_ignore_ascii_case("true"),
|
||||
),
|
||||
)?;
|
||||
}
|
||||
engine::ai::save_model_preferences(
|
||||
db.conn(),
|
||||
optional_str(self.setting_value("ai.default_model")),
|
||||
optional_str(self.setting_value("ai.title_model")),
|
||||
optional_str(self.setting_value("ai.image_model")),
|
||||
self.setting_value("ai.system_prompt"),
|
||||
)?;
|
||||
self.airplane = self
|
||||
.setting_value("ai.airplane_mode")
|
||||
.eq_ignore_ascii_case("true");
|
||||
engine::ai::save_system_prompt(db.conn(), self.setting_value("ai.system_prompt"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2496,15 +2529,20 @@ fn setting_field_label(locale: UiLocale, key: &str, fallback: &str) -> String {
|
||||
"meta.blogmark_category" => "settings.blogmarkCategory",
|
||||
"meta.categories" => "editor.categories",
|
||||
"meta.category_editing" => "tui.categoryEditing",
|
||||
"ai.airplane_mode" => "settings.offlineMode",
|
||||
"ai.endpoint.online.url" => "settings.onlineEndpointUrl",
|
||||
"ai.endpoint.online.model" => "settings.onlineEndpointModel",
|
||||
"ai.endpoint.online.model" => "settings.onlineChatModel",
|
||||
"ai.endpoint.online.api_key" => "settings.onlineApiKey",
|
||||
"ai.endpoint.online.title_model" => "settings.onlineTitleModel",
|
||||
"ai.endpoint.online.image_model" => "settings.onlineImageModel",
|
||||
"ai.endpoint.online.chat_supports_tools" => "settings.onlineModelSupportsTools",
|
||||
"ai.endpoint.online.image_supports_vision" => "settings.onlineModelSupportsVision",
|
||||
"ai.endpoint.airplane.url" => "settings.airplaneEndpointUrl",
|
||||
"ai.endpoint.airplane.model" => "settings.airplaneEndpointModel",
|
||||
"ai.default_model" => "settings.defaultModel",
|
||||
"ai.title_model" => "settings.titleModel",
|
||||
"ai.image_model" => "settings.imageAnalysisModel",
|
||||
"ai.endpoint.airplane.model" => "settings.airplaneChatModel",
|
||||
"ai.endpoint.airplane.api_key" => "settings.airplaneApiKey",
|
||||
"ai.endpoint.airplane.title_model" => "settings.airplaneTitleModel",
|
||||
"ai.endpoint.airplane.image_model" => "settings.airplaneImageModel",
|
||||
"ai.endpoint.airplane.chat_supports_tools" => "settings.airplaneModelSupportsTools",
|
||||
"ai.endpoint.airplane.image_supports_vision" => "settings.airplaneModelSupportsVision",
|
||||
"ai.system_prompt" => "settings.systemPrompt",
|
||||
"technology.runtime" => "tui.settingRuntime",
|
||||
"meta.semantic_similarity_enabled" => "settings.semanticSimilarityEnabled",
|
||||
@@ -4083,16 +4121,9 @@ mod tests {
|
||||
.find(|field| field.key == "ai.endpoint.airplane.model")
|
||||
.unwrap()
|
||||
.value = "local-model".into();
|
||||
remote
|
||||
.settings_fields
|
||||
.iter_mut()
|
||||
.find(|field| field.key == "ai.default_model")
|
||||
.unwrap()
|
||||
.value = "local-model".into();
|
||||
remote.save_settings().unwrap();
|
||||
let ai = engine::ai::load_ai_settings(fixture.db().conn(), false).unwrap();
|
||||
assert_eq!(ai.airplane_endpoint.model, "local-model");
|
||||
assert_eq!(ai.default_model.as_deref(), Some("local-model"));
|
||||
assert_eq!(ai.airplane.endpoint.model, "local-model");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -47,7 +47,8 @@ use crate::views::{
|
||||
post_editor::{LinkedMediaItem, PostEditorMsg, PostEditorState, ResolvedPostLink},
|
||||
script_editor::{ScriptEditorMsg, ScriptEditorState},
|
||||
settings_view::{
|
||||
AiModelOption, SettingsCategoryRow, SettingsMsg, SettingsViewState, default_category_rows,
|
||||
AiModeViewState, AiModelOption, SettingsCategoryRow, SettingsMsg, SettingsViewState,
|
||||
default_category_rows,
|
||||
},
|
||||
site_validation::SiteValidationState,
|
||||
tags_view::{self, TagsMsg, TagsSection, TagsViewState},
|
||||
@@ -1501,13 +1502,14 @@ impl BdsApp {
|
||||
conversation_id,
|
||||
result,
|
||||
} => {
|
||||
let locale = self.ui_locale;
|
||||
if let Some(state) = self.chat_editors.get_mut(&conversation_id) {
|
||||
state.streaming = false;
|
||||
state.clear_streaming();
|
||||
state.active_tool = None;
|
||||
match result {
|
||||
Ok(_) => state.error = None,
|
||||
Err(error) => state.error = Some(error),
|
||||
Err(error) => state.error = Some(localize_chat_error(locale, &error)),
|
||||
}
|
||||
if let Some(db) = &self.db {
|
||||
state.set_messages(
|
||||
@@ -2572,6 +2574,22 @@ impl BdsApp {
|
||||
// ── Settings ──
|
||||
Message::SetOfflineMode(mode) => {
|
||||
self.offline_mode = mode;
|
||||
let models = self.chat_model_options();
|
||||
let selected = self.db.as_ref().and_then(|db| {
|
||||
ai::load_ai_settings(db.conn(), mode)
|
||||
.ok()
|
||||
.map(|settings| settings.active().endpoint.model.clone())
|
||||
.filter(|model| !model.trim().is_empty())
|
||||
});
|
||||
for (id, state) in &mut self.chat_editors {
|
||||
state.model_options = models.clone();
|
||||
if let Some(model) = selected.as_ref() {
|
||||
state.conversation.model = Some(model.clone());
|
||||
if let Some(db) = &self.db {
|
||||
let _ = engine::chat::set_conversation_model(db.conn(), id, model);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.sync_menu_state();
|
||||
Task::none()
|
||||
}
|
||||
@@ -3546,62 +3564,33 @@ impl BdsApp {
|
||||
|
||||
fn chat_model_options(&self) -> Vec<ChatModelChoice> {
|
||||
let mut models = self
|
||||
.db
|
||||
.settings_state
|
||||
.as_ref()
|
||||
.and_then(|db| engine::chat::list_models(db.conn()).ok())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|model| {
|
||||
let context = model.context_window.div_ceil(1_000).to_string();
|
||||
let output = model.max_output_tokens.div_ceil(1_000).to_string();
|
||||
let capability = t(
|
||||
self.ui_locale,
|
||||
if model.supports_tools {
|
||||
"chat.model.tools"
|
||||
} else {
|
||||
"chat.model.textOnly"
|
||||
},
|
||||
);
|
||||
ChatModelChoice {
|
||||
id: model.id,
|
||||
label: tw(
|
||||
self.ui_locale,
|
||||
"chat.model.option",
|
||||
&[
|
||||
("provider", &model.provider),
|
||||
("name", &model.name),
|
||||
("context", &context),
|
||||
("output", &output),
|
||||
("capability", &capability),
|
||||
],
|
||||
),
|
||||
}
|
||||
.map(|state| {
|
||||
let mode = if self.offline_mode {
|
||||
&state.airplane_ai
|
||||
} else {
|
||||
&state.online_ai
|
||||
};
|
||||
mode.model_options
|
||||
.iter()
|
||||
.map(|model| ChatModelChoice {
|
||||
id: model.id.clone(),
|
||||
label: model.label.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
.unwrap_or_default();
|
||||
if let Some(db) = &self.db
|
||||
&& let Ok(settings) = ai::load_ai_settings(db.conn(), self.offline_mode)
|
||||
{
|
||||
if let Some(model) = settings.default_model
|
||||
&& !models.iter().any(|choice| choice.id == model)
|
||||
{
|
||||
let model = settings.active().endpoint.model.clone();
|
||||
if !model.trim().is_empty() && !models.iter().any(|choice| choice.id == model) {
|
||||
models.push(ChatModelChoice {
|
||||
id: model.clone(),
|
||||
label: model,
|
||||
});
|
||||
}
|
||||
let endpoint_model = if self.offline_mode {
|
||||
settings.airplane_endpoint.model
|
||||
} else {
|
||||
settings.online_endpoint.model
|
||||
};
|
||||
if !endpoint_model.trim().is_empty()
|
||||
&& !models.iter().any(|choice| choice.id == endpoint_model)
|
||||
{
|
||||
models.push(ChatModelChoice {
|
||||
id: endpoint_model.clone(),
|
||||
label: endpoint_model,
|
||||
});
|
||||
}
|
||||
}
|
||||
models.sort_by(|left, right| left.label.cmp(&right.label));
|
||||
models.dedup_by(|left, right| left.id == right.id);
|
||||
@@ -3610,10 +3599,17 @@ impl BdsApp {
|
||||
|
||||
fn create_chat_conversation(&mut self) -> Task<Message> {
|
||||
let model = self
|
||||
.chat_model_options()
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|choice| choice.id);
|
||||
.db
|
||||
.as_ref()
|
||||
.and_then(|db| ai::load_ai_settings(db.conn(), self.offline_mode).ok())
|
||||
.map(|settings| settings.active().endpoint.model.clone())
|
||||
.filter(|model| !model.trim().is_empty())
|
||||
.or_else(|| {
|
||||
self.chat_model_options()
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|choice| choice.id)
|
||||
});
|
||||
let title = model.as_deref().map_or_else(
|
||||
|| t(self.ui_locale, "chat.new"),
|
||||
|model| tw(self.ui_locale, "chat.newWithModel", &[("model", model)]),
|
||||
@@ -3735,6 +3731,7 @@ impl BdsApp {
|
||||
conversation_id,
|
||||
message,
|
||||
} => {
|
||||
let message = localize_chat_error(self.ui_locale, &message);
|
||||
if let Some(state) = self.chat_editors.get_mut(&conversation_id) {
|
||||
state.error = Some(message);
|
||||
}
|
||||
@@ -3939,15 +3936,15 @@ impl BdsApp {
|
||||
.iter()
|
||||
.any(|tab| tab.tab_type == TabType::Settings)
|
||||
{
|
||||
self.settings_state = None;
|
||||
if let Some(tab) = self
|
||||
.tabs
|
||||
.iter()
|
||||
.find(|tab| tab.tab_type == TabType::Settings)
|
||||
.cloned()
|
||||
{
|
||||
self.load_editor_for_tab(&tab);
|
||||
let active_section = self
|
||||
.settings_state
|
||||
.as_ref()
|
||||
.and_then(|state| state.active_section.clone());
|
||||
let mut state = self.hydrate_settings_state();
|
||||
if let Some(section) = active_section {
|
||||
state.focus_section(section);
|
||||
}
|
||||
self.settings_state = Some(state);
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -6822,28 +6819,8 @@ impl BdsApp {
|
||||
state.system_prompt = iced::widget::text_editor::Content::with_text(&setting.value);
|
||||
}
|
||||
if let Ok(ai_settings) = ai::load_ai_settings(db.conn(), self.offline_mode) {
|
||||
state.online_endpoint_url = ai_settings.online_endpoint.url;
|
||||
state.online_endpoint_model = ai_settings.online_endpoint.model;
|
||||
state.online_api_key_configured = ai_settings.online_endpoint.api_key_configured;
|
||||
if !state.online_endpoint_model.is_empty() {
|
||||
state.online_model_options = vec![AiModelOption {
|
||||
id: state.online_endpoint_model.clone(),
|
||||
label: state.online_endpoint_model.clone(),
|
||||
supports_vision: false,
|
||||
}];
|
||||
}
|
||||
state.airplane_endpoint_url = ai_settings.airplane_endpoint.url;
|
||||
state.airplane_endpoint_model = ai_settings.airplane_endpoint.model;
|
||||
if !state.airplane_endpoint_model.is_empty() {
|
||||
state.airplane_model_options = vec![AiModelOption {
|
||||
id: state.airplane_endpoint_model.clone(),
|
||||
label: state.airplane_endpoint_model.clone(),
|
||||
supports_vision: false,
|
||||
}];
|
||||
}
|
||||
state.default_model = ai_settings.default_model.unwrap_or_default();
|
||||
state.title_model = ai_settings.title_model.unwrap_or_default();
|
||||
state.image_model = ai_settings.image_model.unwrap_or_default();
|
||||
state.online_ai = Self::ai_mode_view_state(ai_settings.online);
|
||||
state.airplane_ai = Self::ai_mode_view_state(ai_settings.airplane);
|
||||
}
|
||||
state.mcp_enabled = engine::settings::get_effective(db.conn(), "mcp.http.enabled")
|
||||
.ok()
|
||||
@@ -6874,7 +6851,6 @@ impl BdsApp {
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
state.offline_mode = self.offline_mode;
|
||||
state
|
||||
}
|
||||
|
||||
@@ -7507,51 +7483,58 @@ impl BdsApp {
|
||||
state.ssh_username.clear();
|
||||
state.ssh_remote_path.clear();
|
||||
}
|
||||
SettingsMsg::OfflineModeChanged(b) => {
|
||||
state.offline_mode = b;
|
||||
return Task::done(Message::SetOfflineMode(b));
|
||||
SettingsMsg::AiEndpointUrlChanged(kind, value) => {
|
||||
Self::ai_mode_state_mut(state, kind).endpoint_url = value;
|
||||
}
|
||||
SettingsMsg::OnlineEndpointUrlChanged(value) => {
|
||||
state.online_endpoint_url = value;
|
||||
SettingsMsg::AiApiKeyChanged(kind, value) => {
|
||||
Self::ai_mode_state_mut(state, kind).api_key_input = value;
|
||||
}
|
||||
SettingsMsg::OnlineEndpointModelChanged(value) => {
|
||||
state.online_endpoint_model = value;
|
||||
SettingsMsg::AiChatModelChanged(kind, value) => {
|
||||
let mode = Self::ai_mode_state_mut(state, kind);
|
||||
mode.chat_supports_tools = mode
|
||||
.model_options
|
||||
.iter()
|
||||
.find(|option| option.id == value)
|
||||
.is_some_and(|option| option.supports_tools);
|
||||
mode.chat_model = value;
|
||||
}
|
||||
SettingsMsg::OnlineApiKeyChanged(value) => {
|
||||
state.online_api_key_input = value;
|
||||
SettingsMsg::AiTitleModelChanged(kind, value) => {
|
||||
Self::ai_mode_state_mut(state, kind).title_model = value;
|
||||
}
|
||||
SettingsMsg::AirplaneEndpointUrlChanged(value) => {
|
||||
state.airplane_endpoint_url = value;
|
||||
SettingsMsg::AiImageModelChanged(kind, value) => {
|
||||
let mode = Self::ai_mode_state_mut(state, kind);
|
||||
mode.image_supports_vision = mode
|
||||
.model_options
|
||||
.iter()
|
||||
.find(|option| option.id == value)
|
||||
.is_some_and(|option| option.supports_vision);
|
||||
mode.image_model = value;
|
||||
}
|
||||
SettingsMsg::AirplaneEndpointModelChanged(value) => {
|
||||
state.airplane_endpoint_model = value;
|
||||
SettingsMsg::AiToolsChanged(kind, value) => {
|
||||
Self::ai_mode_state_mut(state, kind).chat_supports_tools = value;
|
||||
}
|
||||
SettingsMsg::DefaultModelChanged(value) => {
|
||||
state.default_model = value;
|
||||
SettingsMsg::AiVisionChanged(kind, value) => {
|
||||
Self::ai_mode_state_mut(state, kind).image_supports_vision = value;
|
||||
}
|
||||
SettingsMsg::TitleModelChanged(value) => {
|
||||
state.title_model = value;
|
||||
}
|
||||
SettingsMsg::ImageModelChanged(value) => {
|
||||
state.image_model = value;
|
||||
}
|
||||
SettingsMsg::RefreshOnlineModels => {
|
||||
SettingsMsg::RefreshAiModels(kind) => {
|
||||
if let Some(db) = &self.db {
|
||||
match Self::refresh_ai_models(db, state, AiEndpointKind::Online) {
|
||||
Ok(()) => {
|
||||
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"))
|
||||
}
|
||||
Err(error) => self.notify_operation_failed("common.save", error),
|
||||
match Self::refresh_ai_models(db, state, kind) {
|
||||
Ok(()) => self.notify(
|
||||
ToastLevel::Success,
|
||||
&t(self.ui_locale, "settings.modelsLoaded"),
|
||||
),
|
||||
Err(error) => self.notify_operation_failed("settings.refreshModels", error),
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsMsg::RefreshAirplaneModels => {
|
||||
SettingsMsg::TestAi(kind) => {
|
||||
if let Some(db) = &self.db {
|
||||
match Self::refresh_ai_models(db, state, AiEndpointKind::Airplane) {
|
||||
Ok(()) => {
|
||||
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"))
|
||||
}
|
||||
Err(error) => self.notify_operation_failed("common.save", error),
|
||||
match Self::test_ai_settings(db, state, kind) {
|
||||
Ok(()) => self.notify(
|
||||
ToastLevel::Success,
|
||||
&t(self.ui_locale, "settings.testChatSuccess"),
|
||||
),
|
||||
Err(error) => self.notify_operation_failed("settings.testChat", error),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8160,37 +8143,14 @@ impl BdsApp {
|
||||
}
|
||||
}
|
||||
TabType::Settings if self.settings_state.is_none() => {
|
||||
let mut state = SettingsViewState::default();
|
||||
if let Some(ref project) = self.active_project {
|
||||
state.project_name = project.name.clone();
|
||||
state.project_description = iced::widget::text_editor::Content::with_text(
|
||||
&project.description.clone().unwrap_or_default(),
|
||||
);
|
||||
state.data_path = project.data_path.clone().unwrap_or_default();
|
||||
}
|
||||
if let Some(ref data_dir) = self.data_dir {
|
||||
if let Ok(meta) = engine::meta::read_project_json(data_dir) {
|
||||
state.public_url = meta.public_url.unwrap_or_default();
|
||||
state.default_author = meta.default_author.unwrap_or_default();
|
||||
state.max_posts_per_page = meta.max_posts_per_page.to_string();
|
||||
state.image_import_concurrency = meta.image_import_concurrency.to_string();
|
||||
}
|
||||
if let Ok(pub_prefs) = engine::meta::read_publishing_json(data_dir) {
|
||||
state.ssh_host = pub_prefs.ssh_host.unwrap_or_default();
|
||||
state.ssh_username = pub_prefs.ssh_user.unwrap_or_default();
|
||||
state.ssh_remote_path = pub_prefs.ssh_remote_path.unwrap_or_default();
|
||||
state.ssh_mode = format!("{:?}", pub_prefs.ssh_mode).to_lowercase();
|
||||
}
|
||||
}
|
||||
state.offline_mode = self.offline_mode;
|
||||
self.settings_state = Some(state);
|
||||
self.settings_state = Some(self.hydrate_settings_state());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_ai_models(
|
||||
db: &Database,
|
||||
_db: &Database,
|
||||
state: &mut SettingsViewState,
|
||||
kind: AiEndpointKind,
|
||||
) -> Result<(), String> {
|
||||
@@ -8201,92 +8161,122 @@ impl BdsApp {
|
||||
.map(|model| AiModelOption {
|
||||
id: model.id,
|
||||
label: model.name,
|
||||
supports_tools: model.supports_tools,
|
||||
supports_vision: model.supports_vision,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
match kind {
|
||||
AiEndpointKind::Online => state.online_model_options = options,
|
||||
AiEndpointKind::Airplane => state.airplane_model_options = options,
|
||||
Self::ai_mode_state_mut(state, kind).model_options = options;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn test_ai_settings(
|
||||
_db: &Database,
|
||||
state: &SettingsViewState,
|
||||
kind: AiEndpointKind,
|
||||
) -> Result<(), String> {
|
||||
let endpoint = Self::compose_ai_endpoint(state, kind)?;
|
||||
let mode = Self::ai_mode_state(state, kind);
|
||||
let models = [&mode.chat_model, &mode.title_model, &mode.image_model]
|
||||
.into_iter()
|
||||
.filter(|model| !model.trim().is_empty())
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
if models.is_empty() {
|
||||
return Err("select at least one model".to_string());
|
||||
}
|
||||
for model in models {
|
||||
ai::test_chat(&endpoint, model).map_err(|error| error.to_string())?;
|
||||
}
|
||||
let _ = db;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_ai_settings_state(db: &Database, state: &mut SettingsViewState) -> Result<(), String> {
|
||||
if Self::endpoint_has_configuration(state, AiEndpointKind::Online) {
|
||||
let online_endpoint = Self::compose_ai_endpoint(state, AiEndpointKind::Online)?;
|
||||
ai::test_endpoint(&online_endpoint).map_err(|error| error.to_string())?;
|
||||
ai::save_endpoint(db.conn(), &online_endpoint).map_err(|error| error.to_string())?;
|
||||
state.online_api_key_input.clear();
|
||||
state.online_api_key_configured = true;
|
||||
for kind in [AiEndpointKind::Online, AiEndpointKind::Airplane] {
|
||||
if Self::endpoint_has_configuration(state, kind) {
|
||||
let endpoint = Self::compose_ai_endpoint(state, kind)?;
|
||||
ai::save_endpoint(db.conn(), &endpoint).map_err(|error| error.to_string())?;
|
||||
}
|
||||
let mode = Self::ai_mode_state(state, kind);
|
||||
ai::save_model_preferences(
|
||||
db.conn(),
|
||||
kind,
|
||||
(!mode.title_model.trim().is_empty()).then_some(mode.title_model.as_str()),
|
||||
(!mode.image_model.trim().is_empty()).then_some(mode.image_model.as_str()),
|
||||
Some(mode.chat_supports_tools),
|
||||
Some(mode.image_supports_vision),
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
if Self::endpoint_has_configuration(state, AiEndpointKind::Airplane) {
|
||||
let airplane_endpoint = Self::compose_ai_endpoint(state, AiEndpointKind::Airplane)?;
|
||||
ai::test_endpoint(&airplane_endpoint).map_err(|error| error.to_string())?;
|
||||
ai::save_endpoint(db.conn(), &airplane_endpoint).map_err(|error| error.to_string())?;
|
||||
ai::save_system_prompt(db.conn(), &state.system_prompt.text())
|
||||
.map_err(|error| error.to_string())?;
|
||||
for kind in [AiEndpointKind::Online, AiEndpointKind::Airplane] {
|
||||
let mode = Self::ai_mode_state_mut(state, kind);
|
||||
if !mode.api_key_input.trim().is_empty() {
|
||||
mode.api_key_configured = true;
|
||||
}
|
||||
mode.api_key_input.clear();
|
||||
}
|
||||
ai::save_model_preferences(
|
||||
db.conn(),
|
||||
(!state.default_model.trim().is_empty()).then_some(state.default_model.as_str()),
|
||||
(!state.title_model.trim().is_empty()).then_some(state.title_model.as_str()),
|
||||
(!state.image_model.trim().is_empty()).then_some(state.image_model.as_str()),
|
||||
&state.system_prompt.text(),
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn endpoint_has_configuration(state: &SettingsViewState, kind: AiEndpointKind) -> bool {
|
||||
match kind {
|
||||
AiEndpointKind::Online => {
|
||||
!state.online_endpoint_url.trim().is_empty()
|
||||
|| !state.online_endpoint_model.trim().is_empty()
|
||||
|| !state.online_api_key_input.trim().is_empty()
|
||||
|| state.online_api_key_configured
|
||||
}
|
||||
AiEndpointKind::Airplane => {
|
||||
!state.airplane_endpoint_url.trim().is_empty()
|
||||
|| !state.airplane_endpoint_model.trim().is_empty()
|
||||
}
|
||||
}
|
||||
let mode = Self::ai_mode_state(state, kind);
|
||||
!mode.endpoint_url.trim().is_empty()
|
||||
|| !mode.chat_model.trim().is_empty()
|
||||
|| !mode.api_key_input.trim().is_empty()
|
||||
|| mode.api_key_configured
|
||||
}
|
||||
|
||||
fn compose_ai_endpoint(
|
||||
state: &SettingsViewState,
|
||||
kind: AiEndpointKind,
|
||||
) -> Result<AiEndpointConfig, String> {
|
||||
let (url, model, configured) = match kind {
|
||||
AiEndpointKind::Online => (
|
||||
state.online_endpoint_url.trim().to_string(),
|
||||
state.online_endpoint_model.trim().to_string(),
|
||||
state.online_api_key_configured,
|
||||
),
|
||||
AiEndpointKind::Airplane => (
|
||||
state.airplane_endpoint_url.trim().to_string(),
|
||||
state.airplane_endpoint_model.trim().to_string(),
|
||||
false,
|
||||
),
|
||||
};
|
||||
let api_key = if kind == AiEndpointKind::Online {
|
||||
let input = state.online_api_key_input.trim();
|
||||
if !input.is_empty() {
|
||||
Some(input.to_string())
|
||||
} else if configured {
|
||||
ai::load_endpoint_api_key(kind).map_err(|error| error.to_string())?
|
||||
} else {
|
||||
None
|
||||
}
|
||||
let mode = Self::ai_mode_state(state, kind);
|
||||
let input = mode.api_key_input.trim();
|
||||
let api_key = if !input.is_empty() {
|
||||
Some(input.to_string())
|
||||
} else if mode.api_key_configured {
|
||||
ai::load_endpoint_api_key(kind).map_err(|error| error.to_string())?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(AiEndpointConfig {
|
||||
kind,
|
||||
url,
|
||||
model,
|
||||
url: mode.endpoint_url.trim().to_string(),
|
||||
model: mode.chat_model.trim().to_string(),
|
||||
api_key,
|
||||
})
|
||||
}
|
||||
|
||||
fn ai_mode_state(state: &SettingsViewState, kind: AiEndpointKind) -> &AiModeViewState {
|
||||
match kind {
|
||||
AiEndpointKind::Online => &state.online_ai,
|
||||
AiEndpointKind::Airplane => &state.airplane_ai,
|
||||
}
|
||||
}
|
||||
|
||||
fn ai_mode_state_mut(
|
||||
state: &mut SettingsViewState,
|
||||
kind: AiEndpointKind,
|
||||
) -> &mut AiModeViewState {
|
||||
match kind {
|
||||
AiEndpointKind::Online => &mut state.online_ai,
|
||||
AiEndpointKind::Airplane => &mut state.airplane_ai,
|
||||
}
|
||||
}
|
||||
|
||||
fn ai_mode_view_state(settings: ai::AiModeSettings) -> AiModeViewState {
|
||||
AiModeViewState {
|
||||
endpoint_url: settings.endpoint.url,
|
||||
chat_model: settings.endpoint.model,
|
||||
title_model: settings.title_model.unwrap_or_default(),
|
||||
image_model: settings.image_model.unwrap_or_default(),
|
||||
api_key_configured: settings.endpoint.api_key_configured,
|
||||
chat_supports_tools: settings.chat_supports_tools.unwrap_or(false),
|
||||
image_supports_vision: settings.image_supports_vision.unwrap_or(false),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_preview_server(&mut self) -> Result<(), String> {
|
||||
let Some(project) = self.active_project.as_ref() else {
|
||||
return Err(t(self.ui_locale, "engine.generateSiteNoProject"));
|
||||
@@ -8995,6 +8985,16 @@ fn language_label(locale: UiLocale, code: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn localize_chat_error(locale: UiLocale, error: &str) -> String {
|
||||
if error.contains("AI unavailable - configure") {
|
||||
t(locale, "chat.unavailable.guidance")
|
||||
} else if let Some(detail) = error.strip_prefix("parse error: AI provider returned ") {
|
||||
format!("{} {detail}", t(locale, "chat.providerError"))
|
||||
} else {
|
||||
error.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_error_closes_connection(code: &str) -> bool {
|
||||
code == "connection_lost"
|
||||
}
|
||||
@@ -9003,10 +9003,11 @@ fn remote_error_closes_connection(code: &str) -> bool {
|
||||
mod tests {
|
||||
use super::{
|
||||
BdsApp, Message, POST_AUTO_SAVE_DELAY_MS, PersistedMediaState, PersistedPostState,
|
||||
PostStatus, SettingsMsg, month_abbreviation, persist_media_editor_state_impl,
|
||||
persist_post_editor_preview_state_impl, persist_post_editor_state_impl,
|
||||
remote_error_closes_connection, save_editor_settings_state_impl,
|
||||
save_script_editor_state_impl, save_template_editor_state_impl,
|
||||
PostStatus, SettingsMsg, localize_chat_error, month_abbreviation,
|
||||
persist_media_editor_state_impl, persist_post_editor_preview_state_impl,
|
||||
persist_post_editor_state_impl, remote_error_closes_connection,
|
||||
save_editor_settings_state_impl, save_script_editor_state_impl,
|
||||
save_template_editor_state_impl,
|
||||
};
|
||||
use crate::i18n::t;
|
||||
use crate::platform::menu::MenuAction;
|
||||
@@ -9020,7 +9021,7 @@ mod tests {
|
||||
use crate::views::modal;
|
||||
use crate::views::post_editor::{PostEditorMsg, PostEditorState};
|
||||
use crate::views::script_editor::{ScriptEditorMsg, ScriptEditorState};
|
||||
use crate::views::settings_view::SettingsViewState;
|
||||
use crate::views::settings_view::{AiModeViewState, SettingsSection, SettingsViewState};
|
||||
use crate::views::template_editor::TemplateEditorState;
|
||||
use bds_core::db::Database;
|
||||
use bds_core::db::fts::ensure_fts_tables;
|
||||
@@ -9029,7 +9030,7 @@ mod tests {
|
||||
use bds_core::engine::task::{TaskStatus, TaskStatus::*};
|
||||
use bds_core::engine::{ai, chat, media, menu, meta, post, script, template, wordpress_import};
|
||||
use bds_core::i18n::UiLocale;
|
||||
use bds_core::model::{ChatRole, Project, ScriptKind, TemplateKind};
|
||||
use bds_core::model::{ChatRole, DomainEvent, Project, ScriptKind, TemplateKind};
|
||||
use chrono::{Datelike, TimeZone};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
@@ -9922,8 +9923,11 @@ mod tests {
|
||||
fn save_ai_settings_allows_airplane_only_configuration() {
|
||||
let (db, _project, _tmp) = setup();
|
||||
let mut state = SettingsViewState {
|
||||
airplane_endpoint_url: spawn_models_server(),
|
||||
airplane_endpoint_model: "llama3.2".to_string(),
|
||||
airplane_ai: AiModeViewState {
|
||||
endpoint_url: spawn_models_server(),
|
||||
chat_model: "llama3.2".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
system_prompt: iced::widget::text_editor::Content::with_text("Use JSON only."),
|
||||
..SettingsViewState::default()
|
||||
};
|
||||
@@ -9931,13 +9935,86 @@ mod tests {
|
||||
BdsApp::save_ai_settings_state(&db, &mut state).unwrap();
|
||||
|
||||
let settings = ai::load_ai_settings(db.conn(), false).unwrap();
|
||||
assert!(settings.online_endpoint.url.is_empty());
|
||||
assert!(settings.online_endpoint.model.is_empty());
|
||||
assert_eq!(settings.airplane_endpoint.url, state.airplane_endpoint_url);
|
||||
assert_eq!(settings.airplane_endpoint.model, "llama3.2");
|
||||
assert!(settings.online.endpoint.url.is_empty());
|
||||
assert!(settings.online.endpoint.model.is_empty());
|
||||
assert_eq!(
|
||||
settings.airplane.endpoint.url,
|
||||
state.airplane_ai.endpoint_url
|
||||
);
|
||||
assert_eq!(settings.airplane.endpoint.model, "llama3.2");
|
||||
assert_eq!(settings.system_prompt.trim_end(), "Use JSON only.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_change_rehydrates_ai_configuration() {
|
||||
let (db, project, tmp) = setup();
|
||||
bds_core::engine::settings::set(
|
||||
db.conn(),
|
||||
"ai.endpoint.online.url",
|
||||
"http://127.0.0.1:9000/v1",
|
||||
)
|
||||
.unwrap();
|
||||
bds_core::engine::settings::set(
|
||||
db.conn(),
|
||||
"ai.endpoint.online.model",
|
||||
"mlx-community--gemma-4-12B-8bit",
|
||||
)
|
||||
.unwrap();
|
||||
bds_core::engine::settings::set(db.conn(), "ai.endpoint.online.api_key_configured", "true")
|
||||
.unwrap();
|
||||
let mut app = make_app(db, project, &tmp);
|
||||
app.tabs.push(Tab {
|
||||
id: "settings".to_string(),
|
||||
tab_type: TabType::Settings,
|
||||
title: "Settings".to_string(),
|
||||
is_transient: false,
|
||||
is_dirty: false,
|
||||
});
|
||||
let mut settings_state = SettingsViewState::default();
|
||||
settings_state.focus_section(SettingsSection::AI);
|
||||
app.settings_state = Some(settings_state);
|
||||
|
||||
app.handle_domain_event(DomainEvent::SettingsChanged {
|
||||
project_id: None,
|
||||
key: "ai.endpoint.online.url".to_string(),
|
||||
});
|
||||
|
||||
let state = app.settings_state.as_ref().unwrap();
|
||||
assert_eq!(state.online_ai.endpoint_url, "http://127.0.0.1:9000/v1");
|
||||
assert_eq!(
|
||||
state.online_ai.chat_model,
|
||||
"mlx-community--gemma-4-12B-8bit"
|
||||
);
|
||||
assert!(state.online_ai.api_key_configured);
|
||||
assert_eq!(state.active_section, Some(SettingsSection::AI));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_unavailable_chat_errors_are_localized() {
|
||||
let raw = "validation error: AI unavailable - configure online endpoint in Settings";
|
||||
for locale in UiLocale::all() {
|
||||
assert_eq!(
|
||||
localize_chat_error(*locale, raw),
|
||||
t(*locale, "chat.unavailable.guidance")
|
||||
);
|
||||
}
|
||||
let provider =
|
||||
"parse error: AI provider returned 400 Bad Request: tokenizer.chat_template is not set";
|
||||
for locale in UiLocale::all() {
|
||||
assert_eq!(
|
||||
localize_chat_error(*locale, provider),
|
||||
format!(
|
||||
"{} 400 Bad Request: tokenizer.chat_template is not set",
|
||||
t(*locale, "chat.providerError")
|
||||
)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
localize_chat_error(UiLocale::En, "connection refused"),
|
||||
"connection refused"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_editor_save_flow_persists_changes() {
|
||||
let (db, project, tmp) = setup();
|
||||
|
||||
@@ -201,6 +201,31 @@ pub fn labeled_input<'a, Message: Clone + 'a>(
|
||||
.into()
|
||||
}
|
||||
|
||||
/// A labeled password input field.
|
||||
pub fn labeled_secure_input<'a, Message: Clone + 'a>(
|
||||
label: &str,
|
||||
placeholder: &str,
|
||||
value: &str,
|
||||
on_change: impl Fn(String) -> Message + 'a,
|
||||
) -> Element<'a, Message> {
|
||||
column![
|
||||
text(label.to_string())
|
||||
.size(12)
|
||||
.color(LABEL_COLOR)
|
||||
.shaping(Shaping::Advanced),
|
||||
text_input(placeholder, value)
|
||||
.on_input(on_change)
|
||||
.secure(true)
|
||||
.size(14)
|
||||
.padding([8, 10])
|
||||
.width(Length::Fill)
|
||||
.style(field_style),
|
||||
]
|
||||
.spacing(6)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// A labeled select/dropdown field.
|
||||
pub fn labeled_select<'a, T, Message>(
|
||||
label: &str,
|
||||
|
||||
@@ -2,6 +2,7 @@ use iced::widget::text::Shaping;
|
||||
use iced::widget::{button, column, container, row, scrollable, text, text_editor, text_input};
|
||||
use iced::{Alignment, Color, Element, Length};
|
||||
|
||||
use bds_core::engine::ai::AiEndpointKind;
|
||||
use bds_core::i18n::UiLocale;
|
||||
|
||||
use crate::app::Message;
|
||||
@@ -12,6 +13,7 @@ use crate::i18n::{t, tw};
|
||||
pub struct AiModelOption {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub supports_tools: bool,
|
||||
pub supports_vision: bool,
|
||||
}
|
||||
|
||||
@@ -21,6 +23,19 @@ impl std::fmt::Display for AiModelOption {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AiModeViewState {
|
||||
pub endpoint_url: String,
|
||||
pub chat_model: String,
|
||||
pub title_model: String,
|
||||
pub image_model: String,
|
||||
pub api_key_input: String,
|
||||
pub api_key_configured: bool,
|
||||
pub model_options: Vec<AiModelOption>,
|
||||
pub chat_supports_tools: bool,
|
||||
pub image_supports_vision: bool,
|
||||
}
|
||||
|
||||
/// Collapsible section identifiers per editor_settings.allium.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum SettingsSection {
|
||||
@@ -136,18 +151,8 @@ pub struct SettingsViewState {
|
||||
pub ssh_username: String,
|
||||
pub ssh_remote_path: String,
|
||||
// AI
|
||||
pub offline_mode: bool,
|
||||
pub online_endpoint_url: String,
|
||||
pub online_endpoint_model: String,
|
||||
pub online_api_key_input: String,
|
||||
pub online_api_key_configured: bool,
|
||||
pub online_model_options: Vec<AiModelOption>,
|
||||
pub airplane_endpoint_url: String,
|
||||
pub airplane_endpoint_model: String,
|
||||
pub airplane_model_options: Vec<AiModelOption>,
|
||||
pub default_model: String,
|
||||
pub title_model: String,
|
||||
pub image_model: String,
|
||||
pub online_ai: AiModeViewState,
|
||||
pub airplane_ai: AiModeViewState,
|
||||
pub system_prompt: text_editor::Content,
|
||||
// Technology
|
||||
pub semantic_similarity_enabled: bool,
|
||||
@@ -195,18 +200,8 @@ impl Clone for SettingsViewState {
|
||||
ssh_host: self.ssh_host.clone(),
|
||||
ssh_username: self.ssh_username.clone(),
|
||||
ssh_remote_path: self.ssh_remote_path.clone(),
|
||||
offline_mode: self.offline_mode,
|
||||
online_endpoint_url: self.online_endpoint_url.clone(),
|
||||
online_endpoint_model: self.online_endpoint_model.clone(),
|
||||
online_api_key_input: self.online_api_key_input.clone(),
|
||||
online_api_key_configured: self.online_api_key_configured,
|
||||
online_model_options: self.online_model_options.clone(),
|
||||
airplane_endpoint_url: self.airplane_endpoint_url.clone(),
|
||||
airplane_endpoint_model: self.airplane_endpoint_model.clone(),
|
||||
airplane_model_options: self.airplane_model_options.clone(),
|
||||
default_model: self.default_model.clone(),
|
||||
title_model: self.title_model.clone(),
|
||||
image_model: self.image_model.clone(),
|
||||
online_ai: self.online_ai.clone(),
|
||||
airplane_ai: self.airplane_ai.clone(),
|
||||
system_prompt: text_editor::Content::with_text(&self.system_prompt.text()),
|
||||
semantic_similarity_enabled: self.semantic_similarity_enabled,
|
||||
mcp_enabled: self.mcp_enabled,
|
||||
@@ -252,18 +247,8 @@ impl Default for SettingsViewState {
|
||||
ssh_host: String::new(),
|
||||
ssh_username: String::new(),
|
||||
ssh_remote_path: String::new(),
|
||||
offline_mode: false,
|
||||
online_endpoint_url: String::new(),
|
||||
online_endpoint_model: String::new(),
|
||||
online_api_key_input: String::new(),
|
||||
online_api_key_configured: false,
|
||||
online_model_options: Vec::new(),
|
||||
airplane_endpoint_url: String::new(),
|
||||
airplane_endpoint_model: String::new(),
|
||||
airplane_model_options: Vec::new(),
|
||||
default_model: String::new(),
|
||||
title_model: String::new(),
|
||||
image_model: String::new(),
|
||||
online_ai: AiModeViewState::default(),
|
||||
airplane_ai: AiModeViewState::default(),
|
||||
system_prompt: text_editor::Content::new(),
|
||||
semantic_similarity_enabled: false,
|
||||
mcp_enabled: false,
|
||||
@@ -343,17 +328,15 @@ pub enum SettingsMsg {
|
||||
SavePublishing,
|
||||
ClearPublishing,
|
||||
// AI
|
||||
OfflineModeChanged(bool),
|
||||
OnlineEndpointUrlChanged(String),
|
||||
OnlineEndpointModelChanged(String),
|
||||
OnlineApiKeyChanged(String),
|
||||
RefreshOnlineModels,
|
||||
AirplaneEndpointUrlChanged(String),
|
||||
AirplaneEndpointModelChanged(String),
|
||||
RefreshAirplaneModels,
|
||||
DefaultModelChanged(String),
|
||||
TitleModelChanged(String),
|
||||
ImageModelChanged(String),
|
||||
AiEndpointUrlChanged(AiEndpointKind, String),
|
||||
AiApiKeyChanged(AiEndpointKind, String),
|
||||
AiChatModelChanged(AiEndpointKind, String),
|
||||
AiTitleModelChanged(AiEndpointKind, String),
|
||||
AiImageModelChanged(AiEndpointKind, String),
|
||||
AiToolsChanged(AiEndpointKind, bool),
|
||||
AiVisionChanged(AiEndpointKind, bool),
|
||||
RefreshAiModels(AiEndpointKind),
|
||||
TestAi(AiEndpointKind),
|
||||
SystemPromptAction(text_editor::Action),
|
||||
SaveAi,
|
||||
ResetSystemPrompt,
|
||||
@@ -786,104 +769,17 @@ fn section_content<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
|
||||
}
|
||||
|
||||
fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
|
||||
let active_model_options = if state.offline_mode {
|
||||
&state.airplane_model_options
|
||||
} else {
|
||||
&state.online_model_options
|
||||
};
|
||||
let model_options = std::iter::once(AiModelOption {
|
||||
id: String::new(),
|
||||
label: t(locale, "tags.noTemplate"),
|
||||
supports_vision: false,
|
||||
})
|
||||
.chain(active_model_options.iter().cloned())
|
||||
.collect::<Vec<_>>();
|
||||
let image_model_options = std::iter::once(AiModelOption {
|
||||
id: String::new(),
|
||||
label: t(locale, "tags.noTemplate"),
|
||||
supports_vision: false,
|
||||
})
|
||||
.chain(
|
||||
active_model_options
|
||||
.iter()
|
||||
.filter(|option| option.supports_vision)
|
||||
.cloned(),
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let offline = inputs::labeled_checkbox(
|
||||
&t(locale, "settings.offlineMode"),
|
||||
state.offline_mode,
|
||||
|b| Message::Settings(SettingsMsg::OfflineModeChanged(b)),
|
||||
);
|
||||
let online_url = inputs::labeled_input(
|
||||
&t(locale, "settings.onlineEndpointUrl"),
|
||||
let online = ai_mode_block(
|
||||
&state.online_ai,
|
||||
AiEndpointKind::Online,
|
||||
locale,
|
||||
"https://api.example.com/v1",
|
||||
&state.online_endpoint_url,
|
||||
|value| Message::Settings(SettingsMsg::OnlineEndpointUrlChanged(value)),
|
||||
);
|
||||
let online_model = inputs::labeled_input(
|
||||
&t(locale, "settings.onlineEndpointModel"),
|
||||
"gpt-4.1-mini",
|
||||
&state.online_endpoint_model,
|
||||
|value| Message::Settings(SettingsMsg::OnlineEndpointModelChanged(value)),
|
||||
);
|
||||
let keychain_placeholder = t(locale, "settings.keychainConfigured");
|
||||
let online_api_key = inputs::labeled_input(
|
||||
&t(locale, "settings.onlineApiKey"),
|
||||
if state.online_api_key_configured {
|
||||
&keychain_placeholder
|
||||
} else {
|
||||
"sk-..."
|
||||
},
|
||||
&state.online_api_key_input,
|
||||
|value| Message::Settings(SettingsMsg::OnlineApiKeyChanged(value)),
|
||||
);
|
||||
let online_refresh = button(text(t(locale, "settings.refreshModels")).size(13))
|
||||
.on_press(Message::Settings(SettingsMsg::RefreshOnlineModels))
|
||||
.style(inputs::secondary_button)
|
||||
.padding([6, 16]);
|
||||
|
||||
let airplane_url = inputs::labeled_input(
|
||||
&t(locale, "settings.airplaneEndpointUrl"),
|
||||
let airplane = ai_mode_block(
|
||||
&state.airplane_ai,
|
||||
AiEndpointKind::Airplane,
|
||||
locale,
|
||||
"http://localhost:11434/v1",
|
||||
&state.airplane_endpoint_url,
|
||||
|value| Message::Settings(SettingsMsg::AirplaneEndpointUrlChanged(value)),
|
||||
);
|
||||
let airplane_model = inputs::labeled_input(
|
||||
&t(locale, "settings.airplaneEndpointModel"),
|
||||
"llama3.2",
|
||||
&state.airplane_endpoint_model,
|
||||
|value| Message::Settings(SettingsMsg::AirplaneEndpointModelChanged(value)),
|
||||
);
|
||||
let airplane_refresh = button(text(t(locale, "settings.refreshModels")).size(13))
|
||||
.on_press(Message::Settings(SettingsMsg::RefreshAirplaneModels))
|
||||
.style(inputs::secondary_button)
|
||||
.padding([6, 16]);
|
||||
|
||||
let default_model = inputs::labeled_select(
|
||||
&t(locale, "settings.defaultModel"),
|
||||
&model_options,
|
||||
model_options
|
||||
.iter()
|
||||
.find(|option| option.id == state.default_model),
|
||||
|option| Message::Settings(SettingsMsg::DefaultModelChanged(option.id)),
|
||||
);
|
||||
let title_model = inputs::labeled_select(
|
||||
&t(locale, "settings.titleModel"),
|
||||
&model_options,
|
||||
model_options
|
||||
.iter()
|
||||
.find(|option| option.id == state.title_model),
|
||||
|option| Message::Settings(SettingsMsg::TitleModelChanged(option.id)),
|
||||
);
|
||||
let image_model = inputs::labeled_select(
|
||||
&t(locale, "settings.imageAnalysisModel"),
|
||||
&image_model_options,
|
||||
image_model_options
|
||||
.iter()
|
||||
.find(|option| option.id == state.image_model),
|
||||
|option| Message::Settings(SettingsMsg::ImageModelChanged(option.id)),
|
||||
);
|
||||
let prompt = column![
|
||||
text(t(locale, "settings.systemPrompt"))
|
||||
@@ -909,27 +805,118 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
|
||||
]
|
||||
.spacing(8);
|
||||
|
||||
column![
|
||||
offline,
|
||||
text(t(locale, "settings.onlineEndpointSection"))
|
||||
.size(12)
|
||||
.color(inputs::LABEL_COLOR),
|
||||
online_url,
|
||||
row![online_model, online_api_key].spacing(12),
|
||||
online_refresh,
|
||||
text(t(locale, "settings.airplaneEndpointSection"))
|
||||
.size(12)
|
||||
.color(inputs::LABEL_COLOR),
|
||||
airplane_url,
|
||||
row![airplane_model, airplane_refresh].spacing(12),
|
||||
default_model,
|
||||
title_model,
|
||||
image_model,
|
||||
prompt,
|
||||
btns,
|
||||
]
|
||||
.spacing(8)
|
||||
.padding([0, 16])
|
||||
column![online, airplane, prompt, btns,]
|
||||
.spacing(8)
|
||||
.padding([0, 16])
|
||||
.into()
|
||||
}
|
||||
|
||||
fn ai_mode_block<'a>(
|
||||
state: &'a AiModeViewState,
|
||||
kind: AiEndpointKind,
|
||||
locale: UiLocale,
|
||||
url_placeholder: &'a str,
|
||||
) -> Element<'a, Message> {
|
||||
let mut options = state.model_options.clone();
|
||||
for model in [&state.chat_model, &state.title_model, &state.image_model] {
|
||||
if !model.is_empty() && !options.iter().any(|option| option.id == *model) {
|
||||
options.push(AiModelOption {
|
||||
id: model.clone(),
|
||||
label: model.clone(),
|
||||
supports_tools: false,
|
||||
supports_vision: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
options.sort_by(|left, right| left.label.cmp(&right.label));
|
||||
options.insert(
|
||||
0,
|
||||
AiModelOption {
|
||||
id: String::new(),
|
||||
label: t(locale, "tags.noTemplate"),
|
||||
supports_tools: false,
|
||||
supports_vision: false,
|
||||
},
|
||||
);
|
||||
let selected = |id: &str| options.iter().find(|option| option.id == id);
|
||||
let section_key = match kind {
|
||||
AiEndpointKind::Online => "settings.onlineEndpointSection",
|
||||
AiEndpointKind::Airplane => "settings.airplaneEndpointSection",
|
||||
};
|
||||
let url_key = match kind {
|
||||
AiEndpointKind::Online => "settings.onlineEndpointUrl",
|
||||
AiEndpointKind::Airplane => "settings.airplaneEndpointUrl",
|
||||
};
|
||||
let api_key = match kind {
|
||||
AiEndpointKind::Online => "settings.onlineApiKey",
|
||||
AiEndpointKind::Airplane => "settings.airplaneApiKey",
|
||||
};
|
||||
let keychain_placeholder = t(locale, "settings.keychainConfigured");
|
||||
|
||||
inputs::card(
|
||||
column![
|
||||
text(t(locale, section_key)).size(15),
|
||||
row![
|
||||
inputs::labeled_input(
|
||||
&t(locale, url_key),
|
||||
url_placeholder,
|
||||
&state.endpoint_url,
|
||||
move |value| {
|
||||
Message::Settings(SettingsMsg::AiEndpointUrlChanged(kind, value))
|
||||
}
|
||||
),
|
||||
button(text(t(locale, "settings.refreshModels")).size(13))
|
||||
.on_press(Message::Settings(SettingsMsg::RefreshAiModels(kind)))
|
||||
.style(inputs::secondary_button)
|
||||
.padding([6, 16]),
|
||||
]
|
||||
.spacing(12)
|
||||
.align_y(Alignment::End),
|
||||
inputs::labeled_secure_input(
|
||||
&t(locale, api_key),
|
||||
if state.api_key_configured {
|
||||
&keychain_placeholder
|
||||
} else {
|
||||
"sk-..."
|
||||
},
|
||||
&state.api_key_input,
|
||||
move |value| Message::Settings(SettingsMsg::AiApiKeyChanged(kind, value)),
|
||||
),
|
||||
inputs::labeled_select(
|
||||
&t(locale, "settings.chatModel"),
|
||||
&options,
|
||||
selected(&state.chat_model),
|
||||
move |option| Message::Settings(SettingsMsg::AiChatModelChanged(kind, option.id)),
|
||||
),
|
||||
inputs::labeled_checkbox(
|
||||
&t(locale, "settings.modelSupportsTools"),
|
||||
state.chat_supports_tools,
|
||||
move |value| Message::Settings(SettingsMsg::AiToolsChanged(kind, value)),
|
||||
),
|
||||
inputs::labeled_select(
|
||||
&t(locale, "settings.titleModel"),
|
||||
&options,
|
||||
selected(&state.title_model),
|
||||
move |option| Message::Settings(SettingsMsg::AiTitleModelChanged(kind, option.id)),
|
||||
),
|
||||
inputs::labeled_select(
|
||||
&t(locale, "settings.imageAnalysisModel"),
|
||||
&options,
|
||||
selected(&state.image_model),
|
||||
move |option| Message::Settings(SettingsMsg::AiImageModelChanged(kind, option.id)),
|
||||
),
|
||||
inputs::labeled_checkbox(
|
||||
&t(locale, "settings.modelSupportsVision"),
|
||||
state.image_supports_vision,
|
||||
move |value| Message::Settings(SettingsMsg::AiVisionChanged(kind, value)),
|
||||
),
|
||||
button(text(t(locale, "settings.testChat")).size(13))
|
||||
.on_press(Message::Settings(SettingsMsg::TestAi(kind)))
|
||||
.style(inputs::secondary_button)
|
||||
.padding([6, 16]),
|
||||
]
|
||||
.spacing(8),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
|
||||
@@ -649,14 +649,14 @@ fn is_ai_enabled(settings_state: Option<&SettingsViewState>, offline_mode: bool)
|
||||
return false;
|
||||
};
|
||||
|
||||
if offline_mode {
|
||||
!state.airplane_endpoint_url.trim().is_empty()
|
||||
&& !state.airplane_endpoint_model.trim().is_empty()
|
||||
let mode = if offline_mode {
|
||||
&state.airplane_ai
|
||||
} else {
|
||||
!state.online_endpoint_url.trim().is_empty()
|
||||
&& !state.online_endpoint_model.trim().is_empty()
|
||||
&& (state.online_api_key_configured || !state.online_api_key_input.trim().is_empty())
|
||||
}
|
||||
&state.online_ai
|
||||
};
|
||||
!mode.endpoint_url.trim().is_empty()
|
||||
&& !mode.chat_model.trim().is_empty()
|
||||
&& (offline_mode || mode.api_key_configured || !mode.api_key_input.trim().is_empty())
|
||||
}
|
||||
|
||||
fn loading_view<'a>(locale: UiLocale) -> Element<'a, Message> {
|
||||
@@ -965,14 +965,14 @@ mod tests {
|
||||
assert!(!is_ai_enabled(Some(&settings), false));
|
||||
assert!(!is_ai_enabled(Some(&settings), true));
|
||||
|
||||
settings.online_endpoint_url = "https://api.example.com/v1".to_string();
|
||||
settings.online_endpoint_model = "gpt-4.1-mini".to_string();
|
||||
settings.online_api_key_configured = true;
|
||||
settings.online_ai.endpoint_url = "https://api.example.com/v1".to_string();
|
||||
settings.online_ai.chat_model = "gpt-4.1-mini".to_string();
|
||||
settings.online_ai.api_key_configured = true;
|
||||
assert!(is_ai_enabled(Some(&settings), false));
|
||||
assert!(!is_ai_enabled(Some(&settings), true));
|
||||
|
||||
settings.airplane_endpoint_url = "http://localhost:11434/v1".to_string();
|
||||
settings.airplane_endpoint_model = "llama3.2".to_string();
|
||||
settings.airplane_ai.endpoint_url = "http://localhost:11434/v1".to_string();
|
||||
settings.airplane_ai.chat_model = "llama3.2".to_string();
|
||||
assert!(is_ai_enabled(Some(&settings), true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ fn dialog_keys_exist_in_all_locales() {
|
||||
#[test]
|
||||
fn toast_keys_exist_in_all_locales() {
|
||||
let keys = [
|
||||
"editor.saved",
|
||||
"projectSelector.toast.switched",
|
||||
"projectSelector.toast.switchFailed",
|
||||
"projectSelector.toast.created",
|
||||
|
||||
@@ -119,6 +119,7 @@ chat-new = Neue Unterhaltung
|
||||
chat-newWithModel = Unterhaltung mit { $model }
|
||||
chat-unavailable-title = Die dialogbasierte KI ist nicht verfügbar
|
||||
chat-unavailable-guidance = Konfiguriere den aktiven KI-Endpunkt und das Modell in den Einstellungen. Im Flugmodus wird nur der lokale Endpunkt verwendet.
|
||||
chat-providerError = Fehler des KI-Anbieters:
|
||||
chat-unavailable-openSettings = KI-Einstellungen öffnen
|
||||
chat-model-none = Kein Modell ausgewählt
|
||||
chat-model-label = Modell
|
||||
@@ -372,6 +373,7 @@ sidebar-filter-tags = Tags
|
||||
sidebar-filter-categories = Kategorien
|
||||
sidebar-filter-calendar = Archiv
|
||||
sidebar-filter-noResults = Keine Treffer
|
||||
editor-saved = Gespeichert.
|
||||
editor-title = Titel
|
||||
editor-titlePlaceholder = Titel eingeben...
|
||||
editor-slug = Slug
|
||||
@@ -489,10 +491,27 @@ settings-onlineEndpointSection = Online-Endpunkt
|
||||
settings-onlineEndpointUrl = URL des Online-Endpunkts
|
||||
settings-onlineEndpointModel = Modell des Online-Endpunkts
|
||||
settings-onlineApiKey = Online-API-Schlüssel
|
||||
settings-airplaneApiKey = Lokaler API-Schlüssel (optional)
|
||||
settings-airplaneEndpointSection = Flugmodus-Endpunkt
|
||||
settings-airplaneEndpointUrl = URL des Flugmodus-Endpunkts
|
||||
settings-airplaneEndpointModel = Modell des Flugmodus-Endpunkts
|
||||
settings-refreshModels = Modelle aktualisieren
|
||||
settings-chatModel = Chat-Modell
|
||||
settings-modelSupportsTools = Ausgewähltes Chat-Modell unterstützt Werkzeugaufrufe
|
||||
settings-modelSupportsVision = Ausgewähltes Bildmodell unterstützt Bilderkennung
|
||||
settings-testChat = Chat testen
|
||||
settings-modelsLoaded = Modelle geladen.
|
||||
settings-testChatSuccess = Die ausgewählten Modelle haben erfolgreich geantwortet.
|
||||
settings-onlineChatModel = Online-Chat-Modell
|
||||
settings-onlineTitleModel = Online-Titelmodell
|
||||
settings-onlineImageModel = Online-Bildmodell
|
||||
settings-onlineModelSupportsTools = Online-Chat-Modell unterstützt Werkzeugaufrufe
|
||||
settings-onlineModelSupportsVision = Online-Bildmodell unterstützt Bilderkennung
|
||||
settings-airplaneChatModel = Lokales Chat-Modell
|
||||
settings-airplaneTitleModel = Lokales Titelmodell
|
||||
settings-airplaneImageModel = Lokales Bildmodell
|
||||
settings-airplaneModelSupportsTools = Lokales Chat-Modell unterstützt Werkzeugaufrufe
|
||||
settings-airplaneModelSupportsVision = Lokales Bildmodell unterstützt Bilderkennung
|
||||
settings-defaultModel = Standardmodell
|
||||
settings-titleModel = Titelmodell
|
||||
settings-imageAnalysisModel = Bildanalysemodell
|
||||
|
||||
@@ -119,6 +119,7 @@ chat-new = New Chat
|
||||
chat-newWithModel = Chat with { $model }
|
||||
chat-unavailable-title = Conversational AI is unavailable
|
||||
chat-unavailable-guidance = Configure the active AI endpoint and model in Settings. Airplane mode uses only the local endpoint.
|
||||
chat-providerError = AI provider error:
|
||||
chat-unavailable-openSettings = Open AI Settings
|
||||
chat-model-none = No model selected
|
||||
chat-model-label = Model
|
||||
@@ -363,6 +364,7 @@ sidebar-filter-tags = Tags
|
||||
sidebar-filter-categories = Categories
|
||||
sidebar-filter-calendar = Archive
|
||||
sidebar-filter-noResults = No matching items
|
||||
editor-saved = Saved.
|
||||
editor-title = Title
|
||||
editor-titlePlaceholder = Enter title...
|
||||
editor-slug = Slug
|
||||
@@ -489,10 +491,27 @@ settings-onlineEndpointSection = Online Endpoint
|
||||
settings-onlineEndpointUrl = Online Endpoint URL
|
||||
settings-onlineEndpointModel = Online Endpoint Model
|
||||
settings-onlineApiKey = Online API Key
|
||||
settings-airplaneApiKey = Local API Key (Optional)
|
||||
settings-airplaneEndpointSection = Airplane Endpoint
|
||||
settings-airplaneEndpointUrl = Airplane Endpoint URL
|
||||
settings-airplaneEndpointModel = Airplane Endpoint Model
|
||||
settings-refreshModels = Refresh Models
|
||||
settings-chatModel = Chat Model
|
||||
settings-modelSupportsTools = Selected chat model supports tool calls
|
||||
settings-modelSupportsVision = Selected image model supports vision
|
||||
settings-testChat = Test Chat
|
||||
settings-modelsLoaded = Models loaded.
|
||||
settings-testChatSuccess = Selected models responded successfully.
|
||||
settings-onlineChatModel = Online Chat Model
|
||||
settings-onlineTitleModel = Online Title Model
|
||||
settings-onlineImageModel = Online Image Model
|
||||
settings-onlineModelSupportsTools = Online chat model supports tool calls
|
||||
settings-onlineModelSupportsVision = Online image model supports vision
|
||||
settings-airplaneChatModel = Local Chat Model
|
||||
settings-airplaneTitleModel = Local Title Model
|
||||
settings-airplaneImageModel = Local Image Model
|
||||
settings-airplaneModelSupportsTools = Local chat model supports tool calls
|
||||
settings-airplaneModelSupportsVision = Local image model supports vision
|
||||
settings-defaultModel = Default Model
|
||||
settings-titleModel = Title Model
|
||||
settings-imageAnalysisModel = Image Analysis Model
|
||||
|
||||
@@ -119,6 +119,7 @@ chat-new = Nueva conversación
|
||||
chat-newWithModel = Conversación con { $model }
|
||||
chat-unavailable-title = La IA conversacional no está disponible
|
||||
chat-unavailable-guidance = Configura el punto de acceso de IA activo y el modelo en Ajustes. El modo avión usa únicamente el punto de acceso local.
|
||||
chat-providerError = Error del proveedor de IA:
|
||||
chat-unavailable-openSettings = Abrir ajustes de IA
|
||||
chat-model-none = Ningún modelo seleccionado
|
||||
chat-model-label = Modelo
|
||||
@@ -372,6 +373,7 @@ sidebar-filter-tags = Etiquetas
|
||||
sidebar-filter-categories = Categorías
|
||||
sidebar-filter-calendar = Archivo
|
||||
sidebar-filter-noResults = Sin resultados
|
||||
editor-saved = Guardado.
|
||||
editor-title = Título
|
||||
editor-titlePlaceholder = Introducir título...
|
||||
editor-slug = Slug
|
||||
@@ -489,10 +491,27 @@ settings-onlineEndpointSection = Extremo en línea
|
||||
settings-onlineEndpointUrl = URL del extremo en línea
|
||||
settings-onlineEndpointModel = Modelo del extremo en línea
|
||||
settings-onlineApiKey = Clave API en línea
|
||||
settings-airplaneApiKey = Clave API local (opcional)
|
||||
settings-airplaneEndpointSection = Extremo de modo avión
|
||||
settings-airplaneEndpointUrl = URL del extremo de modo avión
|
||||
settings-airplaneEndpointModel = Modelo del extremo de modo avión
|
||||
settings-refreshModels = Actualizar modelos
|
||||
settings-chatModel = Modelo de chat
|
||||
settings-modelSupportsTools = El modelo de chat seleccionado admite llamadas a herramientas
|
||||
settings-modelSupportsVision = El modelo de imagen seleccionado admite visión
|
||||
settings-testChat = Probar chat
|
||||
settings-modelsLoaded = Modelos cargados.
|
||||
settings-testChatSuccess = Los modelos seleccionados respondieron correctamente.
|
||||
settings-onlineChatModel = Modelo de chat en línea
|
||||
settings-onlineTitleModel = Modelo de títulos en línea
|
||||
settings-onlineImageModel = Modelo de imágenes en línea
|
||||
settings-onlineModelSupportsTools = El modelo de chat en línea admite llamadas a herramientas
|
||||
settings-onlineModelSupportsVision = El modelo de imágenes en línea admite visión
|
||||
settings-airplaneChatModel = Modelo de chat local
|
||||
settings-airplaneTitleModel = Modelo de títulos local
|
||||
settings-airplaneImageModel = Modelo de imágenes local
|
||||
settings-airplaneModelSupportsTools = El modelo de chat local admite llamadas a herramientas
|
||||
settings-airplaneModelSupportsVision = El modelo de imágenes local admite visión
|
||||
settings-defaultModel = Modelo predeterminado
|
||||
settings-titleModel = Modelo de títulos
|
||||
settings-imageAnalysisModel = Modelo de análisis de imágenes
|
||||
|
||||
@@ -119,6 +119,7 @@ chat-new = Nouvelle conversation
|
||||
chat-newWithModel = Conversation avec { $model }
|
||||
chat-unavailable-title = L’IA conversationnelle est indisponible
|
||||
chat-unavailable-guidance = Configurez le point d’accès IA actif et le modèle dans les réglages. Le mode avion utilise uniquement le point d’accès local.
|
||||
chat-providerError = Erreur du fournisseur d’IA :
|
||||
chat-unavailable-openSettings = Ouvrir les réglages IA
|
||||
chat-model-none = Aucun modèle sélectionné
|
||||
chat-model-label = Modèle
|
||||
@@ -372,6 +373,7 @@ sidebar-filter-tags = Tags
|
||||
sidebar-filter-categories = Catégories
|
||||
sidebar-filter-calendar = Archives
|
||||
sidebar-filter-noResults = Aucun résultat
|
||||
editor-saved = Enregistré.
|
||||
editor-title = Titre
|
||||
editor-titlePlaceholder = Saisir le titre...
|
||||
editor-slug = Slug
|
||||
@@ -489,10 +491,27 @@ settings-onlineEndpointSection = Point de terminaison en ligne
|
||||
settings-onlineEndpointUrl = URL du point de terminaison en ligne
|
||||
settings-onlineEndpointModel = Modèle du point de terminaison en ligne
|
||||
settings-onlineApiKey = Clé API en ligne
|
||||
settings-airplaneApiKey = Clé API locale (facultative)
|
||||
settings-airplaneEndpointSection = Point de terminaison mode avion
|
||||
settings-airplaneEndpointUrl = URL du point de terminaison mode avion
|
||||
settings-airplaneEndpointModel = Modèle du point de terminaison mode avion
|
||||
settings-refreshModels = Rafraîchir les modèles
|
||||
settings-chatModel = Modèle de discussion
|
||||
settings-modelSupportsTools = Le modèle de discussion sélectionné prend en charge les appels d’outils
|
||||
settings-modelSupportsVision = Le modèle d’image sélectionné prend en charge la vision
|
||||
settings-testChat = Tester la discussion
|
||||
settings-modelsLoaded = Modèles chargés.
|
||||
settings-testChatSuccess = Les modèles sélectionnés ont répondu correctement.
|
||||
settings-onlineChatModel = Modèle de discussion en ligne
|
||||
settings-onlineTitleModel = Modèle de titre en ligne
|
||||
settings-onlineImageModel = Modèle d’image en ligne
|
||||
settings-onlineModelSupportsTools = Le modèle de discussion en ligne prend en charge les appels d’outils
|
||||
settings-onlineModelSupportsVision = Le modèle d’image en ligne prend en charge la vision
|
||||
settings-airplaneChatModel = Modèle de discussion local
|
||||
settings-airplaneTitleModel = Modèle de titre local
|
||||
settings-airplaneImageModel = Modèle d’image local
|
||||
settings-airplaneModelSupportsTools = Le modèle de discussion local prend en charge les appels d’outils
|
||||
settings-airplaneModelSupportsVision = Le modèle d’image local prend en charge la vision
|
||||
settings-defaultModel = Modèle par défaut
|
||||
settings-titleModel = Modèle de titres
|
||||
settings-imageAnalysisModel = Modèle d'analyse d'image
|
||||
|
||||
@@ -119,6 +119,7 @@ chat-new = Nuova conversazione
|
||||
chat-newWithModel = Conversazione con { $model }
|
||||
chat-unavailable-title = L’IA conversazionale non è disponibile
|
||||
chat-unavailable-guidance = Configura l’endpoint IA attivo e il modello nelle impostazioni. La modalità aereo usa solo l’endpoint locale.
|
||||
chat-providerError = Errore del provider IA:
|
||||
chat-unavailable-openSettings = Apri impostazioni IA
|
||||
chat-model-none = Nessun modello selezionato
|
||||
chat-model-label = Modello
|
||||
@@ -372,6 +373,7 @@ sidebar-filter-tags = Tag
|
||||
sidebar-filter-categories = Categorie
|
||||
sidebar-filter-calendar = Archivio
|
||||
sidebar-filter-noResults = Nessun risultato
|
||||
editor-saved = Salvato.
|
||||
editor-title = Titolo
|
||||
editor-titlePlaceholder = Inserisci titolo...
|
||||
editor-slug = Slug
|
||||
@@ -489,10 +491,27 @@ settings-onlineEndpointSection = Endpoint online
|
||||
settings-onlineEndpointUrl = URL endpoint online
|
||||
settings-onlineEndpointModel = Modello endpoint online
|
||||
settings-onlineApiKey = Chiave API online
|
||||
settings-airplaneApiKey = Chiave API locale (facoltativa)
|
||||
settings-airplaneEndpointSection = Endpoint modalità aereo
|
||||
settings-airplaneEndpointUrl = URL endpoint modalità aereo
|
||||
settings-airplaneEndpointModel = Modello endpoint modalità aereo
|
||||
settings-refreshModels = Aggiorna modelli
|
||||
settings-chatModel = Modello chat
|
||||
settings-modelSupportsTools = Il modello chat selezionato supporta le chiamate agli strumenti
|
||||
settings-modelSupportsVision = Il modello immagini selezionato supporta la visione
|
||||
settings-testChat = Prova chat
|
||||
settings-modelsLoaded = Modelli caricati.
|
||||
settings-testChatSuccess = I modelli selezionati hanno risposto correttamente.
|
||||
settings-onlineChatModel = Modello chat online
|
||||
settings-onlineTitleModel = Modello titoli online
|
||||
settings-onlineImageModel = Modello immagini online
|
||||
settings-onlineModelSupportsTools = Il modello chat online supporta le chiamate agli strumenti
|
||||
settings-onlineModelSupportsVision = Il modello immagini online supporta la visione
|
||||
settings-airplaneChatModel = Modello chat locale
|
||||
settings-airplaneTitleModel = Modello titoli locale
|
||||
settings-airplaneImageModel = Modello immagini locale
|
||||
settings-airplaneModelSupportsTools = Il modello chat locale supporta le chiamate agli strumenti
|
||||
settings-airplaneModelSupportsVision = Il modello immagini locale supporta la visione
|
||||
settings-defaultModel = Modello predefinito
|
||||
settings-titleModel = Modello titoli
|
||||
settings-imageAnalysisModel = Modello analisi immagini
|
||||
|
||||
@@ -12,8 +12,12 @@ use "./media.allium" as media
|
||||
entity AiEndpoint {
|
||||
kind: online | airplane
|
||||
url: String
|
||||
api_key: String? -- encrypted via SecureKeyStore; null for local models
|
||||
model: String
|
||||
api_key: String? -- encrypted via SecureKeyStore; required online, optional locally
|
||||
model: String -- chat model
|
||||
title_model: String?
|
||||
image_model: String?
|
||||
chat_supports_tools: Boolean? -- operator override of discovered capability
|
||||
image_supports_vision: Boolean? -- operator override of discovered capability
|
||||
-- online: cloud provider (OpenAI, Anthropic-via-proxy, etc.)
|
||||
-- airplane: local model (Ollama, LM Studio, etc.)
|
||||
}
|
||||
@@ -81,6 +85,10 @@ surface AiEndpointSurface {
|
||||
endpoint.url
|
||||
endpoint.api_key when endpoint.api_key != null
|
||||
endpoint.model
|
||||
endpoint.title_model when endpoint.title_model != null
|
||||
endpoint.image_model when endpoint.image_model != null
|
||||
endpoint.chat_supports_tools when endpoint.chat_supports_tools != null
|
||||
endpoint.image_supports_vision when endpoint.image_supports_vision != null
|
||||
}
|
||||
|
||||
surface AiModelSurface {
|
||||
@@ -177,8 +185,10 @@ surface AiConfigurationSurface {
|
||||
facing _: AiOperator
|
||||
|
||||
provides:
|
||||
SetAiEndpointRequested(kind, url, api_key, model)
|
||||
SetAiEndpointRequested(kind, url, api_key, model, title_model, image_model, chat_supports_tools, image_supports_vision)
|
||||
RemoveAiEndpointRequested(kind)
|
||||
RefreshEndpointModelsRequested(kind, url, api_key)
|
||||
TestEndpointModelsRequested(kind)
|
||||
RefreshModelCatalogRequested(source)
|
||||
}
|
||||
|
||||
@@ -221,13 +231,17 @@ config {
|
||||
}
|
||||
|
||||
rule SetAiEndpoint {
|
||||
when: SetAiEndpointRequested(kind, url, api_key, model)
|
||||
when: SetAiEndpointRequested(kind, url, api_key, model, title_model, image_model, chat_supports_tools, image_supports_vision)
|
||||
ensures:
|
||||
let endpoint = AiEndpoint.created(
|
||||
kind: kind,
|
||||
url: url,
|
||||
api_key: api_key,
|
||||
model: model
|
||||
model: model,
|
||||
title_model: title_model,
|
||||
image_model: image_model,
|
||||
chat_supports_tools: chat_supports_tools,
|
||||
image_supports_vision: image_supports_vision
|
||||
)
|
||||
endpoint.kind = kind
|
||||
}
|
||||
@@ -335,6 +349,22 @@ rule RefreshModelCatalog {
|
||||
ensures: ModelCatalogUpdated()
|
||||
}
|
||||
|
||||
rule RefreshEndpointModels {
|
||||
when: RefreshEndpointModelsRequested(kind, url, api_key)
|
||||
requires: url != ""
|
||||
requires: kind = airplane or api_key != null
|
||||
-- Discovery calls GET /models and does not require a selected model.
|
||||
ensures: EndpointModelsLoaded(kind)
|
||||
}
|
||||
|
||||
rule TestEndpointModels {
|
||||
when: TestEndpointModelsRequested(kind)
|
||||
requires: exists endpoint in AiEndpoints where endpoint.kind = kind
|
||||
-- Sends a minimal chat-completions request to each distinct configured
|
||||
-- chat, title, and image model for this endpoint.
|
||||
ensures: EndpointModelsResponded(kind)
|
||||
}
|
||||
|
||||
invariant AirplaneModeGating {
|
||||
-- Endpoint routing based on airplane (offline) mode:
|
||||
-- airplane_mode = true -> use airplane endpoint (local model)
|
||||
@@ -347,8 +377,8 @@ invariant AirplaneModeGating {
|
||||
|
||||
invariant AirplaneModeModelSwap {
|
||||
-- In airplane mode, cloud models are never contacted.
|
||||
-- Chat uses the configured offline chat model when needed.
|
||||
-- Image analysis uses the configured offline vision-capable model when needed.
|
||||
-- Chat, title generation, and image analysis use the models and capability
|
||||
-- overrides configured for the active endpoint profile.
|
||||
-- If no suitable offline model is configured, the operation fails with
|
||||
-- actionable guidance instead of silently falling back to the online endpoint.
|
||||
}
|
||||
@@ -356,9 +386,11 @@ invariant AirplaneModeModelSwap {
|
||||
invariant TwoEndpointModel {
|
||||
-- Two configurable OpenAI-compatible endpoints:
|
||||
-- online: for cloud providers (requires API key)
|
||||
-- airplane: for local models (no API key required)
|
||||
-- airplane: for local models (API key optional)
|
||||
-- Both use the OpenAI Chat Completions wire format.
|
||||
-- Endpoint selection is configurable rather than tied to hard-coded providers.
|
||||
-- URL, credentials, chat/title/image models, and capability overrides are
|
||||
-- independent. Endpoint selection is controlled only by the status-bar
|
||||
-- airplane-mode switch, not by a duplicate Settings toggle.
|
||||
}
|
||||
|
||||
invariant AdvisoryModelCatalog {
|
||||
@@ -387,6 +419,12 @@ invariant VisionCapabilityGate {
|
||||
-- before the runtime sends multimodal requests to them.
|
||||
}
|
||||
|
||||
invariant OperatorCapabilityOverrides {
|
||||
-- Discovered tool and vision capabilities prefill the profile controls.
|
||||
-- Explicit operator overrides take precedence at runtime so models with
|
||||
-- incomplete metadata can enable or disable tools and image input safely.
|
||||
}
|
||||
|
||||
invariant ChatContextTruncation {
|
||||
-- Chat requests are trimmed to fit within the selected model's context window.
|
||||
-- Oldest user/assistant pairs are dropped first.
|
||||
|
||||
@@ -53,17 +53,21 @@ value SettingsCategoryRow {
|
||||
}
|
||||
|
||||
value SettingsAISection {
|
||||
anthropic_api_key: String? -- masked + Change/Save
|
||||
mistral_api_key: String? -- masked + Change/Save
|
||||
ollama_enabled: Boolean -- toggle, shows model capabilities table (tools/vision)
|
||||
lm_studio_enabled: Boolean -- toggle, shows model capabilities table
|
||||
offline_mode: Boolean -- toggle, switches between online and airplane endpoint
|
||||
default_model: String? -- select from active endpoint models
|
||||
title_model: String? -- select
|
||||
image_analysis_model: String? -- select (vision-capable only)
|
||||
online: SettingsAIEndpoint
|
||||
airplane: SettingsAIEndpoint
|
||||
system_prompt: String -- textarea (12 rows) + Save + Reset to Default
|
||||
}
|
||||
|
||||
value SettingsAIEndpoint {
|
||||
url: String
|
||||
api_key: String? -- masked; optional for airplane endpoint
|
||||
chat_model: String?
|
||||
title_model: String?
|
||||
image_model: String?
|
||||
chat_supports_tools: Boolean
|
||||
image_supports_vision: Boolean
|
||||
}
|
||||
|
||||
value SettingsTechnologySection {
|
||||
semantic_similarity_enabled: Boolean -- checkbox: enable duplicate search + related-post embeddings
|
||||
-- Scripting runtime is fixed at the application layer; no runtime switch is exposed.
|
||||
@@ -130,8 +134,9 @@ surface SettingsViewSurface {
|
||||
SettingsCategoryUpdated(category_row)
|
||||
SettingsCategoryRemoved(category_name)
|
||||
SettingsCategoriesResetToDefaults()
|
||||
SettingsAIApiKeySaved(provider, key)
|
||||
SettingsAIModelRefreshRequested(endpoint)
|
||||
SettingsAIProfileSaved(kind, endpoint)
|
||||
SettingsAIModelRefreshRequested(kind, url, api_key)
|
||||
SettingsAIChatTestRequested(kind)
|
||||
SettingsAISystemPromptSaved(prompt)
|
||||
SettingsAISystemPromptReset()
|
||||
SettingsPublishingSaved(publishing_data)
|
||||
@@ -179,23 +184,27 @@ surface SettingsViewSurface {
|
||||
-- "Reset to Defaults" restores default categories set.
|
||||
|
||||
@guarantee AISection
|
||||
-- Section 4: Anthropic API key, Mistral API key (both masked + Change/Save),
|
||||
-- Ollama toggle (with model capabilities table: tools/vision),
|
||||
-- LM Studio toggle (with capabilities table),
|
||||
-- Offline mode toggle (with dedicated model selectors for chat/title/image),
|
||||
-- Default model (with catalog info: max output, context window),
|
||||
-- Title model, Image analysis model,
|
||||
-- Section 4: independent Online and Airplane endpoint cards. Each has
|
||||
-- URL, masked API key (optional locally), Refresh Models, chat/title/image
|
||||
-- selectors, explicit tools/vision capability overrides, and Test Chat.
|
||||
-- Settings has no airplane-mode toggle; the status-bar switch selects
|
||||
-- the active profile and immediately swaps chat/model routing.
|
||||
-- System prompt (textarea config.settings_system_prompt_rows rows + Save + Reset).
|
||||
|
||||
@guarantee AIApiKeyValidation
|
||||
-- On Save: test API call to endpoint before persisting.
|
||||
-- On failure: toast error message, key not saved.
|
||||
-- On success: key encrypted via SecureKeyStore, masked display shown.
|
||||
-- API keys are encrypted via SecureKeyStore and displayed masked.
|
||||
-- Online profiles require a key; airplane profiles permit authenticated
|
||||
-- or unauthenticated local OpenAI-compatible endpoints.
|
||||
|
||||
@guarantee AIModelRefresh
|
||||
-- Refresh Models button per endpoint fetches model list from URL.
|
||||
-- Model selector populated from fetched list.
|
||||
-- Per-model info: max output tokens, context window (when available).
|
||||
-- Discovery requires URL and online credentials but no preselected model.
|
||||
-- All three role selectors are populated independently from the result;
|
||||
-- advertised capability metadata prefills tools and vision overrides.
|
||||
|
||||
@guarantee AIChatTest
|
||||
-- Test Chat sends a minimal request through every distinct model selected
|
||||
-- for chat, title generation, or image analysis in the chosen profile.
|
||||
|
||||
@guarantee TechnologySection
|
||||
-- Section 5: Semantic Similarity toggle
|
||||
|
||||
Reference in New Issue
Block a user