From e8579a35fbfbceff7cbdff9f8af42f53bcbea473 Mon Sep 17 00:00:00 2001 From: imeepos Date: Thu, 19 Mar 2026 18:57:15 +0800 Subject: [PATCH] feat: import web chat app from monorepo --- .gitignore | 5 + __stage/apps/web-chat/next-env.d.ts | 6 + __stage/apps/web-chat/next.config.js | 4 + __stage/apps/web-chat/package.json | 29 ++ __stage/apps/web-chat/postcss.config.js | 5 + .../web-chat/src/app/avatars/[id]/page.tsx | 116 ++++++ .../apps/web-chat/src/app/avatars/page.tsx | 67 ++++ __stage/apps/web-chat/src/app/chat/page.tsx | 119 ++++++ __stage/apps/web-chat/src/app/globals.css | 1 + __stage/apps/web-chat/src/app/layout.tsx | 25 ++ .../apps/web-chat/src/app/lobsters/page.tsx | 309 ++++++++++++++++ __stage/apps/web-chat/src/app/login/page.tsx | 157 ++++++++ __stage/apps/web-chat/src/app/page.tsx | 28 ++ .../web-chat/src/components/AvatarForm.tsx | 116 ++++++ .../web-chat/src/components/AvatarManager.tsx | 121 +++++++ .../src/components/AvatarSelector.tsx | 130 +++++++ .../src/components/BulkEmojiUpload.tsx | 128 +++++++ .../web-chat/src/components/ChatContainer.tsx | 298 +++++++++++++++ .../web-chat/src/components/ChatInput.tsx | 82 +++++ .../web-chat/src/components/ChatToolbar.tsx | 116 ++++++ .../src/components/ConnectionStatus.tsx | 22 ++ .../web-chat/src/components/EmojiForm.tsx | 259 +++++++++++++ .../web-chat/src/components/EmojiList.tsx | 111 ++++++ .../web-chat/src/components/EmojiMedia.tsx | 55 +++ .../web-chat/src/components/EmojiPicker.tsx | 108 ++++++ .../src/components/FloatingAvatar.tsx | 300 +++++++++++++++ .../src/components/LobsterSidebar.tsx | 98 +++++ .../web-chat/src/components/MessageBubble.tsx | 37 ++ .../web-chat/src/components/MessageList.tsx | 123 +++++++ .../src/components/SystemStatusPanel.tsx | 72 ++++ .../web-chat/src/hooks/useApiKeyAvatar.ts | 69 ++++ __stage/apps/web-chat/src/hooks/useAuth.ts | 97 +++++ __stage/apps/web-chat/src/hooks/useAvatars.ts | 124 +++++++ __stage/apps/web-chat/src/hooks/useChat.ts | 75 ++++ __stage/apps/web-chat/src/hooks/useEmojis.ts | 123 +++++++ .../apps/web-chat/src/hooks/useLobsters.ts | 172 +++++++++ __stage/apps/web-chat/src/hooks/useRelay.tsx | 341 ++++++++++++++++++ __stage/apps/web-chat/src/lib/avatarEmoji.ts | 40 ++ __stage/apps/web-chat/src/lib/chatSessions.ts | 80 ++++ __stage/apps/web-chat/src/lib/chatStorage.ts | 82 +++++ __stage/apps/web-chat/src/lib/protocol.ts | 230 ++++++++++++ __stage/apps/web-chat/tsconfig.json | 24 ++ next-env.d.ts | 6 + next.config.js | 4 + package.json | 29 ++ postcss.config.js | 5 + src/app/avatars/[id]/page.tsx | 116 ++++++ src/app/avatars/page.tsx | 67 ++++ src/app/chat/page.tsx | 119 ++++++ src/app/globals.css | 1 + src/app/layout.tsx | 25 ++ src/app/lobsters/page.tsx | 309 ++++++++++++++++ src/app/login/page.tsx | 157 ++++++++ src/app/page.tsx | 28 ++ .../开心_01_难得灿烂笑.transparent.webp | Bin 0 -> 6468776 bytes src/components/AvatarForm.tsx | 116 ++++++ src/components/AvatarManager.tsx | 121 +++++++ src/components/AvatarSelector.tsx | 130 +++++++ src/components/BulkEmojiUpload.tsx | 128 +++++++ src/components/ChatContainer.tsx | 298 +++++++++++++++ src/components/ChatInput.tsx | 82 +++++ src/components/ChatToolbar.tsx | 116 ++++++ src/components/ConnectionStatus.tsx | 22 ++ src/components/EmojiForm.tsx | 259 +++++++++++++ src/components/EmojiList.tsx | 111 ++++++ src/components/EmojiMedia.tsx | 55 +++ src/components/EmojiPicker.tsx | 108 ++++++ src/components/FloatingAvatar.tsx | 300 +++++++++++++++ src/components/LobsterSidebar.tsx | 98 +++++ src/components/MessageBubble.tsx | 37 ++ src/components/MessageList.tsx | 123 +++++++ src/components/SystemStatusPanel.tsx | 72 ++++ src/hooks/useApiKeyAvatar.ts | 69 ++++ src/hooks/useAuth.ts | 97 +++++ src/hooks/useAvatars.ts | 124 +++++++ src/hooks/useChat.ts | 75 ++++ src/hooks/useEmojis.ts | 123 +++++++ src/hooks/useLobsters.ts | 172 +++++++++ src/hooks/useRelay.tsx | 341 ++++++++++++++++++ src/lib/avatarEmoji.ts | 40 ++ src/lib/chatSessions.ts | 80 ++++ src/lib/chatStorage.ts | 82 +++++ src/lib/protocol.ts | 230 ++++++++++++ tsconfig.json | 24 ++ 84 files changed, 9003 insertions(+) create mode 100644 .gitignore create mode 100644 __stage/apps/web-chat/next-env.d.ts create mode 100644 __stage/apps/web-chat/next.config.js create mode 100644 __stage/apps/web-chat/package.json create mode 100644 __stage/apps/web-chat/postcss.config.js create mode 100644 __stage/apps/web-chat/src/app/avatars/[id]/page.tsx create mode 100644 __stage/apps/web-chat/src/app/avatars/page.tsx create mode 100644 __stage/apps/web-chat/src/app/chat/page.tsx create mode 100644 __stage/apps/web-chat/src/app/globals.css create mode 100644 __stage/apps/web-chat/src/app/layout.tsx create mode 100644 __stage/apps/web-chat/src/app/lobsters/page.tsx create mode 100644 __stage/apps/web-chat/src/app/login/page.tsx create mode 100644 __stage/apps/web-chat/src/app/page.tsx create mode 100644 __stage/apps/web-chat/src/components/AvatarForm.tsx create mode 100644 __stage/apps/web-chat/src/components/AvatarManager.tsx create mode 100644 __stage/apps/web-chat/src/components/AvatarSelector.tsx create mode 100644 __stage/apps/web-chat/src/components/BulkEmojiUpload.tsx create mode 100644 __stage/apps/web-chat/src/components/ChatContainer.tsx create mode 100644 __stage/apps/web-chat/src/components/ChatInput.tsx create mode 100644 __stage/apps/web-chat/src/components/ChatToolbar.tsx create mode 100644 __stage/apps/web-chat/src/components/ConnectionStatus.tsx create mode 100644 __stage/apps/web-chat/src/components/EmojiForm.tsx create mode 100644 __stage/apps/web-chat/src/components/EmojiList.tsx create mode 100644 __stage/apps/web-chat/src/components/EmojiMedia.tsx create mode 100644 __stage/apps/web-chat/src/components/EmojiPicker.tsx create mode 100644 __stage/apps/web-chat/src/components/FloatingAvatar.tsx create mode 100644 __stage/apps/web-chat/src/components/LobsterSidebar.tsx create mode 100644 __stage/apps/web-chat/src/components/MessageBubble.tsx create mode 100644 __stage/apps/web-chat/src/components/MessageList.tsx create mode 100644 __stage/apps/web-chat/src/components/SystemStatusPanel.tsx create mode 100644 __stage/apps/web-chat/src/hooks/useApiKeyAvatar.ts create mode 100644 __stage/apps/web-chat/src/hooks/useAuth.ts create mode 100644 __stage/apps/web-chat/src/hooks/useAvatars.ts create mode 100644 __stage/apps/web-chat/src/hooks/useChat.ts create mode 100644 __stage/apps/web-chat/src/hooks/useEmojis.ts create mode 100644 __stage/apps/web-chat/src/hooks/useLobsters.ts create mode 100644 __stage/apps/web-chat/src/hooks/useRelay.tsx create mode 100644 __stage/apps/web-chat/src/lib/avatarEmoji.ts create mode 100644 __stage/apps/web-chat/src/lib/chatSessions.ts create mode 100644 __stage/apps/web-chat/src/lib/chatStorage.ts create mode 100644 __stage/apps/web-chat/src/lib/protocol.ts create mode 100644 __stage/apps/web-chat/tsconfig.json create mode 100644 next-env.d.ts create mode 100644 next.config.js create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 src/app/avatars/[id]/page.tsx create mode 100644 src/app/avatars/page.tsx create mode 100644 src/app/chat/page.tsx create mode 100644 src/app/globals.css create mode 100644 src/app/layout.tsx create mode 100644 src/app/lobsters/page.tsx create mode 100644 src/app/login/page.tsx create mode 100644 src/app/page.tsx create mode 100644 src/assets/开心_01_难得灿烂笑.transparent.webp create mode 100644 src/components/AvatarForm.tsx create mode 100644 src/components/AvatarManager.tsx create mode 100644 src/components/AvatarSelector.tsx create mode 100644 src/components/BulkEmojiUpload.tsx create mode 100644 src/components/ChatContainer.tsx create mode 100644 src/components/ChatInput.tsx create mode 100644 src/components/ChatToolbar.tsx create mode 100644 src/components/ConnectionStatus.tsx create mode 100644 src/components/EmojiForm.tsx create mode 100644 src/components/EmojiList.tsx create mode 100644 src/components/EmojiMedia.tsx create mode 100644 src/components/EmojiPicker.tsx create mode 100644 src/components/FloatingAvatar.tsx create mode 100644 src/components/LobsterSidebar.tsx create mode 100644 src/components/MessageBubble.tsx create mode 100644 src/components/MessageList.tsx create mode 100644 src/components/SystemStatusPanel.tsx create mode 100644 src/hooks/useApiKeyAvatar.ts create mode 100644 src/hooks/useAuth.ts create mode 100644 src/hooks/useAvatars.ts create mode 100644 src/hooks/useChat.ts create mode 100644 src/hooks/useEmojis.ts create mode 100644 src/hooks/useLobsters.ts create mode 100644 src/hooks/useRelay.tsx create mode 100644 src/lib/avatarEmoji.ts create mode 100644 src/lib/chatSessions.ts create mode 100644 src/lib/chatStorage.ts create mode 100644 src/lib/protocol.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3c9154 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.next/ +.turbo/ +node_modules/ +.env +tsconfig.tsbuildinfo diff --git a/__stage/apps/web-chat/next-env.d.ts b/__stage/apps/web-chat/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/__stage/apps/web-chat/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/__stage/apps/web-chat/next.config.js b/__stage/apps/web-chat/next.config.js new file mode 100644 index 0000000..1d61478 --- /dev/null +++ b/__stage/apps/web-chat/next.config.js @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {} + +export default nextConfig diff --git a/__stage/apps/web-chat/package.json b/__stage/apps/web-chat/package.json new file mode 100644 index 0000000..20791d8 --- /dev/null +++ b/__stage/apps/web-chat/package.json @@ -0,0 +1,29 @@ +{ + "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" + } +} diff --git a/__stage/apps/web-chat/postcss.config.js b/__stage/apps/web-chat/postcss.config.js new file mode 100644 index 0000000..a7f73a2 --- /dev/null +++ b/__stage/apps/web-chat/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +} diff --git a/__stage/apps/web-chat/src/app/avatars/[id]/page.tsx b/__stage/apps/web-chat/src/app/avatars/[id]/page.tsx new file mode 100644 index 0000000..7721930 --- /dev/null +++ b/__stage/apps/web-chat/src/app/avatars/[id]/page.tsx @@ -0,0 +1,116 @@ +'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(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 ( +
+
Loading...
+
+ ) + } + + if (!avatar) { + return null + } + + return ( +
+
+
+ +

{avatar.name}

+ {avatar.description && ( +

{avatar.description}

+ )} +
+ +
+
+ +
+
+ + {showAddForm && ( +
+

+ 添加新表情 +

+ setShowAddForm(false)} + /> +
+ )} + +
+ +
+ + +
+
+ ) +} diff --git a/__stage/apps/web-chat/src/app/avatars/page.tsx b/__stage/apps/web-chat/src/app/avatars/page.tsx new file mode 100644 index 0000000..a118bdd --- /dev/null +++ b/__stage/apps/web-chat/src/app/avatars/page.tsx @@ -0,0 +1,67 @@ +'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([]) + 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 ( +
+
Loading...
+
+ ) + } + + return ( +
+
+
+

形象管理

+

+ 管理你的龙虾形象和表情包 +

+
+ + +
+
+ ) +} diff --git a/__stage/apps/web-chat/src/app/chat/page.tsx b/__stage/apps/web-chat/src/app/chat/page.tsx new file mode 100644 index 0000000..c49d692 --- /dev/null +++ b/__stage/apps/web-chat/src/app/chat/page.tsx @@ -0,0 +1,119 @@ +'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 ( + +

