chore: remove import staging files

This commit is contained in:
imeepos
2026-03-19 18:57:33 +08:00
parent e8579a35fb
commit 21a1f272fc
41 changed files with 0 additions and 4499 deletions

View File

@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,4 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
export default nextConfig

View File

@@ -1,29 +0,0 @@
{
"name": "@sker-pro/web-chat",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "next dev --hostname 0.0.0.0 --port=3001",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@opennextjs/cloudflare": "^1.0.0",
"@bowong/auth": "git+https://gitea.bowongai.com/bowong/claushow-auth.git#main",
"better-auth": "^1.2.0",
"next": "^15.3.3",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250312.0",
"@tailwindcss/postcss": "^4.1.13",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.13",
"typescript": "^5.9.3"
}
}

View File

@@ -1,5 +0,0 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}

View File

@@ -1,116 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useAuth } from '@/hooks/useAuth'
import { useAvatars, type AvatarWithEmojis } from '@/hooks/useAvatars'
import { BulkEmojiUpload } from '@/components/BulkEmojiUpload'
import { EmojiList } from '@/components/EmojiList'
import { EmojiForm } from '@/components/EmojiForm'
export default function AvatarDetailPage() {
const router = useRouter()
const params = useParams()
const avatarId = params?.id as string
const { user, loading: authLoading } = useAuth()
const { fetchAvatar } = useAvatars()
const [avatar, setAvatar] = useState<AvatarWithEmojis | null>(null)
const [loading, setLoading] = useState(true)
const [showAddForm, setShowAddForm] = useState(false)
useEffect(() => {
if (!authLoading && !user) {
router.replace('/login')
}
}, [user, authLoading, router])
useEffect(() => {
if (user && avatarId) {
loadAvatar()
}
}, [user, avatarId])
const loadAvatar = async () => {
try {
setLoading(true)
const data = await fetchAvatar(avatarId)
setAvatar(data)
} catch (err) {
console.error('Failed to load avatar:', err)
alert('加载失败')
router.replace('/avatars')
} finally {
setLoading(false)
}
}
const handleAddSuccess = () => {
setShowAddForm(false)
loadAvatar()
}
if (authLoading || !user || loading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="text-slate-600">Loading...</div>
</div>
)
}
if (!avatar) {
return null
}
return (
<div className="min-h-screen bg-slate-50">
<div className="mx-auto max-w-6xl px-4 py-8">
<div className="mb-6">
<button
onClick={() => router.replace('/avatars')}
className="mb-4 text-sm text-blue-600 hover:text-blue-700"
>
</button>
<h1 className="text-3xl font-bold text-slate-900">{avatar.name}</h1>
{avatar.description && (
<p className="mt-2 text-slate-600">{avatar.description}</p>
)}
</div>
<div className="mb-6">
<div className="flex flex-wrap gap-3">
<button
onClick={() => setShowAddForm(!showAddForm)}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
{showAddForm ? '取消添加' : '添加表情'}
</button>
</div>
</div>
{showAddForm && (
<div className="mb-6 rounded-lg border border-slate-200 bg-white p-6">
<h2 className="mb-4 text-lg font-semibold text-slate-900">
</h2>
<EmojiForm
avatarId={avatarId}
onSuccess={handleAddSuccess}
onCancel={() => setShowAddForm(false)}
/>
</div>
)}
<div className="mb-6">
<BulkEmojiUpload avatarId={avatarId} onSuccess={loadAvatar} />
</div>
<EmojiList
avatarId={avatarId}
emojis={avatar.emojis}
onRefresh={loadAvatar}
/>
</div>
</div>
)
}

View File

@@ -1,67 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useAuth } from '@/hooks/useAuth'
import { useAvatars, type Avatar } from '@/hooks/useAvatars'
import { AvatarManager } from '@/components/AvatarManager'
export default function AvatarsPage() {
const router = useRouter()
const { user, loading: authLoading } = useAuth()
const { fetchAvatars } = useAvatars()
const [avatars, setAvatars] = useState<Avatar[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!authLoading && !user) {
router.replace('/login')
}
}, [user, authLoading, router])
useEffect(() => {
if (user) {
loadAvatars()
}
}, [user])
const loadAvatars = async () => {
try {
setLoading(true)
const data = await fetchAvatars()
setAvatars(Array.isArray(data) ? data : [])
} catch (err) {
console.error('Failed to load avatars:', err)
setAvatars([])
} finally {
setLoading(false)
}
}
if (authLoading || !user) {
return (
<div className="flex h-screen items-center justify-center">
<div className="text-slate-600">Loading...</div>
</div>
)
}
return (
<div className="min-h-screen bg-slate-50">
<div className="mx-auto max-w-6xl px-4 py-8">
<div className="mb-6">
<h1 className="text-3xl font-bold text-slate-900"></h1>
<p className="mt-2 text-slate-600">
</p>
</div>
<AvatarManager
avatars={avatars}
loading={loading}
onRefresh={loadAvatars}
/>
</div>
</div>
)
}

View File

@@ -1,119 +0,0 @@
'use client'
import { Suspense, useCallback, useEffect, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useAuth } from '@/hooks/useAuth'
import { useLobsters, type LobsterWithStatus } from '@/hooks/useLobsters'
import { LobsterSidebar } from '@/components/LobsterSidebar'
import { ChatContainer } from '@/components/ChatContainer'
import { FloatingAvatar } from '@/components/FloatingAvatar'
export default function ChatPage() {
return (
<Suspense
fallback={
<main className="flex min-h-screen items-center justify-center">
<p className="text-sm text-slate-500">Loading...</p>
</main>
}
>
<ChatPageInner />
</Suspense>
)
}
function ChatPageInner() {
const router = useRouter()
const searchParams = useSearchParams()
const { user, loading: authLoading, token } = useAuth()
const { lobstersWithStatus, fetchApiKeys } = useLobsters()
const [activeLobster, setActiveLobster] = useState<LobsterWithStatus | null>(null)
const [sidebarCollapsed, setSidebarCollapsed] = useState(true)
// Auth guard
useEffect(() => {
if (authLoading) return
if (!user || !token) {
router.replace('/login')
}
}, [user, token, authLoading, router])
// Fetch lobster list on mount
useEffect(() => {
if (user) void fetchApiKeys()
}, [user, fetchApiKeys])
// Restore selection from URL query param
useEffect(() => {
const lobsterIdFromUrl = searchParams.get('lobsterId')
if (lobsterIdFromUrl && !activeLobster && lobstersWithStatus.length > 0) {
const found = lobstersWithStatus.find((l) => l.id === lobsterIdFromUrl)
if (found) setActiveLobster(found)
}
}, [searchParams, lobstersWithStatus, activeLobster])
// Keep activeLobster in sync with fresh status data
useEffect(() => {
if (activeLobster) {
const fresh = lobstersWithStatus.find((l) => l.id === activeLobster.id)
if (fresh && fresh.isOnline !== activeLobster.isOnline) {
setActiveLobster(fresh)
}
}
}, [lobstersWithStatus, activeLobster])
const handleSelect = useCallback(
(lobster: LobsterWithStatus) => {
setActiveLobster(lobster)
setSidebarCollapsed(true)
// Update URL without navigation
const url = new URL(window.location.href)
url.searchParams.set('lobsterId', lobster.id)
window.history.replaceState(null, '', url.toString())
},
[],
)
if (authLoading || !user || !token) {
return (
<main className="flex min-h-screen items-center justify-center">
<p className="text-sm text-slate-500">Loading...</p>
</main>
)
}
return (
<main className="flex h-screen overflow-hidden bg-white">
<LobsterSidebar
lobsters={lobstersWithStatus}
activeLobsterId={activeLobster?.id ?? null}
onSelect={handleSelect}
collapsed={sidebarCollapsed}
onToggle={() => setSidebarCollapsed((c) => !c)}
/>
<div className="flex flex-1 flex-col">
{activeLobster ? (
<ChatContainer
key={activeLobster.id}
lobsterId={activeLobster.id}
lobsterName={activeLobster.name}
isOnline={activeLobster.isOnline}
userName={user.name ?? user.email}
/>
) : (
<div className="flex flex-1 items-center justify-center">
<div className="text-center">
<p className="text-sm text-slate-500 pl-10 md:pl-0">
Select a lobster to start chatting
</p>
</div>
</div>
)}
</div>
<FloatingAvatar apikeyId={activeLobster?.id ?? null} />
</main>
)
}

View File

@@ -1 +0,0 @@
@import 'tailwindcss';

View File

@@ -1,25 +0,0 @@
import type { Metadata } from 'next'
import { AuthProvider } from '@/hooks/useAuth'
import { RelayProvider } from '@/hooks/useRelay'
import './globals.css'
export const metadata: Metadata = {
title: 'Web Chat',
description: 'Chat with AI via WebSocket relay',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className="bg-white text-slate-950 antialiased">
<AuthProvider>
<RelayProvider>{children}</RelayProvider>
</AuthProvider>
</body>
</html>
)
}

View File

