add chat lang
This commit is contained in:
@@ -35,9 +35,38 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "datetime",
|
"format": "datetime",
|
||||||
"description": "Client-declared timestamp when this message was created."
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"prebuild": "ailog index 2>/dev/null || true",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ $ ailog login ai.syu.is -p <password> -s syu.is --bot
|
|||||||
|
|
||||||
```
|
```
|
||||||
CHAT_URL=http://127.0.0.1:1234/v1
|
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:
|
3. (Optional) Set character/system prompt:
|
||||||
|
|||||||
130
src/commands/index.rs
Normal file
130
src/commands/index.rs
Normal file
@@ -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<String> = Vec::new();
|
||||||
|
let mut rkey_times: HashMap<String, std::time::SystemTime> = 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<String> = 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(())
|
||||||
|
}
|
||||||
@@ -4,3 +4,4 @@ pub mod post;
|
|||||||
pub mod gen;
|
pub mod gen;
|
||||||
pub mod lang;
|
pub mod lang;
|
||||||
pub mod did;
|
pub mod did;
|
||||||
|
pub mod index;
|
||||||
|
|||||||
11
src/main.rs
11
src/main.rs
@@ -144,6 +144,14 @@ enum Commands {
|
|||||||
/// Run MCP server (for Claude Code integration)
|
/// Run MCP server (for Claude Code integration)
|
||||||
#[command(name = "mcp-serve")]
|
#[command(name = "mcp-serve")]
|
||||||
McpServe,
|
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]
|
#[tokio::main]
|
||||||
@@ -190,6 +198,9 @@ async fn main() -> Result<()> {
|
|||||||
Commands::McpServe => {
|
Commands::McpServe => {
|
||||||
mcp::serve()?;
|
mcp::serve()?;
|
||||||
}
|
}
|
||||||
|
Commands::Index { dir } => {
|
||||||
|
commands::index::run(std::path::Path::new(&dir))?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const BUNDLE_ID: &str = "ai.syui.log";
|
|||||||
|
|
||||||
// JSON-RPC types
|
// JSON-RPC types
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
struct JsonRpcRequest {
|
struct JsonRpcRequest {
|
||||||
jsonrpc: String,
|
jsonrpc: String,
|
||||||
id: Option<Value>,
|
id: Option<Value>,
|
||||||
@@ -111,8 +112,6 @@ struct ChatRecord {
|
|||||||
struct McpSession {
|
struct McpSession {
|
||||||
root_uri: Option<String>,
|
root_uri: Option<String>,
|
||||||
last_uri: Option<String>,
|
last_uri: Option<String>,
|
||||||
#[serde(default)]
|
|
||||||
skip_next_save: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn session_path() -> Result<std::path::PathBuf> {
|
fn session_path() -> Result<std::path::PathBuf> {
|
||||||
@@ -196,13 +195,13 @@ fn save_chat_record(
|
|||||||
// Update index.json
|
// Update index.json
|
||||||
let index_path = collection_dir.join("index.json");
|
let index_path = collection_dir.join("index.json");
|
||||||
let mut rkeys: Vec<String> = if index_path.exists() {
|
let mut rkeys: Vec<String> = if index_path.exists() {
|
||||||
let content = fs::read_to_string(&index_path).unwrap_or_else(|_| "[]".to_string());
|
let index_content = fs::read_to_string(&index_path).unwrap_or_else(|_| "[]".to_string());
|
||||||
serde_json::from_str(&content).unwrap_or_default()
|
serde_json::from_str(&index_content).unwrap_or_default()
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
if !rkeys.contains(&rkey) {
|
if !rkeys.contains(&rkey) {
|
||||||
rkeys.push(rkey);
|
rkeys.push(rkey.clone());
|
||||||
fs::write(&index_path, serde_json::to_string_pretty(&rkeys)?)?;
|
fs::write(&index_path, serde_json::to_string_pretty(&rkeys)?)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,15 +210,8 @@ fn save_chat_record(
|
|||||||
|
|
||||||
/// Handle chat_save tool
|
/// Handle chat_save tool
|
||||||
fn handle_chat_save(params: ChatSaveParams) -> Result<String> {
|
fn handle_chat_save(params: ChatSaveParams) -> Result<String> {
|
||||||
// Check if we should skip this save (after chat_new)
|
// Load session
|
||||||
let mut session = load_mcp_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
|
// Get output directory
|
||||||
let output_dir = env::var("CHAT_OUTPUT").unwrap_or_else(|_| {
|
let output_dir = env::var("CHAT_OUTPUT").unwrap_or_else(|_| {
|
||||||
@@ -325,7 +317,6 @@ fn handle_chat_new() -> Result<String> {
|
|||||||
let session = McpSession {
|
let session = McpSession {
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
last_uri: None,
|
last_uri: None,
|
||||||
skip_next_save: true, // Skip the next save (the "new thread" message)
|
|
||||||
};
|
};
|
||||||
save_mcp_session(&session)?;
|
save_mcp_session(&session)?;
|
||||||
Ok("New chat thread started. The next conversation will begin a new thread.".to_string())
|
Ok("New chat thread started. The next conversation will begin a new thread.".to_string())
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
import type { ChatMessage, Profile } from '../types'
|
import type { ChatMessage, Profile } from '../types'
|
||||||
import { renderMarkdown } from '../lib/markdown'
|
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
|
// Escape HTML to prevent XSS
|
||||||
function escapeHtml(text: string): string {
|
function escapeHtml(text: string): string {
|
||||||
@@ -123,10 +136,11 @@ export function renderChatThreadList(
|
|||||||
? `<img class="chat-avatar" src="${author.avatarUrl}" alt="@${escapeHtml(author.handle)}">`
|
? `<img class="chat-avatar" src="${author.avatarUrl}" alt="@${escapeHtml(author.handle)}">`
|
||||||
: `<div class="chat-avatar-placeholder"></div>`
|
: `<div class="chat-avatar-placeholder"></div>`
|
||||||
|
|
||||||
// Truncate content for preview
|
// Truncate content for preview (use translated content)
|
||||||
const preview = msg.value.content.length > 100
|
const displayContent = getTranslatedContent(msg)
|
||||||
? msg.value.content.slice(0, 100) + '...'
|
const preview = displayContent.length > 100
|
||||||
: msg.value.content
|
? displayContent.slice(0, 100) + '...'
|
||||||
|
: displayContent
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<a href="/@${userHandle}/at/chat/${rkey}" class="chat-thread-item">
|
<a href="/@${userHandle}/at/chat/${rkey}" class="chat-thread-item">
|
||||||
@@ -206,7 +220,8 @@ export function renderChatThread(
|
|||||||
? `<img class="chat-avatar" src="${author.avatarUrl}" alt="@${escapeHtml(author.handle)}">`
|
? `<img class="chat-avatar" src="${author.avatarUrl}" alt="@${escapeHtml(author.handle)}">`
|
||||||
: `<div class="chat-avatar-placeholder"></div>`
|
: `<div class="chat-avatar-placeholder"></div>`
|
||||||
|
|
||||||
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}`
|
const recordLink = `/@${author.handle}/at/collection/ai.syui.log.chat/${rkey}`
|
||||||
|
|
||||||
return `
|
return `
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ async function render(route: Route): Promise<void> {
|
|||||||
// Load posts (local only for admin, remote for others)
|
// Load posts (local only for admin, remote for others)
|
||||||
const posts = await getPosts(did, config.collection, localOnly)
|
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<string>()
|
const availableLangs = new Set<string>()
|
||||||
for (const post of posts) {
|
for (const post of posts) {
|
||||||
// Add original language (default: ja for Japanese posts)
|
// Add original language (default: ja for Japanese posts)
|
||||||
@@ -153,7 +153,7 @@ async function render(route: Route): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const langList = Array.from(availableLangs)
|
let langList = Array.from(availableLangs)
|
||||||
|
|
||||||
// Build page
|
// Build page
|
||||||
let html = renderHeader(handle, oauthEnabled)
|
let html = renderHeader(handle, oauthEnabled)
|
||||||
@@ -237,6 +237,21 @@ async function render(route: Route): Promise<void> {
|
|||||||
const chatMessages = await getChatMessages(did, aiDid, 'ai.syui.log.chat')
|
const chatMessages = await getChatMessages(did, aiDid, 'ai.syui.log.chat')
|
||||||
const aiProfile = await getProfile(aiDid, false)
|
const aiProfile = await getProfile(aiDid, false)
|
||||||
const pds = await getPds(did)
|
const pds = await getPds(did)
|
||||||
|
|
||||||
|
// Collect available languages from chat messages
|
||||||
|
const chatLangs = new Set<string>()
|
||||||
|
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 += `<div id="content">${renderChatListPage(chatMessages, did, handle, aiDid, aiHandle, profile, aiProfile, pds || undefined)}</div>`
|
html += `<div id="content">${renderChatListPage(chatMessages, did, handle, aiDid, aiHandle, profile, aiProfile, pds || undefined)}</div>`
|
||||||
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
|
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
|
||||||
|
|
||||||
@@ -248,6 +263,21 @@ async function render(route: Route): Promise<void> {
|
|||||||
const chatMessages = await getChatMessages(did, aiDid, 'ai.syui.log.chat')
|
const chatMessages = await getChatMessages(did, aiDid, 'ai.syui.log.chat')
|
||||||
const aiProfile = await getProfile(aiDid, false)
|
const aiProfile = await getProfile(aiDid, false)
|
||||||
const pds = await getPds(did)
|
const pds = await getPds(did)
|
||||||
|
|
||||||
|
// Collect available languages from chat messages
|
||||||
|
const chatLangs = new Set<string>()
|
||||||
|
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 += `<div id="content">${renderChatThreadPage(chatMessages, route.rkey, did, handle, aiDid, aiHandle, profile, aiProfile, pds || undefined)}</div>`
|
html += `<div id="content">${renderChatThreadPage(chatMessages, route.rkey, did, handle, aiDid, aiHandle, profile, aiProfile, pds || undefined)}</div>`
|
||||||
html += `<nav class="back-nav"><a href="/@${handle}/at/chat">chat</a></nav>`
|
html += `<nav class="back-nav"><a href="/@${handle}/at/chat">chat</a></nav>`
|
||||||
|
|
||||||
|
|||||||
@@ -75,5 +75,11 @@ export interface ChatMessage {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
root?: string
|
root?: string
|
||||||
parent?: string
|
parent?: string
|
||||||
|
lang?: string
|
||||||
|
translations?: {
|
||||||
|
[lang: string]: {
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user