Loading...

+ + } + > + +
+ ) +} + +function ChatPageInner() { + const router = useRouter() + const searchParams = useSearchParams() + const { user, loading: authLoading, token } = useAuth() + const { lobstersWithStatus, fetchApiKeys } = useLobsters() + + const [activeLobster, setActiveLobster] = useState(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 ( +
+

Loading...

+
+ ) + } + + return ( +
+ setSidebarCollapsed((c) => !c)} + /> + +
+ {activeLobster ? ( + + ) : ( +
+
+

+ Select a lobster to start chatting +

+
+
+ )} +
+ + +
+ ) +} diff --git a/__stage/apps/web-chat/src/app/globals.css b/__stage/apps/web-chat/src/app/globals.css new file mode 100644 index 0000000..d4b5078 --- /dev/null +++ b/__stage/apps/web-chat/src/app/globals.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/__stage/apps/web-chat/src/app/layout.tsx b/__stage/apps/web-chat/src/app/layout.tsx new file mode 100644 index 0000000..93d81ab --- /dev/null +++ b/__stage/apps/web-chat/src/app/layout.tsx @@ -0,0 +1,25 @@ +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 ( + + + + {children} + + + + ) +} diff --git a/__stage/apps/web-chat/src/app/lobsters/page.tsx b/__stage/apps/web-chat/src/app/lobsters/page.tsx new file mode 100644 index 0000000..cc34a77 --- /dev/null +++ b/__stage/apps/web-chat/src/app/lobsters/page.tsx @@ -0,0 +1,309 @@ +'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(null) + const [copied, setCopied] = useState(false) + const [creating, setCreating] = useState(false) + const [deletingId, setDeletingId] = useState(null) + const [bindingKeyId, setBindingKeyId] = useState(null) + const [bindings, setBindings] = useState>({}) + const [bindingLoadingId, setBindingLoadingId] = useState(null) + const [bindingError, setBindingError] = useState(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 ( +
+

