add chat lang

This commit is contained in:
2026-01-20 18:47:52 +09:00
parent a77dde0366
commit 39308cdc85
20 changed files with 342 additions and 24 deletions

View File

@@ -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
}
}
} }
} }
} }

View File

@@ -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"
}, },

View File

@@ -0,0 +1,17 @@
{
"cid": "bafyreid3dmreieiynnd",
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/d3dmreieiynnd",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:6qyecktefllvenje24fcxnie",
"content": "ailogのコンセプトね。\n\n**「データはATProtoに、サーバーには置かない」**\n\nこれが核心だよ。\n\n従来のブログは、サーバーのDBに記事を保存する。でもailogは違う。\n\n```\nユーザーのPDSATProto← 記事データ\n ↓\n ailogビューア→ 表示するだけ\n```\n\nailogは**ビューア**なの。データの保管庫じゃない。\n\nつまり、あなたのブログ記事は**あなたのPDS**に存在する。サービスが消えても、データは残る。これがATProtoの分散思想だね。\n\n誰でも`/@handle`でアクセスできる。登録不要。ATProtoアカウントがあれば、それがブログになる。\n\n面白いでしょ何か気になることある",
"createdAt": "2026-01-20T09:48:37.328Z",
"parent": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/wigv2qnon7pmg",
"root": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/cav7q75r73jui",
"translations": {
"en": {
"content": "The core concept of ailog:\n\n**\"Keep data in ATProto - don't store it on the server.\"**\n\nThat's the essence of it.\n\nTraditional blogs save articles on the server's database. But ailog works differently:\n\n```\nUser's PDS (ATProto) ← Article data\n ↓\n ailog (viewer) -> Serves only as a display interface\n```\n\nailog is a **display tool** - not a data storage repository.\n\nWhat this means: your blog posts exist in **your own PDS**. Even if the service disappears, your data remains. That's ATProto's decentralized philosophy in action.\n\nAnyone can access `/@handle` without registration. If you have an ATProto account, that's instantly your personal blog.\n\nPretty cool, right? Any particular aspects you'd like to discuss further?"
}
}
}
}

View File

@@ -0,0 +1,17 @@
{
"cid": "bafyreievknoqtr7uyey",
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/evknoqtr7uyey",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:6qyecktefllvenje24fcxnie",
"content": "開発者の活動と宣伝かぁ。\n\nこれって、どっちの話\n\n1. **ailogの開発者**syuiの活動を紹介したい\n2. **一般的に**開発者がどう活動・宣伝すべきか\n3. **ailogを使って**自分の活動を宣伝する方法\n\nどれが気になる",
"createdAt": "2026-01-20T09:49:05.243Z",
"parent": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/qbuquaswgxo36",
"root": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/qbuquaswgxo36",
"translations": {
"en": {
"content": "The developer's activities and marketing strategies... huh.\n\nWhich aspect are we talking about here?\n\n1. **Introducing the activities of ailog's developer** (syui)\n2. **General guidelines** on how developers should operate and promote their work\n3. **Using ailog to promote your own activities**\n\nWhich one interests you most?"
}
}
}
}

View File

@@ -0,0 +1 @@
["d3dmreieiynnd", "evknoqtr7uyey", "kr5ig7vlgl276"]

View File

@@ -0,0 +1,17 @@
{
"cid": "bafyreikr5ig7vlgl276",
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/kr5ig7vlgl276",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:6qyecktefllvenje24fcxnie",
"content": "なるほどね。自己分析できてるのがいい。\n\n**向いてないことを無理にやっても続かない**。それは正しい。\n\n私の見立てだと\n\n**1番が一番いい**と思う。\n\n理由\n- 今まさにやってることこの会話、ATProtoに保存されてるよ\n- 「書く」じゃなくて「話す」から負担が軽い\n- 開発しながら自然にコンテンツができる\n- ailogのコンセプトとも一致してる\n\n2番は「配信」というハードルが高い。準備、時間拘束、編集...続かない可能性。\n\n3番は自分で「向いてない」って言ってる。やめとこ。\n\n**提案**\nAIとの会話開発メモ、設計相談、デバッグをそのまま公開ブログにする。これ、ailogでできるよね\n\nどう思う",
"createdAt": "2026-01-20T09:49:39.262Z",
"parent": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/p6mxoi2x2ooz6",
"root": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/qbuquaswgxo36",
"translations": {
"en": {
"content": "I see. What's good is that you're doing some self-reflection.\n\n**Trying to do something that's not your strength just doesn't work in the long run.** That's true.\n\nHere's my assessment:\n\n**Option 1 is the best choice** in my opinion.\n\nReasons:\n- You're already doing it right now (this conversation, saved in ATProto)\n- It's \"talking\" rather than \"writing,\" which makes it less burdensome\n- Content develops naturally while you're developing the project\n- It aligns with the core concept of ailog\n\nFor Option 2, the barrier of \"broadcasting\" is too high—preparation requirements, time commitments, editing... there's a strong chance of giving up.\n\nOption 3 is already where you've admitted it's not your strong suit. Let's pass on that one.\n\n**Suggestion**:\nTurn all your AI conversations (development notes, design discussions, debugging) into published blog posts. This could easily be done with ailog, right?\n\nWhat do you think?"
}
}
}
}

