test ai chat

This commit is contained in:
2026-01-20 13:46:28 +09:00
parent 6b7e6c4ecd
commit 5faf5abbb6
17 changed files with 1374 additions and 44 deletions

260
src/web/components/chat.ts Normal file
View File

@@ -0,0 +1,260 @@
import type { ChatMessage, Profile } from '../types'
import { renderMarkdown } from '../lib/markdown'
// Escape HTML to prevent XSS
function escapeHtml(text: string): string {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
// Format date/time for chat
function formatChatTime(dateStr: string): string {
const d = new Date(dateStr)
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hour = String(d.getHours()).padStart(2, '0')
const min = String(d.getMinutes()).padStart(2, '0')
return `${month}/${day} ${hour}:${min}`
}
// Extract rkey from AT URI
function getRkeyFromUri(uri: string): string {
return uri.split('/').pop() || ''
}
// Profile info for authors
interface AuthorInfo {
did: string
handle: string
avatarUrl?: string
}
// Build author info map
function buildAuthorMap(
userDid: string,
userHandle: string,
botDid: string,
botHandle: string,
userProfile?: Profile | null,
botProfile?: Profile | null,
pds?: string
): Map<string, AuthorInfo> {
const authors = new Map<string, AuthorInfo>()
// User info
let userAvatarUrl = ''
if (userProfile?.value.avatar) {
const cid = userProfile.value.avatar.ref.$link
userAvatarUrl = pds ? `${pds}/xrpc/com.atproto.sync.getBlob?did=${userDid}&cid=${cid}` : `/content/${userDid}/blob/${cid}`
}
authors.set(userDid, { did: userDid, handle: userHandle, avatarUrl: userAvatarUrl })
// Bot info
let botAvatarUrl = ''
if (botProfile?.value.avatar) {
const cid = botProfile.value.avatar.ref.$link
botAvatarUrl = pds ? `${pds}/xrpc/com.atproto.sync.getBlob?did=${botDid}&cid=${cid}` : `/content/${botDid}/blob/${cid}`
}
authors.set(botDid, { did: botDid, handle: botHandle, avatarUrl: botAvatarUrl })
return authors
}
// Render chat threads list (conversations this user started)
export function renderChatThreadList(
messages: ChatMessage[],
userDid: string,
userHandle: string,
botDid: string,
botHandle: string,
userProfile?: Profile | null,
botProfile?: Profile | null,
pds?: string
): string {
// Build set of all message URIs
const allUris = new Set(messages.map(m => m.uri))
// Find root messages by this user:
// 1. No root field (explicit start of conversation)
// 2. Or root points to non-existent message (orphaned, treat as root)
// For orphaned roots, only keep the oldest message per orphaned root URI
const orphanedRootFirstMsg = new Map<string, ChatMessage>()
const rootMessages: ChatMessage[] = []
for (const msg of messages) {
if (msg.value.author !== userDid) continue
if (!msg.value.root) {
// No root = explicit conversation start
rootMessages.push(msg)
} else if (!allUris.has(msg.value.root)) {
// Orphaned root - keep only the oldest message per orphaned root
const existing = orphanedRootFirstMsg.get(msg.value.root)
if (!existing || new Date(msg.value.createdAt) < new Date(existing.value.createdAt)) {
orphanedRootFirstMsg.set(msg.value.root, msg)
}
}
}
// Add orphaned root representatives
for (const msg of orphanedRootFirstMsg.values()) {
rootMessages.push(msg)
}
if (rootMessages.length === 0) {
return '<p class="no-posts">No chat threads yet.</p>'
}
const authors = buildAuthorMap(userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds)
// Sort by createdAt (newest first)
const sorted = [...rootMessages].sort((a, b) =>
new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()
)
const items = sorted.map(msg => {
const authorDid = msg.value.author
const time = formatChatTime(msg.value.createdAt)
const rkey = getRkeyFromUri(msg.uri)
const author = authors.get(authorDid) || { did: authorDid, handle: authorDid.slice(0, 20) + '...' }
const avatarHtml = author.avatarUrl
? `<img class="chat-avatar" src="${author.avatarUrl}" alt="@${escapeHtml(author.handle)}">`
: `<div class="chat-avatar-placeholder"></div>`
// Truncate content for preview
const preview = msg.value.content.length > 100
? msg.value.content.slice(0, 100) + '...'
: msg.value.content
return `
<a href="/@${userHandle}/at/chat/${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-time">${time}</span>
</div>
<div class="chat-thread-preview">${escapeHtml(preview)}</div>
</div>
</a>
`
}).join('')
return `<div class="chat-thread-list">${items}</div>`
}
// Render single chat thread (full conversation)
export function renderChatThread(
messages: ChatMessage[],
rootRkey: string,
userDid: string,
userHandle: string,
botDid: string,
botHandle: string,
userProfile?: Profile | null,
botProfile?: Profile | null,
pds?: string
): string {
// Find root message
const rootUri = `at://${userDid}/ai.syui.log.chat/${rootRkey}`
const rootMsg = messages.find(m => m.uri === rootUri)
if (!rootMsg) {
return '<p class="error">Chat thread not found.</p>'
}
// Find all messages in this thread
// 1. The root message itself
// 2. Messages with root === rootUri (direct children)
// 3. If this is an orphaned root (root points to non-existent), find siblings with same original root
const originalRoot = rootMsg.value.root
const allUris = new Set(messages.map(m => m.uri))
const isOrphanedRoot = originalRoot && !allUris.has(originalRoot)
const threadMessages = messages.filter(msg => {
// Include the root message itself
if (msg.uri === rootUri) return true
// Include messages that point to this as root
if (msg.value.root === rootUri) return true
// If orphaned, include messages with the same original root
if (isOrphanedRoot && msg.value.root === originalRoot) return true
return false
})
if (threadMessages.length === 0) {
return '<p class="error">No messages in this thread.</p>'
}
const authors = buildAuthorMap(userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds)
// Sort by createdAt
const sorted = [...threadMessages].sort((a, b) =>
new Date(a.value.createdAt).getTime() - new Date(b.value.createdAt).getTime()
)
const items = sorted.map(msg => {
const authorDid = msg.value.author
const time = formatChatTime(msg.value.createdAt)
const rkey = getRkeyFromUri(msg.uri)
const author = authors.get(authorDid) || { did: authorDid, handle: authorDid.slice(0, 20) + '...' }
const avatarHtml = author.avatarUrl
? `<img class="chat-avatar" src="${author.avatarUrl}" alt="@${escapeHtml(author.handle)}">`
: `<div class="chat-avatar-placeholder"></div>`
const content = renderMarkdown(msg.value.content)
const recordLink = `/@${author.handle}/at/collection/ai.syui.log.chat/${rkey}`
return `
<article class="chat-message">
<div class="chat-avatar-col">
${avatarHtml}
</div>
<div class="chat-content-col">
<div class="chat-message-header">
<a href="/@${author.handle}" class="chat-author">@${escapeHtml(author.handle)}</a>
<a href="${recordLink}" class="chat-time">${time}</a>
</div>
<div class="chat-content">${content}</div>
</div>
</article>
`
}).join('')
return `<div class="chat-list">${items}</div>`
}
// Render chat list page
export function renderChatListPage(
messages: ChatMessage[],
userDid: string,
userHandle: string,
botDid: string,
botHandle: string,
userProfile?: Profile | null,
botProfile?: Profile | null,
pds?: string
): string {
const list = renderChatThreadList(messages, userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds)
return `<div class="chat-container">${list}</div>`
}
// Render chat thread page
export function renderChatThreadPage(
messages: ChatMessage[],
rootRkey: string,
userDid: string,
userHandle: string,
botDid: string,
botHandle: string,
userProfile?: Profile | null,
botProfile?: Profile | null,
pds?: string
): string {
const thread = renderChatThread(messages, rootRkey, userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds)
return `<div class="chat-container">${thread}</div>`
}

View File

@@ -21,13 +21,18 @@ export function setCurrentLang(lang: string): void {
localStorage.setItem('preferred-lang', lang)
}
export function renderModeTabs(handle: string, activeTab: 'blog' | 'browser' | 'post' = 'blog'): string {
export function renderModeTabs(handle: string, activeTab: 'blog' | 'browser' | 'post' | 'chat' = 'blog', isLocalUser: boolean = false): string {
let tabs = `
<a href="/" class="tab">/</a>
<a href="/@${handle}" class="tab ${activeTab === 'blog' ? 'active' : ''}">${handle}</a>
<a href="/@${handle}/at" class="tab ${activeTab === 'browser' ? 'active' : ''}">at</a>
`
// Chat tab only for local user (admin)
if (isLocalUser) {
tabs += `<a href="/@${handle}/at/chat" class="tab ${activeTab === 'chat' ? 'active' : ''}">chat</a>`
}
if (isLoggedIn()) {
tabs += `<a href="/@${handle}/at/post" class="tab ${activeTab === 'post' ? 'active' : ''}">post</a>`
}

View File

@@ -1,5 +1,5 @@
import { xrpcUrl, comAtprotoIdentity, comAtprotoRepo } from '../lexicons'
import type { AppConfig, Networks, Profile, Post, ListRecordsResponse } from '../types'
import type { AppConfig, Networks, Profile, Post, ListRecordsResponse, ChatMessage } from '../types'
// Cache
let configCache: AppConfig | null = null
@@ -368,3 +368,53 @@ export interface SearchPost {
}
record: unknown
}
// Load chat messages from both user and bot repos
export async function getChatMessages(
userDid: string,
botDid: string,
collection: string = 'ai.syui.log.chat'
): Promise<ChatMessage[]> {
const messages: ChatMessage[] = []
// Load from both DIDs
for (const did of [userDid, botDid]) {
// Try local first
try {
const res = await fetch(`/content/${did}/${collection}/index.json`)
if (res.ok && isJsonResponse(res)) {
const rkeys: string[] = await res.json()
for (const rkey of rkeys) {
const msgRes = await fetch(`/content/${did}/${collection}/${rkey}.json`)
if (msgRes.ok && isJsonResponse(msgRes)) {
messages.push(await msgRes.json())
}
}
continue
}
} catch {
// Try remote
}
// Remote fallback
const pds = await getPds(did)
if (!pds) continue
try {
const host = pds.replace('https://', '')
const url = `${xrpcUrl(host, comAtprotoRepo.listRecords)}?repo=${did}&collection=${collection}&limit=100`
const res = await fetch(url)
if (res.ok) {
const data: ListRecordsResponse<ChatMessage> = await res.json()
messages.push(...data.records)
}
} catch {
// Failed
}
}
// Sort by createdAt
return messages.sort((a, b) =>
new Date(a.value.createdAt).getTime() - new Date(b.value.createdAt).getTime()
)
}

View File

@@ -1,5 +1,5 @@
export interface Route {
type: 'home' | 'user' | 'post' | 'postpage' | 'atbrowser' | 'service' | 'collection' | 'record'
type: 'home' | 'user' | 'post' | 'postpage' | 'atbrowser' | 'service' | 'collection' | 'record' | 'chat' | 'chat-thread'
handle?: string
rkey?: string
service?: string
@@ -51,6 +51,18 @@ export function parseRoute(): Route {
return { type: 'postpage', handle: postPageMatch[1] }
}
// Chat thread: /@handle/at/chat/{rkey}
const chatThreadMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)$/)
if (chatThreadMatch) {
return { type: 'chat-thread', handle: chatThreadMatch[1], rkey: chatThreadMatch[2] }
}
// Chat list: /@handle/at/chat
const chatMatch = path.match(/^\/@([^/]+)\/at\/chat\/?$/)
if (chatMatch) {
return { type: 'chat', handle: chatMatch[1] }
}
// Post detail page: /@handle/rkey (for config.collection)
const postMatch = path.match(/^\/@([^/]+)\/([^/]+)$/)
if (postMatch) {
@@ -79,6 +91,10 @@ export function navigate(route: Route): void {
path = `/@${route.handle}/at/collection/${route.collection}`
} else if (route.type === 'record' && route.handle && route.collection && route.rkey) {
path = `/@${route.handle}/at/collection/${route.collection}/${route.rkey}`
} 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}`
}
window.history.pushState({}, '', path)

View File

@@ -1,5 +1,5 @@
import './styles/main.css'
import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks } from './lib/api'
import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks, getChatMessages } from './lib/api'
import { parseRoute, onRouteChange, navigate, type Route } from './lib/router'
import { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost } from './lib/auth'
import { validateRecord } from './lib/lexicon'
@@ -10,6 +10,7 @@ import { renderPostForm, setupPostForm } from './components/postform'
import { renderCollectionButtons, renderServerInfo, renderServiceList, renderCollectionList, renderRecordList, renderRecordDetail } from './components/browser'
import { renderModeTabs, renderLangSelector, setupModeTabs } from './components/mode-tabs'
import { renderFooter } from './components/footer'
import { renderChatListPage, renderChatThreadPage } from './components/chat'
import { showLoading, hideLoading } from './components/loading'
const app = document.getElementById('app')!
@@ -157,10 +158,11 @@ async function render(route: Route): Promise<void> {
// Build page
let html = renderHeader(handle, oauthEnabled)
// Mode tabs (Blog/Browser/Post/PDS)
// Mode tabs (Blog/Browser/Post/Chat/PDS)
const activeTab = route.type === 'postpage' ? 'post' :
(route.type === 'chat' || route.type === 'chat-thread') ? 'chat' :
(route.type === 'atbrowser' || route.type === 'service' || route.type === 'collection' || route.type === 'record' ? 'browser' : 'blog')
html += renderModeTabs(handle, activeTab)
html += renderModeTabs(handle, activeTab, localOnly)
// Profile section
if (profile) {
@@ -226,6 +228,29 @@ async function render(route: Route): Promise<void> {
html += `<div id="post-form">${renderPostForm(config.collection)}</div>`
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
const aiDid = 'did:plc:6qyecktefllvenje24fcxnie' // ai.syui.ai
const aiHandle = 'ai.syui.ai'
// Load messages for the current user (did) and bot
const chatMessages = await getChatMessages(did, aiDid, 'ai.syui.log.chat')
const aiProfile = await getProfile(aiDid, false)
const pds = await getPds(did)
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>`
} else if (route.type === 'chat-thread' && route.rkey) {
// Chat thread page - show full conversation
const aiDid = 'did:plc:6qyecktefllvenje24fcxnie' // ai.syui.ai
const aiHandle = 'ai.syui.ai'
const chatMessages = await getChatMessages(did, aiDid, 'ai.syui.log.chat')
const aiProfile = await getProfile(aiDid, false)
const pds = await getPds(did)
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>`
} else {
// User page: compact collection buttons + posts
const collections = await describeRepo(did)

View File

@@ -2271,3 +2271,216 @@ button.tab {
color: #e0e0e0;
}
}
/* Chat Styles - Bluesky social-app style */
.chat-container {
margin: 10px 0;
}
.chat-list {
display: flex;
flex-direction: column;
}
.chat-message {
display: flex;
gap: 10px;
padding: 12px 0;
border-bottom: 1px solid #e0e0e0;
}
.chat-message:last-child {
border-bottom: none;
}
.chat-avatar-col {
flex-shrink: 0;
}
.chat-avatar {
width: 42px;
height: 42px;
border-radius: 50%;
object-fit: cover;
}
.chat-avatar-placeholder {
width: 42px;
height: 42px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.chat-content-col {
flex: 1;
min-width: 0;
}
.chat-message-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.chat-author {
font-weight: 600;
color: #1a1a1a;
text-decoration: none;
font-size: 0.95rem;
}
.chat-author:hover {
text-decoration: underline;
}
.chat-time {
color: #888;
font-size: 0.85rem;
text-decoration: none;
}
.chat-time:hover {
text-decoration: underline;
color: #666;
}
.chat-content {
line-height: 1.5;
color: #1a1a1a;
}
.chat-content p {
margin: 0 0 8px 0;
}
.chat-content p:last-child {
margin-bottom: 0;
}
.chat-content pre {
background: #f5f5f5;
padding: 10px;
border-radius: 8px;
overflow-x: auto;
font-size: 0.9rem;
margin: 8px 0;
}
.chat-content code {
background: #f0f0f0;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
.chat-content pre code {
background: none;
padding: 0;
}
.chat-content a {
color: var(--btn-color);
}
/* Dark mode chat */
@media (prefers-color-scheme: dark) {
.chat-message {
border-color: #333;
}
.chat-author {
color: #e0e0e0;
}
.chat-time {
color: #888;
}
.chat-time:hover {
color: #aaa;
}
.chat-content {
color: #e0e0e0;
}
.chat-content pre {
background: #2a2a2a;
}
.chat-content code {
background: #333;
}
.chat-avatar-placeholder {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.chat-thread-item {
border-color: #333;
}
.chat-thread-item:hover {
background: #2a2a2a;
}
.chat-thread-preview {
color: #999;
}
}
/* Chat Thread List */
.chat-thread-list {
display: flex;
flex-direction: column;
}
.chat-thread-item {
display: flex;
gap: 10px;
padding: 12px;
border-bottom: 1px solid #e0e0e0;
text-decoration: none;
color: inherit;
transition: background-color 0.15s;
}
.chat-thread-item:hover {
background: #f5f5f5;
}
.chat-thread-item:last-child {
border-bottom: none;
}
.chat-thread-content {
flex: 1;
min-width: 0;
}
.chat-thread-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.chat-thread-header .chat-author {
font-weight: 600;
color: #1a1a1a;
}
.chat-thread-header .chat-time {
color: #888;
font-size: 0.85rem;
}
.chat-thread-preview {
color: #666;
font-size: 0.95rem;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -64,3 +64,16 @@ export interface ListRecordsResponse<T> {
records: T[]
cursor?: string
}
export interface ChatMessage {
cid: string
uri: string
value: {
$type: string
content: string
author: string
createdAt: string
root?: string
parent?: string
}
}