chore: remove import staging files
This commit is contained in:
6
__stage/apps/web-chat/next-env.d.ts
vendored
6
__stage/apps/web-chat/next-env.d.ts
vendored
@@ -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.
|
||||
@@ -1,4 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {}
|
||||
|
||||
export default nextConfig
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
@import 'tailwindcss';
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 = ``
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user