diff --git a/src/app/chat/components/ai-disclosure-banner.module.css b/src/app/chat/components/ai-disclosure-banner.module.css new file mode 100644 index 00000000..040606dc --- /dev/null +++ b/src/app/chat/components/ai-disclosure-banner.module.css @@ -0,0 +1,9 @@ +/* AiDisclosureBanner 样式(独立文件以便 chat-area 内联版本可复用) */ + +.banner { + align-self: center; + background: transparent; + font-size: var(--font-size-xs, 11px); + color: var(--color-text-banner, #8c8b8b); + text-align: center; +} diff --git a/src/app/chat/components/ai-disclosure-banner.tsx b/src/app/chat/components/ai-disclosure-banner.tsx new file mode 100644 index 00000000..ea4c5628 --- /dev/null +++ b/src/app/chat/components/ai-disclosure-banner.tsx @@ -0,0 +1,18 @@ +"use client"; +/** + * AiDisclosureBanner AI 声明横幅 + * + * 原始 Dart: lib/ui/chat/widgets/ai_disclosure_banner.dart(17 行) + * + * 视觉:居中灰色小字 "All content is generated by AI" + * 用途:位于聊天区顶部,声明 AI 生成内容(合规 / 法律要求) + */ +import styles from "./chat-area.module.css"; + +export function AiDisclosureBanner() { + return ( +
+ All content is generated by AI +
+ ); +} diff --git a/src/app/chat/components/browser-hint-overlay.module.css b/src/app/chat/components/browser-hint-overlay.module.css new file mode 100644 index 00000000..877b34e0 --- /dev/null +++ b/src/app/chat/components/browser-hint-overlay.module.css @@ -0,0 +1,29 @@ +/* BrowserHintOverlay 浏览器提示样式 */ + +.overlay { + position: absolute; + top: var(--spacing-2, 8px); + right: var(--spacing-2, 8px); + display: flex; + flex-direction: column; + align-items: flex-end; + gap: var(--spacing-1, 4px); + z-index: 20; + pointer-events: none; +} + +.emoji { + font-size: 24px; + line-height: 1; +} + +.hintBubble { + padding: var(--spacing-2, 8px) var(--spacing-3, 12px); + background: var(--color-blur-background, rgba(13, 11, 20, 0.85)); + border-radius: var(--radius-md, 8px); + font-size: var(--font-size-sm, 12px); + font-weight: 500; + color: #fff; + backdrop-filter: blur(10px); + max-width: 200px; +} diff --git a/src/app/chat/components/browser-hint-overlay.tsx b/src/app/chat/components/browser-hint-overlay.tsx new file mode 100644 index 00000000..28d7b9b6 --- /dev/null +++ b/src/app/chat/components/browser-hint-overlay.tsx @@ -0,0 +1,57 @@ +"use client"; +/** + * BrowserHintOverlay 浏览器提示 + * + * 原始 Dart: lib/ui/chat/widgets/browser_hint_overlay.dart(76 行) + * + * 用途:在应用内浏览器(WebView、微信内置浏览器等)中显示提示, + * 引导用户使用外部浏览器访问。 + * + * 行为: + * - 开发环境始终显示(测试方便) + * - 生产环境仅在应用内浏览器中显示 + * - 视觉:右上角 👆 + 模糊背景气泡 + */ +import { useEffect, useState } from "react"; + +import styles from "./browser-hint-overlay.module.css"; + +export interface BrowserHintOverlayProps { + /** 自定义提示文本 */ + text?: string; + /** 强制显示(开发模式测试用) */ + forceShow?: boolean; +} + +const DEFAULT_TEXT = "Tap ⋮ menu, then 'Open in browser'"; + +function detectInAppBrowser(): boolean { + if (typeof navigator === "undefined") return false; + const ua = navigator.userAgent; + // 常见应用内浏览器 UA 特征 + return /MicroMessenger|Weibo|QQ\//.test(ua); +} + +export function BrowserHintOverlay({ + text = DEFAULT_TEXT, + forceShow = false, +}: BrowserHintOverlayProps) { + const [isInAppBrowser, setIsInAppBrowser] = useState(false); + + useEffect(() => { + setIsInAppBrowser(detectInAppBrowser()); + }, []); + + // 开发模式或应用内浏览器才显示 + const shouldShow = forceShow || isInAppBrowser; + if (!shouldShow) return null; + + return ( +
+ +
{text}
+
+ ); +} diff --git a/src/app/chat/components/character-intro.module.css b/src/app/chat/components/character-intro.module.css new file mode 100644 index 00000000..adaddc8c --- /dev/null +++ b/src/app/chat/components/character-intro.module.css @@ -0,0 +1,17 @@ +/* CharacterIntro 角色介绍样式 */ + +.intro { + padding: var(--spacing-2, 8px) var(--spacing-5, 20px); +} + +.card { + padding: var(--spacing-3, 12px); + background: var(--color-bubble-background, rgba(255, 255, 255, 0.05)); + border-radius: var(--radius-md, 8px); + font-size: var(--font-size-sm, 14px); + line-height: 1.5; + color: var(--color-text-secondary, #9e9e9e); + max-height: 7.5em; /* 5 行 max */ + overflow: hidden; + backdrop-filter: blur(10px); +} diff --git a/src/app/chat/components/character-intro.tsx b/src/app/chat/components/character-intro.tsx new file mode 100644 index 00000000..4bccf83a --- /dev/null +++ b/src/app/chat/components/character-intro.tsx @@ -0,0 +1,40 @@ +"use client"; +/** + * CharacterIntro 角色介绍卡片 + * + * 原始 Dart: lib/ui/chat/widgets/character_intro.dart(49 行) + * + * 视觉:模糊背景卡片,灰色字体,5 行 max + * 用途:首次进入聊天时展示 AI 角色介绍(Elio 设定) + */ +import styles from "./character-intro.module.css"; + +export interface CharacterIntroProps { + /** 角色介绍文本 */ + text?: string; + /** 最大行数(默认 5) */ + maxLines?: number; +} + +const DEFAULT_TEXT = + "Meet Elio Silvestri, your exclusive AI boyfriend. " + + "He's charming, witty, and always there for you. " + + "Chat with him anytime, anywhere — about your day, your dreams, or anything in between."; + +export function CharacterIntro({ text, maxLines = 5 }: CharacterIntroProps) { + return ( +
+
+ {text ?? DEFAULT_TEXT} +
+
+ ); +} diff --git a/src/app/chat/components/chat-area.module.css b/src/app/chat/components/chat-area.module.css new file mode 100644 index 00000000..46afec97 --- /dev/null +++ b/src/app/chat/components/chat-area.module.css @@ -0,0 +1,105 @@ +/* ChatArea 消息列表样式 */ + +.area { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: var(--spacing-4, 16px) var(--spacing-5, 20px); + display: flex; + flex-direction: column; + gap: var(--spacing-3, 12px); +} + +.dateHeader { + display: flex; + align-items: center; + justify-content: center; + margin: var(--spacing-2, 8px) 0; +} + +.dateChip { + background: var(--color-surface-muted, rgba(255, 255, 255, 0.06)); + border-radius: var(--radius-md, 8px); + padding: var(--spacing-1, 4px) var(--spacing-3, 12px); + font-size: var(--font-size-xs, 11px); + opacity: 0.75; +} + +.aiDisclosure { + align-self: center; + background: var(--color-surface-muted, rgba(255, 255, 255, 0.06)); + border-radius: var(--radius-md, 8px); + padding: var(--spacing-2, 8px) var(--spacing-4, 16px); + font-size: var(--font-size-sm, 12px); + opacity: 0.75; + text-align: center; +} + +.bubbleRowAi { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-1, 4px); +} + +.bubbleRowUser { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: var(--spacing-1, 4px); +} + +.bubbleAi { + max-width: 75%; + background: var(--color-bubble-ai, rgba(255, 255, 255, 0.08)); + padding: var(--spacing-3, 12px) var(--spacing-4, 16px); + border-radius: var(--radius-lg, 12px); + font-size: var(--font-size-base, 14px); + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} + +.bubbleUser { + max-width: 75%; + background: var(--color-bubble-user, #4f46e5); + padding: var(--spacing-3, 12px) var(--spacing-4, 16px); + border-radius: var(--radius-lg, 12px); + font-size: var(--font-size-base, 14px); + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; + color: #fff; +} + +.bubbleTime { + font-size: var(--font-size-xs, 11px); + opacity: 0.5; +} + +.typingBubble { + display: flex; + align-items: center; + gap: var(--spacing-1, 4px); + padding: var(--spacing-3, 12px) var(--spacing-4, 16px); + background: var(--color-bubble-ai, rgba(255, 255, 255, 0.08)); + border-radius: var(--radius-lg, 12px); + width: fit-content; +} + +.typingDot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + margin: 0 2px; + animation: typingBlink 1.2s infinite; +} +.typingDot:nth-child(2) { animation-delay: 0.15s; } +.typingDot:nth-child(3) { animation-delay: 0.3s; } + +@keyframes typingBlink { + 0%, 60%, 100% { opacity: 0.2; } + 30% { opacity: 1; } +} diff --git a/src/app/chat/components/chat-area.tsx b/src/app/chat/components/chat-area.tsx new file mode 100644 index 00000000..530d1e21 --- /dev/null +++ b/src/app/chat/components/chat-area.tsx @@ -0,0 +1,105 @@ +"use client"; +/** + * ChatArea 消息列表区 + * + * 原始 Dart: lib/ui/chat/widgets/chat_area.dart(153 行) + * + * 组成(自上而下): + * - AI 披露横幅 + * - 日期分隔条 + * - 消息气泡(AI 左 / 用户右) + * - AI 正在回复时的加载动画气泡 + * + * 滚动行为: + * - 新消息到达时自动滚到底部 + * - 用户向上滚动时不强制拉回(保持阅读上下文) + */ +import { useEffect, useRef } from "react"; + +import type { UiMessage } from "@/models/chat/ui-message"; + +import { DateHeader } from "./date-header"; +import { LottieMessageBubble } from "./lottie-message-bubble"; +import { MessageBubble } from "./message-bubble"; +import styles from "./chat-area.module.css"; + +export interface ChatAreaProps { + messages: readonly UiMessage[]; + isReplyingAI: boolean; + isGuest: boolean; +} + +export function ChatArea({ messages, isReplyingAI, isGuest }: ChatAreaProps) { + const scrollRef = useRef(null); + const prevLengthRef = useRef(messages.length); + + // 新消息 → 滚到底部 + useEffect(() => { + if (messages.length !== prevLengthRef.current) { + scrollRef.current?.scrollTo({ + top: scrollRef.current.scrollHeight, + behavior: "smooth", + }); + prevLengthRef.current = messages.length; + } + }, [messages.length]); + + return ( +
+ + + {messages.length === 0 && !isReplyingAI && ( + + )} + + {renderMessagesWithDateHeaders(messages)} + + {isReplyingAI && } +
+ ); +} + +/** 渲染消息列表(按日期分组插入分隔条) */ +function renderMessagesWithDateHeaders(messages: readonly UiMessage[]) { + const items: Array<{ type: "date"; date: string; key: string } | { type: "msg"; message: UiMessage; key: string }> = []; + + messages.forEach((m, i) => { + if (i === 0 || m.date !== messages[i - 1].date) { + items.push({ type: "date", date: m.date, key: `d-${i}-${m.date}` }); + } + items.push({ type: "msg", message: m, key: `m-${i}` }); + }); + + return items.map((item) => + item.type === "date" ? ( + + ) : ( + + ), + ); +} + +function AiDisclosure() { + return ( +
+ You're chatting with an AI companion. +
+ ); +} + +function EmptyState({ isGuest }: { isGuest: boolean }) { + return ( +
+ {isGuest + ? "Say hi to start chatting. (Guest mode · 40 free messages)" + : "Say hi to start chatting."} +
+ ); +} + + diff --git a/src/app/chat/components/chat-header.module.css b/src/app/chat/components/chat-header.module.css new file mode 100644 index 00000000..8701f63f --- /dev/null +++ b/src/app/chat/components/chat-header.module.css @@ -0,0 +1,56 @@ +/* ChatHeader 顶部栏样式 */ + +.header { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-4, 16px) var(--spacing-5, 20px); + background: var(--color-header-background, rgba(0, 0, 0, 0.3)); +} + +.guestBanner { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-2, 8px); + padding: var(--spacing-2, 8px) var(--spacing-4, 16px); + background: var(--color-accent, #f84d96); + color: #fff; + font-size: var(--font-size-sm, 12px); + font-weight: 500; + cursor: pointer; + border: 0; + text-align: center; +} + +.guestBannerIcon { + width: var(--icon-size-sm, 16px); + height: var(--icon-size-sm, 16px); +} + +.headerRow { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--spacing-4, 16px); +} + +.menuButton { + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-2, 8px); + background: var(--color-dark-background, #111)cc; + border-radius: 9999px; + border: 0; + cursor: pointer; + color: var(--color-text-primary, #fff); +} + +.menuIcon { + width: var(--icon-size-xl, 32px); + height: var(--icon-size-xl, 32px); +} diff --git a/src/app/chat/components/chat-header.tsx b/src/app/chat/components/chat-header.tsx new file mode 100644 index 00000000..3330145a --- /dev/null +++ b/src/app/chat/components/chat-header.tsx @@ -0,0 +1,71 @@ +"use client"; +/** + * ChatHeader 顶部栏 + * + * 原始 Dart: lib/ui/chat/widgets/chat_header.dart(112 行) + * + * 两种模式: + * - 游客模式(isGuest=true):渲染 `GuestBanner` 提示注册(点击 → /auth) + * - 登录模式(isGuest=false):渲染 `MenuButton`(点击 → 打开 ChatMenuBar) + */ +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { ROUTES } from "@/lib/routes"; + +import { ChatMenuBar } from "./chat-menu-bar"; +import styles from "./chat-header.module.css"; + +export interface ChatHeaderProps { + isGuest: boolean; +} + +export function ChatHeader({ isGuest }: ChatHeaderProps) { + const router = useRouter(); + const [menuOpen, setMenuOpen] = useState(false); + + if (isGuest) { + return ( +
+ +
+ ); + } + + return ( + <> +
+
+ {/* 菜单按钮(点击 → 打开 ChatMenuBar 浮层) */} + +
+
+ + setMenuOpen(false)} + environmentName="dev" + /> + + ); +} diff --git a/src/app/chat/components/chat-input-bar.module.css b/src/app/chat/components/chat-input-bar.module.css new file mode 100644 index 00000000..f8bd14d5 --- /dev/null +++ b/src/app/chat/components/chat-input-bar.module.css @@ -0,0 +1,35 @@ +/* ChatInputBar 输入栏容器样式 */ + +.bar { + flex: 0 0 auto; + padding: var(--spacing-3, 12px) var(--spacing-5, 20px) var(--spacing-5, 20px); + background: var(--color-input-bar-background, rgba(0, 0, 0, 0.4)); + backdrop-filter: blur(8px); + border-top: 1px solid var(--color-chat-input-border, rgba(255, 255, 255, 0.12)); + display: flex; + flex-direction: column; + gap: var(--spacing-2, 8px); +} + +.row { + display: flex; + align-items: center; + gap: var(--spacing-2, 8px); + padding: var(--spacing-1, 4px) var(--spacing-3, 12px); + background: var(--color-bubble-background, #fff); + border-radius: 9999px; + border: 1px solid var(--color-chat-input-border, rgba(255, 255, 255, 0.2)); + transition: border-color 0.2s, box-shadow 0.2s; +} + +.rowFocused { + border-color: var(--color-accent, #f84d96); + box-shadow: 0 4px 12px var(--color-input-shadow, rgba(0, 0, 0, 0.1)); +} + +.textFieldWrap { + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; +} diff --git a/src/app/chat/components/chat-input-bar.tsx b/src/app/chat/components/chat-input-bar.tsx new file mode 100644 index 00000000..f9a132d5 --- /dev/null +++ b/src/app/chat/components/chat-input-bar.tsx @@ -0,0 +1,86 @@ +"use client"; +/** + * ChatInputBar 输入栏 + * + * 原始 Dart: lib/ui/chat/widgets/chat_input_bar.dart(121 行) + * + * 组成(从左到右): + * - ChatMoreButton(⊞):展开更多选项(图片/语音/功能) + * - ChatInputTextField:文本输入 + * - ChatSendButton(↑):发送 + * + * 状态管理(自包含): + * - input:受控文本 + * - focused:是否聚焦(视觉状态) + * - hasContent:是否有内容(决定发送按钮启用) + */ +import { useState } from "react"; + +import { useChatDispatch } from "@/stores/chat/chat-context"; + +import { ChatInputTextField } from "./chat-input-text-field"; +import { ChatMoreButton } from "./chat-more-button"; +import { ChatSendButton } from "./chat-send-button"; +import { FunctionButtonBar } from "./function-button-bar"; +import { ImageUploadButton } from "./image-upload-button"; +import styles from "./chat-input-bar.module.css"; + +export interface ChatInputBarProps { + disabled?: boolean; +} + +export function ChatInputBar({ disabled = false }: ChatInputBarProps) { + const dispatch = useChatDispatch(); + const [input, setInput] = useState(""); + const [focused, setFocused] = useState(false); + const [showFunctions, setShowFunctions] = useState(false); + + const hasContent = input.trim().length > 0; + + const handleSend = () => { + if (!hasContent) return; + dispatch({ type: "ChatSendMessage", content: input }); + setInput(""); + }; + + return ( +
+ {showFunctions && ( + { + /* 触发:ImageUploadButton 已在 ChatInputBar 中渲染 */ + }, + }, + { key: "restart", icon: "↻", label: "Restart" }, + { key: "history", icon: "⏱", label: "History" }, + { key: "call", icon: "📞", label: "Call" }, + ]} + /> + )} + +
+ + setShowFunctions((v) => !v)} /> +
setFocused(true)} + onBlur={() => setFocused(false)} + > + +
+ +
+
+ ); +} diff --git a/src/app/chat/components/chat-input-text-field.module.css b/src/app/chat/components/chat-input-text-field.module.css new file mode 100644 index 00000000..fb31bc62 --- /dev/null +++ b/src/app/chat/components/chat-input-text-field.module.css @@ -0,0 +1,18 @@ +/* ChatInputTextField 输入框样式 */ + +.textField { + width: 100%; + height: 40px; + padding: 0 var(--spacing-4, 16px); + border: 0; + outline: none; + background: transparent; + color: var(--color-text-foreground, #000); + font-size: var(--font-size-base, 14px); + resize: none; + font-family: inherit; +} + +.textField::placeholder { + color: var(--color-text-hint, #757575); +} diff --git a/src/app/chat/components/chat-input-text-field.tsx b/src/app/chat/components/chat-input-text-field.tsx new file mode 100644 index 00000000..b0f4c881 --- /dev/null +++ b/src/app/chat/components/chat-input-text-field.tsx @@ -0,0 +1,56 @@ +"use client"; +/** + * ChatInputTextField 输入框 + * + * 原始 Dart: lib/ui/chat/widgets/chat_input_text_field.dart(75 行) + * + * 行为: + * - 多行输入(最多 5 行) + * - Enter 直接发送(无 Shift/Ctrl) + * - Shift+Enter / Ctrl+Enter 换行 + * - 受控 + 非受控:value + onChange + */ +import { type FormEvent, type KeyboardEvent } from "react"; + +import styles from "./chat-input-text-field.module.css"; + +export interface ChatInputTextFieldProps { + value: string; + onChange: (value: string) => void; + onSubmit: () => void; + placeholder?: string; + disabled?: boolean; + autoFocus?: boolean; +} + +export function ChatInputTextField({ + value, + onChange, + onSubmit, + placeholder = "Say something…", + disabled = false, + autoFocus = false, +}: ChatInputTextFieldProps) { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Enter") return; + if (e.shiftKey || e.ctrlKey || e.metaKey) return; // 修饰键 → 换行 + e.preventDefault(); + onSubmit(); + }; + + return ( +