From 5faf5abbb6741ad3312e3e7469b34c750209fd95 Mon Sep 17 00:00:00 2001 From: syui Date: Tue, 20 Jan 2026 13:46:28 +0900 Subject: [PATCH] test ai chat --- .env.example | 9 + Cargo.toml | 1 + lexicons/ai.syui.log.chat.json | 43 ++++ readme.md | 98 ++++++++ src/commands/auth.rs | 47 +++- src/commands/post.rs | 156 ++++++++++--- src/commands/token.rs | 28 +++ src/lms/chat.rs | 395 ++++++++++++++++++++++++++++++++ src/lms/mod.rs | 1 + src/main.rs | 46 +++- src/web/components/chat.ts | 260 +++++++++++++++++++++ src/web/components/mode-tabs.ts | 7 +- src/web/lib/api.ts | 52 ++++- src/web/lib/router.ts | 18 +- src/web/main.ts | 31 ++- src/web/styles/main.css | 213 +++++++++++++++++ src/web/types.ts | 13 ++ 17 files changed, 1374 insertions(+), 44 deletions(-) create mode 100644 lexicons/ai.syui.log.chat.json create mode 100644 src/lms/chat.rs create mode 100644 src/web/components/chat.ts diff --git a/.env.example b/.env.example index 647e7fa..9517220 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,12 @@ # LMS Translation API TRANSLATE_URL=http://127.0.0.1:1234/v1 TRANSLATE_MODEL=plamo-2-translate + +# Chat API +CHAT_URL=http://127.0.0.1:1234/v1 +CHAT_MODEL=gpt-oss-20b +# CHAT_MAX_TOKENS=2048 + +# Character/system prompt (choose one) +# CHAT_SYSTEM="You are ai, a friendly AI assistant." +# CHAT_SYSTEM_FILE=./character.txt diff --git a/Cargo.toml b/Cargo.toml index bc37255..36933e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,3 +22,4 @@ dirs = "5.0" chrono = { version = "0.4", features = ["serde"] } rand = "0.8" dotenvy = "0.15" +rustyline = "15" diff --git a/lexicons/ai.syui.log.chat.json b/lexicons/ai.syui.log.chat.json new file mode 100644 index 0000000..5553118 --- /dev/null +++ b/lexicons/ai.syui.log.chat.json @@ -0,0 +1,43 @@ +{ + "lexicon": 1, + "id": "ai.syui.log.chat", + "defs": { + "main": { + "type": "record", + "description": "Record containing a chat message in a conversation.", + "key": "tid", + "record": { + "type": "object", + "required": ["content", "author", "createdAt"], + "properties": { + "content": { + "type": "string", + "maxLength": 100000, + "maxGraphemes": 10000, + "description": "The content of the message." + }, + "author": { + "type": "string", + "format": "did", + "description": "DID of the message author." + }, + "root": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of the root message in the thread." + }, + "parent": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of the parent message being replied to." + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when this message was created." + } + } + } + } + } +} diff --git a/readme.md b/readme.md index d425165..052d2ae 100644 --- a/readme.md +++ b/readme.md @@ -247,3 +247,101 @@ The current implementation uses the DNS-based approach instead, which works toda ### Reference - [resolve-lexicon](https://resolve-lexicon.pages.dev/) - Browser-compatible lexicon resolver + +## chat + +Chat with AI bot and save conversations to ATProto. + +### Setup + +1. Login as user and bot: + +```sh +# User login +$ ailog login user.syu.is -p -s syu.is + +# Bot login +$ ailog login ai.syu.is -p -s syu.is --bot +``` + +2. Configure LLM endpoint in `.env`: + +``` +CHAT_URL=http://127.0.0.1:1234/v1 +CHAT_MODEL=gemma-2-9b +``` + +3. (Optional) Set character/system prompt: + +```sh +# Direct prompt +CHAT_SYSTEM="You are ai, a friendly AI assistant." + +# Or load from file +CHAT_SYSTEM_FILE=./character.txt +``` + +### Usage + +```sh +# Start a new conversation +$ ailog chat --new "hello" + +# Continue the conversation +$ ailog chat "how are you?" + +# Interactive mode (new session) +$ ailog chat --new + +# Interactive mode (continue) +$ ailog chat +``` + +### Data Storage + +Messages are saved locally to `public/content/{did}/ai.syui.log.chat/`: + +``` +public/content/ +├── did:plc:xxx/ # User's messages +│ └── ai.syui.log.chat/ +│ ├── index.json +│ └── {rkey}.json +└── did:plc:yyy/ # Bot's messages + └── ai.syui.log.chat/ + ├── index.json + └── {rkey}.json +``` + +### Sync & Push + +```sh +# Sync bot data from PDS to local +$ ailog sync --bot + +# Push local chat to PDS +$ ailog push -c ai.syui.log.chat --bot +``` + +### Web Display + +View chat threads at `/@{handle}/at/chat`: + +- `/@user.syu.is/at/chat` - Thread list (conversations started by user) +- `/@user.syu.is/at/chat/{rkey}` - Full conversation thread + +### Record Schema + +```json +{ + "$type": "ai.syui.log.chat", + "content": "message text", + "author": "did:plc:xxx", + "createdAt": "2025-01-01T00:00:00.000Z", + "root": "at://did:plc:xxx/ai.syui.log.chat/{rkey}", + "parent": "at://did:plc:yyy/ai.syui.log.chat/{rkey}" +} +``` + +- `root`: First message URI in the thread (empty for conversation start) +- `parent`: Previous message URI in the thread diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 6a93c82..76e3e1f 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -20,7 +20,7 @@ struct CreateSessionResponse { } /// Login to ATProto PDS -pub async fn login(handle: &str, password: &str, pds: &str) -> Result<()> { +pub async fn login(handle: &str, password: &str, pds: &str, is_bot: bool) -> Result<()> { let client = reqwest::Client::new(); let url = lexicons::url(pds, &com_atproto_server::CREATE_SESSION); @@ -29,7 +29,8 @@ pub async fn login(handle: &str, password: &str, pds: &str) -> Result<()> { password: password.to_string(), }; - println!("Logging in to {} as {}...", pds, handle); + let account_type = if is_bot { "bot" } else { "user" }; + println!("Logging in to {} as {} ({})...", pds, handle, account_type); let res = client .post(&url) @@ -54,7 +55,11 @@ pub async fn login(handle: &str, password: &str, pds: &str) -> Result<()> { pds: Some(pds.to_string()), }; - token::save_session(&session)?; + if is_bot { + token::save_bot_session(&session)?; + } else { + token::save_session(&session)?; + } println!("Logged in as {} ({})", session.handle, session.did); Ok(()) @@ -95,3 +100,39 @@ pub async fn refresh_session() -> Result { Ok(session) } + +/// Refresh bot access token +pub async fn refresh_bot_session() -> Result { + let session = token::load_bot_session()?; + let pds = session.pds.as_deref().unwrap_or("bsky.social"); + + let client = reqwest::Client::new(); + let url = lexicons::url(pds, &com_atproto_server::REFRESH_SESSION); + + let res = client + .post(&url) + .header("Authorization", format!("Bearer {}", session.refresh_jwt)) + .send() + .await + .context("Failed to refresh bot session")?; + + if !res.status().is_success() { + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + anyhow::bail!("Bot refresh failed: {} - {}. Try 'ailog login --bot' again.", status, body); + } + + let new_session: CreateSessionResponse = res.json().await?; + + let session = Session { + did: new_session.did, + handle: new_session.handle, + access_jwt: new_session.access_jwt, + refresh_jwt: new_session.refresh_jwt, + pds: Some(pds.to_string()), + }; + + token::save_bot_session(&session)?; + + Ok(session) +} diff --git a/src/commands/post.rs b/src/commands/post.rs index f3882e8..c2790f9 100644 --- a/src/commands/post.rs +++ b/src/commands/post.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::fs; -use super::auth; +use super::{auth, token}; use crate::lexicons::{self, com_atproto_repo, com_atproto_identity}; #[derive(Debug, Serialize)] @@ -234,39 +234,55 @@ struct DescribeRepoResponse { } /// Sync PDS data to local content directory -pub async fn sync_to_local(output: &str) -> Result<()> { - let config_content = fs::read_to_string("public/config.json") - .context("config.json not found")?; - let config: Config = serde_json::from_str(&config_content)?; - - println!("Syncing data for {}", config.handle); - +pub async fn sync_to_local(output: &str, is_bot: bool, collection_override: Option<&str>) -> Result<()> { let client = reqwest::Client::new(); - // Resolve handle to DID - let resolve_url = format!( - "{}?handle={}", - lexicons::url("public.api.bsky.app", &com_atproto_identity::RESOLVE_HANDLE), - config.handle - ); - let res = client.get(&resolve_url).send().await?; - let resolve: serde_json::Value = res.json().await?; - let did = resolve["did"].as_str().context("Could not resolve handle")?; + let (did, pds, _handle, collection) = if is_bot { + // Bot mode: use bot.json + let session = token::load_bot_session()?; + let pds = session.pds.as_deref().unwrap_or("bsky.social"); + let collection = collection_override.unwrap_or("ai.syui.log.chat"); + println!("Syncing bot data for {} ({})", session.handle, session.did); + (session.did.clone(), format!("https://{}", pds), session.handle.clone(), collection.to_string()) + } else { + // User mode: use config.json + let config_content = fs::read_to_string("public/config.json") + .context("config.json not found")?; + let config: Config = serde_json::from_str(&config_content)?; + + println!("Syncing data for {}", config.handle); + + // Resolve handle to DID + let resolve_url = format!( + "{}?handle={}", + lexicons::url("public.api.bsky.app", &com_atproto_identity::RESOLVE_HANDLE), + config.handle + ); + let res = client.get(&resolve_url).send().await?; + let resolve: serde_json::Value = res.json().await?; + let did = resolve["did"].as_str().context("Could not resolve handle")?.to_string(); + + // Get PDS from DID document + let plc_url = format!("https://plc.directory/{}", did); + let res = client.get(&plc_url).send().await?; + let did_doc: serde_json::Value = res.json().await?; + let pds = did_doc["service"] + .as_array() + .and_then(|services| { + services.iter().find(|s| s["type"] == "AtprotoPersonalDataServer") + }) + .and_then(|s| s["serviceEndpoint"].as_str()) + .context("Could not find PDS")? + .to_string(); + + let collection = collection_override + .map(|s| s.to_string()) + .unwrap_or_else(|| config.collection.as_deref().unwrap_or("ai.syui.log.post").to_string()); + + (did, pds, config.handle.clone(), collection) + }; println!("DID: {}", did); - - // Get PDS from DID document - let plc_url = format!("https://plc.directory/{}", did); - let res = client.get(&plc_url).send().await?; - let did_doc: serde_json::Value = res.json().await?; - let pds = did_doc["service"] - .as_array() - .and_then(|services| { - services.iter().find(|s| s["type"] == "AtprotoPersonalDataServer") - }) - .and_then(|s| s["serviceEndpoint"].as_str()) - .context("Could not find PDS")?; - println!("PDS: {}", pds); // Remove https:// prefix for lexicons::url @@ -332,7 +348,6 @@ pub async fn sync_to_local(output: &str) -> Result<()> { } // 3. Sync collection records - let collection = config.collection.as_deref().unwrap_or("ai.syui.log.post"); let records_url = format!( "{}?repo={}&collection={}&limit=100", lexicons::url(pds_host, &com_atproto_repo::LIST_RECORDS), @@ -370,3 +385,82 @@ pub async fn sync_to_local(output: &str) -> Result<()> { Ok(()) } + +/// Push local content to PDS +pub async fn push_to_remote(input: &str, collection: &str, is_bot: bool) -> Result<()> { + let session = if is_bot { + auth::refresh_bot_session().await? + } else { + auth::refresh_session().await? + }; + let pds = session.pds.as_deref().unwrap_or("bsky.social"); + let did = &session.did; + + // Build collection directory path + let collection_dir = format!("{}/{}/{}", input, did, collection); + + if !std::path::Path::new(&collection_dir).exists() { + anyhow::bail!("Collection directory not found: {}", collection_dir); + } + + println!("Pushing records from {} to {}", collection_dir, collection); + + let client = reqwest::Client::new(); + let url = lexicons::url(pds, &com_atproto_repo::PUT_RECORD); + + let mut count = 0; + for entry in fs::read_dir(&collection_dir)? { + let entry = entry?; + let path = entry.path(); + + // Skip non-JSON files and index.json + if path.extension().map(|e| e != "json").unwrap_or(true) { + continue; + } + let filename = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + if filename == "index" { + continue; + } + + let rkey = filename.to_string(); + let content = fs::read_to_string(&path)?; + let record_data: Value = serde_json::from_str(&content)?; + + // Extract value from record (sync saves as {uri, cid, value}) + let record = if record_data.get("value").is_some() { + record_data["value"].clone() + } else { + record_data + }; + + let req = PutRecordRequest { + repo: did.clone(), + collection: collection.to_string(), + rkey: rkey.clone(), + record, + }; + + println!("Pushing: {}", rkey); + + let res = client + .post(&url) + .header("Authorization", format!("Bearer {}", session.access_jwt)) + .json(&req) + .send() + .await?; + + if !res.status().is_success() { + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + println!(" Failed: {} - {}", status, body); + } else { + let result: PutRecordResponse = res.json().await?; + println!(" OK: {}", result.uri); + count += 1; + } + } + + println!("Pushed {} records to {}", count, collection); + + Ok(()) +} diff --git a/src/commands/token.rs b/src/commands/token.rs index f37b5a6..db2b7d8 100644 --- a/src/commands/token.rs +++ b/src/commands/token.rs @@ -27,6 +27,16 @@ pub fn token_path() -> Result { Ok(config_dir.join("token.json")) } +/// Get bot token file path: ~/Library/Application Support/ai.syui.log/bot.json +pub fn bot_token_path() -> Result { + let config_dir = dirs::config_dir() + .context("Could not find config directory")? + .join(BUNDLE_ID); + + fs::create_dir_all(&config_dir)?; + Ok(config_dir.join("bot.json")) +} + /// Load session from token file pub fn load_session() -> Result { let path = token_path()?; @@ -44,3 +54,21 @@ pub fn save_session(session: &Session) -> Result<()> { println!("Token saved to {:?}", path); Ok(()) } + +/// Load bot session from bot token file +pub fn load_bot_session() -> Result { + let path = bot_token_path()?; + let content = fs::read_to_string(&path) + .with_context(|| format!("Bot token file not found: {:?}. Run 'ailog login --bot' first.", path))?; + let session: Session = serde_json::from_str(&content)?; + Ok(session) +} + +/// Save bot session to bot token file +pub fn save_bot_session(session: &Session) -> Result<()> { + let path = bot_token_path()?; + let content = serde_json::to_string_pretty(session)?; + fs::write(&path, content)?; + println!("Bot token saved to {:?}", path); + Ok(()) +} diff --git a/src/lms/chat.rs b/src/lms/chat.rs new file mode 100644 index 0000000..d280c30 --- /dev/null +++ b/src/lms/chat.rs @@ -0,0 +1,395 @@ +use anyhow::{anyhow, Result}; +use rustyline::error::ReadlineError; +use rustyline::DefaultEditor; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::env; +use std::fs; +use std::path::Path; + +use crate::commands::token::{self, BUNDLE_ID}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ChatMessage { + role: String, + content: String, +} + +#[derive(Debug, Serialize)] +struct ChatRequest { + model: String, + messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + max_tokens: Option, +} + +#[derive(Debug, Deserialize)] +struct ChatChoice { + message: ChatMessageResponse, +} + +#[derive(Debug, Deserialize)] +struct ChatMessageResponse { + content: String, +} + +#[derive(Debug, Deserialize)] +struct ChatResponse { + choices: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ChatRecord { + uri: String, + cid: String, + value: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ChatSession { + root_uri: Option, + last_uri: Option, + messages: Vec, +} + +impl Default for ChatSession { + fn default() -> Self { + Self { + root_uri: None, + last_uri: None, + messages: Vec::new(), + } + } +} + +/// Get system prompt from environment or file +fn get_system_prompt() -> String { + // 1. Try CHAT_SYSTEM env var directly + if let Ok(prompt) = env::var("CHAT_SYSTEM") { + return prompt; + } + + // 2. Try CHAT_SYSTEM_FILE env var (path to file) + if let Ok(file_path) = env::var("CHAT_SYSTEM_FILE") { + if let Ok(content) = fs::read_to_string(&file_path) { + return content.trim().to_string(); + } + } + + // 3. Default prompt + "You are a helpful assistant. Respond concisely.".to_string() +} + +/// Create new chat session with system prompt +fn new_session_with_prompt() -> ChatSession { + ChatSession { + root_uri: None, + last_uri: None, + messages: vec![ChatMessage { + role: "system".to_string(), + content: get_system_prompt(), + }], + } +} + +/// Get session file path +fn session_path() -> Result { + let config_dir = dirs::config_dir() + .ok_or_else(|| anyhow!("Could not find config directory"))? + .join(BUNDLE_ID); + fs::create_dir_all(&config_dir)?; + Ok(config_dir.join("chat_session.json")) +} + +/// Load chat session (updates system prompt from current env) +fn load_session() -> Result { + let path = session_path()?; + if path.exists() { + let content = fs::read_to_string(&path)?; + let mut session: ChatSession = serde_json::from_str(&content)?; + + // Update system prompt from current environment + let system_prompt = get_system_prompt(); + if let Some(first) = session.messages.first_mut() { + if first.role == "system" { + first.content = system_prompt; + } + } else { + session.messages.insert(0, ChatMessage { + role: "system".to_string(), + content: system_prompt, + }); + } + + Ok(session) + } else { + Ok(ChatSession::default()) + } +} + +/// Save chat session +fn save_session(session: &ChatSession) -> Result<()> { + let path = session_path()?; + let content = serde_json::to_string_pretty(session)?; + fs::write(&path, content)?; + Ok(()) +} + +/// Generate TID +fn generate_tid() -> String { + use rand::Rng; + const CHARSET: &[u8] = b"234567abcdefghijklmnopqrstuvwxyz"; + let mut rng = rand::thread_rng(); + (0..13) + .map(|_| { + let idx = rng.gen_range(0..CHARSET.len()); + CHARSET[idx] as char + }) + .collect() +} + +/// Call LLM API +async fn call_llm(client: &reqwest::Client, url: &str, model: &str, messages: &[ChatMessage]) -> Result { + let max_tokens = env::var("CHAT_MAX_TOKENS") + .ok() + .and_then(|v| v.parse().ok()); + + let req = ChatRequest { + model: model.to_string(), + messages: messages.to_vec(), + max_tokens, + }; + + let res = client.post(url).json(&req).send().await?; + + if !res.status().is_success() { + let status = res.status(); + let body = res.text().await?; + return Err(anyhow!("LLM call failed ({}): {}", status, body)); + } + + let chat_res: ChatResponse = res.json().await?; + chat_res + .choices + .first() + .map(|c| c.message.content.trim().to_string()) + .ok_or_else(|| anyhow!("No response from LLM")) +} + +/// Save chat record to local file +fn save_chat_local( + output_dir: &str, + did: &str, + content: &str, + author_did: &str, + root_uri: Option<&str>, + parent_uri: Option<&str>, +) -> Result { + let rkey = generate_tid(); + let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); + let uri = format!("at://{}/ai.syui.log.chat/{}", did, rkey); + + let mut value = serde_json::json!({ + "$type": "ai.syui.log.chat", + "content": content, + "author": author_did, + "createdAt": now, + }); + + if let Some(root) = root_uri { + value["root"] = serde_json::json!(root); + } + if let Some(parent) = parent_uri { + value["parent"] = serde_json::json!(parent); + } + + let record = ChatRecord { + uri: uri.clone(), + cid: format!("bafyrei{}", rkey), + value, + }; + + // Create directory: {output_dir}/{did}/ai.syui.log.chat/ + let collection_dir = Path::new(output_dir) + .join(did) + .join("ai.syui.log.chat"); + fs::create_dir_all(&collection_dir)?; + + // Save record: {rkey}.json + let file_path = collection_dir.join(format!("{}.json", rkey)); + let json_content = serde_json::to_string_pretty(&record)?; + fs::write(&file_path, json_content)?; + + // Update index.json + let index_path = collection_dir.join("index.json"); + let mut rkeys: Vec = if index_path.exists() { + let index_content = fs::read_to_string(&index_path).unwrap_or_else(|_| "[]".to_string()); + serde_json::from_str(&index_content).unwrap_or_else(|_| Vec::new()) + } else { + Vec::new() + }; + if !rkeys.contains(&rkey.to_string()) { + rkeys.push(rkey.to_string()); + fs::write(&index_path, serde_json::to_string_pretty(&rkeys)?)?; + } + + Ok(uri) +} + +/// Process a single message and get response +async fn process_message( + client: &reqwest::Client, + llm_url: &str, + model: &str, + output_dir: &str, + user_did: &str, + bot_did: &str, + session: &mut ChatSession, + input: &str, +) -> Result { + // Add user message to history + session.messages.push(ChatMessage { + role: "user".to_string(), + content: input.to_string(), + }); + + // Save user message to local file + let user_uri = save_chat_local( + output_dir, + user_did, + input, + user_did, + session.root_uri.as_deref(), + session.last_uri.as_deref(), + )?; + + // Set root if first message + if session.root_uri.is_none() { + session.root_uri = Some(user_uri.clone()); + } + + // Call LLM with full history + let response = call_llm(client, llm_url, model, &session.messages).await?; + + // Add assistant message to history + session.messages.push(ChatMessage { + role: "assistant".to_string(), + content: response.clone(), + }); + + // Save AI response to local file + let ai_uri = save_chat_local( + output_dir, + bot_did, + &response, + bot_did, + session.root_uri.as_deref(), + Some(&user_uri), + )?; + + session.last_uri = Some(ai_uri); + + // Save session + save_session(session)?; + + Ok(response) +} + +/// Run chat - interactive or single message +pub async fn run(input: Option<&str>, new_session: bool) -> Result<()> { + let chat_url = env::var("CHAT_URL") + .or_else(|_| env::var("TRANSLATE_URL")) + .unwrap_or_else(|_| "http://127.0.0.1:1234/v1".to_string()); + let model = env::var("CHAT_MODEL") + .or_else(|_| env::var("TRANSLATE_MODEL")) + .unwrap_or_else(|_| "gpt-oss".to_string()); + let output_dir = env::var("CHAT_OUTPUT").unwrap_or_else(|_| { + // Use absolute path from current working directory + let cwd = env::current_dir().unwrap_or_default(); + cwd.join("public/content").to_string_lossy().to_string() + }); + + // Load user session for DID + let user_token = token::load_session()?; + let user_did = user_token.did.clone(); + + // Load bot session for DID (required) + let bot_did = match token::load_bot_session() { + Ok(s) => s.did, + Err(_) => { + eprintln!("Bot session not found. Please login as bot first:"); + eprintln!(" ailog login -p -s --bot"); + return Ok(()); + } + }; + + // Load or create chat session + let mut session = if new_session { + new_session_with_prompt() + } else { + load_session().unwrap_or_else(|_| new_session_with_prompt()) + }; + + let client = reqwest::Client::new(); + let llm_url = format!("{}/chat/completions", chat_url); + + // Single message mode + if let Some(msg) = input { + let response = process_message( + &client, &llm_url, &model, &output_dir, + &user_did, &bot_did, &mut session, msg, + ).await?; + println!("{}", response); + use std::io::Write; + std::io::stdout().flush()?; + return Ok(()); + } + + // Interactive mode + println!("ailog chat (type 'exit' to quit, Ctrl+C to cancel)"); + println!("model: {}", model); + println!("---"); + + let mut rl = DefaultEditor::new()?; + + loop { + match rl.readline("> ") { + Ok(line) => { + let input = line.trim(); + if input.is_empty() { + continue; + } + if input == "exit" || input == "quit" { + break; + } + + let _ = rl.add_history_entry(input); + + match process_message( + &client, &llm_url, &model, &output_dir, + &user_did, &bot_did, &mut session, input, + ).await { + Ok(response) => println!("\n{}\n", response), + Err(e) => { + eprintln!("Error: {}", e); + // Remove failed message from history + session.messages.pop(); + } + } + } + Err(ReadlineError::Interrupted) => { + println!("^C"); + continue; + } + Err(ReadlineError::Eof) => { + break; + } + Err(e) => { + eprintln!("Error: {}", e); + break; + } + } + } + + Ok(()) +} diff --git a/src/lms/mod.rs b/src/lms/mod.rs index d6edf7c..b9286fc 100644 --- a/src/lms/mod.rs +++ b/src/lms/mod.rs @@ -1 +1,2 @@ +pub mod chat; pub mod translate; diff --git a/src/main.rs b/src/main.rs index 8b6589e..05d2792 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,9 @@ enum Commands { /// PDS server #[arg(short, long, default_value = "bsky.social")] server: String, + /// Login as bot (saves to bot.json) + #[arg(long)] + bot: bool, }, /// Update lexicon schema @@ -75,6 +78,25 @@ enum Commands { /// Output directory #[arg(short, long, default_value = "public/content")] output: String, + /// Sync bot data (uses bot.json) + #[arg(long)] + bot: bool, + /// Collection to sync (for bot) + #[arg(short, long)] + collection: Option, + }, + + /// Push local content to PDS + Push { + /// Input directory + #[arg(short, long, default_value = "public/content")] + input: String, + /// Collection (e.g., ai.syui.log.post) + #[arg(short, long, default_value = "ai.syui.log.post")] + collection: String, + /// Push as bot (uses bot.json) + #[arg(long)] + bot: bool, }, /// Generate lexicon Rust code from ATProto lexicon JSON files @@ -107,6 +129,16 @@ enum Commands { #[arg(short, long, default_value = "bsky.social")] server: String, }, + + /// Chat with AI + #[command(alias = "c")] + Chat { + /// Message to send (optional, starts interactive mode if omitted) + message: Option, + /// Start new conversation + #[arg(long)] + new: bool, + }, } #[tokio::main] @@ -117,8 +149,8 @@ async fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { - Commands::Login { handle, password, server } => { - commands::auth::login(&handle, &password, &server).await?; + Commands::Login { handle, password, server, bot } => { + commands::auth::login(&handle, &password, &server, bot).await?; } Commands::Lexicon { file } => { commands::post::put_lexicon(&file).await?; @@ -132,8 +164,11 @@ async fn main() -> Result<()> { Commands::Delete { collection, rkey } => { commands::post::delete_record(&collection, &rkey).await?; } - Commands::Sync { output } => { - commands::post::sync_to_local(&output).await?; + Commands::Sync { output, bot, collection } => { + commands::post::sync_to_local(&output, bot, collection.as_deref()).await?; + } + Commands::Push { input, collection, bot } => { + commands::post::push_to_remote(&input, &collection, bot).await?; } Commands::Gen { input, output } => { commands::gen::generate(&input, &output)?; @@ -144,6 +179,9 @@ async fn main() -> Result<()> { Commands::Did { handle, server } => { commands::did::resolve(&handle, &server).await?; } + Commands::Chat { message, new } => { + lms::chat::run(message.as_deref(), new).await?; + } } Ok(()) diff --git a/src/web/components/chat.ts b/src/web/components/chat.ts new file mode 100644 index 0000000..bb7835b --- /dev/null +++ b/src/web/components/chat.ts @@ -0,0 +1,260 @@ +import type { ChatMessage, Profile } from '../types' +import { renderMarkdown } from '../lib/markdown' + +// Escape HTML to prevent XSS +function escapeHtml(text: string): string { + const div = document.createElement('div') + div.textContent = text + return div.innerHTML +} + +// Format date/time for chat +function formatChatTime(dateStr: string): string { + const d = new Date(dateStr) + const month = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + const hour = String(d.getHours()).padStart(2, '0') + const min = String(d.getMinutes()).padStart(2, '0') + return `${month}/${day} ${hour}:${min}` +} + +// Extract rkey from AT URI +function getRkeyFromUri(uri: string): string { + return uri.split('/').pop() || '' +} + +// Profile info for authors +interface AuthorInfo { + did: string + handle: string + avatarUrl?: string +} + +// Build author info map +function buildAuthorMap( + userDid: string, + userHandle: string, + botDid: string, + botHandle: string, + userProfile?: Profile | null, + botProfile?: Profile | null, + pds?: string +): Map { + const authors = new Map() + + // User info + let userAvatarUrl = '' + if (userProfile?.value.avatar) { + const cid = userProfile.value.avatar.ref.$link + userAvatarUrl = pds ? `${pds}/xrpc/com.atproto.sync.getBlob?did=${userDid}&cid=${cid}` : `/content/${userDid}/blob/${cid}` + } + authors.set(userDid, { did: userDid, handle: userHandle, avatarUrl: userAvatarUrl }) + + // Bot info + let botAvatarUrl = '' + if (botProfile?.value.avatar) { + const cid = botProfile.value.avatar.ref.$link + botAvatarUrl = pds ? `${pds}/xrpc/com.atproto.sync.getBlob?did=${botDid}&cid=${cid}` : `/content/${botDid}/blob/${cid}` + } + authors.set(botDid, { did: botDid, handle: botHandle, avatarUrl: botAvatarUrl }) + + return authors +} + +// Render chat threads list (conversations this user started) +export function renderChatThreadList( + messages: ChatMessage[], + userDid: string, + userHandle: string, + botDid: string, + botHandle: string, + userProfile?: Profile | null, + botProfile?: Profile | null, + pds?: string +): string { + // Build set of all message URIs + const allUris = new Set(messages.map(m => m.uri)) + + // Find root messages by this user: + // 1. No root field (explicit start of conversation) + // 2. Or root points to non-existent message (orphaned, treat as root) + // For orphaned roots, only keep the oldest message per orphaned root URI + const orphanedRootFirstMsg = new Map() + const rootMessages: ChatMessage[] = [] + + for (const msg of messages) { + if (msg.value.author !== userDid) continue + + if (!msg.value.root) { + // No root = explicit conversation start + rootMessages.push(msg) + } else if (!allUris.has(msg.value.root)) { + // Orphaned root - keep only the oldest message per orphaned root + const existing = orphanedRootFirstMsg.get(msg.value.root) + if (!existing || new Date(msg.value.createdAt) < new Date(existing.value.createdAt)) { + orphanedRootFirstMsg.set(msg.value.root, msg) + } + } + } + + // Add orphaned root representatives + for (const msg of orphanedRootFirstMsg.values()) { + rootMessages.push(msg) + } + + if (rootMessages.length === 0) { + return '

