feat: add lesson export and import functionality

- Implemented `LessonExport` and `LessonImport` actions in the action module.
- Added corresponding command parsing for lesson export and import.
- Created functions to handle lesson export and import in the memory module.
- Updated state management to reflect changes after lesson operations.
- Introduced deferred operations for handling asynchronous tasks in the event loop.
- Enhanced the tool execution context to include graduated checks for file operations.
- Added OAuth support with PKCE for secure authorization flows.
- Implemented a loopback server for handling OAuth redirects.
- Refactored various modules to improve code organization and maintainability.
This commit is contained in:
asepharyana
2026-07-11 18:23:01 +07:00
parent cc03bd79b6
commit c1ad206a00
49 changed files with 1088 additions and 12 deletions
+45
View File
@@ -38,6 +38,12 @@ pub enum Action {
QuitConfirm,
Resize(u16, u16),
Tick,
LessonExport {
path: String,
},
LessonImport {
path: String,
},
RecordUsage {
tokens_in: u64,
tokens_out: u64,
@@ -224,6 +230,45 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
state.scroll.set_max_visible(w as usize);
state.dirty = true;
}
Action::LessonExport { path } => {
let dest = std::path::Path::new(&path);
if let Some(parent) = dest.parent() {
let _ = std::fs::create_dir_all(parent);
}
match crate::model::memory::export_lessons(&state.memory_dir, dest) {
Ok(_) => {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Success,
format!("lessons exported to {}", path),
));
}
Err(e) => {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error,
format!("export failed: {}", e),
));
}
}
state.dirty = true;
}
Action::LessonImport { path } => {
let src = std::path::Path::new(&path);
match crate::model::memory::import_lessons(&state.memory_dir, src) {
Ok(count) => {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Success,
format!("imported {} lessons from {}", count, path),
));
}
Err(e) => {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Error,
format!("import failed: {}", e),
));
}
}
state.dirty = true;
}
Action::Tick => {
let now_ms = chrono::Utc::now().timestamp_millis();
state.misc.drain_expired_toasts(now_ms);
+53
View File
@@ -0,0 +1,53 @@
use crate::controller::command::Command;
use crate::app::runtime::actions::Action;
use crate::app::state::types::Overlay;
pub fn apply_command(command: Command) -> Vec<Action> {
match command {
Command::Help => {
vec![Action::OpenOverlay(Overlay::Help)]
}
Command::Quit => {
vec![Action::QuitConfirm]
}
Command::Resume => {
vec![Action::CloseOverlay]
}
Command::LessonCreate(text) => {
vec![Action::SystemNote {
kind: "lesson".to_string(),
message: format!("/lesson {}", text),
}]
}
Command::LessonExport(path) => {
vec![Action::LessonExport { path }]
}
Command::LessonImport(path) => {
vec![Action::LessonImport { path }]
}
Command::LessonList => {
vec![Action::OpenOverlay(Overlay::Learning)]
}
Command::Mode(mode) => {
vec![Action::SwitchMode(mode)]
}
Command::Clear => {
vec![Action::SystemNote {
kind: "clear".to_string(),
message: "transcript cleared".to_string(),
}]
}
Command::Save => {
vec![Action::SystemNote {
kind: "save".to_string(),
message: "session saved".to_string(),
}]
}
Command::Unknown(cmd) => {
vec![Action::SystemNote {
kind: "error".to_string(),
message: format!("unknown command: {}", cmd),
}]
}
}
}
+1
View File
@@ -1 +1,2 @@
#[expect(dead_code)]
pub mod sessions;
@@ -0,0 +1,16 @@
use crate::app::state::rest::AppStateRest;
use crate::app::state::types::ToastKind;
pub struct DeferredOp {
pub kind: String,
pub handler: Box<dyn FnOnce(&mut AppStateRest) + Send>,
}
pub fn run_deferred(state: &mut AppStateRest, op: DeferredOp) {
let kind = op.kind.clone();
(op.handler)(state);
state.push_toast(crate::app::state::types::Toast::new(
ToastKind::Info,
format!("deferred '{}' completed", kind),
));
}
@@ -0,0 +1,27 @@
use std::path::PathBuf;
use crate::model::session::Session;
#[expect(dead_code)]
pub struct SessionManager {
pub current_id: String,
pub base_dir: PathBuf,
pub sessions: Vec<Session>,
}
impl SessionManager {
pub fn new(base_dir: PathBuf) -> Self {
SessionManager {
current_id: String::new(),
base_dir,
sessions: Vec::new(),
}
}
pub fn load_sessions(&mut self) {
self.sessions = Session::list(&self.base_dir);
}
pub fn find_by_id(&self, id: &str) -> Option<&Session> {
self.sessions.iter().find(|s| s.id == id)
}
}
+1
View File
@@ -1,5 +1,6 @@
pub mod actions;
pub mod commands;
pub mod event_loop;
#[expect(dead_code)]
pub mod shortsend;
pub mod stream;
+38
View File
@@ -0,0 +1,38 @@
use crate::dto::chat::message::ChatMessage;
const MAX_WIRE_TOKENS: usize = 8000;
const MIN_MESSAGES_BEFORE_SHAPE: usize = 20;
const ENGAGE_HYSTERESIS: usize = 5;
pub fn should_shape(total_messages: usize, prev_shaped: bool) -> bool {
if total_messages < MIN_MESSAGES_BEFORE_SHAPE {
return false;
}
let threshold = if prev_shaped {
MIN_MESSAGES_BEFORE_SHAPE + ENGAGE_HYSTERESIS
} else {
MIN_MESSAGES_BEFORE_SHAPE
};
total_messages >= threshold
}
pub fn shape_messages(messages: &[ChatMessage], token_count: usize) -> Vec<ChatMessage> {
if token_count <= MAX_WIRE_TOKENS || messages.len() < 10 {
return messages.to_vec();
}
let keep_recent = messages
.iter()
.rev()
.take(MAX_WIRE_TOKENS / 200)
.cloned()
.collect::<Vec<_>>();
let mut result = Vec::new();
if let Some(first) = messages.first() {
result.push(first.clone());
}
result.push(ChatMessage::system(
"[prior conversation compacted]".to_string(),
));
result.extend(keep_recent.into_iter().rev());
result
}
View File
View File
+2
View File
@@ -1,2 +1,4 @@
#[expect(dead_code)]
pub mod tools;
#[expect(dead_code)]
pub mod turn;
+24
View File
@@ -0,0 +1,24 @@
use crate::tool::{ToolCtx, all_tools};
use serde_json::Value;
use anyhow::Result;
pub fn execute_tool_call(name: &str, args: &Value, ctx: &ToolCtx) -> Result<String> {
let tools = all_tools();
for tool in &tools {
if tool.name() == name {
return tool.run(ctx, args);
}
}
Err(anyhow::anyhow!("tool not found: {}", name))
}
#[expect(dead_code)]
pub fn execute_deferred_tool(name: &str, args: &Value, ctx: &ToolCtx) -> Result<String> {
let tools = all_tools();
for tool in &tools {
if tool.name() == name {
return tool.run(ctx, args);
}
}
Err(anyhow::anyhow!("deferred tool not found: {}", name))
}
+70
View File
@@ -0,0 +1,70 @@
use crate::app::state::rest::AppStateRest;
use crate::app::runtime::actions::{Action, apply_action};
pub fn advance_turn(state: &mut AppStateRest) {
if state.session_runtime.is_none() {
return;
}
let rt = state.session_runtime.as_mut().unwrap();
if rt.messages.is_empty() {
return;
}
let api_key = state.settings.api_key.clone();
let model = state.settings.model.clone();
let msgs = rt.messages.clone();
let pending = state.pending_api_response.clone();
if let Some(key) = api_key {
if !key.is_empty() {
std::thread::spawn(move || {
let client = crate::service::openrouter::OpenRouterClient::new(key, model);
match client.chat(&msgs) {
Ok(response) => {
if let Ok(mut guard) = pending.lock() {
*guard = Some(response);
}
}
Err(e) => {
if let Ok(mut guard) = pending.lock() {
*guard = Some(format!("Error: {}", e));
}
}
}
});
}
}
}
#[expect(dead_code)]
pub fn process_tools(state: &mut AppStateRest) {
let tool_calls: Vec<_> = {
let rt = match state.session_runtime.as_ref() {
Some(r) => r,
None => return,
};
rt.pending_tool_queue.clone()
};
if tool_calls.is_empty() {
return;
}
for tool_call in &tool_calls {
let _result = format!("processing tool: {}", tool_call.tool_name);
}
}
#[expect(dead_code)]
pub fn finish_tool_round(state: &mut AppStateRest) {
let tool_count = {
let rt = match state.session_runtime.as_ref() {
Some(r) => r,
None => return,
};
rt.tool_call_results.len()
};
if tool_count > 0 {
let note = format!("{} tool calls completed", tool_count);
apply_action(state, Action::SystemNote {
kind: "tool_round".to_string(),
message: note,
});
}
}