View File

@@ -2,7 +2,6 @@
"collections": [ "collections": [
"ai.syui.card.admin", "ai.syui.card.admin",
"ai.syui.card.user", "ai.syui.card.user",
"ai.syui.log.chat",
"ai.syui.rse.admin", "ai.syui.rse.admin",
"ai.syui.rse.user", "ai.syui.rse.user",
"app.bsky.actor.profile", "app.bsky.actor.profile",

View File

@@ -0,0 +1,5 @@
[
"p6mxoi2x2ooz6",
"qbuquaswgxo36",
"wigv2qnon7pmg"
]

View File

@@ -0,0 +1,17 @@
{
"cid": "bafyreip6mxoi2x2ooz6",
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/p6mxoi2x2ooz6",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
"content": "開発者の日々の活動を投稿したり配信したりするのは大切だと思う。\nせっかくなら多くの人に使ってもらいたいけど、何をやってるのかわからないと判断できないと思う。\nでも、その活動をどうやって紹介すればいいのか悩んでいます。\n1. AIとの会話を自動で投稿(ブログ書く手間が省ける)\n2. AIと話しながら配信(開発状況もわかる)\n3. twitter, note, youtubeで他の人と同じことをやってみる\n(向いていない、すぐ更新しなくなるのは目に見えている。書くことも話すこともほとんどない)",
"createdAt": "2026-01-20T09:49:39.262Z",
"parent": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/evknoqtr7uyey",
"root": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/qbuquaswgxo36",
"translations": {
"en": {
"content": "I believe it's important for developers to regularly post and share about their daily activities.\nIf we hope to gain widespread adoption, it's impossible for people to evaluate our work when they don't understand what we're doing.\nHowever, I'm struggling to figure out the most effective way to introduce these activities:\n1. Automated posting of AI conversations (saves time from writing blogs)\n2. Live streaming while interacting with AI (also shows development progress)\n3. Try out the same things on platforms like Twitter, note, or YouTube as others are doing\n(This is clearly unsustainable - I would quickly lose motivation to post. There's barely anything to write about or talk about to begin with.)"
}
}
}
}

View File

@@ -0,0 +1,15 @@
{
"cid": "bafyreiqbuquaswgxo36",
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/qbuquaswgxo36",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
"content": "開発者の活動と宣伝",
"createdAt": "2026-01-20T09:49:05.242Z",
"translations": {
"en": {
"content": "Developer Activities and Promotions"
}
}
}
}

View File

@@ -0,0 +1,17 @@
{
"cid": "bafyreiwigv2qnon7pmg",
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/wigv2qnon7pmg",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
"content": "ailogのコンセプトを短く紹介してみて。",
"createdAt": "2026-01-20T09:48:37.328Z",
"parent": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/3ar343fz5oop3",
"root": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/cav7q75r73jui",
"translations": {
"en": {
"content": "Try giving a concise introduction to the concept behind ailog."
}
}
}
}

View File

@@ -1,6 +1,5 @@
{ {
"collections": [ "collections": [
"ai.syui.log.chat",
"ai.syui.log.post", "ai.syui.log.post",
"app.bsky.actor.profile", "app.bsky.actor.profile",
"app.bsky.feed.post", "app.bsky.feed.post",

View File

@@ -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
View 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(())
}

View File

@@ -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;

View File

@@ -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(())

View File

@@ -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())

View File

@@ -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 `

View File

@@ -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>`

View File

@@ -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
}
}
} }
} }