No chat threads yet.

' + } + + const authors = buildAuthorMap(userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds) + + // Sort by createdAt (newest first) + const sorted = [...rootMessages].sort((a, b) => + new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime() + ) + + const items = sorted.map(msg => { + const authorDid = msg.value.author + const time = formatChatTime(msg.value.createdAt) + const rkey = getRkeyFromUri(msg.uri) + const author = authors.get(authorDid) || { did: authorDid, handle: authorDid.slice(0, 20) + '...' } + + const avatarHtml = author.avatarUrl + ? `@${escapeHtml(author.handle)}` + : `
` + + // Truncate content for preview + const preview = msg.value.content.length > 100 + ? msg.value.content.slice(0, 100) + '...' + : msg.value.content + + return ` + +
+ ${avatarHtml} +
+
+
+ @${escapeHtml(author.handle)} + ${time} +
+
${escapeHtml(preview)}
+
+
+ ` + }).join('') + + return `
${items}
` +} + +// Render single chat thread (full conversation) +export function renderChatThread( + messages: ChatMessage[], + rootRkey: string, + userDid: string, + userHandle: string, + botDid: string, + botHandle: string, + userProfile?: Profile | null, + botProfile?: Profile | null, + pds?: string +): string { + // Find root message + const rootUri = `at://${userDid}/ai.syui.log.chat/${rootRkey}` + const rootMsg = messages.find(m => m.uri === rootUri) + + if (!rootMsg) { + return '