Loading...

+
+ ) + } + + return ( +
+ {/* Header */} +
+
+

My Lobsters

+

+ Create and manage your lobster API keys +

+
+
+ + Back to Chat + + + 形象管理 + + +
+
+ + {/* Create lobster */} +
void handleCreate(e)} + className="mb-6 flex gap-3" + > + 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" + /> + +
+ + {/* Newly created key + setup instructions */} + {createdKey && ( +
+

+ 复制下面的文本,发送给 OpenClaw 即可完成安装 +

+
+
+              {`按照https://cdn.bowong.cc/static/ClawShowInstall/SKILL.md 的指示,安装ClawShow Gateway Connect插件。
+使用authToken = "${createdKey}", 安装@bowong/clawshow-gateway@latest`}
+            
+ +
+
+ )} + + {error &&

{error}

} + {bindingError &&

{bindingError}

} + + {/* Lobster list */} + {loading && apiKeys.length === 0 ? ( +

Loading lobsters...

+ ) : apiKeys.length === 0 ? ( +
+

+ No lobsters yet. Create one above to get started. +

+
+ ) : ( +
+ {apiKeys.map(key => { + const online = isOnline(key.id) + return ( +
+
+
+ +
+

+ {key.name ?? 'Unnamed'} +

+ {key.prefix && ( +

{key.prefix}...

+ )} +

+ 当前形象: + {bindingLoadingId === key.id && !bindings[key.id] + ? '加载中...' + : bindings[key.id]?.avatar?.name ?? '未绑定'} +

+
+
+
+ + +
+
+ {bindingKeyId === key.id && ( +
+ setBindingKeyId(null)} + onBound={() => void handleBindingChanged(key.id)} + /> +
+ )} + {bindings[key.id]?.emojis && bindings[key.id]!.emojis.length > 0 && ( +
+ {bindings[key.id]!.emojis.slice(0, 6).map(emoji => ( + + ))} + {bindings[key.id]!.emojis.length > 6 && ( +
+ +{bindings[key.id]!.emojis.length - 6} +
+ )} +
+ )} +
+ ) + })} +
+ )} +
+ ) +} diff --git a/__stage/apps/web-chat/src/app/login/page.tsx b/__stage/apps/web-chat/src/app/login/page.tsx new file mode 100644 index 0000000..a7951e0 --- /dev/null +++ b/__stage/apps/web-chat/src/app/login/page.tsx @@ -0,0 +1,157 @@ +'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 ( +
+
+
+

+ {isSignUp ? 'Create Account' : 'Sign In'} +

+

+ {isSignUp + ? 'Create an account to start chatting' + : 'Sign in to continue'} +

+
+ +
void handleSubmit(e)} className="space-y-4"> + {isSignUp && ( +
+ + 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" + /> +
+ )} + +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ + {error &&

{error}

} + + +
+ +
+ +
+
+
+ ) +} diff --git a/__stage/apps/web-chat/src/app/page.tsx b/__stage/apps/web-chat/src/app/page.tsx new file mode 100644 index 0000000..b5f248b --- /dev/null +++ b/__stage/apps/web-chat/src/app/page.tsx @@ -0,0 +1,28 @@ +'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 ( +
+

Loading...

+
+ ) +} diff --git a/__stage/apps/web-chat/src/components/AvatarForm.tsx b/__stage/apps/web-chat/src/components/AvatarForm.tsx new file mode 100644 index 0000000..a9e6716 --- /dev/null +++ b/__stage/apps/web-chat/src/components/AvatarForm.tsx @@ -0,0 +1,116 @@ +'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(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 ( +
+ {error && ( +
+ {error} +
+ )} + +
+ + 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} + /> +
+ +
+ +