@@ -1,309 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useAuth } from '@/hooks/useAuth'
import { useLobsters } from '@/hooks/useLobsters'
import { useApiKeyAvatar, type ApiKeyAvatar } from '@/hooks/useApiKeyAvatar'
import { AvatarSelector } from '@/components/AvatarSelector'
import { EmojiMedia } from '@/components/EmojiMedia'
export default function LobstersPage() {
const router = useRouter()
const { user, loading: authLoading, signOut } = useAuth()
const { getBoundAvatar } = useApiKeyAvatar()
const {
apiKeys,
onlineLobsters,
loading,
error,
fetchApiKeys,
createApiKey,
deleteApiKey,
} = useLobsters()
const [newName, setNewName] = useState('')
const [createdKey, setCreatedKey] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [creating, setCreating] = useState(false)
const [deletingId, setDeletingId] = useState<string | null>(null)
const [bindingKeyId, setBindingKeyId] = useState<string | null>(null)
const [bindings, setBindings] = useState<Record<string, ApiKeyAvatar | null>>({})
const [bindingLoadingId, setBindingLoadingId] = useState<string | null>(null)
const [bindingError, setBindingError] = useState<string | null>(null)
useEffect(() => {
if (authLoading) return
if (!user) {
router.replace('/login')
return
}
void fetchApiKeys()
}, [user, authLoading, router, fetchApiKeys])
useEffect(() => {
if (apiKeys.length === 0) {
setBindings({})
return
}
let cancelled = false
void (async () => {
const entries = await Promise.all(
apiKeys.map(async key => {
try {
return [key.id, await getBoundAvatar(key.id)] as const
} catch {
return [key.id, null] as const
}
}),
)
if (!cancelled) {
setBindings(Object.fromEntries(entries))
}
})()
return () => {
cancelled = true
}
}, [apiKeys, getBoundAvatar])
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
if (!newName.trim()) return
setCreating(true)
setCreatedKey(null)
setCopied(false)
const key = await createApiKey(newName.trim())
if (key) {
setCreatedKey(key)
setNewName('')
}
setCreating(false)
}
const handleDelete = async (keyId: string) => {
if (!confirm('Are you sure you want to delete this lobster?')) return
setDeletingId(keyId)
await deleteApiKey(keyId)
setDeletingId(null)
}
const handleOpenBinding = async (keyId: string) => {
setBindingError(null)
setBindingKeyId(current => (current === keyId ? null : keyId))
if (bindings[keyId] || bindingKeyId === keyId) {
return
}
try {
setBindingLoadingId(keyId)
const data = await getBoundAvatar(keyId)
setBindings(current => ({ ...current, [keyId]: data }))
} catch (err) {
setBindingError(err instanceof Error ? err.message : '加载形象绑定失败')
} finally {
setBindingLoadingId(null)
}
}
const handleBindingChanged = async (keyId: string) => {
try {
setBindingLoadingId(keyId)
const data = await getBoundAvatar(keyId)
setBindings(current => ({ ...current, [keyId]: data }))
setBindingKeyId(null)
} catch (err) {
setBindingError(err instanceof Error ? err.message : '刷新形象绑定失败')
} finally {
setBindingLoadingId(null)
}
}
const isOnline = (keyId: string) =>
onlineLobsters.some(l => l.lobsterId === keyId)
if (authLoading || !user) {
return (
<main className="flex min-h-screen items-center justify-center">
<p className="text-sm text-slate-500">Loading...</p>
</main>
)
}
return (
<main className="mx-auto min-h-screen max-w-2xl px-4 py-8">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-slate-900">My Lobsters</h1>
<p className="mt-1 text-sm text-slate-500">
Create and manage your lobster API keys
</p>
</div>
<div className="flex items-center gap-3">
<Link
href="/chat"
className="rounded-lg px-4 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50"
>
Back to Chat
</Link>
<Link
href="/avatars"
className="rounded-lg px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100"
>
</Link>
<button
onClick={() => void signOut().then(() => router.replace('/login'))}
className="rounded-lg px-4 py-2 text-sm text-slate-600 hover:bg-slate-100"
>
Sign Out
</button>
</div>
</div>
{/* Create lobster */}
<form
onSubmit={e => void handleCreate(e)}
className="mb-6 flex gap-3"
>
<input
type="text"
value={newName}
onChange={e => setNewName(e.target.value)}
placeholder="Lobster name"
className="flex-1 rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 placeholder-slate-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
<button
type="submit"
disabled={creating || !newName.trim()}
className="rounded-xl bg-blue-600 px-6 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{creating ? 'Creating...' : 'Create Lobster'}
</button>
</form>
{/* Newly created key + setup instructions */}
{createdKey && (
<div className="mb-6 rounded-lg border border-green-200 bg-green-50 p-4">
<p className="mb-3 text-sm font-medium text-green-800">
OpenClaw
</p>
<div className="flex items-center gap-2">
<pre className="flex-1 overflow-x-auto whitespace-pre-wrap break-all rounded bg-white px-3 py-2 text-xs text-slate-800 border border-green-200">
{`按照https://cdn.bowong.cc/static/ClawShowInstall/SKILL.md 的指示安装ClawShow Gateway Connect插件。
使用authToken = "${createdKey}", 安装@bowong/clawshow-gateway@latest`}
</pre>
<button
onClick={() => {
void navigator.clipboard.writeText(
`按照https://cdn.bowong.cc/static/ClawShowInstall/SKILL.md 的指示安装ClawShow Gateway Connect插件。\n使用authToken = "${createdKey}", 安装@bowong/clawshow-gateway@latest`
)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
className="shrink-0 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700"
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
)}
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
{bindingError && <p className="mb-4 text-sm text-red-600">{bindingError}</p>}
{/* Lobster list */}
{loading && apiKeys.length === 0 ? (
<p className="text-sm text-slate-500">Loading lobsters...</p>
) : apiKeys.length === 0 ? (
<div className="rounded-lg border border-slate-200 p-8 text-center">
<p className="text-sm text-slate-500">
No lobsters yet. Create one above to get started.
</p>
</div>
) : (
<div className="space-y-3">
{apiKeys.map(key => {
const online = isOnline(key.id)
return (
<div
key={key.id}
className="rounded-lg border border-slate-200 px-4 py-3"
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<span
className={`inline-block h-2.5 w-2.5 rounded-full ${online ? 'bg-green-400' : 'bg-slate-300'}`}
title={online ? 'Online' : 'Offline'}
/>
<div>
<p className="text-sm font-medium text-slate-900">
{key.name ?? 'Unnamed'}
</p>
{key.prefix && (
<p className="text-xs text-slate-400">{key.prefix}...</p>
)}
<p className="mt-1 text-xs text-slate-500">
{bindingLoadingId === key.id && !bindings[key.id]
? '加载中...'
: bindings[key.id]?.avatar?.name ?? '未绑定'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => void handleOpenBinding(key.id)}
className="rounded-lg px-3 py-1.5 text-sm text-blue-600 hover:bg-blue-50"
>
{bindingKeyId === key.id ? '收起形象绑定' : '绑定形象'}
</button>
<button
onClick={() => void handleDelete(key.id)}
disabled={deletingId === key.id}
className="rounded-lg px-3 py-1.5 text-sm text-red-600 hover:bg-red-50 disabled:opacity-50"
>
{deletingId === key.id ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
{bindingKeyId === key.id && (
<div className="mt-4 border-t border-slate-200 pt-4">
<AvatarSelector
apikeyId={key.id}
onClose={() => setBindingKeyId(null)}
onBound={() => void handleBindingChanged(key.id)}
/>
</div>
)}
{bindings[key.id]?.emojis && bindings[key.id]!.emojis.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{bindings[key.id]!.emojis.slice(0, 6).map(emoji => (
<EmojiMedia
key={emoji.id}
alt={emoji.label}
url={emoji.url}
className="h-12 w-12 rounded-lg border border-slate-200 bg-slate-50 object-contain p-1"
fallbackClassName="h-12 w-12 rounded-lg border border-slate-200 bg-slate-50 object-contain p-1"
/>
))}
{bindings[key.id]!.emojis.length > 6 && (
<div className="flex h-12 w-12 items-center justify-center rounded-lg border border-slate-200 text-xs text-slate-500">
+{bindings[key.id]!.emojis.length - 6}
</div>
)}
</div>
)}
</div>
)
})}
</div>
)}
</main>
)
}

View File

@@ -1,157 +0,0 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useAuth } from '@/hooks/useAuth'
export default function LoginPage() {
const router = useRouter()
const { authClient, refreshSession } = useAuth()
const [isSignUp, setIsSignUp] = useState(false)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [name, setName] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
if (isSignUp) {
const { error: signUpError } = await authClient.signUp.email({
email,
password,
name: name || email.split('@')[0],
})
if (signUpError) {
setError(signUpError.message ?? 'Sign up failed')
setLoading(false)
return
}
} else {
const { error: signInError } = await authClient.signIn.email({
email,
password,
})
if (signInError) {
setError(signInError.message ?? 'Sign in failed')
setLoading(false)
return
}
}
await refreshSession()
router.replace('/')
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
setLoading(false)
}
}
return (
<main className="flex min-h-screen items-center justify-center p-4">
<div className="w-full max-w-sm space-y-6">
<div className="text-center">
<h1 className="text-2xl font-semibold text-slate-900">
{isSignUp ? 'Create Account' : 'Sign In'}
</h1>
<p className="mt-2 text-sm text-slate-500">
{isSignUp
? 'Create an account to start chatting'
: 'Sign in to continue'}
</p>
</div>
<form onSubmit={e => void handleSubmit(e)} className="space-y-4">
{isSignUp && (
<div>
<label
htmlFor="name"
className="block text-sm font-medium text-slate-700"
>
Name
</label>
<input
id="name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 placeholder-slate-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
placeholder="Your name"
/>
</div>
)}
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-slate-700"
>
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={e => setEmail(e.target.value)}
className="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 placeholder-slate-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
placeholder="you@example.com"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-slate-700"
>
Password
</label>
<input
id="password"
type="password"
required
minLength={8}
value={password}
onChange={e => setPassword(e.target.value)}
className="mt-1 block w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 placeholder-slate-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
placeholder="At least 8 characters"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full rounded-xl bg-blue-600 px-6 py-3 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading
? 'Please wait...'
: isSignUp
? 'Create Account'
: 'Sign In'}
</button>
</form>
<div className="text-center">
<button
type="button"
onClick={() => {
setIsSignUp(!isSignUp)
setError('')
}}
className="text-sm text-blue-600 hover:underline"
>
{isSignUp
? 'Already have an account? Sign in'
: "Don't have an account? Sign up"}
</button>
</div>
</div>
</main>
)
}

View File

@@ -1,28 +0,0 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useAuth } from '@/hooks/useAuth'
export default function HomePage() {
const router = useRouter()
const { user, loading } = useAuth()
useEffect(() => {
if (loading) return
if (!user) {
router.replace('/login')
return
}
// Logged in — go to chat
router.replace('/chat')
}, [user, loading, router])
return (
<main className="flex min-h-screen items-center justify-center">
<p className="text-sm text-slate-500">Loading...</p>
</main>
)
}

View File

@@ -1,116 +0,0 @@
'use client'
import { useState } from 'react'
import { useAvatars } from '@/hooks/useAvatars'
interface AvatarFormProps {
onSuccess: () => void
onCancel: () => void
initialName?: string
initialDescription?: string
avatarId?: string
}
export function AvatarForm({
onSuccess,
onCancel,
initialName = '',
initialDescription = '',
avatarId,
}: AvatarFormProps) {
const { createAvatar, updateAvatar } = useAvatars()
const [name, setName] = useState(initialName)
const [description, setDescription] = useState(initialDescription)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) {
setError('请输入形象名称')
return
}
try {
setSubmitting(true)
setError(null)
if (avatarId) {
await updateAvatar(avatarId, name.trim(), description.trim() || undefined)
} else {
await createAvatar(name.trim(), description.trim() || undefined)
}
onSuccess()
} catch (err) {
setError(err instanceof Error ? err.message : '操作失败')
} finally {
setSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-lg bg-red-50 p-3 text-sm text-red-700">
{error}
</div>
)}
<div>
<label
htmlFor="name"
className="block text-sm font-medium text-slate-700"
>
*
</label>
<input
id="name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="例如:可爱小龙虾"
className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
disabled={submitting}
/>
</div>
<div>
<label
htmlFor="description"
className="block text-sm font-medium text-slate-700"
>
</label>
<textarea
id="description"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="简单描述这个形象..."
rows={3}
className="mt-1 w-full resize-none rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
disabled={submitting}
/>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={submitting}
className="flex-1 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{submitting ? '保存中...' : avatarId ? '更新' : '创建'}
</button>
<button
type="button"
onClick={onCancel}
disabled={submitting}
className="rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-50"
>
</button>
</div>
</form>
)
}

View File

