From 1a69d287adf3c1321e1c2d5fe3e6550cc025de9a Mon Sep 17 00:00:00 2001 From: syui Date: Tue, 20 Jan 2026 18:47:52 +0900 Subject: [PATCH] add chat lang --- lexicons/ai.syui.log.chat.json | 29 ++++++++ package.json | 1 + readme.md | 2 +- src/commands/index.rs | 130 +++++++++++++++++++++++++++++++++ src/commands/mod.rs | 1 + src/main.rs | 11 +++ src/mcp/mod.rs | 19 ++--- src/web/components/chat.ts | 25 +++++-- src/web/main.ts | 34 ++++++++- src/web/types.ts | 6 ++ 10 files changed, 236 insertions(+), 22 deletions(-) create mode 100644 src/commands/index.rs diff --git a/lexicons/ai.syui.log.chat.json b/lexicons/ai.syui.log.chat.json index 5553118..52fb984 100644 --- a/lexicons/ai.syui.log.chat.json +++ b/lexicons/ai.syui.log.chat.json @@ -35,9 +35,38 @@ "type": "string", "format": "datetime", "description": "Client-declared timestamp when this message was created." + }, + "lang": { + "type": "string", + "maxLength": 10, + "description": "Language code of the original content (e.g., 'ja', 'en')." + }, + "translations": { + "type": "ref", + "ref": "#translationMap", + "description": "Translations of the message in other languages." } } } + }, + "translationMap": { + "type": "object", + "description": "Map of language codes to translations.", + "properties": { + "en": { "type": "ref", "ref": "#translation" }, + "ja": { "type": "ref", "ref": "#translation" } + } + }, + "translation": { + "type": "object", + "description": "A translation of a chat message.", + "properties": { + "content": { + "type": "string", + "maxLength": 100000, + "maxGraphemes": 10000 + } + } } } } diff --git a/package.json b/package.json index 30eab6d..aaa1b1d 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "type": "module", "scripts": { "dev": "vite", + "prebuild": "ailog index 2>/dev/null || true", "build": "tsc && vite build", "preview": "vite preview" }, diff --git a/readme.md b/readme.md index 9f33a29..a449700 100644 --- a/readme.md +++ b/readme.md @@ -268,7 +268,7 @@ $ ailog login ai.syu.is -p -s syu.is --bot ``` CHAT_URL=http://127.0.0.1:1234/v1 -CHAT_MODEL=gemma-2-9b +CHAT_MODEL=gpt-oss-20b ``` 3. (Optional) Set character/system prompt: diff --git a/src/commands/index.rs b/src/commands/index.rs new file mode 100644 index 0000000..419b57e --- /dev/null +++ b/src/commands/index.rs @@ -0,0 +1,130 @@ +use anyhow::Result; +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +/// Rebuild index.json files for all collections in content directory +pub fn run(content_dir: &Path) -> Result<()> { + if !content_dir.exists() { + println!("Content directory not found: {}", content_dir.display()); + return Ok(()); + } + + let mut total_updated = 0; + let mut total_created = 0; + + // Iterate through DID directories + for did_entry in fs::read_dir(content_dir)? { + let did_entry = did_entry?; + let did_path = did_entry.path(); + + if !did_path.is_dir() { + continue; + } + + let did_name = did_path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + // Skip non-DID directories + if !did_name.starts_with("did:") { + continue; + } + + // Iterate through collection directories + for col_entry in fs::read_dir(&did_path)? { + let col_entry = col_entry?; + let col_path = col_entry.path(); + + if !col_path.is_dir() { + continue; + } + + let col_name = col_path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + // Collect all rkeys from .json files (excluding special files) + let mut rkeys: Vec = Vec::new(); + let mut rkey_times: HashMap = HashMap::new(); + + for file_entry in fs::read_dir(&col_path)? { + let file_entry = file_entry?; + let file_path = file_entry.path(); + + if !file_path.is_file() { + continue; + } + + let filename = file_path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + // Skip non-json and special files + if !filename.ends_with(".json") { + continue; + } + if filename == "index.json" || filename == "describe.json" || filename == "self.json" { + continue; + } + + // Extract rkey from filename + let rkey = filename.trim_end_matches(".json").to_string(); + + // Get file modification time for sorting + if let Ok(metadata) = file_entry.metadata() { + if let Ok(modified) = metadata.modified() { + rkey_times.insert(rkey.clone(), modified); + } + } + + rkeys.push(rkey); + } + + if rkeys.is_empty() { + continue; + } + + // Sort by modification time (oldest first) or alphabetically + rkeys.sort_by(|a, b| { + match (rkey_times.get(a), rkey_times.get(b)) { + (Some(ta), Some(tb)) => ta.cmp(tb), + _ => a.cmp(b), + } + }); + + // Check existing index.json + let index_path = col_path.join("index.json"); + let existing: Vec = if index_path.exists() { + fs::read_to_string(&index_path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() + } else { + Vec::new() + }; + + // Compare and update if different + if existing != rkeys { + fs::write(&index_path, serde_json::to_string_pretty(&rkeys)?)?; + + if existing.is_empty() && !index_path.exists() { + println!(" Created: {}/{}/index.json ({} records)", did_name, col_name, rkeys.len()); + total_created += 1; + } else { + println!(" Updated: {}/{}/index.json ({} -> {} records)", + did_name, col_name, existing.len(), rkeys.len()); + total_updated += 1; + } + } + } + } + + if total_created == 0 && total_updated == 0 { + println!("All index.json files are up to date."); + } else { + println!("\nDone: {} created, {} updated", total_created, total_updated); + } + + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 4f66577..aac8da9 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -4,3 +4,4 @@ pub mod post; pub mod gen; pub mod lang; pub mod did; +pub mod index; diff --git a/src/main.rs b/src/main.rs index e878a30..070e750 100644 --- a/src/main.rs +++ b/src/main.rs @@ -144,6 +144,14 @@ enum Commands { /// Run MCP server (for Claude Code integration) #[command(name = "mcp-serve")] McpServe, + + /// Rebuild index.json files for content collections + #[command(alias = "i")] + Index { + /// Content directory + #[arg(short, long, default_value = "public/content")] + dir: String, + }, } #[tokio::main] @@ -190,6 +198,9 @@ async fn main() -> Result<()> { Commands::McpServe => { mcp::serve()?; } + Commands::Index { dir } => { + commands::index::run(std::path::Path::new(&dir))?; + } } Ok(()) diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 3b7202a..1dd9d24 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -11,6 +11,7 @@ const BUNDLE_ID: &str = "ai.syui.log"; // JSON-RPC types #[derive(Debug, Deserialize)] +#[allow(dead_code)] struct JsonRpcRequest { jsonrpc: String, id: Option, @@ -111,8 +112,6 @@ struct ChatRecord { struct McpSession { root_uri: Option, last_uri: Option, - #[serde(default)] - skip_next_save: bool, } fn session_path() -> Result { @@ -196,13 +195,13 @@ fn save_chat_record( // Update index.json let index_path = collection_dir.join("index.json"); let mut rkeys: Vec = if index_path.exists() { - let content = fs::read_to_string(&index_path).unwrap_or_else(|_| "[]".to_string()); - serde_json::from_str(&content).unwrap_or_default() + let index_content = fs::read_to_string(&index_path).unwrap_or_else(|_| "[]".to_string()); + serde_json::from_str(&index_content).unwrap_or_default() } else { Vec::new() }; if !rkeys.contains(&rkey) { - rkeys.push(rkey); + rkeys.push(rkey.clone()); fs::write(&index_path, serde_json::to_string_pretty(&rkeys)?)?; } @@ -211,15 +210,8 @@ fn save_chat_record( /// Handle chat_save tool fn handle_chat_save(params: ChatSaveParams) -> Result { - // Check if we should skip this save (after chat_new) + // Load session let mut session = load_mcp_session(); - if session.skip_next_save { - session.skip_next_save = false; - session.root_uri = None; - session.last_uri = None; - save_mcp_session(&session)?; - return Ok("Skipped save (new thread started). Next message will be saved.".to_string()); - } // Get output directory let output_dir = env::var("CHAT_OUTPUT").unwrap_or_else(|_| { @@ -325,7 +317,6 @@ fn handle_chat_new() -> Result { let session = McpSession { root_uri: None, last_uri: None, - skip_next_save: true, // Skip the next save (the "new thread" message) }; save_mcp_session(&session)?; Ok("New chat thread started. The next conversation will begin a new thread.".to_string()) diff --git a/src/web/components/chat.ts b/src/web/components/chat.ts index bb7835b..7d35597 100644 --- a/src/web/components/chat.ts +++ b/src/web/components/chat.ts @@ -1,5 +1,18 @@ import type { ChatMessage, Profile } from '../types' import { renderMarkdown } from '../lib/markdown' +import { getCurrentLang } from './mode-tabs' + +// Get translated content for a chat message +function getTranslatedContent(msg: ChatMessage): string { + const currentLang = getCurrentLang() + const originalLang = msg.value.lang || 'ja' + const translations = msg.value.translations + + if (translations && currentLang !== originalLang && translations[currentLang]) { + return translations[currentLang].content || msg.value.content + } + return msg.value.content +} // Escape HTML to prevent XSS function escapeHtml(text: string): string { @@ -123,10 +136,11 @@ export function renderChatThreadList( ? `@${escapeHtml(author.handle)}` : `
` - // Truncate content for preview - const preview = msg.value.content.length > 100 - ? msg.value.content.slice(0, 100) + '...' - : msg.value.content + // Truncate content for preview (use translated content) + const displayContent = getTranslatedContent(msg) + const preview = displayContent.length > 100 + ? displayContent.slice(0, 100) + '...' + : displayContent return ` @@ -206,7 +220,8 @@ export function renderChatThread( ? `@${escapeHtml(author.handle)}` : `
` - const content = renderMarkdown(msg.value.content) + const displayContent = getTranslatedContent(msg) + const content = renderMarkdown(displayContent) const recordLink = `/@${author.handle}/at/collection/ai.syui.log.chat/${rkey}` return ` diff --git a/src/web/main.ts b/src/web/main.ts index ea197a6..943c6a6 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -140,7 +140,7 @@ async function render(route: Route): Promise { // Load posts (local only for admin, remote for others) const posts = await getPosts(did, config.collection, localOnly) - // Collect available languages from posts + // Collect available languages from posts (used for non-chat pages) const availableLangs = new Set() for (const post of posts) { // Add original language (default: ja for Japanese posts) @@ -153,7 +153,7 @@ async function render(route: Route): Promise { } } } - const langList = Array.from(availableLangs) + let langList = Array.from(availableLangs) // Build page let html = renderHeader(handle, oauthEnabled) @@ -237,6 +237,21 @@ async function render(route: Route): Promise { const chatMessages = await getChatMessages(did, aiDid, 'ai.syui.log.chat') const aiProfile = await getProfile(aiDid, false) const pds = await getPds(did) + + // Collect available languages from chat messages + const chatLangs = new Set() + for (const msg of chatMessages) { + const msgLang = msg.value.lang || 'ja' + chatLangs.add(msgLang) + if (msg.value.translations) { + for (const lang of Object.keys(msg.value.translations)) { + chatLangs.add(lang) + } + } + } + langList = Array.from(chatLangs) + + html += renderLangSelector(langList) html += `
${renderChatListPage(chatMessages, did, handle, aiDid, aiHandle, profile, aiProfile, pds || undefined)}
` html += `
` @@ -248,6 +263,21 @@ async function render(route: Route): Promise { const chatMessages = await getChatMessages(did, aiDid, 'ai.syui.log.chat') const aiProfile = await getProfile(aiDid, false) const pds = await getPds(did) + + // Collect available languages from chat messages + const chatLangs = new Set() + for (const msg of chatMessages) { + const msgLang = msg.value.lang || 'ja' + chatLangs.add(msgLang) + if (msg.value.translations) { + for (const lang of Object.keys(msg.value.translations)) { + chatLangs.add(lang) + } + } + } + langList = Array.from(chatLangs) + + html += renderLangSelector(langList) html += `
${renderChatThreadPage(chatMessages, route.rkey, did, handle, aiDid, aiHandle, profile, aiProfile, pds || undefined)}
` html += `` diff --git a/src/web/types.ts b/src/web/types.ts index 36ccaff..b2ac46a 100644 --- a/src/web/types.ts +++ b/src/web/types.ts @@ -75,5 +75,11 @@ export interface ChatMessage { createdAt: string root?: string parent?: string + lang?: string + translations?: { + [lang: string]: { + content: string + } + } } }