1
0
This commit is contained in:
2026-03-11 20:04:16 +09:00
parent 7d53e9f1d8
commit dbd07c29c7
9 changed files with 600 additions and 40 deletions

View File

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

View File

@@ -14,3 +14,4 @@ pub mod pds;
pub mod gpt;
pub mod oauth;
pub mod setup;
pub mod twofa;

294
src/commands/twofa.rs Normal file
View File

@@ -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<String> {
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<T: serde::de::DeserializeOwned>(
client: &reqwest::Client,
pds: &str,
nsid: &str,
token: &str,
) -> Result<T> {
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::<T>().await.context("Failed to parse response")
}
/// Bearer POST request without body
async fn bearer_post_empty<T: serde::de::DeserializeOwned>(
client: &reqwest::Client,
pds: &str,
nsid: &str,
token: &str,
) -> Result<T> {
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::<T>().await.context("Failed to parse response")
}
/// Bearer POST request with JSON body
async fn bearer_post<B: serde::Serialize>(
client: &reqwest::Client,
pds: &str,
nsid: &str,
token: &str,
body: &B,
) -> Result<String> {
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<token::Session> {
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<String>, flag_email: Option<&str>) -> Result<String> {
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(())
}

View File

@@ -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<String>,
},
/// Disable email 2FA
Disable {
#[arg(long)]
bot: bool,
/// Account email (prompted if not available from session)
#[arg(short, long)]
email: Option<String>,
},
}
#[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 } => {

View File

@@ -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<String>,
}
/// 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub email_auth_factor: Option<bool>,
}
/// 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<String>,
#[serde(default)]
#[allow(dead_code)]
pub email_confirmed: bool,
#[serde(default)]
pub email_auth_factor: bool,
}
/// ATProto createSession / refreshSession response

View File

@@ -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<string, string>()
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<string, AuthorInfo> {
// Build handle→DID mapping
handleToDidMap = new Map<string, string>()
handleToDidMap.set(userHandle, userDid)
handleToDidMap.set(userDid, userDid)
handleToDidMap.set(botHandle, botDid)
handleToDidMap.set(botDid, botDid)
const authors = new Map<string, AuthorInfo>()
// 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 `
<a href="/@${userHandle}/at/chat/${rkey}" class="chat-thread-item">
<a href="/@${userHandle}/at/chat/${chatTypeSlug(chatCollection)}/${rkey}" class="chat-thread-item">
<div class="chat-avatar-col">
${avatarHtml}
</div>
<div class="chat-thread-content">
<div class="chat-thread-header">
<span class="chat-author">@${escapeHtml(author.handle)}</span>
<span class="chat-type-badge" data-type="${chatTypeSlug(chatCollection)}">${chatTypeSlug(chatCollection)}</span>
<span class="chat-time">${time}</span>
</div>
<div class="chat-thread-preview">${escapeHtml(preview)}</div>
@@ -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 `
<article class="chat-message">
@@ -252,9 +277,15 @@ export function renderChatThread(
return `<div class="chat-list">${items}</div>`
}
// 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 `<div class="chat-container">${list}</div>`
// Merge all collections into a single list
const allMessages: ChatMessage[] = []
const messageCollectionMap = new Map<string, string>()
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<string, ChatMessage>()
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 '<div class="chat-container"><p class="no-posts">No chat threads yet.</p></div>'
}
// 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
? `<img class="chat-avatar" src="${author.avatarUrl}" alt="@${escapeHtml(author.handle)}">`
: `<div class="chat-avatar-placeholder"></div>`
const displayContent = getTranslatedContent(msg)
const lines = displayContent.split('\n').slice(0, 3)
const preview = lines.join('\n')
return `
<a href="/@${userHandle}/at/chat/${slug}/${rkey}" class="chat-thread-item">
<div class="chat-avatar-col">
${avatarHtml}
</div>
<div class="chat-thread-content">
<div class="chat-thread-header">
<span class="chat-author">@${escapeHtml(author.handle)}</span>
<span class="chat-type-badge" data-type="${slug}">${slug}</span>
<span class="chat-time">${time}</span>
</div>
<div class="chat-thread-preview">${escapeHtml(preview)}</div>
</div>
</a>
`
}).join('')
return `<div class="chat-container"><div class="chat-thread-list">${items}</div></div>`
}
// Render chat thread page
@@ -307,7 +415,7 @@ export function renderChatEditForm(
<div class="chat-edit-footer">
<span class="chat-edit-collection">${collection}</span>
<div class="chat-edit-buttons">
<a href="/@${userHandle}/at/chat/${rkey}" class="chat-edit-cancel">Cancel</a>
<a href="/@${userHandle}/at/chat/${chatTypeSlug(collection)}/${rkey}" class="chat-edit-cancel">Cancel</a>
<button type="submit" class="chat-edit-save" id="chat-edit-save" data-rkey="${rkey}">Save</button>
</div>
</div>

View File

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

View File

@@ -291,7 +291,7 @@ async function render(route: Route): Promise<void> {
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} 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 += `<div id="content" class="error">Bot not configured in config.json</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
@@ -300,16 +300,23 @@ async function render(route: Route): Promise<void> {
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<string>()
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<void> {
langList = Array.from(chatLangs)
html += renderLangSelector(langList)
html += `<div id="content">${renderChatListPage(chatMessages, did, handle, botDid, botHandle, profile, botProfile, pds || undefined)}</div>`
html += `<div id="content">${renderChatListPage(chatCollections, did, handle, botDid, botHandle, profile, botProfile, pds || undefined)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
}
} 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 += `<div id="content" class="error">Bot not configured in config.json</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} 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<void> {
html += `<nav class="back-nav"><a href="/@${handle}/at/chat">chat</a></nav>`
}
} 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 += `<div id="content" class="error">Bot not configured in config.json</div>`
@@ -370,9 +376,8 @@ async function render(route: Route): Promise<void> {
html += `<nav class="back-nav"><a href="/@${handle}/at/chat">chat</a></nav>`
} 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<void> {
setupVrmPage()
}
// Setup card migration button
if (route.type === 'card-old' && cardMigrationState?.oldApiUser && cardMigrationState?.oldApiCards) {
setupMigrationButton(

View File

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