From dbd07c29c7fbb9cd344dc1e6323561f6fb3b35f1 Mon Sep 17 00:00:00 2001 From: syui Date: Wed, 11 Mar 2026 20:04:16 +0900 Subject: [PATCH] add 2fa --- src/commands/auth.rs | 41 +++++- src/commands/mod.rs | 1 + src/commands/twofa.rs | 294 +++++++++++++++++++++++++++++++++++++ src/main.rs | 45 ++++++ src/types.rs | 36 +++++ src/web/components/chat.ts | 136 +++++++++++++++-- src/web/lib/router.ts | 20 +-- src/web/main.ts | 33 +++-- src/web/styles/main.css | 34 +++++ 9 files changed, 600 insertions(+), 40 deletions(-) create mode 100644 src/commands/twofa.rs diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 5e2d68c..b6fd93c 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -1,3 +1,5 @@ +use std::io::{self, Write}; + use anyhow::Result; use super::oauth; @@ -6,20 +8,53 @@ use crate::lexicons::com_atproto_server; use crate::types::{CreateSessionRequest, CreateSessionResponse}; use crate::xrpc::XrpcClient; -/// Login to ATProto PDS +/// Login to ATProto PDS (with 2FA support) pub async fn login(handle: &str, password: &str, pds: &str, is_bot: bool) -> Result<()> { let client = XrpcClient::new(pds); let req = CreateSessionRequest { identifier: handle.to_string(), password: password.to_string(), + auth_factor_token: None, }; let account_type = if is_bot { "bot" } else { "user" }; println!("Logging in to {} as {} ({})...", pds, handle, account_type); - let session_res: CreateSessionResponse = - client.call_unauth(&com_atproto_server::CREATE_SESSION, &req).await?; + let session_res = match client + .call_unauth::<_, CreateSessionResponse>(&com_atproto_server::CREATE_SESSION, &req) + .await + { + Ok(res) => res, + Err(e) => { + // Check if 2FA is required + let err_str = e.to_string(); + if err_str.contains("AuthFactorTokenRequired") { + eprintln!("2FA is enabled. Check your email for a confirmation code."); + eprint!("Enter 2FA code: "); + io::stderr().flush()?; + let mut code = String::new(); + io::stdin().read_line(&mut code)?; + let code = code.trim().to_string(); + + if code.is_empty() { + anyhow::bail!("No code entered, aborting."); + } + + let req_2fa = CreateSessionRequest { + identifier: handle.to_string(), + password: password.to_string(), + auth_factor_token: Some(code), + }; + + client + .call_unauth(&com_atproto_server::CREATE_SESSION, &req_2fa) + .await? + } else { + return Err(e); + } + } + }; let session = Session { did: session_res.did, diff --git a/src/commands/mod.rs b/src/commands/mod.rs index ddebce9..55b2eda 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -14,3 +14,4 @@ pub mod pds; pub mod gpt; pub mod oauth; pub mod setup; +pub mod twofa; diff --git a/src/commands/twofa.rs b/src/commands/twofa.rs new file mode 100644 index 0000000..0c5a96c --- /dev/null +++ b/src/commands/twofa.rs @@ -0,0 +1,294 @@ +use std::io::{self, Write}; + +use anyhow::{Context, Result}; + +use super::token; +use crate::types::{ + GetSessionResponse, RequestEmailUpdateResponse, UpdateEmailRequest, +}; + +/// Read a line from stdin with a prompt +fn prompt_input(msg: &str) -> Result { + eprint!("{}", msg); + io::stderr().flush()?; + let mut buf = String::new(); + io::stdin().read_line(&mut buf)?; + Ok(buf.trim().to_string()) +} + +/// Build XRPC URL +fn xrpc_url(pds: &str, nsid: &str) -> String { + format!("https://{}/xrpc/{}", pds, nsid) +} + +/// Bearer GET request (bypasses OAuth/DPoP entirely) +async fn bearer_get( + client: &reqwest::Client, + pds: &str, + nsid: &str, + token: &str, +) -> Result { + let url = xrpc_url(pds, nsid); + let res = client + .get(&url) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await + .context("XRPC GET failed")?; + if !res.status().is_success() { + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + anyhow::bail!("XRPC error ({}): {}", status.as_u16(), body); + } + res.json::().await.context("Failed to parse response") +} + +/// Bearer POST request without body +async fn bearer_post_empty( + client: &reqwest::Client, + pds: &str, + nsid: &str, + token: &str, +) -> Result { + let url = xrpc_url(pds, nsid); + let res = client + .post(&url) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await + .context("XRPC POST failed")?; + if !res.status().is_success() { + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + anyhow::bail!("XRPC error ({}): {}", status.as_u16(), body); + } + res.json::().await.context("Failed to parse response") +} + +/// Bearer POST request with JSON body +async fn bearer_post( + client: &reqwest::Client, + pds: &str, + nsid: &str, + token: &str, + body: &B, +) -> Result { + let url = xrpc_url(pds, nsid); + let res = client + .post(&url) + .header("Authorization", format!("Bearer {}", token)) + .json(body) + .send() + .await + .context("XRPC POST failed")?; + let status = res.status(); + let response_body = res.text().await.unwrap_or_default(); + if !status.is_success() { + anyhow::bail!("XRPC error ({}): {}", status.as_u16(), response_body); + } + Ok(response_body) +} + +/// Refresh legacy session via Bearer token (no DPoP) +async fn refresh_legacy( + client: &reqwest::Client, + pds: &str, + refresh_jwt: &str, +) -> Result { + let url = xrpc_url(pds, "com.atproto.server.refreshSession"); + let res = client + .post(&url) + .header("Authorization", format!("Bearer {}", refresh_jwt)) + .send() + .await + .context("Refresh session failed")?; + if !res.status().is_success() { + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + anyhow::bail!("Refresh failed ({}): {}", status.as_u16(), body); + } + + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase")] + struct RefreshResponse { + did: String, + handle: String, + access_jwt: String, + refresh_jwt: String, + } + + let r: RefreshResponse = res.json().await.context("Failed to parse refresh response")?; + Ok(token::Session { + did: r.did, + handle: r.handle, + access_jwt: r.access_jwt, + refresh_jwt: r.refresh_jwt, + pds: Some(pds.to_string()), + }) +} + +/// Load legacy session, refresh it, and return (session, http client, pds). +/// All requests use plain Bearer auth, completely bypassing OAuth/DPoP. +async fn get_legacy_session(is_bot: bool) -> Result<(token::Session, reqwest::Client, String)> { + let session = if is_bot { + token::load_bot_session()? + } else { + token::load_session()? + }; + let pds = session.pds.as_deref().unwrap_or("bsky.social").to_string(); + let client = reqwest::Client::new(); + + let refreshed = refresh_legacy(&client, &pds, &session.refresh_jwt).await?; + if is_bot { + token::save_bot_session(&refreshed)?; + } else { + token::save_session(&refreshed)?; + } + + Ok((refreshed, client, pds)) +} + +/// Resolve email: from --email flag, API response, or interactive prompt +fn resolve_email(api_email: Option, flag_email: Option<&str>) -> Result { + if let Some(e) = flag_email { + return Ok(e.to_string()); + } + if let Some(e) = api_email { + return Ok(e); + } + prompt_input("Email not available from session. Enter your email: ") +} + +/// Show current 2FA status +pub async fn status(is_bot: bool) -> Result<()> { + let (session, client, pds) = get_legacy_session(is_bot).await?; + + let info: GetSessionResponse = bearer_get( + &client, &pds, "com.atproto.server.getSession", &session.access_jwt, + ).await?; + + println!("Handle: {}", info.handle); + println!("Email: {}", info.email.as_deref().unwrap_or("(not available)")); + println!("2FA: {}", if info.email_auth_factor { "enabled" } else { "disabled" }); + + Ok(()) +} + +/// Enable email 2FA +pub async fn enable(is_bot: bool, email_flag: Option<&str>) -> Result<()> { + let (session, client, pds) = get_legacy_session(is_bot).await?; + + let info: GetSessionResponse = bearer_get( + &client, &pds, "com.atproto.server.getSession", &session.access_jwt, + ).await?; + + if info.email_auth_factor { + println!("2FA is already enabled."); + return Ok(()); + } + + let email = resolve_email(info.email, email_flag)?; + + println!("Requesting confirmation code to {}...", email); + let resp: RequestEmailUpdateResponse = bearer_post_empty( + &client, &pds, "com.atproto.server.requestEmailUpdate", &session.access_jwt, + ).await?; + + if !resp.token_required { + let req = UpdateEmailRequest { + email, + token: None, + email_auth_factor: Some(true), + }; + let res_body = bearer_post( + &client, &pds, "com.atproto.server.updateEmail", &session.access_jwt, &req, + ).await?; + if !res_body.is_empty() { + eprintln!("PDS response: {}", res_body); + } + } else { + let token = prompt_input("Enter confirmation code from email: ")?; + if token.is_empty() { + anyhow::bail!("No code entered, aborting."); + } + + let req = UpdateEmailRequest { + email, + token: Some(token), + email_auth_factor: Some(true), + }; + + let res_body = bearer_post( + &client, &pds, "com.atproto.server.updateEmail", &session.access_jwt, &req, + ).await?; + if !res_body.is_empty() { + eprintln!("PDS response: {}", res_body); + } + } + + // Verify + let check: GetSessionResponse = bearer_get( + &client, &pds, "com.atproto.server.getSession", &session.access_jwt, + ).await?; + + if check.email_auth_factor { + println!("2FA enabled."); + } else { + println!("Warning: PDS accepted the request but 2FA is still disabled."); + println!("This PDS may not support email 2FA via updateEmail."); + } + + Ok(()) +} + +/// Disable email 2FA +pub async fn disable(is_bot: bool, email_flag: Option<&str>) -> Result<()> { + let (session, client, pds) = get_legacy_session(is_bot).await?; + + let info: GetSessionResponse = bearer_get( + &client, &pds, "com.atproto.server.getSession", &session.access_jwt, + ).await?; + + if !info.email_auth_factor { + println!("2FA is already disabled."); + return Ok(()); + } + + let email = resolve_email(info.email, email_flag)?; + + println!("Requesting confirmation code to {}...", email); + let _resp: RequestEmailUpdateResponse = bearer_post_empty( + &client, &pds, "com.atproto.server.requestEmailUpdate", &session.access_jwt, + ).await?; + + let token = prompt_input("Enter confirmation code from email: ")?; + if token.is_empty() { + anyhow::bail!("No code entered, aborting."); + } + + let req = UpdateEmailRequest { + email, + token: Some(token), + email_auth_factor: Some(false), + }; + + let res_body = bearer_post( + &client, &pds, "com.atproto.server.updateEmail", &session.access_jwt, &req, + ).await?; + if !res_body.is_empty() { + eprintln!("PDS response: {}", res_body); + } + + // Verify + let check: GetSessionResponse = bearer_get( + &client, &pds, "com.atproto.server.getSession", &session.access_jwt, + ).await?; + + if !check.email_auth_factor { + println!("2FA disabled."); + } else { + println!("Warning: PDS accepted the request but 2FA is still enabled."); + } + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index f601bb8..4b2d9fa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -222,6 +222,38 @@ enum Commands { /// Initialize config Setup, + + /// Two-factor authentication (email 2FA) + #[command(name = "2fa")] + TwoFa { + #[command(subcommand)] + command: TwoFaCommands, + }, +} + +#[derive(Subcommand)] +enum TwoFaCommands { + /// Show 2FA status + Status { + #[arg(long)] + bot: bool, + }, + /// Enable email 2FA + Enable { + #[arg(long)] + bot: bool, + /// Account email (prompted if not available from session) + #[arg(short, long)] + email: Option, + }, + /// Disable email 2FA + Disable { + #[arg(long)] + bot: bool, + /// Account email (prompted if not available from session) + #[arg(short, long)] + email: Option, + }, } #[derive(Subcommand)] @@ -395,6 +427,19 @@ async fn main() -> Result<()> { Commands::Setup => { commands::setup::run()?; } + Commands::TwoFa { command } => { + match command { + TwoFaCommands::Status { bot } => { + commands::twofa::status(bot).await?; + } + TwoFaCommands::Enable { bot, email } => { + commands::twofa::enable(bot, email.as_deref()).await?; + } + TwoFaCommands::Disable { bot, email } => { + commands::twofa::disable(bot, email.as_deref()).await?; + } + } + } Commands::Gpt { command } => { match command { GptCommands::Core { download } => { diff --git a/src/types.rs b/src/types.rs index 776ff33..bf44665 100644 --- a/src/types.rs +++ b/src/types.rs @@ -51,9 +51,45 @@ pub struct DescribeRepoResponse { /// ATProto createSession request #[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] pub struct CreateSessionRequest { pub identifier: String, pub password: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_factor_token: Option, +} + +/// ATProto requestEmailUpdate response +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RequestEmailUpdateResponse { + pub token_required: bool, +} + +/// ATProto updateEmail request +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateEmailRequest { + pub email: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub email_auth_factor: Option, +} + +/// ATProto getSession response +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetSessionResponse { + #[allow(dead_code)] + pub did: String, + pub handle: String, + pub email: Option, + #[serde(default)] + #[allow(dead_code)] + pub email_confirmed: bool, + #[serde(default)] + pub email_auth_factor: bool, } /// ATProto createSession / refreshSession response diff --git a/src/web/components/chat.ts b/src/web/components/chat.ts index d68da34..4bbd179 100644 --- a/src/web/components/chat.ts +++ b/src/web/components/chat.ts @@ -9,7 +9,10 @@ function getTranslatedContent(msg: ChatMessage): string { const translations = msg.value.translations if (translations && currentLang !== originalLang && translations[currentLang]) { - return translations[currentLang].content || msg.value.content.text + const translated = translations[currentLang].content + if (typeof translated === 'string') return translated + if (translated && typeof translated === 'object' && 'text' in translated) return (translated as { text: string }).text + return msg.value.content.text } return msg.value.content.text } @@ -36,8 +39,8 @@ function getRkeyFromUri(uri: string): string { return uri.split('/').pop() || '' } -// Extract DID from AT URI (at://did:plc:xxx/collection/rkey → did:plc:xxx) -function getDidFromUri(uri: string): string { +// Extract authority from AT URI (at://did:plc:xxx/collection/rkey → did:plc:xxx) +function getAuthorityFromUri(uri: string): string { return uri.replace('at://', '').split('/')[0] } @@ -48,6 +51,13 @@ interface AuthorInfo { avatarUrl?: string } +// Handle/DID resolver: maps both handle and DID to DID +let handleToDidMap = new Map() + +function resolveAuthorDid(authority: string): string { + return handleToDidMap.get(authority) || authority +} + // Build author info map function buildAuthorMap( userDid: string, @@ -58,6 +68,13 @@ function buildAuthorMap( botProfile?: Profile | null, pds?: string ): Map { + // Build handle→DID mapping + handleToDidMap = new Map() + handleToDidMap.set(userHandle, userDid) + handleToDidMap.set(userDid, userDid) + handleToDidMap.set(botHandle, botDid) + handleToDidMap.set(botDid, botDid) + const authors = new Map() // User info @@ -79,6 +96,12 @@ function buildAuthorMap( return authors } +// Map collection name to chat type slug +function chatTypeSlug(chatCollection: string): string { + if (chatCollection === 'ai.syui.ue.chat') return 'ue' + return 'log' +} + // Render chat threads list (conversations this user started) export function renderChatThreadList( messages: ChatMessage[], @@ -88,7 +111,8 @@ export function renderChatThreadList( botHandle: string, userProfile?: Profile | null, botProfile?: Profile | null, - pds?: string + pds?: string, + chatCollection: string = 'ai.syui.log.chat' ): string { // Build set of all message URIs const allUris = new Set(messages.map(m => m.uri)) @@ -101,7 +125,7 @@ export function renderChatThreadList( const rootMessages: ChatMessage[] = [] for (const msg of messages) { - if (getDidFromUri(msg.uri) !== userDid) continue + if (resolveAuthorDid(getAuthorityFromUri(msg.uri)) !== userDid) continue if (!msg.value.root) { // No root = explicit conversation start @@ -132,7 +156,7 @@ export function renderChatThreadList( ) const items = sorted.map(msg => { - const authorDid = getDidFromUri(msg.uri) + const authorDid = resolveAuthorDid(getAuthorityFromUri(msg.uri)) const time = formatChatTime(msg.value.publishedAt) const rkey = getRkeyFromUri(msg.uri) const author = authors.get(authorDid) || { did: authorDid, handle: authorDid.slice(0, 20) + '...' } @@ -147,13 +171,14 @@ export function renderChatThreadList( const preview = lines.join('\n') return ` - +
${avatarHtml}
@${escapeHtml(author.handle)} + ${chatTypeSlug(chatCollection)} ${time}
${escapeHtml(preview)}
@@ -217,7 +242,7 @@ export function renderChatThread( ) const items = sorted.map(msg => { - const authorDid = getDidFromUri(msg.uri) + const authorDid = resolveAuthorDid(getAuthorityFromUri(msg.uri)) const time = formatChatTime(msg.value.publishedAt) const rkey = getRkeyFromUri(msg.uri) const author = authors.get(authorDid) || { did: authorDid, handle: authorDid.slice(0, 20) + '...' } @@ -230,7 +255,7 @@ export function renderChatThread( const content = renderMarkdown(displayContent) const recordLink = `/@${author.handle}/at/collection/${chatCollection}/${rkey}` const canEdit = loggedInDid && authorDid === loggedInDid - const editLink = `/@${userHandle}/at/chat/${rkey}/edit` + const editLink = `/@${userHandle}/at/chat/${chatTypeSlug(chatCollection)}/${rkey}/edit` return `
@@ -252,9 +277,15 @@ export function renderChatThread( return `
${items}
` } -// Render chat list page +// Chat collection entry for unified list +export interface ChatCollectionEntry { + collection: string + messages: ChatMessage[] +} + +// Render unified chat list page with filter buttons export function renderChatListPage( - messages: ChatMessage[], + collections: ChatCollectionEntry[], userDid: string, userHandle: string, botDid: string, @@ -263,8 +294,85 @@ export function renderChatListPage( botProfile?: Profile | null, pds?: string ): string { - const list = renderChatThreadList(messages, userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds) - return `
${list}
` + // Merge all collections into a single list + const allMessages: ChatMessage[] = [] + const messageCollectionMap = new Map() + + for (const c of collections) { + for (const msg of c.messages) { + allMessages.push(msg) + messageCollectionMap.set(msg.uri, c.collection) + } + } + + // Build author map with first collection's params (all share same users) + const authors = buildAuthorMap(userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds) + + // Find root messages (same logic as renderChatThreadList) + const allUris = new Set(allMessages.map(m => m.uri)) + const orphanedRootFirstMsg = new Map() + const rootMessages: ChatMessage[] = [] + + for (const msg of allMessages) { + if (resolveAuthorDid(getAuthorityFromUri(msg.uri)) !== userDid) continue + + if (!msg.value.root) { + rootMessages.push(msg) + } else if (!allUris.has(msg.value.root)) { + const existing = orphanedRootFirstMsg.get(msg.value.root) + if (!existing || new Date(msg.value.publishedAt) < new Date(existing.value.publishedAt)) { + orphanedRootFirstMsg.set(msg.value.root, msg) + } + } + } + + for (const msg of orphanedRootFirstMsg.values()) { + rootMessages.push(msg) + } + + if (rootMessages.length === 0) { + return '

No chat threads yet.

' + } + + // Sort by publishedAt (newest first) + const sorted = [...rootMessages].sort((a, b) => + new Date(b.value.publishedAt).getTime() - new Date(a.value.publishedAt).getTime() + ) + + const items = sorted.map(msg => { + const authorDid = resolveAuthorDid(getAuthorityFromUri(msg.uri)) + const time = formatChatTime(msg.value.publishedAt) + const rkey = getRkeyFromUri(msg.uri) + const author = authors.get(authorDid) || { did: authorDid, handle: authorDid.slice(0, 20) + '...' } + const collection = messageCollectionMap.get(msg.uri) || 'ai.syui.log.chat' + const slug = chatTypeSlug(collection) + + const avatarHtml = author.avatarUrl + ? `@${escapeHtml(author.handle)}` + : `
` + + const displayContent = getTranslatedContent(msg) + const lines = displayContent.split('\n').slice(0, 3) + const preview = lines.join('\n') + + return ` +
+
+ ${avatarHtml} +
+
+
+ @${escapeHtml(author.handle)} + ${slug} + ${time} +
+
${escapeHtml(preview)}
+
+
+ ` + }).join('') + + return `
${items}
` } // Render chat thread page @@ -307,7 +415,7 @@ export function renderChatEditForm( diff --git a/src/web/lib/router.ts b/src/web/lib/router.ts index 95e94da..17b339c 100644 --- a/src/web/lib/router.ts +++ b/src/web/lib/router.ts @@ -81,16 +81,16 @@ export function parseRoute(): Route { return { type: 'vrm', handle: vrmMatch[1] } } - // Chat edit: /@handle/at/chat/{rkey}/edit - const chatEditMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)\/edit$/) + // Chat edit: /@handle/at/chat/{type}/{rkey}/edit + const chatEditMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)\/([^/]+)\/edit$/) if (chatEditMatch) { - return { type: 'chat-edit', handle: chatEditMatch[1], rkey: chatEditMatch[2] } + return { type: 'chat-edit', handle: chatEditMatch[1], service: chatEditMatch[2], rkey: chatEditMatch[3] } } - // Chat thread: /@handle/at/chat/{rkey} - const chatThreadMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)$/) + // Chat thread: /@handle/at/chat/{type}/{rkey} + const chatThreadMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)\/([^/]+)$/) if (chatThreadMatch) { - return { type: 'chat-thread', handle: chatThreadMatch[1], rkey: chatThreadMatch[2] } + return { type: 'chat-thread', handle: chatThreadMatch[1], service: chatThreadMatch[2], rkey: chatThreadMatch[3] } } // Chat list: /@handle/at/chat @@ -133,10 +133,10 @@ export function navigate(route: Route): void { path = `/@${route.handle}/at/card-old` } 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}` - } else if (route.type === 'chat-edit' && route.handle && route.rkey) { - path = `/@${route.handle}/at/chat/${route.rkey}/edit` + } else if (route.type === 'chat-thread' && route.handle && route.service && route.rkey) { + path = `/@${route.handle}/at/chat/${route.service}/${route.rkey}` + } else if (route.type === 'chat-edit' && route.handle && route.service && route.rkey) { + path = `/@${route.handle}/at/chat/${route.service}/${route.rkey}/edit` } else if (route.type === 'link' && route.handle) { path = `/@${route.handle}/at/link` } else if (route.type === 'vrm' && route.handle) { diff --git a/src/web/main.ts b/src/web/main.ts index 08ce412..4003ba4 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -291,7 +291,7 @@ async function render(route: Route): Promise { html += `` } else if (route.type === 'chat') { - // Chat list page - show threads started by this user + // Chat list page - show all chat collections if (!config.bot) { html += `
Bot not configured in config.json
` html += `` @@ -300,16 +300,23 @@ async function render(route: Route): Promise { const botHandle = config.bot.handle const chatCollection = config.chatCollection || 'ai.syui.log.chat' - // Load messages and profiles in parallel - const [chatMessages, botProfile, pds] = await Promise.all([ + // Load all chat collections in parallel + const [logMessages, ueMessages, botProfile, pds] = await Promise.all([ getChatMessages(did, botDid, chatCollection), + getChatMessages(did, botDid, 'ai.syui.ue.chat'), getProfile(botDid, false), getPds(did) ]) - // Collect available languages from chat messages + const chatCollections = [ + { collection: chatCollection, messages: logMessages }, + { collection: 'ai.syui.ue.chat', messages: ueMessages }, + ].filter(c => c.messages.length > 0) + + // Collect available languages from all chat messages + const allChatMsgs = [...logMessages, ...ueMessages] const chatLangs = new Set() - for (const msg of chatMessages) { + for (const msg of allChatMsgs) { const msgLang = msg.value.langs?.[0] || 'ja' chatLangs.add(msgLang) if (msg.value.translations) { @@ -321,21 +328,20 @@ async function render(route: Route): Promise { langList = Array.from(chatLangs) html += renderLangSelector(langList) - html += `
${renderChatListPage(chatMessages, did, handle, botDid, botHandle, profile, botProfile, pds || undefined)}
` + html += `
${renderChatListPage(chatCollections, did, handle, botDid, botHandle, profile, botProfile, pds || undefined)}
` html += `` } - } else if (route.type === 'chat-thread' && route.rkey) { - // Chat thread page - show full conversation + } else if (route.type === 'chat-thread' && route.service && route.rkey) { + // Chat thread page - route.service is 'log' or 'ue' if (!config.bot) { html += `
Bot not configured in config.json
` html += `` } else { const botDid = config.bot.did const botHandle = config.bot.handle - const chatCollection = config.chatCollection || 'ai.syui.log.chat' + const chatCollection = route.service === 'ue' ? 'ai.syui.ue.chat' : (config.chatCollection || 'ai.syui.log.chat') - // Load messages and profiles in parallel const [chatMessages, botProfile, pds] = await Promise.all([ getChatMessages(did, botDid, chatCollection), getProfile(botDid, false), @@ -360,7 +366,7 @@ async function render(route: Route): Promise { html += `` } - } else if (route.type === 'chat-edit' && route.rkey) { + } else if (route.type === 'chat-edit' && route.service && route.rkey) { // Chat edit page if (!config.bot) { html += `
Bot not configured in config.json
` @@ -370,9 +376,8 @@ async function render(route: Route): Promise { html += `` } else { const botDid = config.bot.did - const chatCollection = config.chatCollection || 'ai.syui.log.chat' + const chatCollection = route.service === 'ue' ? 'ai.syui.ue.chat' : (config.chatCollection || 'ai.syui.log.chat') - // Get the specific message const chatMessages = await getChatMessages(did, botDid, chatCollection) const targetUri = `at://${did}/${chatCollection}/${route.rkey}` const message = chatMessages.find(m => m.uri === targetUri) @@ -464,6 +469,8 @@ async function render(route: Route): Promise { setupVrmPage() } + + // Setup card migration button if (route.type === 'card-old' && cardMigrationState?.oldApiUser && cardMigrationState?.oldApiCards) { setupMigrationButton( diff --git a/src/web/styles/main.css b/src/web/styles/main.css index c731853..7a51daf 100644 --- a/src/web/styles/main.css +++ b/src/web/styles/main.css @@ -2576,6 +2576,35 @@ button.tab { } } +/* Chat Type Badge */ +.chat-type-badge { + font-size: 0.7em; + padding: 1px 6px; + border-radius: 3px; +} + +.chat-type-badge[data-type="log"] { + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; +} + +.chat-type-badge[data-type="ue"] { + background: rgba(168, 85, 247, 0.15); + color: #a855f7; +} + +@media (prefers-color-scheme: dark) { + .chat-type-badge[data-type="log"] { + background: rgba(59, 130, 246, 0.25); + color: #60a5fa; + } + + .chat-type-badge[data-type="ue"] { + background: rgba(168, 85, 247, 0.25); + color: #c084fc; + } +} + /* Chat Thread List */ .chat-thread-list { display: flex; @@ -2612,6 +2641,11 @@ button.tab { margin-bottom: 4px; } +.chat-thread-header .chat-type-badge { + order: 1; + margin-left: auto; +} + .chat-thread-header .chat-author { font-weight: 600; color: #1a1a1a;