feat: first take at M4
This commit is contained in:
@@ -8,6 +8,7 @@ use iced::{Element, Subscription, Task};
|
||||
use bds_core::db::Database;
|
||||
use bds_core::engine::task::{TaskId, TaskManager, TaskStatus};
|
||||
use bds_core::engine;
|
||||
use bds_core::engine::ai::{self, AiEndpointConfig, AiEndpointKind};
|
||||
use bds_core::i18n::{detect_os_locale, UiLocale};
|
||||
use bds_core::model::{Media, Post, PostStatus, Project, PublishingPreferences, Script, SshMode, Template};
|
||||
|
||||
@@ -28,7 +29,7 @@ use crate::views::{
|
||||
template_editor::{TemplateEditorState, TemplateEditorMsg},
|
||||
script_editor::{ScriptEditorState, ScriptEditorMsg},
|
||||
tags_view::{self, TagsMsg, TagsSection, TagsViewState},
|
||||
settings_view::{default_category_rows, SettingsCategoryRow, SettingsViewState, SettingsMsg},
|
||||
settings_view::{default_category_rows, AiModelOption, SettingsCategoryRow, SettingsViewState, SettingsMsg},
|
||||
dashboard::{DashboardCategory, DashboardRecentPost, DashboardState, DashboardStats, DashboardTag, DashboardTimelineMonth},
|
||||
};
|
||||
|
||||
@@ -257,6 +258,15 @@ fn persist_media_editor_state_impl(
|
||||
}
|
||||
}
|
||||
|
||||
fn load_generation_post_body(data_dir: &Path, post: &Post) -> Result<String, String> {
|
||||
if let Some(content) = &post.content {
|
||||
return Ok(content.clone());
|
||||
}
|
||||
let raw = std::fs::read_to_string(data_dir.join(&post.file_path)).map_err(|e| e.to_string())?;
|
||||
let (_frontmatter, body) = bds_core::util::frontmatter::read_post_file(&raw)?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
fn save_template_editor_state_impl(
|
||||
db: &Database,
|
||||
project_id: &str,
|
||||
@@ -1766,11 +1776,47 @@ impl BdsApp {
|
||||
"engine.generateSiteStarted",
|
||||
|db_path, project_id, data_dir, tm, tid| {
|
||||
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
||||
tm.report_progress(tid, Some(0.20), Some("Generating calendar...".into()));
|
||||
engine::calendar::regenerate_calendar(db.conn(), &data_dir, &project_id)
|
||||
let metadata = engine::meta::read_project_json(&data_dir).map_err(|e| e.to_string())?;
|
||||
if metadata.public_url.as_deref().unwrap_or("").trim().is_empty() {
|
||||
return Err("public URL is required before generating the site".to_string());
|
||||
}
|
||||
let main_language = metadata.main_language.clone().unwrap_or_else(|| "en".to_string());
|
||||
let all_posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project_id)
|
||||
.map_err(|e| e.to_string())?;
|
||||
tm.report_progress(tid, Some(0.90), Some("Calendar written".into()));
|
||||
Ok("done".to_string())
|
||||
let published_posts = all_posts
|
||||
.into_iter()
|
||||
.filter(|post| post.status == PostStatus::Published)
|
||||
.collect::<Vec<_>>();
|
||||
let total = published_posts.len().max(1) as f32;
|
||||
let mut sources = Vec::new();
|
||||
for (index, post) in published_posts.into_iter().enumerate() {
|
||||
tm.report_progress(
|
||||
tid,
|
||||
Some(((index as f32) / total) * 0.7),
|
||||
Some(format!("Rendering {}", post.slug)),
|
||||
);
|
||||
let body_markdown = load_generation_post_body(&data_dir, &post)?;
|
||||
sources.push(engine::generation::PublishedPostSource { post, body_markdown });
|
||||
}
|
||||
let output_dir = data_dir.join("html");
|
||||
std::fs::create_dir_all(&output_dir).map_err(|e| e.to_string())?;
|
||||
tm.report_progress(tid, Some(0.85), Some("Writing generated files".into()));
|
||||
let report = engine::generation::generate_starter_site(
|
||||
db.conn(),
|
||||
&output_dir,
|
||||
&project_id,
|
||||
&metadata,
|
||||
&sources,
|
||||
&main_language,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
tm.report_progress(tid, Some(1.0), Some("Site generation complete".into()));
|
||||
Ok(format!(
|
||||
"written={}, skipped={}, output={}",
|
||||
report.written_paths.len(),
|
||||
report.skipped_paths.len(),
|
||||
output_dir.display(),
|
||||
))
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -4526,6 +4572,30 @@ impl BdsApp {
|
||||
if let Ok(setting) = bds_core::db::queries::setting::get_setting_by_key(db.conn(), "ai.system_prompt") {
|
||||
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.offline_mode = self.offline_mode;
|
||||
state
|
||||
@@ -4972,17 +5042,44 @@ impl BdsApp {
|
||||
state.offline_mode = b;
|
||||
return Task::done(Message::SetOfflineMode(b));
|
||||
}
|
||||
SettingsMsg::OnlineEndpointUrlChanged(value) => { state.online_endpoint_url = value; }
|
||||
SettingsMsg::OnlineEndpointModelChanged(value) => { state.online_endpoint_model = value; }
|
||||
SettingsMsg::OnlineApiKeyChanged(value) => { state.online_api_key_input = value; }
|
||||
SettingsMsg::AirplaneEndpointUrlChanged(value) => { state.airplane_endpoint_url = value; }
|
||||
SettingsMsg::AirplaneEndpointModelChanged(value) => { state.airplane_endpoint_model = value; }
|
||||
SettingsMsg::DefaultModelChanged(value) => { state.default_model = value; }
|
||||
SettingsMsg::TitleModelChanged(value) => { state.title_model = value; }
|
||||
SettingsMsg::ImageModelChanged(value) => { state.image_model = value; }
|
||||
SettingsMsg::RefreshOnlineModels => {
|
||||
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(ToastLevel::Error, &format!("Save failed: {error}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsMsg::RefreshAirplaneModels => {
|
||||
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(ToastLevel::Error, &format!("Save failed: {error}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsMsg::SystemPromptAction(action) => {
|
||||
state.system_prompt.perform(action);
|
||||
}
|
||||
SettingsMsg::SaveSystemPrompt => {
|
||||
SettingsMsg::SaveAi => {
|
||||
if let Some(db) = &self.db {
|
||||
match bds_core::db::queries::setting::set_setting_value(
|
||||
db.conn(),
|
||||
"ai.system_prompt",
|
||||
&state.system_prompt.text(),
|
||||
bds_core::util::now_unix_ms(),
|
||||
) {
|
||||
match Self::save_ai_settings_state(db, state) {
|
||||
Ok(()) => self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")),
|
||||
Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")),
|
||||
}
|
||||
@@ -5396,4 +5493,86 @@ impl BdsApp {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_ai_models(
|
||||
db: &Database,
|
||||
state: &mut SettingsViewState,
|
||||
kind: AiEndpointKind,
|
||||
) -> Result<(), String> {
|
||||
let endpoint = Self::compose_ai_endpoint(state, kind)?;
|
||||
let models = ai::refresh_model_catalog(&endpoint).map_err(|error| error.to_string())?;
|
||||
let options = models
|
||||
.into_iter()
|
||||
.map(|model| AiModelOption {
|
||||
id: model.id,
|
||||
label: model.name,
|
||||
supports_vision: model.supports_vision,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
match kind {
|
||||
AiEndpointKind::Online => state.online_model_options = options,
|
||||
AiEndpointKind::Airplane => state.airplane_model_options = options,
|
||||
}
|
||||
let _ = db;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_ai_settings_state(
|
||||
db: &Database,
|
||||
state: &mut SettingsViewState,
|
||||
) -> Result<(), String> {
|
||||
let online_endpoint = Self::compose_ai_endpoint(state, AiEndpointKind::Online)?;
|
||||
let airplane_endpoint = Self::compose_ai_endpoint(state, AiEndpointKind::Airplane)?;
|
||||
ai::test_endpoint(&online_endpoint).map_err(|error| error.to_string())?;
|
||||
ai::test_endpoint(&airplane_endpoint).map_err(|error| error.to_string())?;
|
||||
ai::save_endpoint(db.conn(), &online_endpoint).map_err(|error| error.to_string())?;
|
||||
ai::save_endpoint(db.conn(), &airplane_endpoint).map_err(|error| error.to_string())?;
|
||||
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())?;
|
||||
state.online_api_key_input.clear();
|
||||
state.online_api_key_configured = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(AiEndpointConfig {
|
||||
kind,
|
||||
url,
|
||||
model,
|
||||
api_key,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,19 @@ use crate::app::Message;
|
||||
use crate::components::inputs;
|
||||
use crate::i18n::{t, tw};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AiModelOption {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub supports_vision: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AiModelOption {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.label)
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapsible section identifiers per editor_settings.allium.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum SettingsSection {
|
||||
@@ -115,6 +128,17 @@ pub struct SettingsViewState {
|
||||
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 system_prompt: text_editor::Content,
|
||||
// Technology
|
||||
pub semantic_similarity_enabled: bool,
|
||||
@@ -156,6 +180,17 @@ impl Clone for SettingsViewState {
|
||||
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(),
|
||||
system_prompt: text_editor::Content::with_text(&self.system_prompt.text()),
|
||||
semantic_similarity_enabled: self.semantic_similarity_enabled,
|
||||
}
|
||||
@@ -190,6 +225,17 @@ impl Default for SettingsViewState {
|
||||
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(),
|
||||
system_prompt: text_editor::Content::new(),
|
||||
semantic_similarity_enabled: false,
|
||||
}
|
||||
@@ -263,8 +309,18 @@ pub enum SettingsMsg {
|
||||
ClearPublishing,
|
||||
// AI
|
||||
OfflineModeChanged(bool),
|
||||
OnlineEndpointUrlChanged(String),
|
||||
OnlineEndpointModelChanged(String),
|
||||
OnlineApiKeyChanged(String),
|
||||
RefreshOnlineModels,
|
||||
AirplaneEndpointUrlChanged(String),
|
||||
AirplaneEndpointModelChanged(String),
|
||||
RefreshAirplaneModels,
|
||||
DefaultModelChanged(String),
|
||||
TitleModelChanged(String),
|
||||
ImageModelChanged(String),
|
||||
SystemPromptAction(text_editor::Action),
|
||||
SaveSystemPrompt,
|
||||
SaveAi,
|
||||
ResetSystemPrompt,
|
||||
// Technology
|
||||
SemanticSimilarityChanged(bool),
|
||||
@@ -659,11 +715,92 @@ 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"),
|
||||
"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))
|
||||
.padding([6, 16]);
|
||||
|
||||
let airplane_url = inputs::labeled_input(
|
||||
&t(locale, "settings.airplaneEndpointUrl"),
|
||||
"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))
|
||||
.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")).size(12).color(inputs::LABEL_COLOR).shaping(Shaping::Advanced),
|
||||
text_editor(&state.system_prompt)
|
||||
@@ -674,7 +811,7 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
|
||||
.spacing(4);
|
||||
let btns = row![
|
||||
button(text(t(locale, "common.save")).size(13))
|
||||
.on_press(Message::Settings(SettingsMsg::SaveSystemPrompt))
|
||||
.on_press(Message::Settings(SettingsMsg::SaveAi))
|
||||
.style(inputs::primary_button)
|
||||
.padding([6, 16]),
|
||||
button(text(t(locale, "settings.resetToDefault")).size(13))
|
||||
@@ -683,7 +820,21 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
|
||||
]
|
||||
.spacing(8);
|
||||
|
||||
column![offline, prompt, btns]
|
||||
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])
|
||||
.into()
|
||||
|
||||
Reference in New Issue
Block a user