Chat thread not found.

' + } + + // Find all messages in this thread + // 1. The root message itself + // 2. Messages with root === rootUri (direct children) + // 3. If this is an orphaned root (root points to non-existent), find siblings with same original root + const originalRoot = rootMsg.value.root + const allUris = new Set(messages.map(m => m.uri)) + const isOrphanedRoot = originalRoot && !allUris.has(originalRoot) + + const threadMessages = messages.filter(msg => { + // Include the root message itself + if (msg.uri === rootUri) return true + // Include messages that point to this as root + if (msg.value.root === rootUri) return true + // If orphaned, include messages with the same original root + if (isOrphanedRoot && msg.value.root === originalRoot) return true + return false + }) + + if (threadMessages.length === 0) { + return '

No messages in this thread.

' + } + + const authors = buildAuthorMap(userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds) + + // Sort by createdAt + const sorted = [...threadMessages].sort((a, b) => + new Date(a.value.createdAt).getTime() - new Date(b.value.createdAt).getTime() + ) + + const items = sorted.map(msg => { + const authorDid = msg.value.author + const time = formatChatTime(msg.value.createdAt) + const rkey = getRkeyFromUri(msg.uri) + const author = authors.get(authorDid) || { did: authorDid, handle: authorDid.slice(0, 20) + '...' } + + const avatarHtml = author.avatarUrl + ? `@${escapeHtml(author.handle)}` + : `
` + + const content = renderMarkdown(msg.value.content) + const recordLink = `/@${author.handle}/at/collection/ai.syui.log.chat/${rkey}` + + return ` + + ` + }).join('') + + return `
${items}
` +} + +// Render chat list page +export function renderChatListPage( + messages: ChatMessage[], + userDid: string, + userHandle: string, + botDid: string, + botHandle: string, + userProfile?: Profile | null, + botProfile?: Profile | null, + pds?: string +): string { + const list = renderChatThreadList(messages, userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds) + return `
${list}
` +} + +// Render chat thread page +export function renderChatThreadPage( + messages: ChatMessage[], + rootRkey: string, + userDid: string, + userHandle: string, + botDid: string, + botHandle: string, + userProfile?: Profile | null, + botProfile?: Profile | null, + pds?: string +): string { + const thread = renderChatThread(messages, rootRkey, userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds) + return `
${thread}
` +} diff --git a/src/web/components/mode-tabs.ts b/src/web/components/mode-tabs.ts index 94c3d63..bdcb4b5 100644 --- a/src/web/components/mode-tabs.ts +++ b/src/web/components/mode-tabs.ts @@ -21,13 +21,18 @@ export function setCurrentLang(lang: string): void { localStorage.setItem('preferred-lang', lang) } -export function renderModeTabs(handle: string, activeTab: 'blog' | 'browser' | 'post' = 'blog'): string { +export function renderModeTabs(handle: string, activeTab: 'blog' | 'browser' | 'post' | 'chat' = 'blog', isLocalUser: boolean = false): string { let tabs = ` / ${handle} at ` + // Chat tab only for local user (admin) + if (isLocalUser) { + tabs += `chat` + } + if (isLoggedIn()) { tabs += `post` } diff --git a/src/web/lib/api.ts b/src/web/lib/api.ts index b4923dd..bfd0c40 100644 --- a/src/web/lib/api.ts +++ b/src/web/lib/api.ts @@ -1,5 +1,5 @@ import { xrpcUrl, comAtprotoIdentity, comAtprotoRepo } from '../lexicons' -import type { AppConfig, Networks, Profile, Post, ListRecordsResponse } from '../types' +import type { AppConfig, Networks, Profile, Post, ListRecordsResponse, ChatMessage } from '../types' // Cache let configCache: AppConfig | null = null @@ -368,3 +368,53 @@ export interface SearchPost { } record: unknown } + +// Load chat messages from both user and bot repos +export async function getChatMessages( + userDid: string, + botDid: string, + collection: string = 'ai.syui.log.chat' +): Promise { + const messages: ChatMessage[] = [] + + // Load from both DIDs + for (const did of [userDid, botDid]) { + // Try local first + try { + const res = await fetch(`/content/${did}/${collection}/index.json`) + if (res.ok && isJsonResponse(res)) { + const rkeys: string[] = await res.json() + for (const rkey of rkeys) { + const msgRes = await fetch(`/content/${did}/${collection}/${rkey}.json`) + if (msgRes.ok && isJsonResponse(msgRes)) { + messages.push(await msgRes.json()) + } + } + continue + } + } catch { + // Try remote + } + + // Remote fallback + const pds = await getPds(did) + if (!pds) continue + + try { + const host = pds.replace('https://', '') + const url = `${xrpcUrl(host, comAtprotoRepo.listRecords)}?repo=${did}&collection=${collection}&limit=100` + const res = await fetch(url) + if (res.ok) { + const data: ListRecordsResponse = await res.json() + messages.push(...data.records) + } + } catch { + // Failed + } + } + + // Sort by createdAt + return messages.sort((a, b) => + new Date(a.value.createdAt).getTime() - new Date(b.value.createdAt).getTime() + ) +} diff --git a/src/web/lib/router.ts b/src/web/lib/router.ts index 61dd689..1a158ad 100644 --- a/src/web/lib/router.ts +++ b/src/web/lib/router.ts @@ -1,5 +1,5 @@ export interface Route { - type: 'home' | 'user' | 'post' | 'postpage' | 'atbrowser' | 'service' | 'collection' | 'record' + type: 'home' | 'user' | 'post' | 'postpage' | 'atbrowser' | 'service' | 'collection' | 'record' | 'chat' | 'chat-thread' handle?: string rkey?: string service?: string @@ -51,6 +51,18 @@ export function parseRoute(): Route { return { type: 'postpage', handle: postPageMatch[1] } } + // Chat thread: /@handle/at/chat/{rkey} + const chatThreadMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)$/) + if (chatThreadMatch) { + return { type: 'chat-thread', handle: chatThreadMatch[1], rkey: chatThreadMatch[2] } + } + + // Chat list: /@handle/at/chat + const chatMatch = path.match(/^\/@([^/]+)\/at\/chat\/?$/) + if (chatMatch) { + return { type: 'chat', handle: chatMatch[1] } + } + // Post detail page: /@handle/rkey (for config.collection) const postMatch = path.match(/^\/@([^/]+)\/([^/]+)$/) if (postMatch) { @@ -79,6 +91,10 @@ export function navigate(route: Route): void { path = `/@${route.handle}/at/collection/${route.collection}` } else if (route.type === 'record' && route.handle && route.collection && route.rkey) { path = `/@${route.handle}/at/collection/${route.collection}/${route.rkey}` + } else if (route.type === 'chat' && route.handle) { + path = `/@${route.handle}/at/chat` + } else if (route.type === 'chat-thread' && route.handle && route.rkey) { + path = `/@${route.handle}/at/chat/${route.rkey}` } window.history.pushState({}, '', path) diff --git a/src/web/main.ts b/src/web/main.ts index 0526fe5..ea197a6 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -1,5 +1,5 @@ import './styles/main.css' -import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks } from './lib/api' +import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks, getChatMessages } from './lib/api' import { parseRoute, onRouteChange, navigate, type Route } from './lib/router' import { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost } from './lib/auth' import { validateRecord } from './lib/lexicon' @@ -10,6 +10,7 @@ import { renderPostForm, setupPostForm } from './components/postform' import { renderCollectionButtons, renderServerInfo, renderServiceList, renderCollectionList, renderRecordList, renderRecordDetail } from './components/browser' import { renderModeTabs, renderLangSelector, setupModeTabs } from './components/mode-tabs' import { renderFooter } from './components/footer' +import { renderChatListPage, renderChatThreadPage } from './components/chat' import { showLoading, hideLoading } from './components/loading' const app = document.getElementById('app')! @@ -157,10 +158,11 @@ async function render(route: Route): Promise { // Build page let html = renderHeader(handle, oauthEnabled) - // Mode tabs (Blog/Browser/Post/PDS) + // Mode tabs (Blog/Browser/Post/Chat/PDS) const activeTab = route.type === 'postpage' ? 'post' : + (route.type === 'chat' || route.type === 'chat-thread') ? 'chat' : (route.type === 'atbrowser' || route.type === 'service' || route.type === 'collection' || route.type === 'record' ? 'browser' : 'blog') - html += renderModeTabs(handle, activeTab) + html += renderModeTabs(handle, activeTab, localOnly) // Profile section if (profile) { @@ -226,6 +228,29 @@ async function render(route: Route): Promise { html += `
${renderPostForm(config.collection)}
` html += `` + } else if (route.type === 'chat') { + // Chat list page - show threads started by this user + const aiDid = 'did:plc:6qyecktefllvenje24fcxnie' // ai.syui.ai + const aiHandle = 'ai.syui.ai' + + // Load messages for the current user (did) and bot + const chatMessages = await getChatMessages(did, aiDid, 'ai.syui.log.chat') + const aiProfile = await getProfile(aiDid, false) + const pds = await getPds(did) + html += `
${renderChatListPage(chatMessages, did, handle, aiDid, aiHandle, profile, aiProfile, pds || undefined)}
` + html += `` + + } else if (route.type === 'chat-thread' && route.rkey) { + // Chat thread page - show full conversation + const aiDid = 'did:plc:6qyecktefllvenje24fcxnie' // ai.syui.ai + const aiHandle = 'ai.syui.ai' + + const chatMessages = await getChatMessages(did, aiDid, 'ai.syui.log.chat') + const aiProfile = await getProfile(aiDid, false) + const pds = await getPds(did) + html += `
${renderChatThreadPage(chatMessages, route.rkey, did, handle, aiDid, aiHandle, profile, aiProfile, pds || undefined)}
` + html += `` + } else { // User page: compact collection buttons + posts const collections = await describeRepo(did) diff --git a/src/web/styles/main.css b/src/web/styles/main.css index 3effaa8..1811b00 100644 --- a/src/web/styles/main.css +++ b/src/web/styles/main.css @@ -2271,3 +2271,216 @@ button.tab { color: #e0e0e0; } } + +/* Chat Styles - Bluesky social-app style */ +.chat-container { + margin: 10px 0; +} + +.chat-list { + display: flex; + flex-direction: column; +} + +.chat-message { + display: flex; + gap: 10px; + padding: 12px 0; + border-bottom: 1px solid #e0e0e0; +} + +.chat-message:last-child { + border-bottom: none; +} + +.chat-avatar-col { + flex-shrink: 0; +} + +.chat-avatar { + width: 42px; + height: 42px; + border-radius: 50%; + object-fit: cover; +} + +.chat-avatar-placeholder { + width: 42px; + height: 42px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.chat-content-col { + flex: 1; + min-width: 0; +} + +.chat-message-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; +} + +.chat-author { + font-weight: 600; + color: #1a1a1a; + text-decoration: none; + font-size: 0.95rem; +} + +.chat-author:hover { + text-decoration: underline; +} + +.chat-time { + color: #888; + font-size: 0.85rem; + text-decoration: none; +} + +.chat-time:hover { + text-decoration: underline; + color: #666; +} + +.chat-content { + line-height: 1.5; + color: #1a1a1a; +} + +.chat-content p { + margin: 0 0 8px 0; +} + +.chat-content p:last-child { + margin-bottom: 0; +} + +.chat-content pre { + background: #f5f5f5; + padding: 10px; + border-radius: 8px; + overflow-x: auto; + font-size: 0.9rem; + margin: 8px 0; +} + +.chat-content code { + background: #f0f0f0; + padding: 2px 6px; + border-radius: 4px; + font-size: 0.9em; +} + +.chat-content pre code { + background: none; + padding: 0; +} + +.chat-content a { + color: var(--btn-color); +} + +/* Dark mode chat */ +@media (prefers-color-scheme: dark) { + .chat-message { + border-color: #333; + } + + .chat-author { + color: #e0e0e0; + } + + .chat-time { + color: #888; + } + + .chat-time:hover { + color: #aaa; + } + + .chat-content { + color: #e0e0e0; + } + + .chat-content pre { + background: #2a2a2a; + } + + .chat-content code { + background: #333; + } + + .chat-avatar-placeholder { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + } + + .chat-thread-item { + border-color: #333; + } + + .chat-thread-item:hover { + background: #2a2a2a; + } + + .chat-thread-preview { + color: #999; + } +} + +/* Chat Thread List */ +.chat-thread-list { + display: flex; + flex-direction: column; +} + +.chat-thread-item { + display: flex; + gap: 10px; + padding: 12px; + border-bottom: 1px solid #e0e0e0; + text-decoration: none; + color: inherit; + transition: background-color 0.15s; +} + +.chat-thread-item:hover { + background: #f5f5f5; +} + +.chat-thread-item:last-child { + border-bottom: none; +} + +.chat-thread-content { + flex: 1; + min-width: 0; +} + +.chat-thread-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.chat-thread-header .chat-author { + font-weight: 600; + color: #1a1a1a; +} + +.chat-thread-header .chat-time { + color: #888; + font-size: 0.85rem; +} + +.chat-thread-preview { + color: #666; + font-size: 0.95rem; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/web/types.ts b/src/web/types.ts index 808bd1c..36ccaff 100644 --- a/src/web/types.ts +++ b/src/web/types.ts @@ -64,3 +64,16 @@ export interface ListRecordsResponse { records: T[] cursor?: string } + +export interface ChatMessage { + cid: string + uri: string + value: { + $type: string + content: string + author: string + createdAt: string + root?: string + parent?: string + } +}