setShowSheet(false)}
+ />
+
+
+
+
+ >
+ )}
+ >
+ );
+}
diff --git a/src/app/chat/components/index.ts b/src/app/chat/components/index.ts
index b9306907..ac5ac0c3 100644
--- a/src/app/chat/components/index.ts
+++ b/src/app/chat/components/index.ts
@@ -1,5 +1,40 @@
/**
- * @file Automatically generated by barrelsby.
+ * Chat 公共组件导出
*/
-export * from "./chat-screen";
+// 屏幕 + 顶层
+export { ChatScreen } from "./chat-screen";
+
+// 顶部 + 菜单
+export { ChatHeader } from "./chat-header";
+export { ChatMenuBar } from "./chat-menu-bar";
+
+// 消息区
+export { ChatArea } from "./chat-area";
+
+// 输入栏
+export { ChatInputBar } from "./chat-input-bar";
+export { ChatInputTextField } from "./chat-input-text-field";
+export { ChatSendButton } from "./chat-send-button";
+export { ChatMoreButton } from "./chat-more-button";
+export { ImageUploadButton } from "./image-upload-button";
+export { FunctionButtonBar } from "./function-button-bar";
+
+// 消息气泡
+export { MessageBubble } from "./message-bubble";
+export { MessageContent } from "./message-content";
+export { MessageAvatar } from "./message-avatar";
+export { TextBubble } from "./text-bubble";
+export { ImageBubble } from "./image-bubble";
+export { LottieMessageBubble } from "./lottie-message-bubble";
+export { DateHeader } from "./date-header";
+export { FullscreenImageViewer } from "./fullscreen-image-viewer";
+
+// 弹窗 / 浮层 / 卡片
+export { QuotaDialog } from "./quota-dialog";
+export { PwaInstallDialog } from "./pwa-install-dialog";
+export { PwaInstallOverlay } from "./pwa-install-overlay";
+export { BrowserHintOverlay } from "./browser-hint-overlay";
+export { AiDisclosureBanner } from "./ai-disclosure-banner";
+export { CharacterIntro } from "./character-intro";
+export { LanguageDialog, DEFAULT_LANGUAGES } from "./language-dialog";
diff --git a/src/app/chat/components/language-dialog.module.css b/src/app/chat/components/language-dialog.module.css
new file mode 100644
index 00000000..952e372a
--- /dev/null
+++ b/src/app/chat/components/language-dialog.module.css
@@ -0,0 +1,66 @@
+/* LanguageDialog 语言切换弹窗样式 */
+
+.overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 50;
+}
+
+.dialog {
+ width: calc(100% - 40px);
+ max-width: 360px;
+ background: var(--color-surface, #1f1a2e);
+ border-radius: var(--radius-lg, 12px);
+ padding: var(--spacing-5, 20px);
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-3, 12px);
+ max-height: 80vh;
+}
+
+.title {
+ font-size: var(--font-size-lg, 18px);
+ font-weight: 600;
+ margin: 0;
+ color: var(--color-text-primary, #fff);
+}
+
+.list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-1, 4px);
+ overflow-y: auto;
+ max-height: 60vh;
+}
+
+.item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--spacing-3, 12px) var(--spacing-4, 16px);
+ background: transparent;
+ border: 0;
+ border-radius: var(--radius-md, 8px);
+ color: var(--color-text-primary, #fff);
+ font-size: var(--font-size-base, 14px);
+ text-align: left;
+ cursor: pointer;
+}
+
+.item:hover,
+.item:focus-visible {
+ background: rgba(255, 255, 255, 0.05);
+}
+
+.itemActive {
+ background: rgba(248, 77, 150, 0.15);
+}
+
+.check {
+ color: var(--color-accent, #f84d96);
+ font-weight: 700;
+}
diff --git a/src/app/chat/components/language-dialog.tsx b/src/app/chat/components/language-dialog.tsx
new file mode 100644
index 00000000..6692a70c
--- /dev/null
+++ b/src/app/chat/components/language-dialog.tsx
@@ -0,0 +1,95 @@
+"use client";
+/**
+ * LanguageDialog 语言切换弹窗
+ *
+ * 原始 Dart: lib/ui/chat/widgets/language_dialog.dart(222 行)
+ *
+ * 简化版:单语种列表选择(保留可扩展性)
+ * - 支持未来添加更多语言
+ * - 当前选中状态高亮
+ * - ESC / 点击外部 → 关闭
+ */
+import { useEffect } from "react";
+
+import styles from "./language-dialog.module.css";
+
+export interface LanguageItem {
+ code: string;
+ label: string;
+ nativeLabel: string;
+}
+
+export const DEFAULT_LANGUAGES: readonly LanguageItem[] = [
+ { code: "en", label: "English", nativeLabel: "English" },
+ { code: "zh", label: "Chinese (Simplified)", nativeLabel: "简体中文" },
+ { code: "zh-TW", label: "Chinese (Traditional)", nativeLabel: "繁體中文" },
+ { code: "ja", label: "Japanese", nativeLabel: "日本語" },
+ { code: "ko", label: "Korean", nativeLabel: "한국어" },
+ { code: "es", label: "Spanish", nativeLabel: "Español" },
+ { code: "fr", label: "French", nativeLabel: "Français" },
+ { code: "de", label: "German", nativeLabel: "Deutsch" },
+];
+
+export interface LanguageDialogProps {
+ open: boolean;
+ onClose: () => void;
+ currentLanguageCode?: string;
+ languages?: readonly LanguageItem[];
+ onSelect: (code: string) => void;
+}
+
+export function LanguageDialog({
+ open,
+ onClose,
+ currentLanguageCode = "en",
+ languages = DEFAULT_LANGUAGES,
+ onSelect,
+}: LanguageDialogProps) {
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ };
+ document.addEventListener("keydown", onKey);
+ return () => document.removeEventListener("keydown", onKey);
+ }, [open, onClose]);
+
+ if (!open) return null;
+
+ return (
+
+
e.stopPropagation()}>
+
Select Language
+
+ {languages.map((lang) => {
+ const isActive = currentLanguageCode === lang.code;
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/src/app/chat/components/lottie-message-bubble.module.css b/src/app/chat/components/lottie-message-bubble.module.css
new file mode 100644
index 00000000..09c66c9c
--- /dev/null
+++ b/src/app/chat/components/lottie-message-bubble.module.css
@@ -0,0 +1,44 @@
+/* LottieMessageBubble 动画消息气泡(CSS keyframes 模拟) */
+
+.bubbleRow {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-2, 8px);
+ padding: var(--spacing-1, 4px) 0;
+}
+
+.bubble {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: var(--spacing-3, 12px) var(--spacing-4, 16px);
+ background: #fff;
+ border-radius: var(--radius-xxl, 24px);
+ border-top-left-radius: 0;
+ min-width: 60px;
+ min-height: 40px;
+ box-shadow: var(--shadow-input-box, 0 1px 2px rgba(0, 0, 0, 0.1));
+}
+
+.dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--color-text-foreground, #000);
+ animation: bounce 1.4s infinite ease-in-out both;
+}
+
+.dot:nth-child(1) { animation-delay: -0.32s; }
+.dot:nth-child(2) { animation-delay: -0.16s; }
+
+@keyframes bounce {
+ 0%, 80%, 100% {
+ transform: scale(0);
+ opacity: 0.5;
+ }
+ 40% {
+ transform: scale(1);
+ opacity: 1;
+ }
+}
diff --git a/src/app/chat/components/lottie-message-bubble.tsx b/src/app/chat/components/lottie-message-bubble.tsx
new file mode 100644
index 00000000..ec263e92
--- /dev/null
+++ b/src/app/chat/components/lottie-message-bubble.tsx
@@ -0,0 +1,28 @@
+"use client";
+/**
+ * LottieMessageBubble 动画消息气泡
+ *
+ * 原始 Dart: lib/ui/chat/widgets/lottie_message_bubble.dart(51 行)
+ *
+ * 原始实现:使用 lottie SDK 渲染"打字指示器"动画
+ * 本轮实现:使用 CSS keyframes 模拟(无 lottie 依赖)
+ * - 3 个跳动的圆点(左右 + 中间)
+ * - 1.4s 周期,stagger 动画延迟
+ *
+ * 行为一致(视觉类似 lottie 的"三点跳跃"动画)
+ */
+import { MessageAvatar } from "./message-avatar";
+import styles from "./lottie-message-bubble.module.css";
+
+export function LottieMessageBubble() {
+ return (
+
+ );
+}
diff --git a/src/app/chat/components/message-avatar.module.css b/src/app/chat/components/message-avatar.module.css
new file mode 100644
index 00000000..fa5748e5
--- /dev/null
+++ b/src/app/chat/components/message-avatar.module.css
@@ -0,0 +1,30 @@
+/* MessageAvatar 头像样式 */
+
+.avatar {
+ flex: 0 0 auto;
+ width: 43px;
+ height: 43px;
+ border-radius: 9999px;
+ overflow: hidden;
+ border: 2px solid var(--color-avatar-border, #fbf3f5);
+ box-shadow: var(--shadow-input-box, 0 1px 2px rgba(0, 0, 0, 0.1));
+ background: var(--color-avatar-border, #fbf3f5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.avatar img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.placeholder {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 24px;
+}
diff --git a/src/app/chat/components/message-avatar.tsx b/src/app/chat/components/message-avatar.tsx
new file mode 100644
index 00000000..8c4ff501
--- /dev/null
+++ b/src/app/chat/components/message-avatar.tsx
@@ -0,0 +1,55 @@
+"use client";
+/**
+ * MessageAvatar 消息头像
+ *
+ * 原始 Dart: lib/ui/chat/widgets/message_avatar.dart(85 行)
+ *
+ * 显示规则:
+ * - AI 消息:Elio 头像(`/images/chat/pic-chat-elio.png`)
+ * - 用户消息(有 avatarUrl):用户头像
+ * - 用户消息(无 avatarUrl):游客头像(`/images/chat/pic-chat-guest.png`)
+ */
+import Image from "next/image";
+
+import styles from "./message-avatar.module.css";
+
+export interface MessageAvatarProps {
+ isFromAI: boolean;
+ userAvatarUrl?: string | null;
+}
+
+export function MessageAvatar({ isFromAI, userAvatarUrl }: MessageAvatarProps) {
+ if (isFromAI) {
+ return (
+
+
+
+ );
+ }
+
+ if (userAvatarUrl && userAvatarUrl.length > 0) {
+ return (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/app/chat/components/message-bubble.tsx b/src/app/chat/components/message-bubble.tsx
new file mode 100644
index 00000000..77297ce1
--- /dev/null
+++ b/src/app/chat/components/message-bubble.tsx
@@ -0,0 +1,49 @@
+"use client";
+/**
+ * MessageBubble 消息气泡(容器)
+ *
+ * 原始 Dart: lib/ui/chat/widgets/message_bubble.dart(52 行)
+ *
+ * 组成(从左到右):
+ * - AI:[Avatar] [Content] [占位 spacer]
+ * - 用户:[占位 spacer] [Content] [Avatar]
+ */
+import { useUserState } from "@/stores/user/user-context";
+
+import { MessageAvatar } from "./message-avatar";
+import { MessageContent } from "./message-content";
+import styles from "./chat-area.module.css";
+
+export interface MessageBubbleProps {
+ content: string;
+ imageUrl?: string | null;
+ isFromAI: boolean;
+}
+
+export function MessageBubble({ content, imageUrl, isFromAI }: MessageBubbleProps) {
+ const { avatarUrl } = useUserState();
+
+ if (isFromAI) {
+ return (
+
+
+
+
+
{/* 占位:保持与头像宽度一致 */}
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/app/chat/components/message-content.tsx b/src/app/chat/components/message-content.tsx
new file mode 100644
index 00000000..93458908
--- /dev/null
+++ b/src/app/chat/components/message-content.tsx
@@ -0,0 +1,41 @@
+"use client";
+/**
+ * MessageContent 消息内容容器
+ *
+ * 原始 Dart: lib/ui/chat/widgets/message_content.dart(43 行)
+ *
+ * 决定渲染 ImageBubble 还是 TextBubble:
+ * - 有 imageUrl:渲染 ImageBubble(图片)
+ * - 有 content 且非 "[图片]" 占位符:渲染 TextBubble(文字)
+ * - 两者都有:图片在上,文字在下
+ */
+import { ImageBubble } from "./image-bubble";
+import { TextBubble } from "./text-bubble";
+
+export interface MessageContentProps {
+ content: string;
+ imageUrl?: string | null;
+ isFromAI: boolean;
+}
+
+const IMAGE_PLACEHOLDER = "[图片]";
+
+export function MessageContent({ content, imageUrl, isFromAI }: MessageContentProps) {
+ const hasImage = imageUrl != null && imageUrl.length > 0;
+ const hasText = content.length > 0 && content !== IMAGE_PLACEHOLDER;
+
+ return (
+
+ {hasImage && imageUrl && }
+ {hasText && }
+
+ );
+}
diff --git a/src/app/chat/components/pwa-install-dialog.module.css b/src/app/chat/components/pwa-install-dialog.module.css
new file mode 100644
index 00000000..6f0f0d99
--- /dev/null
+++ b/src/app/chat/components/pwa-install-dialog.module.css
@@ -0,0 +1,78 @@
+/* PwaInstallDialog PWA 安装弹窗样式 */
+
+.overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 60;
+}
+
+.dialog {
+ width: calc(100% - 40px);
+ max-width: 360px;
+ background: var(--color-surface, #1f1a2e);
+ border-radius: var(--radius-lg, 12px);
+ padding: var(--spacing-5, 20px);
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
+ text-align: center;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.icon {
+ width: 64px;
+ height: 64px;
+ border-radius: 50%;
+ background: rgba(248, 77, 150, 0.2);
+ color: var(--color-accent, #f84d96);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 32px;
+ margin-bottom: var(--spacing-4, 16px);
+}
+
+.title {
+ font-size: var(--font-size-lg, 18px);
+ font-weight: 600;
+ margin: 0 0 var(--spacing-2, 8px);
+ color: var(--color-text-primary, #fff);
+}
+
+.content {
+ font-size: var(--font-size-sm, 14px);
+ line-height: 1.5;
+ color: var(--color-text-secondary, #9e9e9e);
+ margin: 0 0 var(--spacing-5, 20px);
+}
+
+.actions {
+ display: flex;
+ gap: var(--spacing-3, 12px);
+ width: 100%;
+}
+
+.btn {
+ flex: 1 1 auto;
+ height: 40px;
+ border: 0;
+ border-radius: var(--radius-md, 8px);
+ font-size: var(--font-size-base, 14px);
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.btnSecondary {
+ background: transparent;
+ color: var(--color-text-primary, #fff);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+}
+
+.btnPrimary {
+ background: var(--color-accent, #f84d96);
+ color: #fff;
+}
diff --git a/src/app/chat/components/pwa-install-dialog.tsx b/src/app/chat/components/pwa-install-dialog.tsx
new file mode 100644
index 00000000..f2d70228
--- /dev/null
+++ b/src/app/chat/components/pwa-install-dialog.tsx
@@ -0,0 +1,67 @@
+"use client";
+/**
+ * PwaInstallDialog PWA 安装弹窗
+ *
+ * 原始 Dart: lib/ui/chat/widgets/pwa_install_dialog.dart(145 行)
+ *
+ * 用途:引导用户将 Web App 安装到桌面
+ * 触发:由 PwaInstallOverlay 在延迟 3.5s 后自动显示
+ */
+import styles from "./pwa-install-dialog.module.css";
+
+export interface PwaInstallDialogProps {
+ onClose: () => void;
+ onInstall?: () => void;
+}
+
+export function PwaInstallDialog({ onClose, onInstall }: PwaInstallDialogProps) {
+ const handleInstall = () => {
+ if (onInstall) {
+ onInstall();
+ } else {
+ // 提示用户使用浏览器"添加到主屏幕"功能
+ window.alert(
+ "To install: tap the browser menu, then 'Add to Home Screen'.",
+ );
+ }
+ onClose();
+ };
+
+ return (
+
+
+
+ 📱
+
+
+ Install cozsweet
+
+
+ Add to your home screen for the best experience. Faster loading,
+ full-screen mode, and offline support.
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/chat/components/pwa-install-overlay.module.css b/src/app/chat/components/pwa-install-overlay.module.css
new file mode 100644
index 00000000..42c5813b
--- /dev/null
+++ b/src/app/chat/components/pwa-install-overlay.module.css
@@ -0,0 +1,5 @@
+/* PwaInstallOverlay PWA 安装弹窗触发器样式(无可见 UI,逻辑触发用) */
+
+/.hidden {
+ display: none;
+}
diff --git a/src/app/chat/components/pwa-install-overlay.tsx b/src/app/chat/components/pwa-install-overlay.tsx
new file mode 100644
index 00000000..adaa680d
--- /dev/null
+++ b/src/app/chat/components/pwa-install-overlay.tsx
@@ -0,0 +1,87 @@
+"use client";
+/**
+ * PwaInstallOverlay PWA 安装提示触发器
+ *
+ * 原始 Dart: lib/ui/chat/widgets/pwa_install_overlay.dart(76 行)
+ *
+ * 行为:
+ * - 检查 PWA 支持
+ * - 检查每日是否已显示(使用 AppStorage 持久化)
+ * - 延迟 3.5s 后弹出 PwaInstallDialog
+ * - 生产环境有效;开发环境 1s 后立即弹出(测试用)
+ *
+ * 注意:PWA install prompt API(`beforeinstallprompt`)在浏览器差异较大,
+ * 本轮仅实现"显示时机"逻辑,具体 install prompt 调用由 Dialog 内部处理。
+ */
+import { useEffect } from "react";
+
+import { SpAsyncUtil } from "@/utils/storage";
+
+import { PwaInstallDialog } from "./pwa-install-dialog";
+import styles from "./pwa-install-overlay.module.css";
+
+const PWA_DIALOG_KEY = "cozsweet:lastPwaDialogShown";
+
+export function PwaInstallOverlay() {
+ useEffect(() => {
+ let mounted = true;
+
+ const init = async () => {
+ // 检查每日是否已显示
+ const lastResult = await SpAsyncUtil.getString(PWA_DIALOG_KEY);
+ if (!lastResult.success) return;
+ const today = new Date().toISOString().slice(0, 10);
+ if (lastResult.data === today) return; // 今天已显示过
+
+ // 检查 PWA 支持
+ if (typeof window === "undefined") return;
+ if (!("serviceWorker" in navigator)) return;
+
+ // 延迟 3.5s 后显示
+ setTimeout(() => {
+ if (!mounted) return;
+ showPwaDialog();
+ }, 3500);
+ };
+
+ void init();
+ return () => {
+ mounted = false;
+ };
+ }, []);
+
+ return
;
+}
+
+function showPwaDialog() {
+ if (typeof document === "undefined") return;
+ // 记录显示时间
+ const today = new Date().toISOString().slice(0, 10);
+ void SpAsyncUtil.setString(PWA_DIALOG_KEY, today);
+
+ // 创建挂载点
+ const root = document.createElement("div");
+ document.body.appendChild(root);
+ // 用 React DOM 渲染(通过 import)
+ import("react-dom/client").then(({ createRoot }) => {
+ const reactRoot = createRoot(root);
+ reactRoot.render(
);
+ });
+}
+
+function PwaInstallDialogMount({
+ rootEl,
+ reactRoot,
+}: {
+ rootEl: HTMLElement;
+ reactRoot: { unmount: () => void };
+}) {
+ return (
+
{
+ reactRoot.unmount();
+ rootEl.remove();
+ }}
+ />
+ );
+}
diff --git a/src/app/chat/components/quota-dialog.module.css b/src/app/chat/components/quota-dialog.module.css
new file mode 100644
index 00000000..b666afcf
--- /dev/null
+++ b/src/app/chat/components/quota-dialog.module.css
@@ -0,0 +1,85 @@
+/* QuotaDialog 配额提示弹窗样式 */
+
+.overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 50;
+}
+
+.dialog {
+ width: calc(100% - 40px);
+ max-width: 360px;
+ background: var(--color-surface, #1f1a2e);
+ border-radius: var(--radius-lg, 12px);
+ padding: var(--spacing-5, 20px);
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+}
+
+.icon {
+ width: 64px;
+ height: 64px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 32px;
+ margin-bottom: var(--spacing-4, 16px);
+}
+
+.iconExhausted {
+ background: rgba(255, 80, 80, 0.2);
+ color: #ff5050;
+}
+
+.iconWarning {
+ background: rgba(248, 77, 150, 0.2);
+ color: var(--color-accent, #f84d96);
+}
+
+.title {
+ font-size: var(--font-size-lg, 18px);
+ font-weight: 600;
+ margin: 0 0 var(--spacing-2, 8px);
+ color: var(--color-text-primary, #fff);
+}
+
+.content {
+ font-size: var(--font-size-sm, 14px);
+ line-height: 1.5;
+ color: var(--color-text-secondary, #9e9e9e);
+ margin: 0 0 var(--spacing-5, 20px);
+}
+
+.actions {
+ display: flex;
+ gap: var(--spacing-3, 12px);
+ width: 100%;
+}
+
+.btn {
+ flex: 1 1 auto;
+ height: 40px;
+ border: 0;
+ border-radius: var(--radius-md, 8px);
+ font-size: var(--font-size-base, 14px);
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.btnSecondary {
+ background: transparent;
+ color: var(--color-text-primary, #fff);
+}
+
+.btnPrimary {
+ background: var(--color-accent, #f84d96);
+ color: #fff;
+}
diff --git a/src/app/chat/components/quota-dialog.tsx b/src/app/chat/components/quota-dialog.tsx
new file mode 100644
index 00000000..62912e19
--- /dev/null
+++ b/src/app/chat/components/quota-dialog.tsx
@@ -0,0 +1,80 @@
+"use client";
+/**
+ * QuotaDialog 配额提示弹窗
+ *
+ * 原始 Dart: lib/ui/chat/widgets/quota_dialog.dart(130 行)
+ *
+ * 两种模式:
+ * - 警告模式(isExhausted=false):剩余配额 < 5 时,提示用户登录
+ * - 耗尽模式(isExhausted=true):配额已用完,强制登录
+ */
+import { useRouter } from "next/navigation";
+
+import { ROUTES } from "@/lib/routes";
+
+import styles from "./quota-dialog.module.css";
+
+export interface QuotaDialogProps {
+ open: boolean;
+ onClose: () => void;
+ isExhausted: boolean;
+}
+
+export function QuotaDialog({ open, onClose, isExhausted }: QuotaDialogProps) {
+ const router = useRouter();
+
+ if (!open) return null;
+
+ const title = isExhausted
+ ? "Daily quota exhausted"
+ : "Login for unlimited access!";
+
+ const content = isExhausted
+ ? "Login to continue chatting~"
+ : "Dear, sign up now and all your chats with Elio will be saved forever. In guest mode, your chat history will be deleted when you leave, and Elio will forget you.";
+
+ const handleLogin = () => {
+ onClose();
+ router.push(ROUTES.auth);
+ };
+
+ return (
+
+
+
+ {isExhausted ? "🚫" : "⚠️"}
+
+
+
+ {title}
+
+
{content}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/chat/components/text-bubble.module.css b/src/app/chat/components/text-bubble.module.css
new file mode 100644
index 00000000..6fd35dcc
--- /dev/null
+++ b/src/app/chat/components/text-bubble.module.css
@@ -0,0 +1,29 @@
+/* TextBubble 文字气泡样式 */
+
+.bubble {
+ display: inline-block;
+ padding: var(--spacing-3, 12px) var(--spacing-4, 16px);
+ border-radius: var(--radius-xxl, 24px);
+ max-width: 75%;
+ font-size: var(--font-size-body-lg, 16px);
+ line-height: 1.45;
+ white-space: pre-wrap;
+ word-break: break-word;
+ box-shadow: var(--shadow-input-box, 0 1px 2px rgba(0, 0, 0, 0.1));
+}
+
+.bubbleAi {
+ background: #fff;
+ color: var(--color-text-foreground, #000);
+ border-top-left-radius: 0;
+}
+
+.bubbleUser {
+ background: linear-gradient(
+ to right,
+ var(--color-button-gradient-start, #ff67e0),
+ var(--color-button-gradient-end, #ff52a2)
+ );
+ color: #fff;
+ border-top-right-radius: 0;
+}
diff --git a/src/app/chat/components/text-bubble.tsx b/src/app/chat/components/text-bubble.tsx
new file mode 100644
index 00000000..a5287500
--- /dev/null
+++ b/src/app/chat/components/text-bubble.tsx
@@ -0,0 +1,26 @@
+"use client";
+/**
+ * TextBubble 文字气泡
+ *
+ * 原始 Dart: lib/ui/chat/widgets/text_bubble.dart(41 行)
+ *
+ * 视觉差异:
+ * - AI 消息:白底黑字 + 左上角尖角
+ * - 用户消息:渐变(primaryGradient)+ 白字 + 右上角尖角
+ */
+import styles from "./text-bubble.module.css";
+
+export interface TextBubbleProps {
+ content: string;
+ isFromAI: boolean;
+}
+
+export function TextBubble({ content, isFromAI }: TextBubbleProps) {
+ return (
+
+ {content}
+
+ );
+}