@@ -1,121 +0,0 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useAvatars, type Avatar } from '@/hooks/useAvatars'
import { AvatarForm } from './AvatarForm'
interface AvatarManagerProps {
avatars: Avatar[]
loading: boolean
onRefresh: () => void
}
export function AvatarManager({
avatars,
loading,
onRefresh,
}: AvatarManagerProps) {
const avatarList = Array.isArray(avatars) ? avatars : []
const router = useRouter()
const { deleteAvatar } = useAvatars()
const [showCreateForm, setShowCreateForm] = useState(false)
const [deletingId, setDeletingId] = useState<string | null>(null)
const handleDelete = async (avatarId: string) => {
if (!confirm('确定要删除这个形象吗?所有关联的表情也会被删除。')) {
return
}
try {
setDeletingId(avatarId)
await deleteAvatar(avatarId)
onRefresh()
} catch (err) {
console.error('Failed to delete avatar:', err)
alert('删除失败,请重试')
} finally {
setDeletingId(null)
}
}
const handleCreateSuccess = () => {
setShowCreateForm(false)
onRefresh()
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-slate-600">...</div>
</div>
)
}
return (
<div>
<div className="mb-6">
<button
onClick={() => setShowCreateForm(true)}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
</button>
</div>
{showCreateForm && (
<div className="mb-6 rounded-lg border border-slate-200 bg-white p-6">
<h2 className="mb-4 text-lg font-semibold text-slate-900">
</h2>
<AvatarForm
onSuccess={handleCreateSuccess}
onCancel={() => setShowCreateForm(false)}
/>
</div>
)}
{avatarList.length === 0 ? (
<div className="rounded-lg border border-slate-200 bg-white p-12 text-center">
<p className="text-slate-600"></p>
<p className="mt-2 text-sm text-slate-500">
</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{avatarList.map(avatar => (
<div
key={avatar.id}
className="rounded-lg border border-slate-200 bg-white p-6 transition-shadow hover:shadow-md"
>
<h3 className="text-lg font-semibold text-slate-900">
{avatar.name}
</h3>
{avatar.description && (
<p className="mt-2 text-sm text-slate-600">
{avatar.description}
</p>
)}
<div className="mt-4 flex gap-2">
<button
onClick={() => router.push(`/avatars/${avatar.id}`)}
className="flex-1 rounded-lg border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
>
</button>
<button
onClick={() => handleDelete(avatar.id)}
disabled={deletingId === avatar.id}
className="rounded-lg border border-red-300 px-3 py-2 text-sm font-medium text-red-700 hover:bg-red-50 disabled:opacity-50"
>
{deletingId === avatar.id ? '删除中...' : '删除'}
</button>
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -1,130 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { useAvatars, type Avatar } from '@/hooks/useAvatars'
import { useApiKeyAvatar } from '@/hooks/useApiKeyAvatar'
interface AvatarSelectorProps {
apikeyId: string
onClose?: () => void
onBound?: () => void
}
export function AvatarSelector({ apikeyId, onClose, onBound }: AvatarSelectorProps) {
const { fetchAvatars } = useAvatars()
const { bindAvatar, getBoundAvatar } = useApiKeyAvatar()
const [avatars, setAvatars] = useState<Avatar[]>([])
const [currentAvatarId, setCurrentAvatarId] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [binding, setBinding] = useState(false)
useEffect(() => {
loadData()
}, [apikeyId])
const loadData = async () => {
try {
setLoading(true)
const [avatarsList, boundData] = await Promise.all([
fetchAvatars(),
getBoundAvatar(apikeyId),
])
setAvatars(avatarsList)
setCurrentAvatarId(boundData.apikey.avatar_id)
} catch (err) {
console.error('Failed to load data:', err)
} finally {
setLoading(false)
}
}
const handleSelect = async (avatarId: string | null) => {
try {
setBinding(true)
await bindAvatar(apikeyId, avatarId)
setCurrentAvatarId(avatarId)
onBound?.()
if (onClose) onClose()
} catch (err) {
console.error('Failed to bind avatar:', err)
alert('绑定失败,请重试')
} finally {
setBinding(false)
}
}
if (loading) {
return (
<div className="rounded-lg border border-slate-200 bg-white p-6 shadow-lg">
<div className="text-center text-slate-600">...</div>
</div>
)
}
return (
<div className="rounded-lg border border-slate-200 bg-white p-6 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-slate-900"></h3>
{onClose && (
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600"
>
</button>
)}
</div>
{avatars.length === 0 ? (
<div className="text-center">
<p className="text-slate-600"></p>
<a
href="/avatars"
className="mt-2 inline-block text-sm text-blue-600 hover:text-blue-700"
>
</a>
</div>
) : (
<div className="space-y-2">
<button
onClick={() => handleSelect(null)}
disabled={binding}
className={`w-full rounded-lg border px-4 py-3 text-left transition-colors ${
currentAvatarId === null
? 'border-blue-500 bg-blue-50'
: 'border-slate-200 hover:bg-slate-50'
} disabled:opacity-50`}
>
<div className="font-medium text-slate-900">使</div>
<div className="text-sm text-slate-600"></div>
</button>
{avatars.map(avatar => (
<button
key={avatar.id}
onClick={() => handleSelect(avatar.id)}
disabled={binding}
className={`w-full rounded-lg border px-4 py-3 text-left transition-colors ${
currentAvatarId === avatar.id
? 'border-blue-500 bg-blue-50'
: 'border-slate-200 hover:bg-slate-50'
} disabled:opacity-50`}
>
<div className="font-medium text-slate-900">{avatar.name}</div>
{avatar.description && (
<div className="text-sm text-slate-600">{avatar.description}</div>
)}
</button>
))}
</div>
)}
{binding && (
<div className="mt-4 text-center text-sm text-slate-600">
...
</div>
)}
</div>
)
}

View File

@@ -1,128 +0,0 @@
'use client'
import { useRef, useState } from 'react'
import { useEmojis } from '@/hooks/useEmojis'
interface UploadItemResult {
name: string
status: 'pending' | 'uploading' | 'success' | 'error'
message?: string
}
interface BulkEmojiUploadProps {
avatarId: string
onSuccess: () => void
}
export function BulkEmojiUpload({ avatarId, onSuccess }: BulkEmojiUploadProps) {
const inputRef = useRef<HTMLInputElement | null>(null)
const { uploadEmojiFile, createEmoji } = useEmojis()
const [uploading, setUploading] = useState(false)
const [results, setResults] = useState<UploadItemResult[]>([])
const [summary, setSummary] = useState<string | null>(null)
const updateItem = (name: string, patch: Partial<UploadItemResult>) => {
setResults(current =>
current.map(item => (item.name === name ? { ...item, ...patch } : item)),
)
}
const handleFilesSelected = async (files: FileList | null) => {
if (!files || files.length === 0) {
return
}
const selectedFiles = Array.from(files).filter(file => file.name.toLowerCase().endsWith('.webm'))
if (selectedFiles.length === 0) {
setSummary('请选择至少一个 webm 文件')
return
}
setUploading(true)
setSummary(null)
setResults(selectedFiles.map(file => ({ name: file.name, status: 'pending' })))
let successCount = 0
for (const [index, file] of selectedFiles.entries()) {
try {
updateItem(file.name, { status: 'uploading', message: '上传中...' })
const uploaded = await uploadEmojiFile(file)
await createEmoji(avatarId, uploaded.label, uploaded.url, undefined, index)
updateItem(file.name, { status: 'success', message: uploaded.label })
successCount += 1
} catch (error) {
updateItem(file.name, {
status: 'error',
message: error instanceof Error ? error.message : '上传失败',
})
}
}
setUploading(false)
setSummary(`成功 ${successCount} 个,失败 ${selectedFiles.length - successCount}`)
if (successCount > 0) {
onSuccess()
}
if (inputRef.current) {
inputRef.current.value = ''
}
}
return (
<div className="rounded-lg border border-slate-200 bg-white p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900"></h2>
<p className="mt-1 text-sm text-slate-500">
webm
</p>
</div>
<label className="inline-flex cursor-pointer items-center justify-center rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800">
{uploading ? '上传中...' : '选择 webm 文件'}
<input
ref={inputRef}
type="file"
accept=".webm,video/webm"
multiple
className="hidden"
disabled={uploading}
onChange={event => void handleFilesSelected(event.target.files)}
/>
</label>
</div>
{summary && (
<div className="mt-4 rounded-lg bg-slate-100 px-3 py-2 text-sm text-slate-700">
{summary}
</div>
)}
{results.length > 0 && (
<div className="mt-4 space-y-2">
{results.map(item => (
<div
key={item.name}
className="flex items-center justify-between rounded-lg border border-slate-200 px-3 py-2 text-sm"
>
<span className="truncate pr-4 text-slate-700">{item.name}</span>
<span
className={
item.status === 'success'
? 'text-emerald-600'
: item.status === 'error'
? 'text-red-600'
: 'text-slate-500'
}
>
{item.message || item.status}
</span>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -1,298 +0,0 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useChat } from '@/hooks/useChat'
import { useRelay, useRelaySession } from '@/hooks/useRelay'
import {
createChatSession,
loadChatSessionState,
saveChatSessionState,
type ChatSessionMeta,
} from '@/lib/chatSessions'
import type { ChatMessage, SessionEventMessage } from '@/lib/protocol'
import { ConnectionStatus } from './ConnectionStatus'
import { MessageList } from './MessageList'
import { ChatInput } from './ChatInput'
import { ChatToolbar } from './ChatToolbar'
import { SystemStatusPanel } from './SystemStatusPanel'
interface ChatContainerProps {
lobsterId: string
lobsterName: string
isOnline: boolean
userName?: string
}
export function ChatContainer({
lobsterId,
lobsterName,
isOnline,
userName,
}: ChatContainerProps) {
const { publishAvatarEvent } = useRelay()
const [sessions, setSessions] = useState<ChatSessionMeta[]>([])
const [activeSessionId, setActiveSessionId] = useState('')
const [showStatusPanel, setShowStatusPanel] = useState(false)
const [queueStatus, setQueueStatus] = useState<SessionEventMessage['payload'] | null>(null)
const [pendingDispatchCount, setPendingDispatchCount] = useState(0)
const sessionId = activeSessionId
useEffect(() => {
const stored = loadChatSessionState(lobsterId)
if (stored) {
setSessions(stored.sessions)
setActiveSessionId(stored.activeSessionId)
return
}
const initialSession = createChatSession(lobsterId, 1)
setSessions([initialSession])
setActiveSessionId(initialSession.id)
}, [lobsterId])
useEffect(() => {
if (!activeSessionId || sessions.length === 0) return
saveChatSessionState(lobsterId, {
sessions,
activeSessionId,
})
}, [lobsterId, sessions, activeSessionId])
const {
messages,
aiTyping,
setAiTyping,
addMessage,
addMessages,
createUserMessage,
clearAllMessages,
} = useChat(sessionId)
const touchSession = useCallback((targetSessionId: string) => {
setSessions(prev =>
prev.map(session =>
session.id === targetSessionId
? { ...session, updatedAt: Date.now() }
: session,
),
)
}, [])
const createNextSession = useCallback(() => {
const nextSession = createChatSession(lobsterId, sessions.length + 1)
setSessions(prev => [nextSession, ...prev])
setActiveSessionId(nextSession.id)
setShowStatusPanel(false)
return nextSession
}, [lobsterId, sessions.length])
const onMessage = useCallback(
(msg: ChatMessage) => {
addMessage(msg)
setAiTyping(false)
setPendingDispatchCount(prev => Math.max(0, prev - 1))
touchSession(msg.sessionId)
},
[addMessage, setAiTyping, touchSession],
)
const onTyping = useCallback(
(isTyping: boolean) => {
setAiTyping(isTyping)
},
[setAiTyping],
)
const onHistoryResponse = useCallback(
(msgs: ChatMessage[]) => {
addMessages(msgs)
},
[addMessages],
)
const onSessionEvent = useCallback((msg: SessionEventMessage) => {
if (msg.payload.event !== 'queue_status') return
if (msg.payload.state === 'idle') {
setQueueStatus(null)
setPendingDispatchCount(0)
return
}
setQueueStatus(msg.payload)
}, [])
const optimisticQueueStatus = useMemo<SessionEventMessage['payload'] | null>(() => {
if (pendingDispatchCount <= 0) return null
if (pendingDispatchCount === 1) {
return {
event: 'queue_status',
state: 'processing',
depth: 0,
}
}
return {
event: 'queue_status',
state: 'enqueued',
depth: pendingDispatchCount - 1,
}
}, [pendingDispatchCount])
const effectiveQueueStatus = queueStatus ?? optimisticQueueStatus
const { state, send, requestHistory, clearHistory } = useRelaySession(sessionId, {
onMessage,
onTyping,
onSessionEvent,
onHistoryResponse,
})
// Request history on connect
useEffect(() => {
if (state === 'connected') {
publishAvatarEvent({ type: 'history_request' })
requestHistory()
}
}, [state, requestHistory, publishAvatarEvent])
useEffect(() => {
setQueueStatus(null)
setPendingDispatchCount(0)
setAiTyping(false)
}, [sessionId])
const handleSend = useCallback(
(text: string) => {
const msg = createUserMessage(text)
msg.targetLobsterId = lobsterId
addMessage(msg)
setPendingDispatchCount(prev => prev + 1)
publishAvatarEvent({ type: 'message' })
send(msg)
setAiTyping(true)
touchSession(msg.sessionId)
},
[createUserMessage, addMessage, send, lobsterId, setAiTyping, touchSession, publishAvatarEvent],
)
const handleToolbarAction = useCallback(
(key: string) => {
switch (key) {
case 'new_conversation':
createNextSession()
break
case 'system_status':
setShowStatusPanel(prev => !prev)
break
case 'context':
handleSend('/context')
break
case 'clear_context':
handleSend('/clear')
clearAllMessages()
publishAvatarEvent({ type: 'history_clear' })
clearHistory()
setQueueStatus(null)
setPendingDispatchCount(0)
break
case 'summary':
handleSend('/summary')
break
case 'plan':
handleSend('/plan')
break
case 'tools':
handleSend('/tools')
break
}
},
[clearAllMessages, clearHistory, createNextSession, handleSend, publishAvatarEvent],
)
const sortedSessions = useMemo(
() => [...sessions].sort((a, b) => b.updatedAt - a.updatedAt),
[sessions],
)
const activeSession = useMemo(
() => sessions.find(session => session.id === activeSessionId) ?? null,
[sessions, activeSessionId],
)
if (!sessionId) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-slate-500">Loading session...</p>
</div>
)
}
return (
<div className="flex h-full flex-col">
<header className="grid grid-cols-[minmax(0,1fr)_minmax(220px,260px)_auto] items-center gap-3 border-b border-slate-200 px-4 py-3">
<div className="flex items-center gap-3 pl-10 md:pl-0">
<span
className={`inline-block h-2.5 w-2.5 rounded-full ${isOnline ? 'bg-green-400' : 'bg-slate-300'}`}
/>
<div>
<p className="text-sm font-medium text-slate-900">{lobsterName}</p>
<p className="text-xs text-slate-500">
{userName ? `${userName} · ` : ''}
{activeSession ? `${activeSession.title} · ` : ''}
{isOnline ? 'Online' : 'Offline'}
</p>
</div>
</div>
<div className="flex justify-center">
<label className="flex min-w-0 items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
<span className="shrink-0 text-xs font-medium text-slate-500">
</span>
<select
value={activeSessionId}
onChange={event => setActiveSessionId(event.target.value)}
className="min-w-0 flex-1 bg-transparent text-sm font-medium text-slate-700 outline-none"
>
{sortedSessions.map(session => (
<option key={session.id} value={session.id}>
{session.title}
</option>
))}
</select>
</label>
</div>
<div className="flex items-center justify-end gap-2">
<ConnectionStatus state={state} />
<ChatToolbar
onAction={handleToolbarAction}
disabled={state !== 'connected'}
/>
</div>
</header>
{showStatusPanel && (
<SystemStatusPanel
connectionState={state}
isLobsterOnline={isOnline}
sessionId={sessionId}
messageCount={messages.length}
onClose={() => setShowStatusPanel(false)}
/>
)}
{effectiveQueueStatus && (
<div className="border-b border-amber-200 bg-amber-50 px-4 py-2 text-xs text-amber-900">
{effectiveQueueStatus.state === 'processing' && '正在处理当前消息'}
{effectiveQueueStatus.state === 'enqueued' &&
`消息已加入队列${typeof effectiveQueueStatus.depth === 'number' && effectiveQueueStatus.depth > 0 ? `,前方 ${effectiveQueueStatus.depth}` : ''}`}
</div>
)}
<MessageList messages={messages} aiTyping={aiTyping} />
<ChatInput
onSend={handleSend}
disabled={state !== 'connected'}
lobsterId={lobsterId}
/>
</div>
)
}

View File

@@ -1,82 +0,0 @@
'use client'
import { useRef, useState } from 'react'
import { EmojiPicker } from './EmojiPicker'
interface ChatInputProps {
onSend: (text: string) => void
disabled?: boolean
lobsterId?: string
}
export function ChatInput({ onSend, disabled, lobsterId }: ChatInputProps) {
const [text, setText] = useState('')
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const inputRef = useRef<HTMLTextAreaElement>(null)
const handleSend = () => {
const trimmed = text.trim()
if (!trimmed) return
onSend(trimmed)
setText('')
inputRef.current?.focus()
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
const handleEmojiSelect = (emojiUrl: string) => {
// 插入表情图片的markdown格式
const emojiMarkdown = `![emoji](${emojiUrl})`
const newText = text + (text ? ' ' : '') + emojiMarkdown
setText(newText)
inputRef.current?.focus()
}
return (
<div className="border-t border-slate-200 px-4 py-3">
<div className="flex items-end gap-2">
{lobsterId && (
<div className="relative">
<button
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
disabled={disabled}
className="rounded-xl border border-slate-300 px-3 py-2.5 text-sm hover:bg-slate-50 disabled:opacity-50"
title="选择表情"
>
😊
</button>
{showEmojiPicker && (
<EmojiPicker
apikeyId={lobsterId}
onSelect={handleEmojiSelect}
onClose={() => setShowEmojiPicker(false)}
/>
)}
</div>
)}
<textarea
ref={inputRef}
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
disabled={disabled}
rows={1}
className="flex-1 resize-none rounded-xl border border-slate-300 px-4 py-2.5 text-sm focus:border-blue-500 focus:outline-none disabled:opacity-50"
/>
<button
onClick={handleSend}
disabled={disabled || !text.trim()}
className="rounded-xl bg-blue-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
Send
</button>
</div>
</div>
)
}

View File

@@ -1,116 +0,0 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
export interface ToolbarAction {
label: string
key: string
group: 'session' | 'ai'
confirm?: string // confirmation message, if set will prompt before executing
}
const TOOLBAR_ACTIONS: ToolbarAction[] = [
{ label: '新对话', key: 'new_conversation', group: 'session', confirm: '确定要开始新对话吗?系统会创建一个新的会话,并保留当前会话。' },
{ label: '系统状态', key: 'system_status', group: 'session' },
{ label: '查看上下文', key: 'context', group: 'ai' },
{ label: '清空上下文', key: 'clear_context', group: 'ai', confirm: '确定要清空上下文吗?这将清空消息并重置 AI 上下文。' },
{ label: '总结对话', key: 'summary', group: 'ai' },
{ label: '查看计划', key: 'plan', group: 'ai' },
{ label: '工具列表', key: 'tools', group: 'ai' },
]
interface ChatToolbarProps {
onAction: (key: string) => void
disabled?: boolean
}
export function ChatToolbar({
onAction,
disabled,
}: ChatToolbarProps) {
const [open, setOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const handleToggle = useCallback(() => {
setOpen(prev => !prev)
}, [])
const handleAction = useCallback(
(action: ToolbarAction) => {
if (action.confirm && !window.confirm(action.confirm)) return
setOpen(false)
onAction(action.key)
},
[onAction],
)
// Close on click outside
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
const sessionActions = TOOLBAR_ACTIONS.filter(a => a.group === 'session')
const aiActions = TOOLBAR_ACTIONS.filter(a => a.group === 'ai')
return (
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={handleToggle}
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 transition-colors"
aria-label="工具菜单"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-5 w-5"
>
<path d="M3 10a1.5 1.5 0 113 0 1.5 1.5 0 01-3 0zM8.5 10a1.5 1.5 0 113 0 1.5 1.5 0 01-3 0zM15.5 8.5a1.5 1.5 0 100 3 1.5 1.5 0 000-3z" />
</svg>
</button>
{open && (
<div className="absolute right-0 top-full z-50 mt-1 w-44 rounded-lg border border-slate-200 bg-white py-1 shadow-lg">
<div className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
</div>
{sessionActions.map(action => (
<button
key={action.key}
type="button"
onClick={() => handleAction(action)}
className="block w-full px-3 py-1.5 text-left text-sm text-slate-700 hover:bg-slate-50 transition-colors"
>
{action.label}
</button>
))}
<div className="my-1 border-t border-slate-100" />
<div className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
AI
</div>
{aiActions.map(action => (
<button
key={action.key}
type="button"
onClick={() => handleAction(action)}
disabled={disabled}
className="block w-full px-3 py-1.5 text-left text-sm text-slate-700 hover:bg-slate-50 transition-colors disabled:cursor-not-allowed disabled:text-slate-300"
>
{action.label}
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -1,22 +0,0 @@
'use client'
import type { ConnectionState } from '@/hooks/useRelay'
const statusConfig: Record<ConnectionState, { color: string; label: string }> =
{
connecting: { color: 'bg-yellow-400', label: 'Connecting...' },
connected: { color: 'bg-green-400', label: 'Connected' },
disconnected: { color: 'bg-slate-400', label: 'Disconnected' },
error: { color: 'bg-red-400', label: 'Error' },
}
export function ConnectionStatus({ state }: { state: ConnectionState }) {
const { color, label } = statusConfig[state]
return (
<div className="flex items-center gap-2">
<span className={`inline-block h-2.5 w-2.5 rounded-full ${color}`} />
<span className="text-xs text-slate-500">{label}</span>
</div>
)
}

View File

@@ -1,259 +0,0 @@
'use client'
import { useState } from 'react'
import { useEmojis } from '@/hooks/useEmojis'
import {
AVATAR_EMOJI_RESULTS,
AVATAR_EMOJI_TYPES,
supportsAvatarEmojiResult,
type AvatarEmojiResult,
type AvatarEmojiType,
} from '@/lib/protocol'
import { EmojiMedia } from './EmojiMedia'
interface EmojiFormProps {
avatarId: string
onSuccess: () => void
onCancel: () => void
emojiId?: string
initialLabel?: string
initialUrl?: string
initialDescription?: string
initialTriggerType?: AvatarEmojiType
initialTriggerResult?: AvatarEmojiResult | ''
}
export function EmojiForm({
avatarId,
onSuccess,
onCancel,
emojiId,
initialLabel = '',
initialUrl = '',
initialDescription = '',
initialTriggerType = 'message',
initialTriggerResult = '',
}: EmojiFormProps) {
const { createEmoji, updateEmoji } = useEmojis()
const [label, setLabel] = useState(initialLabel)
const [url, setUrl] = useState(initialUrl)
const [description, setDescription] = useState(initialDescription)
const [triggerType, setTriggerType] = useState<AvatarEmojiType>(initialTriggerType)
const [triggerResult, setTriggerResult] = useState<AvatarEmojiResult | ''>(initialTriggerResult)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const resultEnabled = supportsAvatarEmojiResult(triggerType)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!label.trim()) {
setError('请输入表情标签')
return
}
if (!url.trim()) {
setError('请输入表情URL')
return
}
// 简单的URL验证
try {
new URL(url)
} catch {
setError('请输入有效的URL')
return
}
try {
setSubmitting(true)
setError(null)
if (emojiId) {
await updateEmoji(emojiId, {
label: label.trim(),
url: url.trim(),
description: description.trim() || undefined,
trigger_type: triggerType,
trigger_result: resultEnabled
? (triggerResult || null)
: null,
})
} else {
await createEmoji(
avatarId,
label.trim(),
url.trim(),
description.trim() || undefined,
undefined,
triggerType,
resultEnabled ? (triggerResult || undefined) : undefined,
)
}
onSuccess()
} catch (err) {
setError(err instanceof Error ? err.message : '添加失败')
} finally {
setSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-lg bg-red-50 p-3 text-sm text-red-700">
{error}
</div>
)}
<div>
<label
htmlFor="label"
className="block text-sm font-medium text-slate-700"
>
*
</label>
<input
id="label"
type="text"
value={label}
onChange={e => setLabel(e.target.value)}
placeholder="例如:开心、难过、生气"
className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
disabled={submitting}
/>
</div>
<div>
<label
htmlFor="url"
className="block text-sm font-medium text-slate-700"
>
URL *
</label>
<input
id="url"
type="url"
value={url}
onChange={e => setUrl(e.target.value)}
placeholder="https://example.com/emoji.png"
className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
disabled={submitting}
/>
<p className="mt-1 text-xs text-slate-500">
URL地址
</p>
</div>
<div>
<label
htmlFor="triggerType"
className="block text-sm font-medium text-slate-700"
>
*
</label>
<select
id="triggerType"
value={triggerType}
onChange={e => {
const nextType = e.target.value as AvatarEmojiType
setTriggerType(nextType)
if (!supportsAvatarEmojiResult(nextType)) {
setTriggerResult('')
}
}}
className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
disabled={submitting}
>
{AVATAR_EMOJI_TYPES.map(type => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</div>
{resultEnabled && (
<div>
<label
htmlFor="triggerResult"
className="block text-sm font-medium text-slate-700"
>
</label>
<select
id="triggerResult"
value={triggerResult}
onChange={e => setTriggerResult(e.target.value as AvatarEmojiResult | '')}
className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
disabled={submitting}
>
<option value=""></option>
{AVATAR_EMOJI_RESULTS.map(result => (
<option key={result} value={result}>
{result}
</option>
))}
</select>
<p className="mt-1 text-xs text-slate-500">
</p>
</div>
)}
<div>
<label
htmlFor="description"
className="block text-sm font-medium text-slate-700"
>
</label>
<textarea
id="description"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="简单描述这个表情..."
rows={2}
className="mt-1 w-full resize-none rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
disabled={submitting}
/>
</div>
{url && (
<div>
<label className="block text-sm font-medium text-slate-700">
</label>
<div className="mt-2 inline-block rounded-lg border border-slate-200 bg-slate-50 p-4">
<EmojiMedia
url={url}
alt="预览"
className="h-24 w-24 object-contain"
fallbackClassName="h-24 w-24 object-contain"
/>
</div>
</div>
)}
<div className="flex gap-2">
<button
type="submit"
disabled={submitting}
className="flex-1 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{submitting ? (emojiId ? '保存中...' : '添加中...') : (emojiId ? '保存' : '添加')}
</button>
<button
type="button"
onClick={onCancel}
disabled={submitting}
className="rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-50"
>
</button>
</div>
</form>
)
}

View File

@@ -1,111 +0,0 @@
'use client'
import { useState } from 'react'
import { useEmojis } from '@/hooks/useEmojis'
import type { Emoji } from '@/hooks/useAvatars'
import type { AvatarEmojiType } from '@/lib/protocol'
import { EmojiMedia } from './EmojiMedia'
import { EmojiForm } from './EmojiForm'
interface EmojiListProps {
emojis: Emoji[]
onRefresh: () => void
avatarId?: string
}
export function EmojiList({ emojis, onRefresh, avatarId = '' }: EmojiListProps) {
const { deleteEmoji } = useEmojis()
const [deletingId, setDeletingId] = useState<string | null>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const handleDelete = async (emojiId: string) => {
if (!confirm('确定要删除这个表情吗?')) {
return
}
try {
setDeletingId(emojiId)
await deleteEmoji(emojiId)
onRefresh()
} catch (err) {
console.error('Failed to delete emoji:', err)
alert('删除失败,请重试')
} finally {
setDeletingId(null)
}
}
if (emojis.length === 0) {
return (
<div className="rounded-lg border border-slate-200 bg-white p-12 text-center">
<p className="text-slate-600"></p>
<p className="mt-2 text-sm text-slate-500">
</p>
</div>
)
}
return (
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{emojis.map(emoji => (
<div
key={emoji.id}
className="group relative rounded-lg border border-slate-200 bg-white p-4 transition-shadow hover:shadow-md"
>
<div className="aspect-square overflow-hidden rounded-lg bg-slate-100">
<EmojiMedia
url={emoji.url}
alt={emoji.label}
className="h-full w-full object-contain"
fallbackClassName="h-full w-full object-contain"
/>
</div>
<div className="mt-3">
<h3 className="font-medium text-slate-900">{emoji.label}</h3>
<p className="mt-1 text-xs text-slate-500">
{emoji.trigger_type}
{emoji.trigger_result ? ` / ${emoji.trigger_result}` : ''}
</p>
{emoji.description && (
<p className="mt-1 text-sm text-slate-600">{emoji.description}</p>
)}
</div>
<div className="mt-4 flex gap-2">
<button
onClick={() => setEditingId(current => current === emoji.id ? null : emoji.id)}
className="flex-1 rounded-lg border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
>
{editingId === emoji.id ? '取消编辑' : '编辑'}
</button>
<button
onClick={() => handleDelete(emoji.id)}
disabled={deletingId === emoji.id}
className="rounded-lg border border-red-300 px-3 py-2 text-sm font-medium text-red-700 hover:bg-red-50 disabled:opacity-50"
>
{deletingId === emoji.id ? '删除中...' : '删除'}
</button>
</div>
{editingId === emoji.id && avatarId && (
<div className="mt-4 border-t border-slate-200 pt-4">
<EmojiForm
avatarId={avatarId}
emojiId={emoji.id}
initialLabel={emoji.label}
initialUrl={emoji.url}
initialDescription={emoji.description ?? ''}
initialTriggerType={emoji.trigger_type as AvatarEmojiType}
initialTriggerResult={emoji.trigger_result ?? ''}
onSuccess={() => {
setEditingId(null)
onRefresh()
}}
onCancel={() => setEditingId(null)}
/>
</div>
)}
</div>
))}
</div>
)
}

View File

@@ -1,55 +0,0 @@
'use client'
interface EmojiMediaProps {
url: string
alt: string
className?: string
fallbackClassName?: string
autoPlay?: boolean
}
function isVideoUrl(url: string): boolean {
const normalized = url.split('?')[0]?.toLowerCase() ?? ''
return normalized.endsWith('.webm') || normalized.endsWith('.mp4') || normalized.endsWith('.mov')
}
const FALLBACK_DATA_URL =
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="100" height="100"%3E%3Crect fill="%23ddd" width="100" height="100"/%3E%3Ctext x="50%25" y="50%25" text-anchor="middle" dy=".3em" fill="%23999"%3E?%3C/text%3E%3C/svg%3E'
export function EmojiMedia({
url,
alt,
className,
fallbackClassName,
autoPlay = true,
}: EmojiMediaProps) {
if (isVideoUrl(url)) {
return (
<video
src={url}
title={alt}
className={className ? `${className} bg-slate-900` : 'bg-slate-900'}
autoPlay={autoPlay}
loop
muted
playsInline
preload="metadata"
/>
)
}
return (
<img
src={url}
alt={alt}
className={className}
onError={e => {
const target = e.target as HTMLImageElement
target.src = FALLBACK_DATA_URL
if (fallbackClassName) {
target.className = fallbackClassName
}
}}
/>
)
}

View File

@@ -1,108 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { useApiKeyAvatar, type ApiKeyAvatar } from '@/hooks/useApiKeyAvatar'
import { EmojiMedia } from './EmojiMedia'
interface EmojiPickerProps {
apikeyId: string
onSelect: (emoji: string) => void
onClose: () => void
}
export function EmojiPicker({ apikeyId, onSelect, onClose }: EmojiPickerProps) {
const { getBoundAvatar } = useApiKeyAvatar()
const [data, setData] = useState<ApiKeyAvatar | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
loadEmojis()
}, [apikeyId])
const loadEmojis = async () => {
try {
setLoading(true)
const result = await getBoundAvatar(apikeyId)
setData(result)
} catch (err) {
console.error('Failed to load emojis:', err)
} finally {
setLoading(false)
}
}
const handleSelect = (url: string) => {
onSelect(url)
onClose()
}
if (loading) {
return (
<div className="absolute bottom-full left-0 mb-2 w-80 rounded-lg border border-slate-200 bg-white p-4 shadow-lg">
<div className="text-center text-sm text-slate-600">...</div>
</div>
)
}
if (!data?.avatar || data.emojis.length === 0) {
return (
<div className="absolute bottom-full left-0 mb-2 w-80 rounded-lg border border-slate-200 bg-white p-4 shadow-lg">
<div className="mb-2 flex items-center justify-between">
<h3 className="font-semibold text-slate-900"></h3>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600"
>
</button>
</div>
<div className="text-center">
<p className="text-sm text-slate-600">
{!data?.avatar ? '未绑定形象' : '该形象还没有表情'}
</p>
<a
href="/avatars"
className="mt-2 inline-block text-sm text-blue-600 hover:text-blue-700"
>
</a>
</div>
</div>
)
}
return (
<div className="absolute bottom-full left-0 mb-2 w-80 rounded-lg border border-slate-200 bg-white p-4 shadow-lg">
<div className="mb-3 flex items-center justify-between">
<h3 className="font-semibold text-slate-900">{data.avatar.name}</h3>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600"
>
</button>
</div>
<div className="grid max-h-64 grid-cols-4 gap-2 overflow-y-auto">
{data.emojis.map(emoji => (
<button
key={emoji.id}
onClick={() => handleSelect(emoji.url)}
className="group relative aspect-square overflow-hidden rounded-lg border border-slate-200 bg-slate-50 p-2 transition-all hover:border-blue-500 hover:shadow-md"
title={emoji.label}
>
<EmojiMedia
url={emoji.url}
alt={emoji.label}
className="h-full w-full object-contain"
fallbackClassName="h-full w-full object-contain"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent px-1 py-0.5 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100">
{emoji.label}
</div>
</button>
))}
</div>
</div>
)
}

View File

@@ -1,300 +0,0 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import Image from 'next/image'
import { useApiKeyAvatar } from '@/hooks/useApiKeyAvatar'
import { useRelay } from '@/hooks/useRelay'
import type { Emoji } from '@/hooks/useAvatars'
import { resolveAvatarEmoji } from '@/lib/avatarEmoji'
import smilePreview from '@/assets/开心_01_难得灿烂笑.transparent.webp'
const STORAGE_KEY = 'floating-avatar-position'
const SIZE_STORAGE_KEY = 'floating-avatar-size'
const DEFAULT_SIZE = 120
const MIN_SIZE = 60
const MAX_SIZE = 1000 // 最大高度 1000px
const IMAGE_ASPECT_RATIO = 360 / 640 // 原始图片宽高比 (宽/高)
interface FloatingAvatarProps {
apikeyId: string | null
}
export function FloatingAvatar({ apikeyId }: FloatingAvatarProps) {
const { getBoundAvatar } = useApiKeyAvatar()
const { avatarEvent } = useRelay()
const [emojis, setEmojis] = useState<Emoji[]>([])
const [imageUrl, setImageUrl] = useState<string | null>(null)
const [imageAlt, setImageAlt] = useState('Virtual Avatar')
const [position, setPosition] = useState({ x: 16, y: 16 })
const [size, setSize] = useState(DEFAULT_SIZE) // 这是高度
const [isDragging, setIsDragging] = useState(false)
const [isResizing, setIsResizing] = useState(false)
const [isActive, setIsActive] = useState(false)
const dragRef = useRef<{ startX: number; startY: number; initialX: number; initialY: number } | null>(null)
const resizeRef = useRef<{ startX: number; startY: number; initialSize: number } | null>(null)
// 根据高度计算宽度,保持宽高比
const width = Math.round(size * IMAGE_ASPECT_RATIO)
// 从 localStorage 恢复位置和大小
useEffect(() => {
setPosition(current => ({
x: current.x,
y: Math.min(current.y || window.innerHeight - 136, window.innerHeight - 136),
}))
const savedPos = localStorage.getItem(STORAGE_KEY)
if (savedPos) {
try {
const parsed = JSON.parse(savedPos)
setPosition(parsed)
} catch (e) {
console.error('Failed to parse saved position:', e)
}
}
const savedSize = localStorage.getItem(SIZE_STORAGE_KEY)
if (savedSize) {
try {
const parsed = parseInt(savedSize, 10)
if (!isNaN(parsed)) setSize(parsed)
} catch (e) {
console.error('Failed to parse saved size:', e)
}
}
}, [])
useEffect(() => {
if (!apikeyId) {
setEmojis([])
setImageUrl(null)
setImageAlt('Virtual Avatar')
return
}
let cancelled = false
void (async () => {
try {
const data = await getBoundAvatar(apikeyId)
if (cancelled) return
setEmojis(data.emojis || [])
setImageAlt(data.avatar?.name ?? 'Virtual Avatar')
} catch {
if (cancelled) return
setEmojis([])
setImageUrl(null)
setImageAlt('Virtual Avatar')
}
})()
return () => {
cancelled = true
}
}, [apikeyId, getBoundAvatar])
useEffect(() => {
const matchedEmoji = resolveAvatarEmoji(emojis, avatarEvent)
setImageUrl(matchedEmoji?.url ?? null)
}, [emojis, avatarEvent])
// 保存位置到 localStorage
const savePosition = (pos: { x: number; y: number }) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(pos))
}
// 保存大小到 localStorage
const saveSize = (newSize: number) => {
localStorage.setItem(SIZE_STORAGE_KEY, newSize.toString())
}
const handleMouseDown = (e: React.MouseEvent) => {
// 如果点击的是边框区域,不触发拖动
if ((e.target as HTMLElement).classList.contains('resize-border')) {
return
}
e.preventDefault()
e.stopPropagation()
// 如果还未激活,先激活
if (!isActive) {
setIsActive(true)
}
setIsDragging(true)
dragRef.current = {
startX: e.clientX,
startY: e.clientY,
initialX: position.x,
initialY: position.y,
}
}
const handleResizeMouseDown = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setIsResizing(true)
resizeRef.current = {
startX: e.clientX,
startY: e.clientY,
initialSize: size,
}
}
// 点击外部区域取消激活
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('.floating-avatar-container')) {
setIsActive(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
useEffect(() => {
if (!isDragging) return
const handleMouseMove = (e: MouseEvent) => {
if (!dragRef.current) return
const deltaX = e.clientX - dragRef.current.startX
const deltaY = e.clientY - dragRef.current.startY
const newX = dragRef.current.initialX + deltaX
const newY = dragRef.current.initialY + deltaY
// 限制在视口范围内
const maxX = window.innerWidth - width
const maxY = window.innerHeight - size
const boundedX = Math.max(0, Math.min(newX, maxX))
const boundedY = Math.max(0, Math.min(newY, maxY))
setPosition({ x: boundedX, y: boundedY })
}
const handleMouseUp = () => {
setIsDragging(false)
dragRef.current = null
savePosition(position)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
}, [isDragging, position, size, width])
useEffect(() => {
if (!isResizing) return
const handleMouseMove = (e: MouseEvent) => {
if (!resizeRef.current) return
const deltaX = e.clientX - resizeRef.current.startX
const deltaY = e.clientY - resizeRef.current.startY
const delta = Math.max(deltaX, deltaY)
const newSize = resizeRef.current.initialSize + delta
const boundedSize = Math.max(MIN_SIZE, Math.min(newSize, MAX_SIZE))
setSize(boundedSize)
}
const handleMouseUp = () => {
setIsResizing(false)
resizeRef.current = null
saveSize(size)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
}, [isResizing, size])
return (
<div
className="floating-avatar-container fixed z-[9999]"
style={{
left: `${position.x}px`,
top: `${position.y}px`,
width: `${width}px`,
height: `${size}px`,
}}
>
{/* 主图像区域 */}
<div
className="absolute inset-0 cursor-move"
onMouseDown={handleMouseDown}
>
{imageUrl ? (
<img
src={imageUrl}
alt={imageAlt}
draggable={false}
className="select-none w-full h-full pointer-events-none object-contain"
onError={() => {
const fallbackEmoji = resolveAvatarEmoji(emojis, { type: 'default' })
setImageUrl(fallbackEmoji?.url ?? null)
}}
/>
) : (
<Image
src={smilePreview}
alt={imageAlt}
width={width}
height={size}
draggable={false}
className="select-none w-full h-full pointer-events-none object-contain"
/>
)}
</div>
{/* 激活状态的边框 */}
{isActive && (
<>
{/* 边框视觉效果 */}
<div className="absolute inset-0 border-2 border-blue-400 pointer-events-none rounded" />
{/* 上边框 */}
<div
className="resize-border absolute top-0 left-0 right-0 h-2 cursor-ns-resize hover:bg-blue-400/30 z-10"
onMouseDown={handleResizeMouseDown}
/>
{/* 右边框 */}
<div
className="resize-border absolute top-0 right-0 bottom-0 w-2 cursor-ew-resize hover:bg-blue-400/30 z-10"
onMouseDown={handleResizeMouseDown}
/>
{/* 下边框 */}
<div
className="resize-border absolute bottom-0 left-0 right-0 h-2 cursor-ns-resize hover:bg-blue-400/30 z-10"
onMouseDown={handleResizeMouseDown}
/>
{/* 左边框 */}
<div
className="resize-border absolute top-0 left-0 bottom-0 w-2 cursor-ew-resize hover:bg-blue-400/30 z-10"
onMouseDown={handleResizeMouseDown}
/>
{/* 右下角 */}
<div
className="resize-border absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize hover:bg-blue-400/50 z-10"
onMouseDown={handleResizeMouseDown}
/>
</>
)}
</div>
)
}

View File

@@ -1,98 +0,0 @@
'use client'
import Link from 'next/link'
import type { LobsterWithStatus } from '@/hooks/useLobsters'
interface LobsterSidebarProps {
lobsters: LobsterWithStatus[]
activeLobsterId: string | null
onSelect: (lobster: LobsterWithStatus) => void
collapsed?: boolean
onToggle?: () => void
}
export function LobsterSidebar({
lobsters,
activeLobsterId,
onSelect,
collapsed,
onToggle,
}: LobsterSidebarProps) {
return (
<>
{/* Mobile toggle button */}
<button
onClick={onToggle}
className="absolute left-3 top-3 z-20 rounded-lg border border-slate-200 bg-white p-2 shadow-sm md:hidden"
aria-label="Toggle sidebar"
>
<svg className="h-5 w-5 text-slate-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
{/* Backdrop for mobile */}
{!collapsed && (
<div
className="fixed inset-0 z-20 bg-black/20 md:hidden"
onClick={onToggle}
/>
)}
{/* Sidebar */}
<aside
className={`
fixed inset-y-0 left-0 z-30 flex w-72 flex-col border-r border-slate-200 bg-white transition-transform duration-200
md:relative md:z-auto md:translate-x-0
${collapsed ? '-translate-x-full' : 'translate-x-0'}
`}
>
<div className="border-b border-slate-200 px-4 py-3">
<h2 className="text-sm font-semibold text-slate-900">Lobsters</h2>
</div>
<div className="flex-1 overflow-y-auto">
{lobsters.length === 0 ? (
<p className="px-4 py-6 text-center text-xs text-slate-400">
No lobsters yet
</p>
) : (
<ul className="py-1">
{lobsters.map((lobster) => (
<li key={lobster.id}>
<button
onClick={() => onSelect(lobster)}
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm transition-colors ${
lobster.id === activeLobsterId
? 'bg-blue-50 text-blue-700'
: 'text-slate-700 hover:bg-slate-50'
}`}
>
<span
className={`inline-block h-2.5 w-2.5 shrink-0 rounded-full ${
lobster.isOnline ? 'bg-green-400' : 'bg-slate-300'
}`}
/>
<span className="truncate font-medium">{lobster.name}</span>
</button>
</li>
))}
</ul>
)}
</div>
<div className="border-t border-slate-200 px-4 py-3">
<Link
href="/lobsters"
className="flex items-center gap-2 text-xs text-slate-500 hover:text-slate-700"
>
Manage Lobsters
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
</aside>
</>
)
}

View File

@@ -1,37 +0,0 @@
'use client'
import type { ChatMessage } from '@/lib/protocol'
function formatTime(timestamp: number): string {
return new Date(timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
}
export function MessageBubble({ message }: { message: ChatMessage }) {
const isUser = message.from === 'user'
return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[75%] rounded-2xl px-4 py-2.5 ${
isUser
? 'bg-blue-600 text-white'
: 'border border-slate-200 bg-slate-50 text-slate-900'
}`}
>
<p className="whitespace-pre-wrap text-sm leading-relaxed">
{message.text}
</p>
<p
className={`mt-1 text-right text-[10px] ${
isUser ? 'text-blue-200' : 'text-slate-400'
}`}
>
{formatTime(message.timestamp)}
</p>
</div>
</div>
)
}

View File

@@ -1,123 +0,0 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import type { ChatMessage } from '@/lib/protocol'
import smilePreview from '@/assets/开心_01_难得灿烂笑.transparent.webp'
import { MessageBubble } from './MessageBubble'
interface MessageListProps {
messages: ChatMessage[]
aiTyping: boolean
}
export function MessageList({ messages, aiTyping }: MessageListProps) {
const bottomRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<{
pointerId: number
startX: number
startY: number
originX: number
originY: number
} | null>(null)
const [offset, setOffset] = useState({ x: 0, y: 0 })
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages.length, aiTyping])
const handlePointerDown: React.PointerEventHandler<
HTMLDivElement
> = event => {
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: offset.x,
originY: offset.y,
}
event.currentTarget.setPointerCapture(event.pointerId)
}
const handlePointerMove: React.PointerEventHandler<
HTMLDivElement
> = event => {
const dragState = dragRef.current
if (!dragState || dragState.pointerId !== event.pointerId) {
return
}
setOffset({
x: dragState.originX + event.clientX - dragState.startX,
y: dragState.originY + event.clientY - dragState.startY,
})
}
const handlePointerUp: React.PointerEventHandler<HTMLDivElement> = event => {
if (dragRef.current?.pointerId === event.pointerId) {
dragRef.current = null
event.currentTarget.releasePointerCapture(event.pointerId)
}
}
return (
<div className="flex-1 space-y-3 overflow-y-auto px-4 py-4">
{messages.length === 0 && (
<div className="flex h-full flex-col items-center justify-center gap-4 text-center">
<div className="rounded-3xl bg-amber-200 p-6 shadow-sm ring-1 ring-slate-300">
<div
className="rounded-2xl p-4"
style={{
backgroundColor: '#f8fafc',
backgroundImage:
'linear-gradient(45deg, #cbd5e1 25%, transparent 25%), linear-gradient(-45deg, #cbd5e1 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #cbd5e1 75%), linear-gradient(-45deg, transparent 75%, #cbd5e1 75%)',
backgroundPosition: '0 0, 0 12px, 12px -12px, -12px 0px',
backgroundSize: '24px 24px',
}}
>
<div
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
className="inline-block touch-none cursor-grab active:cursor-grabbing"
style={{
transform: `translate(${offset.x}px, ${offset.y}px)`,
}}
>
<img
src={smilePreview.src}
alt="transparent preview"
draggable={false}
className="h-auto w-full max-w-xs select-none object-contain"
/>
</div>
</div>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-slate-600">
</p>
<p className="text-sm text-slate-400">
Send a message to start chatting
</p>
</div>
</div>
)}
{messages.map(msg => (
<MessageBubble key={msg.id} message={msg} />
))}
{aiTyping && (
<div className="flex justify-start">
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-2.5">
<div className="flex items-center gap-1">
<span className="inline-block h-2 w-2 animate-bounce rounded-full bg-slate-400 [animation-delay:0ms]" />
<span className="inline-block h-2 w-2 animate-bounce rounded-full bg-slate-400 [animation-delay:150ms]" />
<span className="inline-block h-2 w-2 animate-bounce rounded-full bg-slate-400 [animation-delay:300ms]" />
</div>
</div>
</div>
)}
<div ref={bottomRef} />
</div>
)
}

View File

@@ -1,72 +0,0 @@
'use client'
import type { ConnectionState } from '@/hooks/useRelay'
interface SystemStatusPanelProps {
connectionState: ConnectionState
isLobsterOnline: boolean
sessionId: string
messageCount: number
onClose: () => void
}
const stateLabels: Record<ConnectionState, string> = {
connecting: '连接中...',
connected: '已连接',
disconnected: '已断开',
error: '连接错误',
}
const stateColors: Record<ConnectionState, string> = {
connecting: 'text-yellow-600',
connected: 'text-green-600',
disconnected: 'text-slate-500',
error: 'text-red-600',
}
export function SystemStatusPanel({
connectionState,
isLobsterOnline,
sessionId,
messageCount,
onClose,
}: SystemStatusPanelProps) {
return (
<div className="border-b border-slate-200 bg-slate-50 px-4 py-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400">
</span>
<button
type="button"
onClick={onClose}
className="text-slate-400 hover:text-slate-600 transition-colors"
aria-label="关闭状态面板"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
<div className="text-slate-500">WebSocket</div>
<div className={stateColors[connectionState]}>
{stateLabels[connectionState]}
</div>
<div className="text-slate-500">Lobster</div>
<div className={isLobsterOnline ? 'text-green-600' : 'text-slate-500'}>
{isLobsterOnline ? '在线' : '离线'}
</div>
<div className="text-slate-500">Session ID</div>
<div className="truncate text-slate-700 font-mono" title={sessionId}>
{sessionId}
</div>
<div className="text-slate-500"></div>
<div className="text-slate-700">{messageCount}</div>
</div>
</div>
)
}

View File

@@ -1,69 +0,0 @@
'use client'
import { useState, useCallback } from 'react'
import { useAuth } from './useAuth'
import type { Avatar, Emoji } from './useAvatars'
export interface ApiKeyAvatar {
apikey: {
id: string
name: string | null
avatar_id: string | null
}
avatar: Avatar | null
emojis: Emoji[]
}
export function useApiKeyAvatar() {
const { relayApiClient } = useAuth()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const bindAvatar = useCallback(
async (apikeyId: string, avatarId: string | null): Promise<void> => {
setLoading(true)
setError(null)
try {
await relayApiClient.apiKeyAvatars.bind(apikeyId, avatarId)
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
setError(message)
throw err
} finally {
setLoading(false)
}
},
[relayApiClient],
)
const getBoundAvatar = useCallback(
async (apikeyId: string): Promise<ApiKeyAvatar> => {
setLoading(true)
setError(null)
try {
const data = await relayApiClient.apiKeyAvatars.get(apikeyId)
return {
apikey: data.apikey,
avatar: data.avatar,
emojis: data.emojis || [],
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
setError(message)
throw err
} finally {
setLoading(false)
}
},
[relayApiClient],
)
return {
loading,
error,
bindAvatar,
getBoundAvatar,
}
}

View File

@@ -1,97 +0,0 @@
'use client'
import {
createContext,
useContext,
useEffect,
useState,
useCallback,
createElement,
type ReactNode,
} from 'react'
import {
createWebAuthClient,
createWebRelayApiClient,
getStoredToken,
clearStoredToken,
type WebAuthClient,
type WebRelayApiClient,
} from '@bowong/auth/client'
const RELAY_URL = process.env.NEXT_PUBLIC_RELAY_URL ?? 'http://localhost:8787'
const authClient = createWebAuthClient(RELAY_URL)
const relayApiClient = createWebRelayApiClient(RELAY_URL)
interface AuthUser {
id: string
name: string | null
email: string
}
interface AuthContextValue {
user: AuthUser | null
loading: boolean
token: string | null
authClient: WebAuthClient
relayApiClient: WebRelayApiClient
signOut: () => Promise<void>
refreshSession: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null)
const [loading, setLoading] = useState(true)
const [token, setToken] = useState<string | null>(null)
const refreshSession = useCallback(async () => {
try {
const { data: session } = await authClient.getSession()
if (session?.user) {
setUser({
id: session.user.id,
name: session.user.name,
email: session.user.email,
})
setToken(getStoredToken())
} else {
setUser(null)
setToken(null)
}
} catch {
setUser(null)
setToken(null)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void refreshSession()
}, [refreshSession])
const signOut = useCallback(async () => {
await authClient.signOut()
clearStoredToken()
setUser(null)
setToken(null)
}, [])
return createElement(
AuthContext.Provider,
{
value: { user, loading, token, authClient, relayApiClient, signOut, refreshSession },
},
children,
)
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext)
if (!ctx) {
throw new Error('useAuth must be used within an AuthProvider')
}
return ctx
}

View File

@@ -1,124 +0,0 @@
'use client'
import { useState, useCallback } from 'react'
import { useAuth } from './useAuth'
import type { AvatarRecord as Avatar, EmojiRecord as Emoji } from '@bowong/auth/client'
export type { Avatar, Emoji }
export interface AvatarWithEmojis extends Avatar {
emojis: Emoji[]
}
export function useAvatars() {
const { relayApiClient } = useAuth()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const fetchAvatars = useCallback(async (): Promise<Avatar[]> => {
setLoading(true)
setError(null)
try {
const data = await relayApiClient.avatars.list()
return data.avatars
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
setError(message)
throw err
} finally {
setLoading(false)
}
}, [relayApiClient])
const fetchAvatar = useCallback(
async (avatarId: string): Promise<AvatarWithEmojis> => {
setLoading(true)
setError(null)
try {
const data = await relayApiClient.avatars.get(avatarId)
return {
...data.avatar,
emojis: data.emojis || [],
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
setError(message)
throw err
} finally {
setLoading(false)
}
},
[relayApiClient],
)
const createAvatar = useCallback(
async (name: string, description?: string): Promise<Avatar> => {
setLoading(true)
setError(null)
try {
const data = await relayApiClient.avatars.create({ name, description })
return data.avatar
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
setError(message)
throw err
} finally {
setLoading(false)
}
},
[relayApiClient],
)
const updateAvatar = useCallback(
async (
avatarId: string,
name: string,
description?: string,
): Promise<void> => {
setLoading(true)
setError(null)
try {
await relayApiClient.avatars.update(avatarId, { name, description })
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
setError(message)
throw err
} finally {
setLoading(false)
}
},
[relayApiClient],
)
const deleteAvatar = useCallback(
async (avatarId: string): Promise<void> => {
setLoading(true)
setError(null)
try {
await relayApiClient.avatars.delete(avatarId)
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
setError(message)
throw err
} finally {
setLoading(false)
}
},
[relayApiClient],
)
return {
loading,
error,
fetchAvatars,
fetchAvatar,
createAvatar,
updateAvatar,
deleteAvatar,
}
}

View File

@@ -1,75 +0,0 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { type ChatMessage, generateMessageId } from '@/lib/protocol'
import { clearMessages, loadMessages, saveMessages } from '@/lib/chatStorage'
export function useChat(sessionId: string) {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [aiTyping, setAiTyping] = useState(false)
// Track current sessionId to avoid stale saves
const sessionIdRef = useRef(sessionId)
sessionIdRef.current = sessionId
// Load from localStorage when switching sessions
useEffect(() => {
setAiTyping(false)
const cached = loadMessages(sessionId)
setMessages(cached)
}, [sessionId])
// Persist to localStorage whenever messages change
const messagesRef = useRef(messages)
messagesRef.current = messages
useEffect(() => {
if (messages.length > 0) {
saveMessages(sessionIdRef.current, messages)
}
}, [messages])
const addMessage = useCallback((msg: ChatMessage) => {
setMessages(prev => {
if (prev.some(m => m.id === msg.id)) return prev
return [...prev, msg]
})
}, [])
const addMessages = useCallback((msgs: ChatMessage[]) => {
setMessages(prev => {
const existingIds = new Set(prev.map(m => m.id))
const newMsgs = msgs.filter(m => !existingIds.has(m.id))
if (newMsgs.length === 0) return prev
return [...newMsgs, ...prev].sort((a, b) => a.timestamp - b.timestamp)
})
}, [])
const clearAllMessages = useCallback(() => {
setMessages([])
setAiTyping(false)
clearMessages(sessionId)
}, [sessionId])
const createUserMessage = useCallback(
(text: string): ChatMessage => ({
type: 'message',
id: generateMessageId(),
sessionId,
from: 'user',
text,
timestamp: Date.now(),
}),
[sessionId],
)
return {
messages,
aiTyping,
setAiTyping,
addMessage,
addMessages,
createUserMessage,
clearAllMessages,
}
}

View File

@@ -1,123 +0,0 @@
'use client'
import { useState, useCallback } from 'react'
import { useAuth } from './useAuth'
import type { Emoji } from './useAvatars'
export function useEmojis() {
const { relayApiClient } = useAuth()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const createEmoji = useCallback(
async (
avatarId: string,
label: string,
url: string,
description?: string,
sortOrder?: number,
triggerType?: string,
triggerResult?: 'success' | 'failure',
): Promise<Emoji> => {
setLoading(true)
setError(null)
try {
const data = await relayApiClient.emojis.create(avatarId, {
label,
url,
description,
trigger_type: triggerType,
trigger_result: triggerResult,
sort_order: sortOrder,
})
return data.emoji
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
setError(message)
throw err
} finally {
setLoading(false)
}
},
[relayApiClient],
)
const updateEmoji = useCallback(
async (
emojiId: string,
updates: {
label?: string
url?: string
description?: string
trigger_type?: string
trigger_result?: 'success' | 'failure' | null
sort_order?: number
},
): Promise<void> => {
setLoading(true)
setError(null)
try {
await relayApiClient.emojis.update(emojiId, updates)
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
setError(message)
throw err
} finally {
setLoading(false)
}
},
[relayApiClient],
)
const deleteEmoji = useCallback(
async (emojiId: string): Promise<void> => {
setLoading(true)
setError(null)
try {
await relayApiClient.emojis.delete(emojiId)
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
setError(message)
throw err
} finally {
setLoading(false)
}
},
[relayApiClient],
)
const uploadEmojiFile = useCallback(
async (file: File): Promise<{ url: string; label: string }> => {
setLoading(true)
setError(null)
try {
const data = await relayApiClient.files.uploadRaw(file)
const label = file.name.replace(/\.[^.]+$/, '').trim()
return {
url: data.data.url,
label: label || '未命名表情',
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
setError(message)
throw err
} finally {
setLoading(false)
}
},
[relayApiClient],
)
return {
loading,
error,
createEmoji,
updateEmoji,
deleteEmoji,
uploadEmojiFile,
}
}

View File

@@ -1,172 +0,0 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useAuth } from './useAuth'
import type { LobsterStatusMessage } from '@/lib/protocol'
export interface ApiKeyItem {
id: string
name: string | null
prefix?: string
createdAt?: string
}
export interface OnlineLobster {
lobsterId?: string
lobsterName?: string
}
export interface LobsterWithStatus {
id: string
name: string
isOnline: boolean
}
/** Shared lobby state — singleton across all useLobsters() consumers. */
let sharedOnlineLobsters: OnlineLobster[] = []
const listeners = new Set<(lobsters: OnlineLobster[]) => void>()
function notifyListeners(lobsters: OnlineLobster[]) {
sharedOnlineLobsters = lobsters
for (const fn of listeners) fn(lobsters)
}
/**
* Update the shared online lobsters state.
* Called by RelayProvider when lobster_status messages arrive on the global WebSocket.
*/
export function updateOnlineLobsters(
action: LobsterStatusMessage['action'],
lobsters: LobsterStatusMessage['lobsters'],
): void {
switch (action) {
case 'list':
notifyListeners(lobsters)
break
case 'online':
notifyListeners([
...sharedOnlineLobsters.filter(
(existing) => !lobsters.some((l) => l.lobsterId === existing.lobsterId),
),
...lobsters,
])
break
case 'offline':
notifyListeners(
sharedOnlineLobsters.filter(
(existing) => !lobsters.some((l) => l.lobsterId === existing.lobsterId),
),
)
break
}
}
export function useLobsters() {
const { authClient } = useAuth()
const [apiKeys, setApiKeys] = useState<ApiKeyItem[]>([])
const [onlineLobsters, setOnlineLobsters] = useState<OnlineLobster[]>(sharedOnlineLobsters)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Subscribe to shared lobby state
useEffect(() => {
const handler = (lobsters: OnlineLobster[]) => setOnlineLobsters(lobsters)
listeners.add(handler)
// Sync initial state
setOnlineLobsters(sharedOnlineLobsters)
return () => { listeners.delete(handler) }
}, [])
const fetchApiKeys = useCallback(async () => {
setLoading(true)
setError(null)
try {
const { data, error: err } = await authClient.apiKey.list()
if (err) {
setError(err.message ?? 'Failed to fetch API keys')
return
}
if (data?.apiKeys) {
setApiKeys(
data.apiKeys.map((k) => ({
id: k.id,
name: k.name,
prefix: k.start ?? k.prefix ?? undefined,
createdAt: k.createdAt ? new Date(k.createdAt).toLocaleDateString() : undefined,
})),
)
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setLoading(false)
}
}, [authClient])
const createApiKey = useCallback(
async (name: string): Promise<string | null> => {
setError(null)
try {
const { data, error: err } = await authClient.apiKey.create({
name,
expiresIn: null,
})
if (err) {
setError(err.message ?? 'Failed to create API key')
return null
}
if (data?.key) {
// Refresh the list
await fetchApiKeys()
return data.key
}
return null
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
return null
}
},
[authClient, fetchApiKeys],
)
const deleteApiKey = useCallback(
async (keyId: string) => {
setError(null)
try {
const { error: err } = await authClient.apiKey.delete({ keyId })
if (err) {
setError(err.message ?? 'Failed to delete API key')
return
}
// Refresh the list
await fetchApiKeys()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
}
},
[authClient, fetchApiKeys],
)
/** Merge apiKeys (all lobsters) with onlineLobsters → sorted: online first, then offline. */
const lobstersWithStatus: LobsterWithStatus[] = useMemo(() => {
const onlineIds = new Set(onlineLobsters.map((l) => l.lobsterId))
const merged = apiKeys.map((k) => ({
id: k.id,
name: k.name ?? 'Unnamed',
isOnline: onlineIds.has(k.id),
}))
// Online first, then offline
return merged.sort((a, b) => (a.isOnline === b.isOnline ? 0 : a.isOnline ? -1 : 1))
}, [apiKeys, onlineLobsters])
return {
apiKeys,
onlineLobsters,
lobstersWithStatus,
loading,
error,
fetchApiKeys,
createApiKey,
deleteApiKey,
}
}

View File

@@ -1,341 +0,0 @@
'use client'
import {
createContext,
createElement,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from 'react'
import {
type AvatarPlaybackEvent,
type ChatMessage,
type LobsterStatusMessage,
type RelayMessage,
type SessionEventMessage,
HEARTBEAT_INTERVAL_MS,
parseRelayMessage,
toAvatarPlaybackEvent,
} from '@/lib/protocol'
import { useAuth } from './useAuth'
import { updateOnlineLobsters } from './useLobsters'
export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error'
const RELAY_URL = process.env.NEXT_PUBLIC_RELAY_URL ?? 'http://localhost:8787'
// ── Session handler types ────────────────────────────────────────
export interface SessionHandlers {
onMessage?: (msg: ChatMessage) => void
onTyping?: (isTyping: boolean) => void
onSessionEvent?: (msg: SessionEventMessage) => void
onHistoryResponse?: (messages: ChatMessage[], hasMore: boolean) => void
onHistoryClearResult?: (ok: boolean, error?: string) => void
onLobsterStatus?: (
action: LobsterStatusMessage['action'],
lobsters: LobsterStatusMessage['lobsters'],
) => void
}
interface SessionRegistration {
handlers: React.RefObject<SessionHandlers>
}
// ── Context type ─────────────────────────────────────────────────
interface RelayContextValue {
state: ConnectionState
avatarEvent: (AvatarPlaybackEvent & { id: string }) | null
publishAvatarEvent: (event: AvatarPlaybackEvent) => void
send: (msg: RelayMessage) => void
joinSession: (sessionId: string, handlers: React.RefObject<SessionHandlers>) => void
leaveSession: (sessionId: string) => void
}
const RelayContext = createContext<RelayContextValue | null>(null)
// ── Provider ─────────────────────────────────────────────────────
export function RelayProvider({ children }: { children: ReactNode }) {
const { token, signOut } = useAuth()
const [state, setState] = useState<ConnectionState>('disconnected')
const [avatarEvent, setAvatarEvent] = useState<(AvatarPlaybackEvent & { id: string }) | null>(null)
const wsRef = useRef<WebSocket | null>(null)
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null)
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const reconnectAttemptsRef = useRef(0)
const invalidSessionHandledRef = useRef(false)
const maxReconnect = 10
/** sessionId → registration mapping for dynamic session subscriptions */
const sessionHandlersRef = useRef(new Map<string, SessionRegistration>())
const cleanup = useCallback(() => {
if (heartbeatRef.current) {
clearInterval(heartbeatRef.current)
heartbeatRef.current = null
}
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current)
reconnectTimerRef.current = null
}
}, [])
const send = useCallback((msg: RelayMessage) => {
const ws = wsRef.current
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg))
}
}, [])
const publishAvatarEvent = useCallback((event: AvatarPlaybackEvent) => {
setAvatarEvent({
...event,
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
})
}, [])
const connect = useCallback(() => {
if (!token) return
cleanup()
setState('connecting')
const wsUrl = `${RELAY_URL.replace(/^http/, 'ws')}/ws/__global__`
const ws = new WebSocket(wsUrl)
wsRef.current = ws
ws.onopen = () => {
const authMessage: RelayMessage = {
type: 'auth',
role: 'browser',
token,
sessionId: '__global__',
}
ws.send(JSON.stringify(authMessage))
publishAvatarEvent({ type: 'auth' })
}
ws.onmessage = (event) => {
const msg = parseRelayMessage(event.data as string)
if (!msg) return
switch (msg.type) {
case 'auth_result': {
publishAvatarEvent(toAvatarPlaybackEvent(msg))
if (msg.ok) {
setState('connected')
reconnectAttemptsRef.current = 0
invalidSessionHandledRef.current = false
// Start heartbeat
heartbeatRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }))
}
}, HEARTBEAT_INTERVAL_MS)
// Re-join any sessions that were registered before reconnect
for (const sessionId of sessionHandlersRef.current.keys()) {
ws.send(JSON.stringify({ type: 'session_join', sessionId }))
}
} else {
setState('error')
if (
msg.error === 'Invalid session token' &&
!invalidSessionHandledRef.current
) {
invalidSessionHandledRef.current = true
cleanup()
void signOut()
}
ws.close(4001, 'Auth failed')
}
break
}
case 'lobster_status': {
const lsm = msg as LobsterStatusMessage
publishAvatarEvent(toAvatarPlaybackEvent(lsm))
// Update shared lobster state
updateOnlineLobsters(lsm.action, lsm.lobsters)
// Also dispatch to any session-level handler that cares
for (const reg of sessionHandlersRef.current.values()) {
reg.handlers.current?.onLobsterStatus?.(lsm.action, lsm.lobsters)
}
break
}
case 'message': {
const chatMsg = msg as ChatMessage
publishAvatarEvent(toAvatarPlaybackEvent(chatMsg))
const reg = sessionHandlersRef.current.get(chatMsg.sessionId)
reg?.handlers.current?.onMessage?.(chatMsg)
break
}
case 'typing': {
publishAvatarEvent(toAvatarPlaybackEvent(msg))
if (msg.from === 'ai') {
const reg = sessionHandlersRef.current.get(msg.sessionId)
reg?.handlers.current?.onTyping?.(msg.isTyping)
}
break
}
case 'session_event': {
publishAvatarEvent(toAvatarPlaybackEvent(msg))
const reg = sessionHandlersRef.current.get(msg.sessionId)
reg?.handlers.current?.onSessionEvent?.(msg)
break
}
case 'history_response': {
publishAvatarEvent(toAvatarPlaybackEvent(msg))
const reg = sessionHandlersRef.current.get(msg.sessionId)
reg?.handlers.current?.onHistoryResponse?.(msg.messages, msg.hasMore)
break
}
case 'history_clear_result': {
publishAvatarEvent(toAvatarPlaybackEvent(msg))
if ('sessionId' in msg) {
const reg = sessionHandlersRef.current.get(msg.sessionId)
reg?.handlers.current?.onHistoryClearResult?.(msg.ok, msg.error)
}
break
}
case 'ack':
publishAvatarEvent(toAvatarPlaybackEvent(msg))
break
case 'pong':
publishAvatarEvent(toAvatarPlaybackEvent(msg))
break
case 'error': {
publishAvatarEvent(toAvatarPlaybackEvent(msg))
console.error('[relay] error:', msg.message)
break
}
default:
break
}
}
ws.onclose = () => {
cleanup()
setState('disconnected')
if (reconnectAttemptsRef.current < maxReconnect) {
const delay = Math.min(2000 * 2 ** reconnectAttemptsRef.current, 60000)
reconnectAttemptsRef.current++
reconnectTimerRef.current = setTimeout(() => {
connect()
}, delay)
} else {
console.warn('[relay] max reconnect attempts reached, cooling down for 5 minutes...')
reconnectTimerRef.current = setTimeout(() => {
reconnectAttemptsRef.current = 0
connect()
}, 5 * 60_000)
}
}
ws.onerror = () => {
setState('error')
}
}, [token, cleanup, signOut])
useEffect(() => {
if (!token) return
connect()
return () => {
cleanup()
if (wsRef.current) {
wsRef.current.close(1000, 'Provider unmount')
wsRef.current = null
}
}
}, [token, connect, cleanup])
const joinSession = useCallback(
(sessionId: string, handlers: React.RefObject<SessionHandlers>) => {
sessionHandlersRef.current.set(sessionId, { handlers })
send({ type: 'session_join', sessionId })
},
[send],
)
const leaveSession = useCallback(
(sessionId: string) => {
sessionHandlersRef.current.delete(sessionId)
send({ type: 'session_leave', sessionId })
},
[send],
)
return createElement(
RelayContext.Provider,
{ value: { state, avatarEvent, publishAvatarEvent, send, joinSession, leaveSession } },
children,
)
}
// ── Hooks ────────────────────────────────────────────────────────
export function useRelay(): RelayContextValue {
const ctx = useContext(RelayContext)
if (!ctx) {
throw new Error('useRelay must be used within a RelayProvider')
}
return ctx
}
/**
* Subscribe to a specific chat session via the global WebSocket.
* Sends session_join on mount (+ after reconnect) and session_leave on unmount.
*/
export function useRelaySession(
sessionId: string,
handlers: SessionHandlers,
) {
const { state, send, joinSession, leaveSession } = useRelay()
// Keep handlers in a ref to avoid effect re-runs
const handlersRef = useRef<SessionHandlers>(handlers)
handlersRef.current = handlers
useEffect(() => {
if (!sessionId) return
joinSession(sessionId, handlersRef)
return () => {
leaveSession(sessionId)
}
}, [sessionId, joinSession, leaveSession])
const requestHistory = useCallback(
(before?: number, limit?: number) => {
if (!sessionId) return
send({
type: 'history_request',
sessionId,
before,
limit,
})
},
[send, sessionId],
)
const clearHistory = useCallback(() => {
if (!sessionId) return
send({ type: 'history_clear', sessionId })
}, [send, sessionId])
return { state, send, requestHistory, clearHistory }
}

View File

@@ -1,40 +0,0 @@
import type { Emoji } from '@/hooks/useAvatars'
import type { AvatarPlaybackEvent } from '@/lib/protocol'
export function resolveAvatarEmoji(
emojis: Emoji[],
event?: AvatarPlaybackEvent | null,
): Emoji | null {
if (!Array.isArray(emojis) || emojis.length === 0) {
return null
}
const normalized = [...emojis].sort((a, b) => {
if (a.sort_order !== b.sort_order) {
return a.sort_order - b.sort_order
}
return a.created_at - b.created_at
})
if (event) {
const exactMatch = normalized.find(emoji =>
emoji.trigger_type === event.type
&& (emoji.trigger_result ?? null) === (event.result ?? null),
)
if (exactMatch) {
return exactMatch
}
const typeMatch = normalized.find(emoji =>
emoji.trigger_type === event.type
&& emoji.trigger_result == null,
)
if (typeMatch) {
return typeMatch
}
}
return normalized.find(emoji => emoji.trigger_type === 'default') ?? normalized[0] ?? null
}

View File

@@ -1,80 +0,0 @@
export interface ChatSessionMeta {
id: string
title: string
createdAt: number
updatedAt: number
}
interface ChatSessionState {
sessions: ChatSessionMeta[]
activeSessionId: string
}
const STORAGE_PREFIX = 'chat-sessions:'
function storageKey(lobsterId: string): string {
return `${STORAGE_PREFIX}${lobsterId}`
}
export function createChatSession(lobsterId: string, index: number): ChatSessionMeta {
const timestamp = Date.now()
const suffix = Math.random().toString(36).slice(2, 8)
return {
id: `lobster:${lobsterId}:${timestamp.toString(36)}-${suffix}`,
title: `会话 ${index}`,
createdAt: timestamp,
updatedAt: timestamp,
}
}
export function loadChatSessionState(lobsterId: string): ChatSessionState | null {
try {
const raw = localStorage.getItem(storageKey(lobsterId))
if (!raw) return null
const parsed = JSON.parse(raw) as unknown
if (
typeof parsed !== 'object' ||
parsed === null ||
!Array.isArray((parsed as ChatSessionState).sessions) ||
typeof (parsed as ChatSessionState).activeSessionId !== 'string'
) {
return null
}
const sessions = (parsed as ChatSessionState).sessions.filter(
session =>
typeof session?.id === 'string' &&
typeof session?.title === 'string' &&
typeof session?.createdAt === 'number' &&
typeof session?.updatedAt === 'number',
)
if (sessions.length === 0) return null
const activeSessionId = sessions.some(
session => session.id === (parsed as ChatSessionState).activeSessionId,
)
? (parsed as ChatSessionState).activeSessionId
: sessions[0].id
return {
sessions,
activeSessionId,
}
} catch {
return null
}
}
export function saveChatSessionState(
lobsterId: string,
state: ChatSessionState,
): void {
try {
localStorage.setItem(storageKey(lobsterId), JSON.stringify(state))
} catch {
// ignore
}
}

View File

@@ -1,82 +0,0 @@
import type { ChatMessage } from './protocol'
const STORAGE_PREFIX = 'chat:'
const MAX_MESSAGES_PER_SESSION = 500
function storageKey(sessionId: string): string {
return `${STORAGE_PREFIX}${sessionId}`
}
/** Load messages for a session from localStorage */
export function loadMessages(sessionId: string): ChatMessage[] {
try {
const raw = localStorage.getItem(storageKey(sessionId))
if (!raw) return []
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return parsed as ChatMessage[]
} catch {
return []
}
}
/** Save messages for a session to localStorage (keeps latest N) */
export function saveMessages(sessionId: string, messages: ChatMessage[]): void {
try {
const trimmed =
messages.length > MAX_MESSAGES_PER_SESSION
? messages.slice(-MAX_MESSAGES_PER_SESSION)
: messages
localStorage.setItem(storageKey(sessionId), JSON.stringify(trimmed))
} catch {
// Storage full — remove oldest session caches and retry once
try {
evictOldest()
const trimmed =
messages.length > MAX_MESSAGES_PER_SESSION
? messages.slice(-MAX_MESSAGES_PER_SESSION)
: messages
localStorage.setItem(storageKey(sessionId), JSON.stringify(trimmed))
} catch {
// Still failing, silently ignore
}
}
}
/** Remove all stored messages for a session */
export function clearMessages(sessionId: string): void {
try {
localStorage.removeItem(storageKey(sessionId))
} catch {
// ignore
}
}
/** Evict the oldest chat session cache to free space */
function evictOldest(): void {
let oldestKey: string | null = null
let oldestTimestamp = Infinity
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (!key?.startsWith(STORAGE_PREFIX)) continue
try {
const raw = localStorage.getItem(key)
if (!raw) continue
const msgs = JSON.parse(raw) as ChatMessage[]
const firstTs = msgs[0]?.timestamp ?? Infinity
if (firstTs < oldestTimestamp) {
oldestTimestamp = firstTs
oldestKey = key
}
} catch {
// Corrupt entry — remove it
if (key) localStorage.removeItem(key)
return
}
}
if (oldestKey) {
localStorage.removeItem(oldestKey)
}
}

View File

@@ -1,230 +0,0 @@
/**
* WebSocket message protocol types for the browser client.
* Mirrors packages/openclaw-channel-web/src/protocol.ts
*/
export type WebSocketRole = 'browser' | 'openclaw'
export type AuthMessage = {
type: 'auth'
role: WebSocketRole
token: string
sessionId?: string
}
export type AuthResultMessage = {
type: 'auth_result'
ok: boolean
error?: string
sessionId?: string
userId?: string
userName?: string
lobsterName?: string
}
export type ChatMessage = {
type: 'message'
id: string
sessionId: string
from: 'user' | 'ai'
text: string
timestamp: number
userId?: string
userName?: string
targetLobsterId?: string
}
export type AckMessage = {
type: 'ack'
messageId: string
sessionId: string
}
export type TypingMessage = {
type: 'typing'
sessionId: string
from: 'user' | 'ai'
isTyping: boolean
}
export type HistoryRequestMessage = {
type: 'history_request'
sessionId: string
before?: number
limit?: number
}
export type HistoryResponseMessage = {
type: 'history_response'
sessionId: string
messages: ChatMessage[]
hasMore: boolean
}
export type PingMessage = { type: 'ping'; timestamp: number }
export type PongMessage = { type: 'pong'; timestamp: number }
export type ErrorMessage = {
type: 'error'
code: string
message: string
sessionId?: string
}
export type SessionQueueMode =
| 'collect'
| 'followup'
| 'steer'
| 'interrupt'
| 'steer-backlog'
export type QueueStatusState = 'processing' | 'enqueued' | 'idle'
export type QueueStatusSessionEvent = {
event: 'queue_status'
state: QueueStatusState
mode?: SessionQueueMode
depth?: number
messageId?: string
}
export type SessionEventPayload = QueueStatusSessionEvent
export type SessionEventMessage = {
type: 'session_event'
sessionId: string
timestamp: number
payload: SessionEventPayload
}
// Lobster 上/下线推送给 browser
export type LobsterStatusMessage = {
type: 'lobster_status'
action: 'online' | 'offline' | 'list'
lobsters: Array<{ lobsterId: string; lobsterName: string }>
}
// Browser 上/下线推送给 openclaw
export type UserStatusMessage = {
type: 'user_status'
action: 'online' | 'offline'
sessionId: string
userId: string
userName?: string
}
// 全局 WebSocket 动态订阅/退订会话
export type SessionJoinMessage = {
type: 'session_join'
sessionId: string
}
export type SessionLeaveMessage = {
type: 'session_leave'
sessionId: string
}
export type HistoryClearMessage = {
type: 'history_clear'
sessionId: string
}
export type HistoryClearResultMessage = {
type: 'history_clear_result'
sessionId: string
ok: boolean
error?: string
}
export const AVATAR_EMOJI_TYPES = [
'default',
'auth',
'auth_result',
'message',
'typing',
'history_request',
'history_response',
'ack',
'ping',
'pong',
'error',
'session_event',
'lobster_status',
'user_status',
'session_join',
'session_leave',
'history_clear',
'history_clear_result',
] as const
export type AvatarEmojiType = (typeof AVATAR_EMOJI_TYPES)[number]
export const AVATAR_EMOJI_RESULTS = ['success', 'failure'] as const
export type AvatarEmojiResult = (typeof AVATAR_EMOJI_RESULTS)[number]
export const AVATAR_EMOJI_RESULT_SUPPORTED_TYPES = [
'auth_result',
'history_clear_result',
] as const satisfies readonly AvatarEmojiType[]
export type AvatarPlaybackEvent = {
type: AvatarEmojiType
result?: AvatarEmojiResult
}
export type RelayMessage =
| AuthMessage
| AuthResultMessage
| ChatMessage
| AckMessage
| TypingMessage
| HistoryRequestMessage
| HistoryResponseMessage
| PingMessage
| PongMessage
| ErrorMessage
| SessionEventMessage
| LobsterStatusMessage
| UserStatusMessage
| SessionJoinMessage
| SessionLeaveMessage
| HistoryClearMessage
| HistoryClearResultMessage
export function supportsAvatarEmojiResult(type: AvatarEmojiType): boolean {
return (AVATAR_EMOJI_RESULT_SUPPORTED_TYPES as readonly string[]).includes(type)
}
export function toAvatarPlaybackEvent(message: RelayMessage): AvatarPlaybackEvent {
switch (message.type) {
case 'auth_result':
case 'history_clear_result':
return {
type: message.type,
result: message.ok ? 'success' : 'failure',
}
default:
return {
type: message.type,
}
}
}
export function parseRelayMessage(raw: string): RelayMessage | null {
try {
const parsed = JSON.parse(raw) as RelayMessage
if (typeof parsed === 'object' && parsed !== null && 'type' in parsed) {
return parsed
}
return null
} catch {
return null
}
}
export function generateMessageId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
export const HEARTBEAT_INTERVAL_MS = 30_000

View File

@@ -1,24 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"incremental": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}