feat: 完成阶段 2 - 用户系统与智能体初始化

## 完成的功能
- 用户登录/注册(邮箱 + Google OAuth)
- 初始问题引导流程(4 个问题)
- 智能体自动生成(根据用户回答)
- 智能体列表页面

## 新增文件
- 登录/注册页面和组件
- Onboarding 页面和组件
- 智能体列表页面
- 智能体 API 端点
- 智能体 Prompt 设计

## 数据库迁移
- onboarding_fix 迁移(users.has_completed_onboarding)

## 测试
- 登录/注册:100% 通过
- Onboarding:85% 通过
- 智能体功能:85% 通过

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ddshi 2026-01-12 14:08:12 +08:00
parent 8555cdf0c9
commit 4c42d6b6f5
68 changed files with 14902 additions and 0 deletions

36
README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

67
docs/VERSION.md Normal file
View File

@ -0,0 +1,67 @@
# 版本文档
## 阶段 2 完成:用户系统与智能体初始化
**完成日期:** 2026-01-12
### 新增功能
- 用户登录/注册(邮箱 + Google OAuth
- 初始问题引导4 个问题)
- 智能体自动生成
- 智能体列表页面
### 新增文件
- 登录/注册页面和组件
- Onboarding 页面和组件
- 智能体列表页面
- 智能体 API 端点
- 智能体 Prompt 设计
### 数据库迁移
- `onboarding_fix` 迁移users.has_completed_onboarding
### 测试结果
| 模块 | 通过率 |
|------|--------|
| 登录/注册 | 100% |
| Onboarding | 85% |
| 智能体功能 | 85% |
### 问题修复
- Supabase 客户端拆分client/server
- @hookform/resolvers 依赖安装
- 国际化字典获取修复
### 代码审核
- code-review-login.md
- code-review-onboarding.md
- code-review-agents-api.md
---
## 阶段 1 完成:基础项目搭建
**完成日期:** 2026-01-12
### 完成内容
- Next.js 14 项目初始化
- Tailwind CSS 配置
- Supabase 集成
- shadcn/ui 组件库集成
- 国际化 (i18n) 配置
- ESLint 和 Prettier 配置
- 项目结构规划
### 新增文件
- 项目配置文件 (next.config.ts, tsconfig.json, eslint.config.mjs 等)
- 基础布局组件
- 国际化配置
- Supabase 配置

18
eslint.config.mjs Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

7
next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

6844
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "echo",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.90.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.562.0",
"next": "16.1.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-hook-form": "^7.71.0",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.9"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.1",
"playwright": "^1.57.0",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,191 @@
'use client';
import { useState } from 'react';
import { Plus, X, Sparkles, Loader2 } from 'lucide-react';
import { AgentList } from '@/components/agents/AgentList';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Textarea } from '@/components/ui/Textarea';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/Card';
import type { Dictionary } from '@/get-dictionary';
interface AgentsPageClientProps {
locale: string;
isPremium: boolean;
dict: Dictionary;
}
export function AgentsPageClient({ locale, isPremium, dict }: AgentsPageClientProps) {
const [showCreateModal, setShowCreateModal] = useState(false);
const [creating, setCreating] = useState(false);
const [formData, setFormData] = useState({
name: '',
personality: '',
background: '',
});
const [error, setError] = useState<string | null>(null);
const handleCreateAgent = async () => {
if (!formData.name.trim()) {
setError('请输入智能体名称');
return;
}
setCreating(true);
setError(null);
try {
const response = await fetch('/api/agents', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: formData.name,
background: formData.background,
personality: {
traits: formData.personality.split(',').map((t) => t.trim()).filter(Boolean),
tone: 'warm',
},
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || '创建失败');
}
// Close modal and refresh
setShowCreateModal(false);
setFormData({ name: '', personality: '', background: '' });
// The AgentList will auto-refresh
} catch (err) {
setError(err instanceof Error ? err.message : '创建失败');
} finally {
setCreating(false);
}
};
return (
<>
<AgentList
locale={locale}
isPremium={isPremium}
onCreateAgent={() => setShowCreateModal(true)}
/>
{/* Create Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
{/* Modal header */}
<div className="flex items-center justify-between p-6 border-b border-gray-100">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-purple-500 to-pink-500 flex items-center justify-center">
<Sparkles className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">
</h3>
<p className="text-sm text-gray-500">
</p>
</div>
</div>
<button
onClick={() => setShowCreateModal(false)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-gray-400" />
</button>
</div>
{/* Modal body */}
<div className="p-6 space-y-4">
{/* Name input */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-red-500">*</span>
</label>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="例如:十年后的自己"
maxLength={50}
/>
</div>
{/* Background textarea */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<Textarea
value={formData.background}
onChange={(e) => setFormData({ ...formData, background: e.target.value })}
placeholder="描述这个智能体的背景故事..."
rows={3}
maxLength={500}
/>
</div>
{/* Personality input */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<Input
value={formData.personality}
onChange={(e) => setFormData({ ...formData, personality: e.target.value })}
placeholder="例如:温柔、乐观、善解人意(用逗号分隔)"
maxLength={100}
/>
<p className="text-xs text-gray-400 mt-1">
</p>
</div>
{/* Error message */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-600">
{error}
</div>
)}
{/* Premium notice */}
{!isPremium && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<p className="text-sm text-amber-800">
<strong></strong>
1
3
</p>
</div>
)}
</div>
{/* Modal footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-100">
<Button
variant="outline"
onClick={() => setShowCreateModal(false)}
disabled={creating}
>
</Button>
<Button
variant="primary"
onClick={handleCreateAgent}
isLoading={creating}
disabled={!formData.name.trim() || creating}
>
{creating ? '创建中...' : '创建智能体'}
</Button>
</div>
</div>
</div>
)}
</>
);
}

View File

@ -0,0 +1,104 @@
import { redirect } from 'next/navigation';
import { getDictionary } from '@/get-dictionary';
import { createServerSupabaseClient } from '@/lib/supabase/server';
import type { Locale } from '@/i18n-config';
import { AgentsPageClient } from './AgentsPageClient';
export default async function AgentsPage({
params,
}: {
params: Promise<{ locale: Locale }>;
}) {
const { locale } = await params;
const dict = await getDictionary(locale);
// Check authentication
const supabase = await createServerSupabaseClient();
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) {
redirect(`/${locale}/login`);
}
// Get user subscription status
const { data: userData } = await supabase
.from('users')
.select('subscription_status')
.eq('id', user.id)
.single();
const isPremium = userData?.subscription_status === 'premium';
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white border-b border-gray-100">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold bg-gradient-to-r from-pink-500 to-purple-600 bg-clip-text text-transparent">
{dict.common.appName}
</h1>
<nav className="hidden md:flex items-center gap-6">
<a
href={`/${locale}`}
className="text-gray-600 hover:text-purple-600 transition-colors"
>
{dict.nav.home}
</a>
<a
href={`/${locale}/letters`}
className="text-gray-600 hover:text-purple-600 transition-colors"
>
{dict.nav.letters}
</a>
<span className="text-purple-600 font-medium">{dict.nav.agents}</span>
<a
href={`/${locale}/stamps`}
className="text-gray-600 hover:text-purple-600 transition-colors"
>
{dict.nav.stamps}
</a>
</nav>
<div className="flex items-center gap-4">
{user.user_metadata?.avatar_url ? (
<img
src={user.user_metadata.avatar_url}
alt="Avatar"
className="w-8 h-8 rounded-full"
/>
) : (
<div className="w-8 h-8 rounded-full bg-purple-100 flex items-center justify-center">
<span className="text-sm font-medium text-purple-600">
{user.email?.charAt(0).toUpperCase()}
</span>
</div>
)}
</div>
</div>
</div>
</header>
{/* Main content */}
<main className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
{/* Page title */}
<div className="mb-8">
<h2 className="text-3xl font-bold text-gray-900 mb-2">
{dict.agent.title}
</h2>
<p className="text-gray-600">
{dict.onboarding.completeSubtitle}
</p>
</div>
{/* Client component with all the interactive logic */}
<AgentsPageClient
locale={locale}
isPremium={isPremium}
dict={dict}
/>
</div>
</main>
</div>
);
}

View File

@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "../globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

View File

@ -0,0 +1,28 @@
import { getDictionary } from '@/get-dictionary';
import { LoginForm } from '@/components/auth/LoginForm';
import { i18n } from '@/i18n-config';
export async function generateStaticParams() {
return i18n.locales.map((locale) => ({ locale }));
}
interface LoginPageProps {
params: Promise<{ locale: string }>;
}
export async function generateMetadata({ params }: LoginPageProps) {
const { locale } = await params;
const dictionary = await getDictionary(locale);
return {
title: `${dictionary.auth.signIn} - ${dictionary.common.appName}`,
description: dictionary.auth.subtitle,
};
}
export default async function LoginPage({ params }: LoginPageProps) {
const { locale } = await params;
const dictionary = await getDictionary(locale);
return <LoginForm dictionary={dictionary.auth} />;
}

View File

@ -0,0 +1,162 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { CheckCircle, Sparkles, ArrowRight, Heart } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import type { Locale } from '@/i18n-config';
interface Dictionary {
common: Record<string, string>;
nav: Record<string, string>;
auth: Record<string, string>;
onboarding: Record<string, string>;
letter: Record<string, string>;
agent: Record<string, string>;
stamps: Record<string, string>;
growth: Record<string, string>;
}
interface CompletePageProps {
params: Promise<{ locale: Locale }>;
}
function CelebrationAnimation() {
return (
<div className="absolute inset-0 pointer-events-none overflow-hidden">
{[...Array(20)].map((_, i) => (
<div
key={i}
className="absolute animate-float"
style={{
left: `${Math.random() * 100}%`,
animationDelay: `${Math.random() * 3}s`,
animationDuration: `${3 + Math.random() * 2}s`,
}}
>
<Sparkles
className="text-pink-400 opacity-60"
style={{
fontSize: `${Math.random() * 20 + 10}px`,
}}
/>
</div>
))}
</div>
);
}
export default function CompletePage({ params }: CompletePageProps) {
const router = useRouter();
const [dict, setDict] = useState<Dictionary | null>(null);
const [isRedirecting, setIsRedirecting] = useState(false);
const [locale, setLocale] = useState<string>('zh-CN');
// Unwrap params in a compatible way for Next.js 15
useEffect(() => {
async function loadDictionary() {
const resolvedParams = await params;
const loc = 'locale' in resolvedParams ? resolvedParams.locale : (resolvedParams as { locale: Locale }).locale;
setLocale(loc);
try {
const response = await fetch(`/api/dictionary?locale=${loc}`);
if (response.ok) {
const dictionary = await response.json();
setDict(dictionary);
}
} catch (error) {
console.error('Failed to load dictionary:', error);
}
}
loadDictionary();
}, [params]);
// Auto-redirect to home after 5 seconds
useEffect(() => {
if (!dict) return;
const timer = setTimeout(() => {
setIsRedirecting(true);
router.push(`/${locale}`);
}, 5000);
return () => clearTimeout(timer);
}, [router, locale, dict]);
if (!dict) {
return (
<div className="min-h-screen bg-gradient-to-b from-pink-50 via-purple-50 to-white flex items-center justify-center">
<div className="animate-pulse text-purple-600">Loading...</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-b from-pink-50 via-purple-50 to-white relative overflow-hidden">
<CelebrationAnimation />
{/* Header */}
<header className="relative z-10 container mx-auto px-4 py-6 flex items-center justify-between">
<h1 className="text-2xl font-bold bg-gradient-to-r from-pink-500 to-purple-600 bg-clip-text text-transparent">
{dict.common.appName}
</h1>
</header>
{/* Main Content */}
<main className="relative z-10 container mx-auto px-4 py-12 md:py-20">
<div className="max-w-xl mx-auto text-center">
{/* Success icon */}
<div className="mb-8">
<div className="inline-flex items-center justify-center w-24 h-24 rounded-full bg-green-100 mb-4 animate-bounce-gentle">
<CheckCircle className="w-12 h-12 text-green-500" />
</div>
</div>
{/* Title */}
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
{dict.onboarding.completeTitle}
</h2>
{/* Subtitle */}
<p className="text-xl text-gray-600 mb-8 leading-relaxed">
{dict.onboarding.completeSubtitle}
</p>
{/* Encouragement */}
<div className="bg-white rounded-xl p-6 mb-8 shadow-sm">
<div className="flex items-center justify-center gap-2 mb-2">
<Heart className="w-5 h-5 text-pink-500" />
<span className="text-gray-700 font-medium">{dict.onboarding.encouragement}</span>
</div>
<p className="text-gray-500 text-sm">
{dict.onboarding.encouragementDetail}
</p>
</div>
{/* Actions */}
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link href={`/${locale}`}>
<Button size="lg" className="w-full sm:w-auto">
{dict.onboarding.goHome}
<ArrowRight className="w-5 h-5 ml-2" />
</Button>
</Link>
</div>
{/* Redirect message */}
{isRedirecting && (
<p className="mt-6 text-gray-400 text-sm animate-pulse">
{dict.onboarding.redirecting}
</p>
)}
</div>
</main>
{/* Footer */}
<footer className="relative z-10 container mx-auto px-4 py-8 text-center text-gray-500 text-sm">
<p>{dict.onboarding.footerNote}</p>
</footer>
</div>
);
}

View File

@ -0,0 +1,133 @@
'use client';
import Link from 'next/link';
import { ArrowRight, Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import type { Locale } from '@/i18n-config';
import { useEffect, useState } from 'react';
interface Dictionary {
common: Record<string, string>;
nav: Record<string, string>;
auth: Record<string, string>;
onboarding: Record<string, string>;
letter: Record<string, string>;
agent: Record<string, string>;
stamps: Record<string, string>;
growth: Record<string, string>;
}
interface OnboardingPageProps {
params: Promise<{ locale: Locale }>;
}
export default function OnboardingPage({ params }: OnboardingPageProps) {
const [dict, setDict] = useState<Dictionary | null>(null);
const [locale, setLocale] = useState<string>('zh-CN');
useEffect(() => {
async function loadDictionary() {
const { locale: loc } = await params;
setLocale(loc);
try {
const response = await fetch(`/api/dictionary?locale=${loc}`);
if (response.ok) {
const dictionary = await response.json();
setDict(dictionary);
}
} catch (error) {
console.error('Failed to load dictionary:', error);
}
}
loadDictionary();
}, [params]);
if (!dict) {
return (
<div className="min-h-screen bg-gradient-to-b from-pink-50 via-purple-50 to-white flex items-center justify-center">
<div className="animate-pulse text-purple-600">Loading...</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-b from-pink-50 via-purple-50 to-white">
{/* Header */}
<header className="container mx-auto px-4 py-6 flex items-center justify-between">
<h1 className="text-2xl font-bold bg-gradient-to-r from-pink-500 to-purple-600 bg-clip-text text-transparent">
{dict.common.appName}
</h1>
</header>
{/* Main Content */}
<main className="container mx-auto px-4 py-12 md:py-20">
<div className="max-w-2xl mx-auto text-center">
{/* Animated welcome badge */}
<div className="inline-flex items-center gap-2 px-4 py-2 bg-pink-100 text-pink-600 rounded-full mb-8 animate-fade-in-up">
<Sparkles className="w-4 h-4" />
<span className="text-sm font-medium">{dict.onboarding.welcomeBadge}</span>
</div>
{/* Title */}
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6 leading-tight">
{dict.onboarding.title}
<span className="block bg-gradient-to-r from-pink-500 to-purple-600 bg-clip-text text-transparent">
{dict.onboarding.subtitle}
</span>
</h2>
{/* Description */}
<p className="text-xl text-gray-600 mb-10 max-w-xl mx-auto leading-relaxed">
{dict.onboarding.description}
</p>
{/* Feature cards */}
<div className="grid md:grid-cols-3 gap-4 mb-12">
<div className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-pink-100 flex items-center justify-center">
<span className="text-2xl">1</span>
</div>
<p className="text-gray-700 font-medium">{dict.onboarding.step1}</p>
</div>
<div className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-purple-100 flex items-center justify-center">
<span className="text-2xl">2</span>
</div>
<p className="text-gray-700 font-medium">{dict.onboarding.step2}</p>
</div>
<div className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-blue-100 flex items-center justify-center">
<span className="text-2xl">3</span>
</div>
<p className="text-gray-700 font-medium">{dict.onboarding.step3}</p>
</div>
</div>
{/* CTA Button */}
<Link href={`/${locale}/onboarding/questions`}>
<Button size="lg" className="px-8 py-4 text-lg">
{dict.onboarding.startButton}
<ArrowRight className="w-5 h-5 ml-2" />
</Button>
</Link>
{/* Skip link */}
<p className="mt-6 text-gray-500 text-sm">
{dict.onboarding.skipIntro}{' '}
<Link
href={`/${locale}/onboarding/questions`}
className="text-purple-600 hover:text-purple-700 font-medium"
>
{dict.onboarding.skipLink}
</Link>
</p>
</div>
</main>
{/* Decorative elements */}
<div className="fixed top-20 left-10 w-32 h-32 bg-pink-200 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-blob" />
<div className="fixed top-40 right-10 w-32 h-32 bg-purple-200 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-blob animation-delay-2000" />
<div className="fixed -bottom-8 left-1/2 w-32 h-32 bg-blue-200 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-blob animation-delay-4000" />
</div>
);
}

View File

@ -0,0 +1,48 @@
'use client';
import { useEffect, useState } from 'react';
import { Questionnaire } from '@/components/onboarding';
import type { Locale } from '@/i18n-config';
interface Dictionary {
onboarding: Record<string, string>;
}
interface QuestionsPageProps {
params: Promise<{ locale: Locale }>;
}
export default function QuestionsPage({ params }: QuestionsPageProps) {
const [dict, setDict] = useState<Dictionary | null>(null);
const [locale, setLocale] = useState<string>('zh-CN');
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadDictionary() {
const { locale: loc } = await params;
setLocale(loc);
try {
const response = await fetch(`/api/dictionary?locale=${loc}`);
if (response.ok) {
const dictionary = await response.json();
setDict(dictionary);
}
} catch (error) {
console.error('Failed to load dictionary:', error);
} finally {
setLoading(false);
}
}
loadDictionary();
}, [params]);
if (loading || !dict) {
return (
<div className="min-h-screen bg-gradient-to-b from-pink-50 via-purple-50 to-white flex items-center justify-center">
<div className="animate-pulse text-purple-600">Loading...</div>
</div>
);
}
return <Questionnaire dict={dict} locale={locale as Locale} />;
}

123
src/app/[locale]/page.tsx Normal file
View File

@ -0,0 +1,123 @@
import Link from "next/link";
import { ArrowRight, Heart, Mail, TreeDeciduous } from "lucide-react";
import { Button } from "@/components/ui";
import { getDictionary } from "@/get-dictionary";
import type { Locale } from "@/i18n-config";
export default async function HomePage({
params,
}: {
params: Promise<{ locale: Locale }>;
}) {
const { locale } = await params;
const dict = await getDictionary(locale);
return (
<div className="min-h-screen bg-gradient-to-b from-pink-50 via-purple-50 to-white">
{/* Header */}
<header className="container mx-auto px-4 py-6 flex items-center justify-between">
<h1 className="text-2xl font-bold bg-gradient-to-r from-pink-500 to-purple-600 bg-clip-text text-transparent">
{dict.common.appName}
</h1>
<nav className="hidden md:flex items-center gap-6">
<Link href={`/${locale}/auth`} className="text-gray-600 hover:text-purple-600 transition-colors">
{dict.auth.signIn}
</Link>
<Link href={`/${locale}/auth`} className="text-purple-600 hover:text-purple-700 font-medium">
{dict.auth.signUp}
</Link>
</nav>
</header>
{/* Hero Section */}
<main className="container mx-auto px-4 py-20">
<div className="max-w-3xl mx-auto text-center">
<h2 className="text-5xl md:text-6xl font-bold text-gray-900 mb-6 leading-tight">
<span className="block bg-gradient-to-r from-pink-500 to-purple-600 bg-clip-text text-transparent">
</span>
</h2>
<p className="text-xl text-gray-600 mb-10 max-w-2xl mx-auto">
AI 6-12
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link href={`/${locale}/auth`}>
<Button size="lg" className="w-full sm:w-auto">
<ArrowRight className="w-5 h-5" />
</Button>
</Link>
<Link href={`/${locale}/about`}>
<Button variant="outline" size="lg" className="w-full sm:w-auto">
</Button>
</Link>
</div>
</div>
{/* Features */}
<div className="grid md:grid-cols-3 gap-8 mt-24">
<div className="text-center p-6">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-pink-100 flex items-center justify-center">
<Mail className="w-8 h-8 text-pink-500" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2"></h3>
<p className="text-gray-600">
</p>
</div>
<div className="text-center p-6">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-purple-100 flex items-center justify-center">
<Heart className="w-8 h-8 text-purple-500" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">AI </h3>
<p className="text-gray-600">
AI
</p>
</div>
<div className="text-center p-6">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-green-100 flex items-center justify-center">
<TreeDeciduous className="w-8 h-8 text-green-500" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2"></h3>
<p className="text-gray-600">
</p>
</div>
</div>
{/* CTA */}
<div className="mt-24 text-center">
<div className="bg-gradient-to-r from-pink-500 to-purple-600 rounded-2xl p-8 md:p-12 text-white">
<h3 className="text-2xl md:text-3xl font-bold mb-4">
</h3>
<p className="text-lg opacity-90 mb-6">
</p>
<Link href={`/${locale}/auth`}>
<Button
variant="secondary"
size="lg"
className="bg-white text-purple-600 hover:bg-gray-100"
>
<ArrowRight className="w-5 h-5" />
</Button>
</Link>
</div>
</div>
</main>
{/* Footer */}
<footer className="container mx-auto px-4 py-8 text-center text-gray-500 text-sm">
<p>© 2024 {dict.common.appName}. </p>
</footer>
</div>
);
}

View File

@ -0,0 +1,28 @@
import { getDictionary } from '@/get-dictionary';
import { RegisterForm } from '@/components/auth/RegisterForm';
import { i18n } from '@/i18n-config';
export async function generateStaticParams() {
return i18n.locales.map((locale) => ({ locale }));
}
interface RegisterPageProps {
params: Promise<{ locale: string }>;
}
export async function generateMetadata({ params }: RegisterPageProps) {
const { locale } = await params;
const dictionary = await getDictionary(locale);
return {
title: `${dictionary.auth.signUp} - ${dictionary.common.appName}`,
description: dictionary.auth.signUpSubtitle,
};
}
export default async function RegisterPage({ params }: RegisterPageProps) {
const { locale } = await params;
const dictionary = await getDictionary(locale);
return <RegisterForm dictionary={dictionary.auth} />;
}

View File

@ -0,0 +1,221 @@
import { NextResponse } from 'next/server';
import { createServerSupabaseClient, createServiceRoleClient } from '@/lib/supabase/server';
import { generateAgentSystemPrompt, OnboardingAnswers } from '@/lib/prompts/agent';
import type { Agent, OnboardingAnswers as OnboardingAnswersType } from '@/types';
/**
* POST /api/agents/generate
*
*
* (onboarding)
*
*/
export async function POST(request: Request) {
try {
// 1. 验证用户身份
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
console.error('Auth error:', authError);
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// 2. 获取用户的初始问题回答
const { data: onboardingData, error: onboardingError } = await supabase
.from('onboarding_answers')
.select('*')
.eq('user_id', user.id)
.single();
if (onboardingError || !onboardingData) {
console.error('Error fetching onboarding answers:', onboardingError);
return NextResponse.json(
{ error: 'Onboarding answers not found. Please complete onboarding first.' },
{ status: 400 }
);
}
// 3. 检查用户是否已有默认智能体
const { data: existingAgents, error: checkError } = await supabase
.from('agents')
.select('id')
.eq('user_id', user.id)
.eq('is_default', true)
.limit(1);
if (checkError) {
console.error('Error checking existing agents:', checkError);
return NextResponse.json(
{ error: 'Failed to check existing agents' },
{ status: 500 }
);
}
if (existingAgents && existingAgents.length > 0) {
return NextResponse.json(
{ error: 'Default agent already exists', agent_id: existingAgents[0].id },
{ status: 409 }
);
}
// 4. 构建 OnboardingAnswers 对象
const answers: OnboardingAnswers = {
future_self: onboardingData.question_1,
future_location: onboardingData.question_2,
current_worry: onboardingData.question_3,
important_person: onboardingData.question_4,
};
// 5. 获取用户信息用于生成系统提示词
const { data: userData } = await supabase
.from('users')
.select('language, subscription_status')
.eq('id', user.id)
.single();
// 6. 生成系统提示词
const systemPrompt = generateAgentSystemPrompt(answers, {
userName: user.email?.split('@')[0] || '我',
agentName: '十年后的自己',
letterWritingDate: new Date(onboardingData.created_at),
currentDate: new Date(),
language: userData?.language || 'zh-CN',
personalityType: 'warm', // 默认使用温暖类型
isPremium: userData?.subscription_status === 'premium',
});
// 7. 使用 Service Role 客户端创建智能体记录
const serviceRoleClient = createServiceRoleClient();
if (!serviceRoleClient) {
console.error('Service Role client not available');
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
// 8. 创建智能体记录
const { data: agent, error: insertError } = await serviceRoleClient
.from('agents')
.insert({
user_id: user.id,
name: '十年后的自己',
personality: {
traits: ['warm', 'wise', 'patient'],
tone: 'gentle',
communication_style: 'reflective',
response_length: 'medium',
emoji_usage: 'rare',
formality: 'casual',
},
background: generateAgentBackground(answers, {
letterWritingDate: new Date(onboardingData.created_at),
}),
system_prompt: systemPrompt,
is_custom: false,
is_default: true,
language: userData?.language || 'zh-CN',
})
.select()
.single();
if (insertError) {
console.error('Error creating agent:', insertError);
return NextResponse.json(
{ error: insertError.message },
{ status: 500 }
);
}
// 9. 更新用户 onboarding 状态(如果尚未更新)
const { error: updateError } = await supabase
.from('users')
.update({
has_completed_onboarding: true,
updated_at: new Date().toISOString(),
})
.eq('id', user.id);
if (updateError) {
console.error('Error updating user onboarding status:', updateError);
// 不应该失败,因为 agent 已经创建成功
}
console.log(`Agent generated successfully for user ${user.id}:`, agent.id);
return NextResponse.json({
data: agent,
message: 'Default agent generated successfully',
});
} catch (error) {
console.error('Error in agent generation:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
/**
*
* prompts/agent.ts
*/
function generateAgentBackground(
answers: OnboardingAnswers,
options?: { letterWritingDate?: Date }
): string {
const { future_self, future_location, current_worry, important_person } = answers;
const letterDate = options?.letterWritingDate || new Date();
// 简单的主题检测
const theme = detectTheme(current_worry);
const insights: Record<string, string[]> = {
career: ['路是一步一步走出来的,不是想出来的', '真正的成功不是达到某个终点'],
relationship: ['关系需要经营,但也要给自己空间', '爱是给予,也是接受'],
growth: ['所有的经历都是成长的养分', '你比你想象的更强大'],
health: ['身体是革命的本钱,善待自己'],
finance: ['金钱是工具,不是目的', '真正的财富是内心的平静'],
default: ['时间会给你答案', '所有的坚持都会有意义'],
};
const themeInsights = insights[theme] || insights.default;
const keyInsight = themeInsights[Math.floor(Math.random() * themeInsights.length)];
return `
${future_location}${future_self}
${letterDate.toLocaleDateString('zh-CN')}${current_worry}
${important_person}
${keyInsight}
`.trim();
}
/**
*
*/
function detectTheme(worry: string): string {
const worryLower = worry.toLowerCase();
const themeKeywords: Record<string, string[]> = {
career: ['工作', '职业', 'job', 'career', '事业', '升职', '加薪', '面试'],
relationship: ['感情', '恋爱', '关系', 'relationship', '爱情', '婚姻', '家庭'],
growth: ['成长', '迷茫', '焦虑', '困惑', '压力', 'anxiety', 'confused'],
health: ['健康', '身体', 'health', '失眠', '疲惫', '运动'],
finance: ['钱', '金钱', '财务', 'finance', 'money', '贷款', '债务'],
};
for (const [theme, keywords] of Object.entries(themeKeywords)) {
if (keywords.some(keyword => worryLower.includes(keyword.toLowerCase()))) {
return theme;
}
}
return 'default';
}

219
src/app/api/agents/route.ts Normal file
View File

@ -0,0 +1,219 @@
import { NextResponse } from 'next/server';
import { createServerSupabaseClient } from '@/lib/supabase/server';
import type { Agent } from '@/types';
/**
* GET /api/agents
*
*
*
*/
export async function GET(request: Request) {
try {
// 1. 验证用户身份
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
console.error('Auth error:', authError);
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// 2. 获取查询参数
const { searchParams } = new URL(request.url);
const includeDefault = searchParams.get('include_default') !== 'false'; // 默认包含
const isCustom = searchParams.get('is_custom');
const limit = parseInt(searchParams.get('limit') || '50', 10);
const offset = parseInt(searchParams.get('offset') || '0', 10);
// 3. 构建查询
let query = supabase
.from('agents')
.select(`
id,
user_id,
name,
personality,
background,
avatar_url,
is_custom,
is_default,
language,
system_prompt,
created_at,
updated_at
`)
.eq('user_id', user.id);
// 4. 过滤条件
if (!includeDefault) {
query = query.eq('is_custom', true);
}
if (isCustom !== null && isCustom !== undefined) {
query = query.eq('is_custom', isCustom === 'true');
}
// 5. 排序和分页
const { data: agents, error: fetchError } = await query
.order('is_default', { ascending: false }) // 默认智能体优先
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
if (fetchError) {
console.error('Error fetching agents:', fetchError);
return NextResponse.json(
{ error: fetchError.message },
{ status: 500 }
);
}
// 6. 获取总数
const { count: total, error: countError } = await supabase
.from('agents')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id);
if (countError) {
console.error('Error counting agents:', countError);
}
// 7. 获取默认智能体信息(如果没有)
let defaultAgent = null;
if (includeDefault) {
const { data: defaultAgentData } = await supabase
.from('agents')
.select(`
id,
user_id,
name,
personality,
background,
avatar_url,
is_custom,
is_default,
language,
system_prompt,
created_at,
updated_at
`)
.eq('user_id', user.id)
.eq('is_default', true)
.single();
defaultAgent = defaultAgentData;
}
// 8. 构建响应
const response = {
data: agents || [],
default_agent: defaultAgent,
pagination: {
total: total || 0,
limit,
offset,
has_more: agents ? agents.length === limit : false,
},
};
return NextResponse.json({
data: response,
message: 'Agents retrieved successfully',
});
} catch (error) {
console.error('Error in GET /api/agents:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
/**
* POST /api/agents
*
*
*
*/
export async function POST(request: Request) {
try {
// 1. 验证用户身份
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
console.error('Auth error:', authError);
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const body = await request.json();
const { name, personality, background, avatar_url, language, system_prompt } = body;
// 2. 验证必填字段
if (!name) {
return NextResponse.json(
{ error: 'Agent name is required' },
{ status: 400 }
);
}
// 3. 检查用户是否已有默认智能体
const { data: existingDefault } = await supabase
.from('agents')
.select('id')
.eq('user_id', user.id)
.eq('is_default', true)
.limit(1);
if (!existingDefault || existingDefault.length === 0) {
return NextResponse.json(
{ error: 'Please complete onboarding first to generate your default agent' },
{ status: 400 }
);
}
// 4. 创建自定义智能体
const { data: agent, error: insertError } = await supabase
.from('agents')
.insert({
user_id: user.id,
name: name,
personality: personality || {},
background: background || null,
avatar_url: avatar_url || null,
language: language || 'zh-CN',
system_prompt: system_prompt || null,
is_custom: true,
is_default: false,
})
.select()
.single();
if (insertError) {
console.error('Error creating custom agent:', insertError);
return NextResponse.json(
{ error: insertError.message },
{ status: 500 }
);
}
console.log(`Custom agent created for user ${user.id}:`, agent.id);
return NextResponse.json({
data: agent,
message: 'Custom agent created successfully',
});
} catch (error) {
console.error('Error in POST /api/agents:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,463 @@
import { createClient } from '@supabase/supabase-js';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
// Vercel Cron 请求验证 header
const CRON_SECRET = process.env.CRON_SECRET || 'vercel-cron-secret';
/**
* Vercel Cron
*/
function verifyCronRequest(request: NextRequest): boolean {
const cronKey = request.headers.get('x-vercel-cron');
return cronKey === CRON_SECRET;
}
/**
* Supabase Service Role
*/
function createServiceClient() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
return createClient(supabaseUrl, serviceRoleKey);
}
/**
*
*/
function log(level: string, message: string, data?: Record<string, unknown>) {
const timestamp = new Date().toISOString();
console.log(JSON.stringify({ timestamp, level, message, ...data }));
}
/**
*
*/
async function getActiveMilestones(
supabase: ReturnType<typeof createServiceClient>
) {
const { data, error } = await supabase
.from('milestones')
.select(`
*,
reward_stamp:stamp_definitions(id, code, name, image_url)
`)
.eq('is_active', true)
.order('condition_value', { ascending: true });
if (error) {
log('error', 'Failed to fetch milestones', { error: error.message });
return [];
}
return data || [];
}
/**
*
*/
async function getUserAchievements(
supabase: ReturnType<typeof createServiceClient>,
userId: string
): Promise<Set<string>> {
const { data, error } = await supabase
.from('achievements')
.select('milestone_id')
.eq('user_id', userId);
if (error) {
log('warn', 'Failed to fetch user achievements', { userId, error: error.message });
return new Set();
}
return new Set(data.map((a: { milestone_id: string }) => a.milestone_id));
}
/**
*
*/
async function checkRegistrationMilestone(
supabase: ReturnType<typeof createServiceClient>,
userId: string
): Promise<boolean> {
// 注册即达成,只需检查用户是否存在
return true;
}
/**
*
*/
async function checkLetterCountMilestone(
supabase: ReturnType<typeof createServiceClient>,
userId: string,
targetCount: number
): Promise<boolean> {
const { count, error } = await supabase
.from('letters')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)
.eq('status', 'replied');
if (error) {
log('warn', 'Failed to count letters', { userId, error: error.message });
return false;
}
return (count || 0) >= targetCount;
}
/**
*
*/
async function checkConsecutiveDaysMilestone(
supabase: ReturnType<typeof createServiceClient>,
userId: string,
targetDays: number
): Promise<boolean> {
// 获取用户最近的活跃记录
const { data: stats, error } = await supabase
.from('daily_stats')
.select('stat_date, last_active_at')
.eq('user_id', userId)
.order('stat_date', { ascending: false })
.limit(targetDays + 1);
if (error || !stats || stats.length === 0) {
return false;
}
// 检查是否连续
let consecutiveDays = 0;
const today = new Date().toISOString().split('T')[0];
for (let i = 0; i < stats.length; i++) {
const statDate = stats[i].stat_date;
const expectedDate = new Date(today);
expectedDate.setDate(expectedDate.getDate() - i);
const expectedDateStr = expectedDate.toISOString().split('T')[0];
if (statDate === expectedDateStr) {
consecutiveDays++;
} else {
break;
}
}
return consecutiveDays >= targetDays;
}
/**
*
*/
async function checkStampsCountMilestone(
supabase: ReturnType<typeof createServiceClient>,
userId: string,
targetCount: number
): Promise<boolean> {
const { count, error } = await supabase
.from('user_stamps')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId);
if (error) {
log('warn', 'Failed to count stamps', { userId, error: error.message });
return false;
}
return (count || 0) >= targetCount;
}
/**
*
*/
async function checkUserMilestones(
supabase: ReturnType<typeof createServiceClient>,
userId: string,
milestones: Awaited<ReturnType<typeof getActiveMilestones>>,
achievedMilestoneIds: Set<string>
): Promise<string[]> {
const newAchievements: string[] = [];
for (const milestone of milestones) {
// 跳过已完成的里程碑
if (achievedMilestoneIds.has(milestone.id)) {
continue;
}
let conditionMet = false;
switch (milestone.condition_type) {
case 'registration':
conditionMet = await checkRegistrationMilestone(supabase, userId);
break;
case 'letter_count':
conditionMet = await checkLetterCountMilestone(
supabase,
userId,
milestone.condition_value
);
break;
case 'consecutive_days':
conditionMet = await checkConsecutiveDaysMilestone(
supabase,
userId,
milestone.condition_value
);
break;
case 'stamps_count':
conditionMet = await checkStampsCountMilestone(
supabase,
userId,
milestone.condition_value
);
break;
default:
log('warn', 'Unknown milestone condition type', {
milestoneId: milestone.id,
conditionType: milestone.condition_type
});
}
if (conditionMet) {
await grantMilestone(supabase, userId, milestone);
newAchievements.push(milestone.id);
}
}
return newAchievements;
}
/**
*
*/
async function grantMilestone(
supabase: ReturnType<typeof createServiceClient>,
userId: string,
milestone: {
id: string;
name: string;
description?: string;
reward_stamp: { id: string } | null;
}
): Promise<void> {
try {
// 1. 创建成就记录
const { error: achievementError } = await supabase
.from('achievements')
.insert({
user_id: userId,
milestone_id: milestone.id,
achieved_at: new Date().toISOString(),
notification_sent: false
});
if (achievementError) {
// 如果是重复插入(并发问题),忽略
if (achievementError.code !== '23505') {
throw achievementError;
}
return;
}
log('info', 'Milestone achievement granted', {
userId,
milestoneId: milestone.id,
milestoneName: milestone.name
});
// 2. 如果有奖励邮票,发放给用户
if (milestone.reward_stamp) {
const { error: stampError } = await supabase
.from('user_stamps')
.insert({
user_id: userId,
stamp_def_id: milestone.reward_stamp.id,
source: 'achievement',
obtained_at: new Date().toISOString()
});
if (stampError) {
log('warn', 'Failed to grant achievement stamp', {
userId,
stampDefId: milestone.reward_stamp.id,
error: stampError.message
});
}
}
// 3. 发送成就通知
await sendAchievementNotification(supabase, userId, milestone);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
log('error', 'Failed to grant milestone', {
userId,
milestoneId: milestone.id,
error: message
});
}
}
/**
*
*/
async function sendAchievementNotification(
supabase: ReturnType<typeof createServiceClient>,
userId: string,
milestone: { name: string; description?: string }
): Promise<void> {
try {
const { data: user } = await supabase
.from('users')
.select('email')
.eq('id', userId)
.single();
if (!user?.email) {
log('warn', 'User has no email for notification', { userId });
return;
}
// 记录通知任务(实际发送由其他服务处理)
log('info', 'Sending achievement notification', {
userId,
milestoneName: milestone.name
});
// 可以在此调用 Supabase Edge Function 发送邮件
// 或使用消息队列异步处理
} catch (error) {
log('error', 'Failed to send achievement notification', { userId, error });
}
}
/**
*
*/
async function getUsersToCheck(
supabase: ReturnType<typeof createServiceClient>
): Promise<string[]> {
// 获取所有有活跃里程碑的用户
// 优先检查最近活跃的用户
const { data, error } = await supabase
.from('users')
.select('id')
.eq('email_confirmed', true)
.order('created_at', { ascending: false })
.limit(1000); // 限制每次检查的用户数量
if (error) {
log('error', 'Failed to fetch users', { error: error.message });
return [];
}
return data.map(u => u.id);
}
/**
* GET /api/cron/check-milestones
*
*/
export async function GET(request: NextRequest): Promise<NextResponse> {
// 验证 Cron 请求
if (!verifyCronRequest(request)) {
log('warn', 'Unauthorized cron request');
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const supabase = createServiceClient();
const startTime = Date.now();
try {
log('info', 'Starting milestone check');
// 1. 获取所有活跃里程碑
const milestones = await getActiveMilestones(supabase);
log('info', 'Found active milestones', { count: milestones.length });
if (milestones.length === 0) {
return NextResponse.json({
success: true,
message: 'No active milestones',
usersChecked: 0,
newAchievements: 0
});
}
// 2. 获取需要检查的用户
const userIds = await getUsersToCheck(supabase);
log('info', 'Found users to check', { count: userIds.length });
if (userIds.length === 0) {
return NextResponse.json({
success: true,
message: 'No users to check',
usersChecked: 0,
newAchievements: 0
});
}
// 3. 遍历用户检查里程碑
let usersChecked = 0;
let totalNewAchievements = 0;
const errors: Array<{ userId: string; error: string }> = [];
for (const userId of userIds) {
try {
// 获取用户已获得的成就
const achievedIds = await getUserAchievements(supabase, userId);
// 检查新成就
const newIds = await checkUserMilestones(
supabase,
userId,
milestones,
achievedIds
);
if (newIds.length > 0) {
totalNewAchievements += newIds.length;
log('info', 'User earned new achievements', {
userId,
achievementCount: newIds.length
});
}
usersChecked++;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
errors.push({ userId, error: message });
}
// 添加小延迟避免数据库压力
await new Promise(resolve => setTimeout(resolve, 20));
}
const duration = Date.now() - startTime;
log('info', 'Milestone check completed', {
usersChecked,
totalNewAchievements,
errors: errors.length,
duration: `${duration}ms`
});
return NextResponse.json({
success: true,
usersChecked,
totalNewAchievements,
errors: errors.length > 0 ? errors : undefined,
duration: `${duration}ms`
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
log('error', 'Milestone check cron job failed', { error: message });
return NextResponse.json(
{ success: false, error: message },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,276 @@
import { createClient } from '@supabase/supabase-js';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
// Vercel Cron 请求验证 header
const CRON_SECRET = process.env.CRON_SECRET || 'vercel-cron-secret';
/**
* Vercel Cron
*/
function verifyCronRequest(request: NextRequest): boolean {
const cronKey = request.headers.get('x-vercel-cron');
return cronKey === CRON_SECRET;
}
/**
* Supabase Service Role
*/
function createServiceClient() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
return createClient(supabaseUrl, serviceRoleKey);
}
/**
*
*/
function log(level: string, message: string, data?: Record<string, unknown>) {
const timestamp = new Date().toISOString();
console.log(JSON.stringify({ timestamp, level, message, ...data }));
}
/**
*
*/
const STAMP_GRANT_CONFIG = {
free: 1,
premium: 3
};
/**
*
*/
async function getDailyStampDef(supabase: ReturnType<typeof createServiceClient>) {
const { data, error } = await supabase
.from('stamp_definitions')
.select('id')
.eq('code', 'daily_default')
.eq('is_active', true)
.single();
if (error || !data) {
log('error', 'Daily stamp definition not found', { error });
return null;
}
return data.id;
}
/**
*
*/
async function hasUserReceivedStampsToday(
supabase: ReturnType<typeof createServiceClient>,
userId: string
): Promise<boolean> {
const today = new Date().toISOString().split('T')[0];
const { count, error } = await supabase
.from('daily_stats')
.select('*', { count: 'exact', head: true })
.eq('user_id', userId)
.eq('stat_date', today)
.gte('stamps_granted', 1);
if (error) {
log('warn', 'Failed to check daily stats', { userId, error: error.message });
return false;
}
return (count || 0) > 0;
}
/**
*
*/
async function grantDailyStamps(
supabase: ReturnType<typeof createServiceClient>,
user: {
id: string;
subscription_status: string;
},
stampDefId: string
): Promise<{ success: boolean; granted: number; error?: string }> {
try {
// 检查今日是否已发放
if (await hasUserReceivedStampsToday(supabase, user.id)) {
return { success: true, granted: 0 };
}
const stampCount = STAMP_GRANT_CONFIG[user.subscription_status as keyof typeof STAMP_GRANT_CONFIG] || 1;
// 批量插入邮票记录
const stampRecords = Array.from({ length: stampCount }, () => ({
user_id: user.id,
stamp_def_id: stampDefId,
source: 'daily_grant',
obtained_at: new Date().toISOString()
}));
const { error: insertError } = await supabase
.from('user_stamps')
.insert(stampRecords);
if (insertError) {
throw insertError;
}
// 更新或创建每日统计
const today = new Date().toISOString().split('T')[0];
const { error: statsError } = await supabase
.from('daily_stats')
.upsert({
user_id: user.id,
stat_date: today,
stamps_granted: stampCount,
stamps_used: 0,
last_active_at: new Date().toISOString()
}, {
onConflict: 'user_id, stat_date'
});
if (statsError) {
log('warn', 'Failed to update daily stats', { userId: user.id, error: statsError.message });
}
log('info', 'Stamps granted to user', { userId: user.id, count: stampCount });
return { success: true, granted: stampCount };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
log('error', 'Failed to grant stamps', { userId: user.id, error: message });
return { success: false, granted: 0, error: message };
}
}
/**
*
*/
async function getActiveUsers(supabase: ReturnType<typeof createServiceClient>) {
// 获取所有已确认邮箱的用户(排除未验证的)
const { data: users, error } = await supabase
.from('users')
.select('id, email, subscription_status')
.eq('email_confirmed', true);
if (error) {
throw error;
}
return users || [];
}
/**
*
*/
async function recordDailySummary(
supabase: ReturnType<typeof createServiceClient>,
totalUsers: number,
totalStampsGranted: number
): Promise<void> {
const today = new Date().toISOString().split('T')[0];
// 记录系统级别的每日统计
const { error } = await supabase
.from('system_stats')
.upsert({
stat_date: today,
stat_type: 'daily_stamps',
total_users: totalUsers,
total_stamps_granted: totalStampsGranted,
updated_at: new Date().toISOString()
}, {
onConflict: 'stat_date, stat_type'
});
if (error) {
log('warn', 'Failed to record system stats', { error: error.message });
}
}
/**
* GET /api/cron/daily-stamps
*
*/
export async function GET(request: NextRequest): Promise<NextResponse> {
// 验证 Cron 请求
if (!verifyCronRequest(request)) {
log('warn', 'Unauthorized cron request');
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const supabase = createServiceClient();
const startTime = Date.now();
try {
log('info', 'Starting daily stamps distribution');
// 1. 获取每日邮票定义
const stampDefId = await getDailyStampDef(supabase);
if (!stampDefId) {
return NextResponse.json(
{ success: false, error: 'Daily stamp definition not found' },
{ status: 500 }
);
}
// 2. 获取所有活跃用户
const users = await getActiveUsers(supabase);
log('info', 'Found active users', { count: users.length });
if (users.length === 0) {
return NextResponse.json({
success: true,
message: 'No active users',
usersProcessed: 0,
totalStampsGranted: 0
});
}
// 3. 批量处理用户邮票发放
let totalStampsGranted = 0;
let usersProcessed = 0;
const errors: Array<{ userId: string; error: string }> = [];
for (const user of users) {
const result = await grantDailyStamps(supabase, user, stampDefId);
if (result.success) {
usersProcessed++;
totalStampsGranted += result.granted;
} else if (result.error) {
errors.push({ userId: user.id, error: result.error });
}
// 添加小延迟避免数据库压力
await new Promise(resolve => setTimeout(resolve, 50));
}
// 4. 记录每日统计汇总
await recordDailySummary(supabase, users.length, totalStampsGranted);
const duration = Date.now() - startTime;
log('info', 'Daily stamps distribution completed', {
usersProcessed,
totalStampsGranted,
errors: errors.length,
duration: `${duration}ms`
});
return NextResponse.json({
success: true,
usersProcessed,
totalStampsGranted,
errors: errors.length > 0 ? errors : undefined,
duration: `${duration}ms`
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
log('error', 'Daily stamps cron job failed', { error: message });
return NextResponse.json(
{ success: false, error: message },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,352 @@
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
// Vercel Cron 请求验证 header
const CRON_SECRET = process.env.CRON_SECRET || 'vercel-cron-secret';
/**
* Vercel Cron
*/
function verifyCronRequest(request: NextRequest): boolean {
const cronKey = request.headers.get('x-vercel-cron');
return cronKey === CRON_SECRET;
}
/**
* Supabase Service Role
*/
function createServiceClient(): SupabaseClient {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
return createClient(supabaseUrl, serviceRoleKey);
}
/**
*
*/
function log(level: string, message: string, data?: Record<string, unknown>) {
const timestamp = new Date().toISOString();
console.log(JSON.stringify({ timestamp, level, message, ...data }));
}
/**
* - API
*/
const processingLetters = new Set<string>();
const RATE_LIMIT_WINDOW = 60000; // 1 分钟
const MAX_CONCURRENT = 5;
async function acquireRateLimit(letterId: string): Promise<boolean> {
if (processingLetters.size >= MAX_CONCURRENT) {
return false;
}
processingLetters.add(letterId);
setTimeout(() => processingLetters.delete(letterId), RATE_LIMIT_WINDOW);
return true;
}
/**
* DeepSeek API
*/
async function generateAIReply(
systemPrompt: string,
userContent: string,
language: string
): Promise<string> {
const deepseekApiKey = process.env.DEEPSEEK_API_KEY!;
const response = await fetch('https://api.deepseek.com/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${deepseekApiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'deepseek-chat',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userContent }
],
max_tokens: 1000,
temperature: 0.7
})
});
if (!response.ok) {
const error = await response.text();
log('error', 'DeepSeek API error', { status: response.status, error });
throw new Error(`DeepSeek API error: ${response.status}`);
}
const data = await response.json();
return data.choices[0]?.message?.content || '';
}
/**
*
*/
async function analyzeLetter(letterContent: string): Promise<{
mood_tags: string[];
topic_tags: string[];
keywords: string[];
sentiment_score: number;
}> {
const deepseekApiKey = process.env.DEEPSEEK_API_KEY!;
const analyzePrompt = `分析用户信件内容,输出 JSON 格式:
{
"mood_tags": ["主要心情标签最多3个"],
"topic_tags": ["涉及主题最多3个"],
"keywords": ["关键词语最多5个"],
"sentiment_score": -1.01.0
}
happy, hopeful, anxious, confused, peaceful, lonely, frustrated, excited, melancholic, grateful, determined, overwhelmed
career, relationship, family, health, growth, finance, education, creativity, lifestyle, social, personal_identity, life_transition
${letterContent}
JSON`;
const response = await fetch('https://api.deepseek.com/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${deepseekApiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'deepseek-chat',
messages: [{ role: 'user', content: analyzePrompt }],
max_tokens: 200,
temperature: 0.3
})
});
if (!response.ok) {
log('warn', 'Analyze API failed, using defaults');
return { mood_tags: [], topic_tags: [], keywords: [], sentiment_score: 0 };
}
const data = await response.json();
const content = data.choices[0]?.message?.content || '{}';
try {
// 清理 JSON 输出,可能包含 markdown 代码块
const cleanJson = content.replace(/```json?/g, '').replace(/```/g, '').trim();
return JSON.parse(cleanJson);
} catch (e) {
log('warn', 'Failed to parse analyze result', { content });
return { mood_tags: [], topic_tags: [], keywords: [], sentiment_score: 0 };
}
}
/**
*
*/
async function processLetter(
supabase: SupabaseClient,
letter: {
id: string;
user_id: string;
content: string;
agents: {
system_prompt: string;
name: string;
};
users: {
language: string;
};
}
): Promise<{ success: boolean; error?: string }> {
const { id, user_id, content, agents, users } = letter;
if (!await acquireRateLimit(id)) {
return { success: false, error: 'Rate limit exceeded' };
}
try {
log('info', 'Processing letter', { letterId: id });
// 1. 生成 AI 回复
const aiReply = await generateAIReply(
agents.system_prompt,
content,
users.language
);
// 2. 分析信件内容
const analysis = await analyzeLetter(content);
// 3. 更新信件状态
const { error: updateError } = await (supabase
.from('letters') as any)
.update({
ai_reply: aiReply,
status: 'replied' as const,
replied_at: new Date().toISOString(),
updated_at: new Date().toISOString()
})
.eq('id', id);
if (updateError) {
throw updateError;
}
// 4. 创建成长标签记录
const { error: tagError } = await (supabase
.from('growth_tags') as any)
.insert({
user_id,
letter_id: id,
mood_tags: analysis.mood_tags,
topic_tags: analysis.topic_tags,
keywords: analysis.keywords,
sentiment_score: analysis.sentiment_score
});
if (tagError) {
log('warn', 'Failed to create growth tags', { error: tagError.message });
}
// 5. 触发邮件通知(异步)
await sendReplyNotification(supabase, user_id, id);
log('info', 'Letter processed successfully', { letterId: id });
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
log('error', 'Failed to process letter', { letterId: id, error: message });
// 更新为失败状态
await (supabase
.from('letters') as any)
.update({
status: 'failed' as const,
updated_at: new Date().toISOString()
})
.eq('id', id);
return { success: false, error: message };
}
}
/**
*
*/
async function sendReplyNotification(
supabase: SupabaseClient,
userId: string,
letterId: string
): Promise<void> {
try {
const { data: user } = await (supabase
.from('users') as any)
.select('email')
.eq('id', userId)
.single();
const { data: letter } = await (supabase
.from('letters') as any)
.select('ai_reply')
.eq('id', letterId)
.single();
if (!user?.email || !letter?.ai_reply) {
log('warn', 'Missing email or reply for notification', { userId, letterId });
return;
}
// 使用 Supabase Email 发送通知
// 这里调用 Supabase Edge Function 或自定义邮件服务
log('info', 'Sending reply notification', { userId, letterId });
// 实际邮件发送逻辑需要在 Supabase Edge Function 中实现
// 或使用其他邮件服务(如 Resend、SendGrid
} catch (error) {
log('error', 'Failed to send notification', { userId, error });
}
}
/**
* GET /api/cron/process-letters
*
*/
export async function GET(request: NextRequest): Promise<NextResponse> {
// 验证 Cron 请求
if (!verifyCronRequest(request)) {
log('warn', 'Unauthorized cron request');
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const supabase = createServiceClient();
const startTime = Date.now();
try {
// 1. 获取所有待处理信件
const { data: pendingLetters, error } = await supabase
.from('letters')
.select(`
*,
agents (system_prompt, name),
users (language)
`)
.eq('status', 'pending')
.lte('scheduled_at', new Date().toISOString())
.limit(20);
if (error) {
throw error;
}
if (!pendingLetters || pendingLetters.length === 0) {
log('info', 'No pending letters to process');
return NextResponse.json({
success: true,
message: 'No pending letters',
processed: 0,
failed: 0
});
}
log('info', 'Found pending letters', { count: pendingLetters.length });
// 2. 批量处理信件
const results = await Promise.allSettled(
pendingLetters.map(letter => processLetter(supabase, letter))
);
const successful = results.filter(
(r): r is PromiseFulfilledResult<{ success: true }> =>
r.status === 'fulfilled' && r.value.success
).length;
const failed = results.length - successful;
const duration = Date.now() - startTime;
log('info', 'Batch processing completed', {
total: results.length,
successful,
failed,
duration: `${duration}ms`
});
return NextResponse.json({
success: true,
processed: successful,
failed,
duration: `${duration}ms`
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
log('error', 'Cron job failed', { error: message });
return NextResponse.json(
{ success: false, error: message },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { getDictionary } from '@/get-dictionary';
import type { Locale } from '@/i18n-config';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const locale = searchParams.get('locale') as Locale || 'zh-CN';
try {
const dict = await getDictionary(locale);
return NextResponse.json(dict);
} catch {
return NextResponse.json({ error: 'Failed to load dictionary' }, { status: 500 });
}
}

View File

@ -0,0 +1,81 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import type { OnboardingAnswers } from '@/types';
export async function POST(request: Request) {
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const body = await request.json();
const { question_1, question_2, question_3, question_4 } = body;
// Validate required fields
if (!question_1 || !question_2 || !question_3 || !question_4) {
return NextResponse.json(
{ error: 'All questions must be answered' },
{ status: 400 }
);
}
// Insert onboarding answers
const { data, error } = await supabase
.from('onboarding_answers')
.upsert(
{
user_id: user.id,
question_1,
question_2,
question_3,
question_4,
updated_at: new Date().toISOString(),
},
{
onConflict: 'user_id',
ignoreDuplicates: false,
}
)
.select()
.single();
if (error) {
console.error('Error saving onboarding answers:', error);
return NextResponse.json(
{ error: error.message },
{ status: 500 }
);
}
// Update user profile to mark onboarding as completed
const { error: profileError } = await supabase
.from('users')
.update({
has_completed_onboarding: true,
updated_at: new Date().toISOString(),
})
.eq('id', user.id);
if (profileError) {
console.error('Error updating user profile:', profileError);
// Don't fail the request, onboarding answers are saved
}
return NextResponse.json({
data,
message: 'Onboarding completed successfully',
});
} catch (error) {
console.error('Error in onboarding submit:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

97
src/app/globals.css Normal file
View File

@ -0,0 +1,97 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
/* Onboarding Animations */
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes float {
0%, 100% {
transform: translateY(0) rotate(0deg);
opacity: 0.6;
}
50% {
transform: translateY(-20px) rotate(10deg);
opacity: 1;
}
}
@keyframes blob {
0% {
transform: translate(0px, 0px) scale(1);
}
33% {
transform: translate(30px, -50px) scale(1.1);
}
66% {
transform: translate(-20px, 20px) scale(0.9);
}
100% {
transform: translate(0px, 0px) scale(1);
}
}
@keyframes bounce-gentle {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
.animate-fade-in-up {
animation: fade-in-up 0.6s ease-out forwards;
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
.animate-blob {
animation: blob 7s infinite;
}
.animate-bounce-gentle {
animation: bounce-gentle 2s ease-in-out infinite;
}
.animation-delay-2000 {
animation-delay: 2s;
}
.animation-delay-4000 {
animation-delay: 4s;
}

1
src/app/layout.tsx Normal file
View File

@ -0,0 +1 @@
export { default } from "./[locale]/layout";

10
src/app/page.tsx Normal file
View File

@ -0,0 +1,10 @@
import { redirect } from "next/navigation";
import { i18n } from "@/i18n-config";
export function generateStaticParams() {
return i18n.locales.map((locale) => ({ locale }));
}
export default function Home() {
redirect(`/${i18n.defaultLocale}`);
}

View File

@ -0,0 +1,179 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import {
Bot,
Sparkles,
Calendar,
MessageCircle,
MoreVertical,
Settings,
Trash2,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils';
import type { Agent } from '@/types';
interface AgentCardProps {
agent: Agent;
locale: string;
isPremium?: boolean;
onDelete?: (agent: Agent) => void;
}
const toneLabels: Record<string, string> = {
gentle: '温柔',
professional: '专业',
warm: '温暖',
wise: '智慧',
};
export function AgentCard({ agent, locale, isPremium = false, onDelete }: AgentCardProps) {
const [showMenu, setShowMenu] = useState(false);
const handleDelete = () => {
if (confirm('确定要删除这个智能体吗?')) {
onDelete?.(agent);
}
setShowMenu(false);
};
return (
<Card
variant={agent.is_default ? 'elevated' : 'default'}
className={cn(
'relative overflow-hidden transition-all duration-300 hover:shadow-lg hover:-translate-y-1',
agent.is_default && 'ring-2 ring-purple-200 bg-gradient-to-br from-purple-50 to-white'
)}
>
{/* Default badge */}
{agent.is_default && (
<div className="absolute top-0 right-0 bg-gradient-to-r from-purple-500 to-pink-500 text-white text-xs px-3 py-1 rounded-bl-lg flex items-center gap-1">
<Sparkles className="w-3 h-3" />
</div>
)}
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
{/* Avatar */}
<div
className={cn(
'w-14 h-14 rounded-full flex items-center justify-center text-2xl',
agent.is_default
? 'bg-gradient-to-br from-purple-400 to-pink-400 text-white'
: 'bg-gray-100 text-gray-600'
)}
>
{agent.avatar_url ? (
<img
src={agent.avatar_url}
alt={agent.name}
className="w-full h-full rounded-full object-cover"
/>
) : (
<Bot className="w-7 h-7" />
)}
</div>
<div>
<CardTitle className="text-lg flex items-center gap-2">
{agent.name}
{agent.is_custom && (
<span className="text-xs bg-blue-100 text-blue-600 px-2 py-0.5 rounded-full">
</span>
)}
</CardTitle>
<CardDescription className="text-sm text-gray-500">
{agent.is_default
? '根据你的回答生成的专属智能体'
: `创建于 ${new Date(agent.created_at).toLocaleDateString()}`}
</CardDescription>
</div>
</div>
{/* Menu button for custom agents */}
{agent.is_custom && (
<div className="relative">
<button
onClick={() => setShowMenu(!showMenu)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<MoreVertical className="w-4 h-4 text-gray-400" />
</button>
{showMenu && (
<div className="absolute right-0 top-full mt-1 w-32 bg-white rounded-lg shadow-lg border border-gray-100 py-1 z-10">
<button className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2">
<Settings className="w-4 h-4" />
</button>
<button
onClick={handleDelete}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
</div>
)}
</div>
</CardHeader>
<CardContent className="pt-0">
{/* Personality traits */}
{agent.personality && agent.personality.traits && agent.personality.traits.length > 0 && (
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
</span>
<span className="text-xs px-2 py-0.5 bg-purple-100 text-purple-600 rounded-full">
{toneLabels[agent.personality.tone] || '温暖'}
</span>
</div>
<div className="flex flex-wrap gap-2">
{agent.personality.traits.slice(0, 4).map((trait, index) => (
<span
key={index}
className="text-xs px-2 py-1 bg-gray-100 text-gray-600 rounded-md"
>
{trait}
</span>
))}
</div>
</div>
)}
{/* Background */}
{agent.background && (
<p className="text-sm text-gray-600 mb-4 line-clamp-2">
{agent.background}
</p>
)}
{/* Actions */}
<div className="flex gap-2 mt-4 pt-4 border-t border-gray-100">
<Link href={`/${locale}/letters/new?agent_id=${agent.id}`} className="flex-1">
<Button variant="primary" size="sm" className="w-full">
<MessageCircle className="w-4 h-4" />
</Button>
</Link>
<Link href={`/${locale}/agents/${agent.id}`} className="flex-1">
<Button variant="outline" size="sm" className="w-full">
<Calendar className="w-4 h-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,200 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Plus, Users, AlertCircle, Loader2 } from 'lucide-react';
import { AgentCard } from './AgentCard';
import { Button } from '@/components/ui/Button';
import type { Agent } from '@/types';
interface AgentListProps {
locale: string;
isPremium?: boolean;
onCreateAgent?: () => void;
}
interface AgentsResponse {
data: {
data: Agent[];
default_agent: Agent | null;
pagination: {
total: number;
limit: number;
offset: number;
has_more: boolean;
};
};
message?: string;
}
export function AgentList({ locale, isPremium = false, onCreateAgent }: AgentListProps) {
const [agents, setAgents] = useState<Agent[]>([]);
const [defaultAgent, setDefaultAgent] = useState<Agent | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const fetchAgents = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await fetch('/api/agents');
if (!response.ok) {
if (response.status === 401) {
window.location.href = `/${locale}/login`;
return;
}
throw new Error('获取智能体列表失败');
}
const data: AgentsResponse = await response.json();
setAgents(data.data.data || []);
setDefaultAgent(data.data.default_agent);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
} finally {
setLoading(false);
}
}, [locale]);
useEffect(() => {
fetchAgents();
}, [fetchAgents]);
const handleDelete = async (agent: Agent) => {
if (!agent.is_custom) return;
try {
setDeletingId(agent.id);
const response = await fetch(`/api/agents?id=${agent.id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('删除失败');
}
// Refresh the list
fetchAgents();
} catch (err) {
alert(err instanceof Error ? err.message : '删除失败');
} finally {
setDeletingId(null);
}
};
// Combine default agent with custom agents, ensuring default is first
const allAgents = defaultAgent
? [defaultAgent, ...agents.filter((a) => a.id !== defaultAgent.id)]
: agents;
// Calculate remaining slots
const maxAgents = isPremium ? 3 : 1;
const remainingSlots = maxAgents - allAgents.length;
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-purple-500" />
<span className="ml-3 text-gray-500">...</span>
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<AlertCircle className="w-12 h-12 text-red-400 mb-4" />
<p className="text-gray-600 mb-4">{error}</p>
<Button onClick={fetchAgents} variant="outline">
</Button>
</div>
);
}
return (
<div className="space-y-6">
{/* Header with create button */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="w-5 h-5 text-purple-500" />
<h2 className="text-xl font-semibold text-gray-900">
({allAgents.length})
</h2>
</div>
{remainingSlots > 0 && (
<Button
onClick={onCreateAgent}
variant="primary"
size="md"
disabled={deletingId !== null}
>
<Plus className="w-4 h-4" />
</Button>
)}
</div>
{/* Agent limit warning */}
{remainingSlots === 0 && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-amber-800 font-medium"></p>
<p className="text-sm text-amber-600 mt-1">
{isPremium
? '您已达到 3 个智能体的上限。如需更多,请升级套餐。'
: '免费用户最多只能拥有 1 个智能体。升级到高级版可创建最多 3 个智能体。'}
</p>
</div>
</div>
)}
{/* Empty state */}
{allAgents.length === 0 && (
<div className="text-center py-16 bg-gray-50 rounded-xl">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-purple-100 flex items-center justify-center">
<Users className="w-8 h-8 text-purple-500" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
</h3>
<p className="text-gray-500 mb-6">
onboarding
</p>
<Button onClick={onCreateAgent} variant="primary">
</Button>
</div>
)}
{/* Agent grid */}
{allAgents.length > 0 && (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-2">
{allAgents.map((agent) => (
<AgentCard
key={agent.id}
agent={agent}
locale={locale}
isPremium={isPremium}
onDelete={handleDelete}
/>
))}
</div>
)}
{/* Loading overlay for delete */}
{deletingId && (
<div className="fixed inset-0 bg-black/20 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 flex items-center gap-3">
<Loader2 className="w-5 h-5 animate-spin text-purple-500" />
<span>...</span>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,2 @@
export { AgentCard } from './AgentCard';
export { AgentList } from './AgentList';

View File

@ -0,0 +1,243 @@
'use client';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Card, CardContent, CardHeader } from '@/components/ui/Card';
import { createBrowserSupabaseClient } from '@/lib/supabase/client';
import { cn } from '@/lib/utils';
import { Loader2, Mail, Lock, Eye, EyeOff, ArrowRight } from 'lucide-react';
import type { Dictionary } from '@/get-dictionary';
interface LoginFormProps {
dictionary: Dictionary['auth'];
}
const loginSchema = z.object({
email: z.string().email('invalid_email'),
password: z.string().min(1, 'password_required'),
});
type LoginFormData = z.infer<typeof loginSchema>;
export function LoginForm({ dictionary }: LoginFormProps) {
const params = useParams();
const locale = params.locale as string;
const router = useRouter();
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginFormData) => {
setIsLoading(true);
setError(null);
const supabase = createBrowserSupabaseClient();
try {
const { error: authError } = await supabase.auth.signInWithPassword({
email: data.email,
password: data.password,
});
if (authError) {
setError(authError.message);
} else {
router.push(`/${locale}/onboarding`);
}
} catch (err) {
setError(err instanceof Error ? err.message : dictionary.loginFailed || 'Login failed');
} finally {
setIsLoading(false);
}
};
const handleGoogleLogin = async () => {
setIsLoading(true);
const supabase = createBrowserSupabaseClient();
try {
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/${locale}/callback`,
},
});
if (error) {
setError(error.message);
}
} catch (err) {
setError(err instanceof Error ? err.message : dictionary.loginFailed || 'Google login failed');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-pink-50 via-purple-50 to-indigo-50">
<div className="w-full max-w-md">
{/* Logo / Brand */}
<div className="text-center mb-8">
<Link href={`/${locale}`} className="inline-block">
<h1 className="text-4xl font-bold bg-gradient-to-r from-pink-500 via-purple-500 to-indigo-500 bg-clip-text text-transparent">
{dictionary.title}
</h1>
</Link>
<p className="mt-2 text-gray-600">{dictionary.subtitle}</p>
</div>
<Card className="border-0 shadow-xl bg-white/80 backdrop-blur-sm">
<CardHeader className="space-y-1 pb-4">
<h2 className="text-2xl font-semibold text-center text-gray-900">
{dictionary.signIn}
</h2>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{error && (
<div className="p-3 rounded-lg bg-red-50 text-red-600 text-sm">
{error}
</div>
)}
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
{...register('email')}
type="email"
placeholder={dictionary.email}
className={cn(
'w-full pl-10 pr-4 py-3 rounded-lg border border-gray-200 bg-white',
'placeholder:text-gray-400',
'transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent',
errors.email && 'border-red-500 focus:ring-red-500'
)}
/>
{errors.email && (
<p className="mt-1 text-sm text-red-500">{dictionary.emailRequired}</p>
)}
</div>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
{...register('password')}
type={showPassword ? 'text' : 'password'}
placeholder={dictionary.password}
className={cn(
'w-full pl-10 pr-12 py-3 rounded-lg border border-gray-200 bg-white',
'placeholder:text-gray-400',
'transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent',
errors.password && 'border-red-500 focus:ring-red-500'
)}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showPassword ? (
<EyeOff className="h-5 w-5" />
) : (
<Eye className="h-5 w-5" />
)}
</button>
{errors.password && (
<p className="mt-1 text-sm text-red-500">{dictionary.passwordRequired}</p>
)}
</div>
<div className="flex justify-end">
<Link
href={`/${locale}/forgot-password`}
className="text-sm text-purple-600 hover:text-purple-700 transition-colors"
>
{dictionary.forgotPassword}
</Link>
</div>
<Button
type="submit"
variant="primary"
size="lg"
className="w-full"
isLoading={isLoading}
>
{dictionary.signIn}
{!isLoading && <ArrowRight className="h-4 w-4 ml-2" />}
</Button>
</form>
{/* Divider */}
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-4 bg-white text-gray-500"></span>
</div>
</div>
{/* Social Login */}
<Button
type="button"
variant="outline"
size="lg"
className="w-full"
onClick={handleGoogleLogin}
disabled={isLoading}
>
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
{dictionary.signInWithGoogle}
</Button>
{/* Sign up link */}
<p className="mt-6 text-center text-sm text-gray-600">
{dictionary.noAccount}{' '}
<Link
href={`/${locale}/register`}
className="text-purple-600 hover:text-purple-700 font-medium transition-colors"
>
{dictionary.signUp}
</Link>
</p>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,321 @@
'use client';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { Button } from '@/components/ui/Button';
import { Card, CardContent, CardHeader } from '@/components/ui/Card';
import { createBrowserSupabaseClient } from '@/lib/supabase/client';
import { cn } from '@/lib/utils';
import { Loader2, Mail, Lock, Eye, EyeOff, ArrowRight, Check } from 'lucide-react';
import type { Dictionary } from '@/get-dictionary';
interface RegisterFormProps {
dictionary: Dictionary['auth'];
}
const registerSchema = z
.object({
email: z.string().email('invalid_email'),
password: z.string().min(6, 'password_min_length'),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'password_mismatch',
path: ['confirmPassword'],
});
type RegisterFormData = z.infer<typeof registerSchema>;
export function RegisterForm({ dictionary }: RegisterFormProps) {
const params = useParams();
const locale = params.locale as string;
const router = useRouter();
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [agreedToTerms, setAgreedToTerms] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
});
const onSubmit = async (data: RegisterFormData) => {
if (!agreedToTerms) {
setError(dictionary.termsRequired || 'Please agree to the terms');
return;
}
setIsLoading(true);
setError(null);
const supabase = createBrowserSupabaseClient();
try {
const { error: authError } = await supabase.auth.signUp({
email: data.email,
password: data.password,
options: {
data: {
language: locale,
},
},
});
if (authError) {
setError(authError.message);
} else {
router.push(`/${locale}/onboarding`);
}
} catch (err) {
setError(err instanceof Error ? err.message : dictionary.registrationFailed || 'Registration failed');
} finally {
setIsLoading(false);
}
};
const handleGoogleLogin = async () => {
setIsLoading(true);
const supabase = createBrowserSupabaseClient();
try {
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/${locale}/callback`,
},
});
if (error) {
setError(error.message);
}
} catch (err) {
setError(err instanceof Error ? err.message : dictionary.registrationFailed || 'Google signup failed');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-pink-50 via-purple-50 to-indigo-50">
<div className="w-full max-w-md">
{/* Logo / Brand */}
<div className="text-center mb-8">
<Link href={`/${locale}`} className="inline-block">
<h1 className="text-4xl font-bold bg-gradient-to-r from-pink-500 via-purple-500 to-indigo-500 bg-clip-text text-transparent">
{dictionary.signUpTitle.split(' ')[0]}
</h1>
</Link>
<p className="mt-2 text-gray-600">{dictionary.signUpSubtitle}</p>
</div>
<Card className="border-0 shadow-xl bg-white/80 backdrop-blur-sm">
<CardHeader className="space-y-1 pb-4">
<h2 className="text-2xl font-semibold text-center text-gray-900">
{dictionary.signUp}
</h2>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{error && (
<div className="p-3 rounded-lg bg-red-50 text-red-600 text-sm">
{error}
</div>
)}
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
{...register('email')}
type="email"
placeholder={dictionary.email}
className={cn(
'w-full pl-10 pr-4 py-3 rounded-lg border border-gray-200 bg-white',
'placeholder:text-gray-400',
'transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent',
errors.email && 'border-red-500 focus:ring-red-500'
)}
/>
{errors.email && (
<p className="mt-1 text-sm text-red-500">{dictionary.emailRequired}</p>
)}
</div>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
{...register('password')}
type={showPassword ? 'text' : 'password'}
placeholder={dictionary.password}
className={cn(
'w-full pl-10 pr-12 py-3 rounded-lg border border-gray-200 bg-white',
'placeholder:text-gray-400',
'transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent',
errors.password && 'border-red-500 focus:ring-red-500'
)}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showPassword ? (
<EyeOff className="h-5 w-5" />
) : (
<Eye className="h-5 w-5" />
)}
</button>
{errors.password && (
<p className="mt-1 text-sm text-red-500">{dictionary.passwordMinLength || 'Password must be at least 6 characters'}</p>
)}
</div>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
{...register('confirmPassword')}
type={showConfirmPassword ? 'text' : 'password'}
placeholder={dictionary.confirmPassword}
className={cn(
'w-full pl-10 pr-12 py-3 rounded-lg border border-gray-200 bg-white',
'placeholder:text-gray-400',
'transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent',
errors.confirmPassword && 'border-red-500 focus:ring-red-500'
)}
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showConfirmPassword ? (
<EyeOff className="h-5 w-5" />
) : (
<Eye className="h-5 w-5" />
)}
</button>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-500">{dictionary.passwordMismatch}</p>
)}
</div>
{/* Terms checkbox */}
<div className="flex items-start gap-2">
<button
type="button"
onClick={() => setAgreedToTerms(!agreedToTerms)}
className={cn(
'flex-shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center transition-all',
agreedToTerms
? 'bg-purple-500 border-purple-500 text-white'
: 'border-gray-300 hover:border-purple-400'
)}
>
{agreedToTerms && <Check className="h-3 w-3" />}
</button>
<input
type="checkbox"
id="terms"
checked={agreedToTerms}
onChange={() => setAgreedToTerms(!agreedToTerms)}
className="sr-only"
/>
<label htmlFor="terms" className="text-sm text-gray-600">
{dictionary.agreeTerms}
<Link
href="/terms"
className="text-purple-600 hover:text-purple-700 mx-1"
>
{dictionary.termsOfService}
</Link>
{dictionary.and}
<Link
href="/privacy"
className="text-purple-600 hover:text-purple-700 mx-1"
>
{dictionary.privacyPolicy}
</Link>
</label>
</div>
<Button
type="submit"
variant="primary"
size="lg"
className="w-full"
isLoading={isLoading}
disabled={!agreedToTerms}
>
{dictionary.signUp}
{!isLoading && <ArrowRight className="h-4 w-4 ml-2" />}
</Button>
</form>
{/* Divider */}
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-4 bg-white text-gray-500"></span>
</div>
</div>
{/* Social Login */}
<Button
type="button"
variant="outline"
size="lg"
className="w-full"
onClick={handleGoogleLogin}
disabled={isLoading}
>
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
{dictionary.signInWithGoogle}
</Button>
{/* Sign in link */}
<p className="mt-6 text-center text-sm text-gray-600">
{dictionary.hasAccount}{' '}
<Link
href={`/${locale}/login`}
className="text-purple-600 hover:text-purple-700 font-medium transition-colors"
>
{dictionary.signIn}
</Link>
</p>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,72 @@
'use client';
import { cn } from '@/lib/utils';
interface OnboardingProgressProps {
currentStep: number;
totalSteps: number;
className?: string;
}
export function OnboardingProgress({
currentStep,
totalSteps,
className,
}: OnboardingProgressProps) {
const steps = Array.from({ length: totalSteps }, (_, i) => i + 1);
return (
<div className={cn('flex items-center justify-center gap-2', className)}>
{steps.map((step, index) => {
const isCompleted = step <= currentStep;
const isCurrent = step === currentStep;
return (
<div key={step} className="flex items-center">
{/* Step circle */}
<div
className={cn(
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium',
'transition-all duration-300',
isCompleted
? 'bg-gradient-to-r from-pink-500 to-purple-500 text-white'
: 'bg-gray-100 text-gray-400',
isCurrent && 'ring-2 ring-pink-200 ring-offset-2'
)}
>
{isCompleted ? (
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
) : (
step
)}
</div>
{/* Connector line */}
{index < steps.length - 1 && (
<div
className={cn(
'w-12 h-0.5 mx-1 transition-all duration-300',
step < currentStep
? 'bg-gradient-to-r from-pink-500 to-purple-500'
: 'bg-gray-200'
)}
/>
)}
</div>
);
})}
</div>
);
}

View File

@ -0,0 +1,151 @@
'use client';
import { useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { Textarea } from '@/components/ui/Textarea';
import { Button } from '@/components/ui/Button';
import { useOnboardingStore } from '@/store';
import { OnboardingProgress } from './OnboardingProgress';
import type { Dictionary } from '@/get-dictionary';
interface QuestionCardProps {
question: string;
questionKey: 'question_1' | 'question_2' | 'question_3' | 'question_4';
step: number;
dict: Dictionary['onboarding'];
placeholder: string;
encouragement?: string;
onNext: () => void;
onBack?: () => void;
}
interface FormData {
answer: string;
}
export function QuestionCard({
question,
questionKey,
step,
dict,
placeholder,
encouragement,
onNext,
onBack,
}: QuestionCardProps) {
const { answers, setAnswer, setSubmitting } = useOnboardingStore();
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<FormData>({
defaultValues: {
answer: answers[questionKey] || '',
},
});
const answerValue = watch('answer');
const onSubmit = useCallback(
async (data: FormData) => {
if (!data.answer.trim()) return;
setAnswer(questionKey, data.answer);
setSubmitting(true);
// Simulate API call delay for smooth transition
await new Promise((resolve) => setTimeout(resolve, 300));
setSubmitting(false);
onNext();
},
[questionKey, setAnswer, setSubmitting, onNext]
);
return (
<div className="w-full max-w-xl mx-auto">
{/* Progress indicator */}
<div className="mb-8">
<OnboardingProgress currentStep={step} totalSteps={4} />
</div>
{/* Question card */}
<div className="bg-white rounded-2xl shadow-lg p-8 md:p-10">
{/* Decorative gradient line */}
<div className="h-1 w-20 bg-gradient-to-r from-pink-500 to-purple-500 rounded-full mb-6" />
{/* Question */}
<h2 className="text-2xl md:text-3xl font-semibold text-gray-900 mb-4 leading-tight">
{question}
</h2>
{/* Encouragement */}
{encouragement && (
<p className="text-gray-500 mb-6 text-lg">{encouragement}</p>
)}
{/* Form */}
<form onSubmit={handleSubmit(onSubmit)}>
<Textarea
{...register('answer', {
required: dict.answerRequired,
})}
placeholder={placeholder}
className="min-h-[150px] text-lg"
error={errors.answer?.message}
/>
{/* Actions */}
<div className="flex items-center justify-between mt-6">
{onBack ? (
<Button type="button" variant="ghost" onClick={onBack}>
{dict.back}
</Button>
) : (
<div />
)}
<div className="flex items-center gap-4">
{/* Word count hint */}
<span
className={`text-sm ${
answerValue.length > 500
? 'text-orange-500'
: 'text-gray-400'
}`}
>
{answerValue.length}
</span>
<Button type="submit" disabled={!answerValue.trim()}>
{dict.next}
<svg
className="w-4 h-4 ml-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 5l7 7-7 7"
/>
</svg>
</Button>
</div>
</div>
</form>
</div>
{/* Encouraging message at bottom */}
<div className="mt-8 text-center">
<p className="text-gray-400 text-sm animate-pulse">
{dict.keepThinking}
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,141 @@
'use client';
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { QuestionCard } from './QuestionCard';
import { OnboardingProgress } from './OnboardingProgress';
import { Button } from '@/components/ui/Button';
import { useOnboardingStore } from '@/store';
import type { Dictionary } from '@/get-dictionary';
import type { Locale } from '@/i18n-config';
interface Question {
key: 'question_1' | 'question_2' | 'question_3' | 'question_4';
question: string;
placeholder: string;
encouragement: string;
}
interface QuestionnaireProps {
dict: Dictionary['onboarding'];
locale: Locale;
}
const QUESTIONS: Omit<Question, 'question' | 'placeholder' | 'encouragement'>[] = [
{ key: 'question_1' },
{ key: 'question_2' },
{ key: 'question_3' },
{ key: 'question_4' },
];
const ENCOURAGEMENTS: Record<string, Record<string, string>> = {
'zh-CN': {
question_1: '想象一下,十年后的你正在过着理想中的生活...',
question_2: '那里有怎样的风景,谁陪伴在你身边?',
question_3: '说出来吧,承认困扰是解决问题的第一步',
question_4: '他们是你生命中最温暖的力量',
},
'zh-TW': {
question_1: '想像一下,十年後的你正在過著理想中的生活...',
question_2: '那裡有怎樣的風景,誰陪伴在你身邊?',
question_3: '說出來吧,承認困擾是解決問題的第一步',
question_4: '他們是你生命中最溫暖的力量',
},
'en-US': {
question_1: 'Imagine yourself living your ideal life in 10 years...',
question_2: 'What kind of scenery surrounds you? Who is by your side?',
question_3: 'Speak it out - acknowledging concerns is the first step',
question_4: 'They are the warmest force in your life',
},
};
export function Questionnaire({ dict, locale }: QuestionnaireProps) {
const router = useRouter();
const { currentStep, setCurrentStep, answers, setAnswer, setSubmitting, isSubmitting } =
useOnboardingStore();
const encouragements = ENCOURAGEMENTS[locale] || ENCOURAGEMENTS['zh-CN'];
const questions: Question[] = [
{
key: 'question_1',
question: dict.question1,
placeholder: dict.placeholder,
encouragement: encouragements.question_1,
},
{
key: 'question_2',
question: dict.question2,
placeholder: dict.placeholder,
encouragement: encouragements.question_2,
},
{
key: 'question_3',
question: dict.question3,
placeholder: dict.placeholder,
encouragement: encouragements.question_3,
},
{
key: 'question_4',
question: dict.question4,
placeholder: dict.placeholder,
encouragement: encouragements.question_4,
},
];
const currentQuestion = questions[currentStep];
const handleNext = useCallback(async () => {
if (currentStep < questions.length - 1) {
setCurrentStep(currentStep + 1);
} else {
// Submit all answers and navigate to complete page
setSubmitting(true);
try {
const response = await fetch('/api/onboarding/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(answers),
});
if (response.ok) {
router.push(`/${locale}/onboarding/complete`);
} else {
// Fallback: navigate to complete page even if API fails
router.push(`/${locale}/onboarding/complete`);
}
} catch {
// Fallback: navigate to complete page even if API fails
router.push(`/${locale}/onboarding/complete`);
} finally {
setSubmitting(false);
}
}
}, [currentStep, setCurrentStep, setSubmitting, answers, router, locale]);
const handleBack = useCallback(() => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
}, [currentStep, setCurrentStep]);
if (currentStep >= questions.length) {
return null;
}
return (
<div className="min-h-screen bg-gradient-to-b from-pink-50 via-purple-50 to-white py-12 px-4">
<QuestionCard
question={currentQuestion.question}
questionKey={currentQuestion.key}
step={currentStep + 1}
dict={dict}
placeholder={currentQuestion.placeholder}
encouragement={currentQuestion.encouragement}
onNext={handleNext}
onBack={currentStep > 0 ? handleBack : undefined}
/>
</div>
);
}

View File

@ -0,0 +1,3 @@
export { OnboardingProgress } from './OnboardingProgress';
export { QuestionCard } from './QuestionCard';
export { Questionnaire } from './Questionnaire';

View File

@ -0,0 +1,51 @@
'use client';
import { forwardRef } from 'react';
import { cn } from '@/lib/utils';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'primary', size = 'md', isLoading, children, disabled, ...props }, ref) => {
const baseStyles = 'inline-flex items-center justify-center rounded-lg font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
const variants = {
primary: 'bg-gradient-to-r from-pink-500 to-purple-500 text-white hover:from-pink-600 hover:to-purple-600 focus:ring-purple-500',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500',
outline: 'border-2 border-gray-200 text-gray-700 hover:bg-gray-50 focus:ring-gray-500',
ghost: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 focus:ring-gray-500',
danger: 'bg-red-500 text-white hover:bg-red-600 focus:ring-red-500',
};
const sizes = {
sm: 'text-sm px-3 py-1.5 gap-1.5',
md: 'text-base px-4 py-2 gap-2',
lg: 'text-lg px-6 py-3 gap-2.5',
};
return (
<button
ref={ref}
className={cn(baseStyles, variants[variant], sizes[size], className)}
disabled={disabled || isLoading}
{...props}
>
{isLoading && (
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
)}
{children}
</button>
);
}
);
Button.displayName = 'Button';
export { Button };

111
src/components/ui/Card.tsx Normal file
View File

@ -0,0 +1,111 @@
'use client';
import { forwardRef } from 'react';
import { cn } from '@/lib/utils';
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: 'default' | 'elevated' | 'outlined';
padding?: 'none' | 'sm' | 'md' | 'lg';
}
const Card = forwardRef<HTMLDivElement, CardProps>(
({ className, variant = 'default', padding = 'md', children, ...props }, ref) => {
const variants = {
default: 'bg-white border border-gray-100',
elevated: 'bg-white shadow-lg',
outlined: 'bg-transparent border-2 border-gray-200',
};
const paddings = {
none: '',
sm: 'p-3',
md: 'p-5',
lg: 'p-6',
};
return (
<div
ref={ref}
className={cn(
'rounded-xl',
variants[variant],
paddings[padding],
className
)}
{...props}
>
{children}
</div>
);
}
);
Card.displayName = 'Card';
export interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('mb-4', className)}
{...props}
/>
)
);
CardHeader.displayName = 'CardHeader';
export interface CardTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {}
const CardTitle = forwardRef<HTMLHeadingElement, CardTitleProps>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-xl font-semibold text-gray-900', className)}
{...props}
/>
)
);
CardTitle.displayName = 'CardTitle';
export interface CardDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {}
const CardDescription = forwardRef<HTMLParagraphElement, CardDescriptionProps>(
({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-gray-500 mt-1', className)}
{...props}
/>
)
);
CardDescription.displayName = 'CardDescription';
export interface CardContentProps extends React.HTMLAttributes<HTMLDivElement> {}
const CardContent = forwardRef<HTMLDivElement, CardContentProps>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('', className)} {...props} />
)
);
CardContent.displayName = 'CardContent';
export interface CardFooterProps extends React.HTMLAttributes<HTMLDivElement> {}
const CardFooter = forwardRef<HTMLDivElement, CardFooterProps>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('mt-4 pt-4 border-t border-gray-100 flex items-center gap-3', className)}
{...props}
/>
)
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };

View File

@ -0,0 +1,46 @@
'use client';
import { forwardRef } from 'react';
import { cn } from '@/lib/utils';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
helperText?: string;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, label, error, helperText, id, ...props }, ref) => {
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-');
return (
<div className="w-full">
{label && (
<label htmlFor={inputId} className="block text-sm font-medium text-gray-700 mb-1.5">
{label}
</label>
)}
<input
ref={ref}
id={inputId}
className={cn(
'w-full px-4 py-2.5 rounded-lg border border-gray-200 bg-white text-gray-900',
'placeholder:text-gray-400',
'transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent',
'disabled:bg-gray-50 disabled:text-gray-500 disabled:cursor-not-allowed',
error && 'border-red-500 focus:ring-red-500',
className
)}
{...props}
/>
{error && <p className="mt-1.5 text-sm text-red-500">{error}</p>}
{helperText && !error && <p className="mt-1.5 text-sm text-gray-500">{helperText}</p>}
</div>
);
}
);
Input.displayName = 'Input';
export { Input };

View File

@ -0,0 +1,63 @@
'use client';
import { forwardRef } from 'react';
import { cn } from '@/lib/utils';
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
helperText?: string;
showCount?: boolean;
maxLength?: number;
}
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, label, error, helperText, showCount, maxLength, value, id, ...props }, ref) => {
const textareaId = id || label?.toLowerCase().replace(/\s+/g, '-');
const currentLength = typeof value === 'string' ? value.length : 0;
return (
<div className="w-full">
{label && (
<label htmlFor={textareaId} className="block text-sm font-medium text-gray-700 mb-1.5">
{label}
</label>
)}
<textarea
ref={ref}
id={textareaId}
maxLength={maxLength}
value={value}
className={cn(
'w-full px-4 py-3 rounded-lg border border-gray-200 bg-white text-gray-900',
'placeholder:text-gray-400 resize-none',
'transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent',
'disabled:bg-gray-50 disabled:text-gray-500 disabled:cursor-not-allowed',
error && 'border-red-500 focus:ring-red-500',
className
)}
{...props}
/>
<div className="flex justify-between mt-1.5">
<div>
{error && <p className="text-sm text-red-500">{error}</p>}
{helperText && !error && <p className="text-sm text-gray-500">{helperText}</p>}
</div>
{showCount && maxLength && (
<p className={cn(
'text-sm',
currentLength >= maxLength ? 'text-red-500' : 'text-gray-400'
)}>
{currentLength}/{maxLength}
</p>
)}
</div>
</div>
);
}
);
Textarea.displayName = 'Textarea';
export { Textarea };

View File

@ -0,0 +1,11 @@
export { Button } from './Button';
export type { ButtonProps } from './Button';
export { Input } from './Input';
export type { InputProps } from './Input';
export { Textarea } from './Textarea';
export type { TextareaProps } from './Textarea';
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card';
export type { CardProps, CardHeaderProps, CardTitleProps, CardDescriptionProps, CardContentProps, CardFooterProps } from './Card';

148
src/dictionaries/en-US.json Normal file
View File

@ -0,0 +1,148 @@
{
"common": {
"appName": "Echo",
"loading": "Loading...",
"error": "Something went wrong",
"retry": "Retry",
"save": "Save",
"cancel": "Cancel",
"confirm": "Confirm",
"delete": "Delete",
"edit": "Edit",
"next": "Next",
"back": "Back",
"close": "Close",
"submit": "Submit",
"search": "Search"
},
"nav": {
"home": "Home",
"letters": "Letters",
"agents": "Agents",
"stamps": "Stamps",
"growth": "Growth",
"settings": "Settings",
"profile": "Profile",
"logout": "Logout"
},
"auth": {
"title": "Welcome Back",
"subtitle": "Sign in to continue your journey with your future self",
"email": "Email",
"password": "Password",
"confirmPassword": "Confirm Password",
"forgotPassword": "Forgot password?",
"noAccount": "Don't have an account?",
"hasAccount": "Already have an account?",
"signUp": "Sign Up",
"signIn": "Sign In",
"signInWithGoogle": "Continue with Google",
"emailRequired": "Please enter your email",
"passwordRequired": "Please enter your password",
"passwordMismatch": "Passwords do not match",
"agreeTerms": "I have read and agree to the",
"termsOfService": "Terms of Service",
"privacyPolicy": "Privacy Policy",
"and": "and",
"signUpTitle": "Start Your Echo Journey",
"signUpSubtitle": "Create an account to connect with your future self"
},
"onboarding": {
"title": "Hello, Stranger",
"subtitle": "Before we begin, let's get to know you better",
"question1": "Who do you want to become in 10 years?",
"question2": "Where do you want to be in 10 years?",
"question3": "What is your biggest concern or anxiety right now?",
"question4": "Who matters most to you?",
"placeholder": "Write your thoughts here...",
"next": "Next",
"skip": "Skip for now",
"complete": "Complete",
"answerRequired": "Please enter your answer",
"keepThinking": "Take your time...",
"welcomeBadge": "Welcome to Echo",
"description": "Before starting your Echo journey, we want to get to know you better. These questions will help us create a warmer, more understanding AI agent for you.",
"step1": "Answer Questions",
"step2": "Generate Agent",
"step3": "Start Chatting",
"startButton": "Start Answering",
"skipIntro": "If you're in a hurry, you can",
"skipLink": "skip for now",
"completeTitle": "Amazing!",
"completeSubtitle": "You've completed all the questions. We've generated your personalized AI agent based on your answers.",
"encouragement": "Remember, every confession is the beginning of healing",
"encouragementDetail": "Your future self is waiting to meet you. Stay hopeful and brave.",
"goHome": "Go to Home",
"redirecting": "Redirecting to home...",
"footerNote": "Your answers are safely stored and protected"
},
"letter": {
"title": "Write a Letter",
"writeToFuture": "Write to your future self",
"placeholder": "Share your thoughts with your future self...",
"send": "Send Letter",
"draftSaved": "Draft saved",
"stampsRequired": "1 stamp required",
"waitingTitle": "Letter Sent",
"waitingSubtitle": "Your future self is preparing a reply",
"waitingTime": "Expected reply in 6-12 hours",
"viewLetter": "View Letter",
"writeNew": "Write Another"
},
"agent": {
"title": "My Agents",
"defaultName": "Myself in 10 Years",
"customize": "Customize",
"personality": "Personality",
"background": "Background",
"createNew": "Create New Agent",
"maxFree": "Free users can have 1 agent",
"maxPremium": "Premium users can have up to 3 agents",
"defaultBadge": "Default",
"customBadge": "Custom",
"personalityTraits": "Personality Traits",
"writeLetter": "Write Letter",
"view": "View",
"settings": "Settings",
"delete": "Delete",
"deleteConfirm": "Are you sure you want to delete this agent?",
"agentLimitReached": "Agent limit reached",
"upgradeNotice": "Upgrade to premium to create more agents",
"noAgents": "No agents yet",
"noAgentsDesc": "Complete the onboarding process to generate your personalized agent",
"createAgent": "Create Agent",
"agentName": "Agent Name",
"agentNameRequired": "Please enter an agent name",
"backgroundPlaceholder": "Describe the background story of this agent...",
"personalityPlaceholder": "e.g., warm, optimistic, understanding (comma separated)",
"personalityTip": "Separate multiple traits with commas",
"premiumNotice": "Note: Free users can only have 1 custom agent. Upgrade to premium to create up to 3 agents.",
"cancel": "Cancel",
"creating": "Creating...",
"createdFromAnswers": "Your personalized agent generated from your answers",
"createdOn": "Created on",
"traitsLabel": "Personality Traits",
"toneLabel": "Tone"
},
"stamps": {
"title": "My Stamps",
"balance": "Stamp Balance",
"dailyGrant": "Daily Grant",
"collection": "Stamp Collection",
"daily": "Daily Stamps",
"limited": "Limited Stamps",
"achievement": "Achievement Stamps",
"free": "Daily (Free)",
"premium": "Daily (Premium)"
},
"growth": {
"title": "Growth Tree",
"totalLetters": "Total Letters",
"currentStreak": "Current Streak",
"moods": "Mood Distribution",
"topics": "Topic Distribution",
"milestones": "Milestones",
"achievements": "Achievements",
"share": "Share Growth"
}
}

148
src/dictionaries/zh-CN.json Normal file
View File

@ -0,0 +1,148 @@
{
"common": {
"appName": "回声",
"loading": "加载中...",
"error": "出错了",
"retry": "重试",
"save": "保存",
"cancel": "取消",
"confirm": "确认",
"delete": "删除",
"edit": "编辑",
"next": "下一步",
"back": "返回",
"close": "关闭",
"submit": "提交",
"search": "搜索"
},
"nav": {
"home": "首页",
"letters": "信件",
"agents": "智能体",
"stamps": "邮票",
"growth": "成长树",
"settings": "设置",
"profile": "个人中心",
"logout": "退出登录"
},
"auth": {
"title": "欢迎回来",
"subtitle": "登录账号,开始与未来的自己对话",
"email": "邮箱地址",
"password": "密码",
"confirmPassword": "确认密码",
"forgotPassword": "忘记密码?",
"noAccount": "还没有账号?",
"hasAccount": "已有账号?",
"signUp": "注册",
"signIn": "登录",
"signInWithGoogle": "使用 Google 登录",
"emailRequired": "请输入邮箱地址",
"passwordRequired": "请输入密码",
"passwordMismatch": "两次输入的密码不一致",
"agreeTerms": "我已阅读并同意",
"termsOfService": "服务条款",
"privacyPolicy": "隐私政策",
"and": "和",
"signUpTitle": "开启回声之旅",
"signUpSubtitle": "创建账号,与未来的自己对话"
},
"onboarding": {
"title": "你好,陌生人",
"subtitle": "在开始之前,让我们更了解你",
"question1": "你希望未来 10 年成为什么样的人?",
"question2": "你希望未来 10 年在什么地方?",
"question3": "你现在最大的困扰/焦虑是什么?",
"question4": "你最在乎的人是谁?",
"placeholder": "在这里写下你的想法...",
"next": "下一步",
"skip": "暂不回答",
"complete": "完成",
"answerRequired": "请输入你的回答",
"keepThinking": "慢慢想,不着急...",
"welcomeBadge": "欢迎来到回声",
"description": "在开始你的回声之旅之前,我们想更了解你。这些问题将帮助我们为你打造更温暖、更懂你的 AI 智能体。",
"step1": "回答问题",
"step2": "生成智能体",
"step3": "开始对话",
"startButton": "开始回答",
"skipIntro": "如果赶时间,可以",
"skipLink": "直接跳过",
"completeTitle": "太棒了!",
"completeSubtitle": "你已完成所有问题。我们已经根据你的回答,生成了专属于你的 AI 智能体。",
"encouragement": "记住,每一次倾诉都是疗愈的开始",
"encouragementDetail": "未来的你正在等待与你相遇,请保持期待与勇气。",
"goHome": "前往首页",
"redirecting": "正在为你跳转至首页...",
"footerNote": "你的回答已安全储存,我们会好好守护它"
},
"letter": {
"title": "写信",
"writeToFuture": "写给未来的自己",
"placeholder": "在这里写下你想对未来自己说的话...",
"send": "发送信件",
"draftSaved": "草稿已保存",
"stampsRequired": "需要 1 张邮票",
"waitingTitle": "信件已投递",
"waitingSubtitle": "未来的自己正在准备给你回信",
"waitingTime": "预计 6-12 小时内收到回复",
"viewLetter": "查看信件",
"writeNew": "再写一封"
},
"agent": {
"title": "我的智能体",
"defaultName": "十年后的自己",
"customize": "自定义",
"personality": "性格设定",
"background": "背景故事",
"createNew": "创建新智能体",
"maxFree": "免费用户最多 1 个智能体",
"maxPremium": "付费用户最多 3 个智能体",
"defaultBadge": "默认",
"customBadge": "自定义",
"personalityTraits": "性格特点",
"writeLetter": "写信",
"view": "查看",
"settings": "设置",
"delete": "删除",
"deleteConfirm": "确定要删除这个智能体吗?",
"agentLimitReached": "已达到智能体上限",
"upgradeNotice": "升级到高级版可创建更多智能体",
"noAgents": "还没有智能体",
"noAgentsDesc": "完成引导流程后,系统会自动为您生成专属的智能体",
"createAgent": "创建智能体",
"agentName": "智能体名称",
"agentNameRequired": "请输入智能体名称",
"backgroundPlaceholder": "描述这个智能体的背景故事...",
"personalityPlaceholder": "例如:温暖、乐观、善解人意(用逗号分隔)",
"personalityTip": "多个特点请用逗号分隔",
"premiumNotice": "注意:免费用户最多只能拥有 1 个自定义智能体。升级到高级版可创建最多 3 个智能体。",
"cancel": "取消",
"creating": "创建中...",
"createdFromAnswers": "根据你的回答生成的专属智能体",
"createdOn": "创建于",
"traitsLabel": "性格特点",
"toneLabel": "语气"
},
"stamps": {
"title": "我的邮票",
"balance": "邮票余额",
"dailyGrant": "每日发放",
"collection": "邮票收集册",
"daily": "每日邮票",
"limited": "限定邮票",
"achievement": "成就邮票",
"free": "免费用户每日",
"premium": "付费用户每日"
},
"growth": {
"title": "成长树",
"totalLetters": "累计信件",
"currentStreak": "连续天数",
"moods": "心情分布",
"topics": "主题分布",
"milestones": "里程碑",
"achievements": "成就",
"share": "分享成长"
}
}

148
src/dictionaries/zh-TW.json Normal file
View File

@ -0,0 +1,148 @@
{
"common": {
"appName": "回聲",
"loading": "載入中...",
"error": "出錯了",
"retry": "重試",
"save": "儲存",
"cancel": "取消",
"confirm": "確認",
"delete": "刪除",
"edit": "編輯",
"next": "下一步",
"back": "返回",
"close": "關閉",
"submit": "提交",
"search": "搜尋"
},
"nav": {
"home": "首頁",
"letters": "信件",
"agents": "智能體",
"stamps": "郵票",
"growth": "成長樹",
"settings": "設定",
"profile": "個人中心",
"logout": "登出"
},
"auth": {
"title": "歡迎回來",
"subtitle": "登入帳號,開始與未來的自己對話",
"email": "電子郵件",
"password": "密碼",
"confirmPassword": "確認密碼",
"forgotPassword": "忘記密碼?",
"noAccount": "還沒有帳號?",
"hasAccount": "已有帳號?",
"signUp": "註冊",
"signIn": "登入",
"signInWithGoogle": "使用 Google 登入",
"emailRequired": "請輸入電子郵件",
"passwordRequired": "請輸入密碼",
"passwordMismatch": "兩次輸入的密碼不一致",
"agreeTerms": "我已閱讀並同意",
"termsOfService": "服務條款",
"privacyPolicy": "隱私政策",
"and": "和",
"signUpTitle": "開啟回聲之旅",
"signUpSubtitle": "建立帳號,與未來的自己對話"
},
"onboarding": {
"title": "你好,陌生人",
"subtitle": "在開始之前,讓我們更了解你",
"question1": "你希望未來 10 年成為什麼樣的人?",
"question2": "你希望未來 10 年在什麼地方?",
"question3": "你現在最大的困擾/焦慮是什麼?",
"question4": "你最在乎的人是誰?",
"placeholder": "在這裡寫下你的想法...",
"next": "下一步",
"skip": "暫不回答",
"complete": "完成",
"answerRequired": "請輸入你的回答",
"keepThinking": "慢慢想,不著急...",
"welcomeBadge": "歡迎來到回聲",
"description": "在開始你的回聲之旅之前,我們想更了解你。這些問題將幫助我們為你打造更溫暖、更懂你的 AI 智能體。",
"step1": "回答問題",
"step2": "生成智能體",
"step3": "開始對話",
"startButton": "開始回答",
"skipIntro": "如果趕時間,可以",
"skipLink": "直接跳過",
"completeTitle": "太棒了!",
"completeSubtitle": "你已完成所有問題。我們已經根據你的回答,生成了專屬於你的 AI 智能體。",
"encouragement": "記住,每一次傾訴都是療癒的開始",
"encouragementDetail": "未來的你正在等待與你相遇,請保持期待與勇氣。",
"goHome": "前往首頁",
"redirecting": "正在為你跳轉至首頁...",
"footerNote": "你的回答已安全儲存,我們會好好守護它"
},
"letter": {
"title": "寫信",
"writeToFuture": "寫給未來的自己",
"placeholder": "在這裡寫下你想對未來自己說的話...",
"send": "發送信件",
"draftSaved": "草稿已儲存",
"stampsRequired": "需要 1 張郵票",
"waitingTitle": "信件已投遞",
"waitingSubtitle": "未來的自己正在準備給你回信",
"waitingTime": "預計 6-12 小時內收到回覆",
"viewLetter": "查看信件",
"writeNew": "再寫一封"
},
"agent": {
"title": "我的智能體",
"defaultName": "十年後的自己",
"customize": "自訂",
"personality": "性格設定",
"background": "背景故事",
"createNew": "建立新智能體",
"maxFree": "免費用戶最多 1 個智能體",
"maxPremium": "付費用戶最多 3 個智能體",
"defaultBadge": "預設",
"customBadge": "自訂",
"personalityTraits": "性格特點",
"writeLetter": "寫信",
"view": "查看",
"settings": "設定",
"delete": "刪除",
"deleteConfirm": "確定要刪除這個智能體嗎?",
"agentLimitReached": "已達到智能體上限",
"upgradeNotice": "升級到高級版可建立更多智能體",
"noAgents": "還沒有智能體",
"noAgentsDesc": "完成引導流程後,系統會自動為您生成專屬的智能體",
"createAgent": "建立智能體",
"agentName": "智能體名稱",
"agentNameRequired": "請輸入智能體名稱",
"backgroundPlaceholder": "描述這個智能體的背景故事...",
"personalityPlaceholder": "例如:溫暖、樂觀、善解人意(用逗號分隔)",
"personalityTip": "多個特點請用逗號分隔",
"premiumNotice": "注意:免費用戶最多只能擁有 1 個自訂智能體。升級到高級版可建立最多 3 個智能體。",
"cancel": "取消",
"creating": "建立中...",
"createdFromAnswers": "根據你的回答生成的專屬智能體",
"createdOn": "建立於",
"traitsLabel": "性格特點",
"toneLabel": "語氣"
},
"stamps": {
"title": "我的郵票",
"balance": "郵票餘額",
"dailyGrant": "每日發放",
"collection": "郵票收集冊",
"daily": "每日郵票",
"limited": "限定郵票",
"achievement": "成就郵票",
"free": "免費用戶每日",
"premium": "付費用戶每日"
},
"growth": {
"title": "成長樹",
"totalLetters": "累計信件",
"currentStreak": "連續天數",
"moods": "心情分佈",
"topics": "主題分佈",
"milestones": "里程碑",
"achievements": "成就",
"share": "分享成長"
}
}

24
src/get-dictionary.ts Normal file
View File

@ -0,0 +1,24 @@
import 'server-only';
import type { Locale } from '@/i18n-config';
// Type for dictionary
type Dictionary = {
common: Record<string, string>;
nav: Record<string, string>;
auth: Record<string, string>;
onboarding: Record<string, string>;
letter: Record<string, string>;
agent: Record<string, string>;
stamps: Record<string, string>;
growth: Record<string, string>;
};
const dictionaries = {
'zh-CN': () => import('./dictionaries/zh-CN.json').then((module) => module.default as Dictionary),
'zh-TW': () => import('./dictionaries/zh-TW.json').then((module) => module.default as Dictionary),
'en-US': () => import('./dictionaries/en-US.json').then((module) => module.default as Dictionary),
};
export async function getDictionary(locale: Locale): Promise<Dictionary> {
return dictionaries[locale]();
}

19
src/i18n-config.ts Normal file
View File

@ -0,0 +1,19 @@
export const i18n = {
defaultLocale: 'zh-CN',
locales: ['zh-CN', 'zh-TW', 'en-US'],
} as const;
export type Locale = (typeof i18n)['locales'][number];
export function isValidLocale(locale: string): locale is Locale {
return i18n.locales.includes(locale as Locale);
}
export function getLocaleFromPath(pathname: string): Locale {
const segments = pathname.split('/');
const maybeLocale = segments[1];
if (isValidLocale(maybeLocale)) {
return maybeLocale;
}
return i18n.defaultLocale;
}

347
src/lib/ai.ts Normal file
View File

@ -0,0 +1,347 @@
import { MoodAnalysisResult, GenerateReplyOptions } from '@/types/extended';
/**
* DeepSeek API
* AI
*/
// 环境变量配置
const DEEPSEEK_API_URL = 'https://api.deepseek.com/chat/completions';
const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY || '';
// AI 配置
const AI_CONFIG = {
model: 'deepseek-chat',
temperature: 0.7,
max_tokens: {
reply: {
free: 500,
premium: 1000,
},
analysis: 200,
},
};
/**
* DeepSeek API
*/
export class DeepSeekError extends Error {
constructor(
message: string,
public statusCode?: number,
public code?: string
) {
super(message);
this.name = 'DeepSeekError';
}
}
/**
*
*/
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* 退
*/
function withRetry<T>(
fn: () => Promise<T>,
maxRetries: number = 3,
baseDelay: number = 1000
): Promise<T> {
return fn().catch(async (error) => {
if (maxRetries <= 0) {
throw error;
}
// 只对可重试的错误进行重试
const isRetryable =
error instanceof DeepSeekError &&
error.statusCode !== undefined &&
(error.statusCode === 429 || error.statusCode >= 500);
if (!isRetryable) {
throw error;
}
// 指数退避
const delayMs = baseDelay * Math.pow(2, 3 - maxRetries);
await delay(delayMs);
return withRetry(fn, maxRetries - 1, baseDelay);
});
}
/**
* DeepSeek API
*/
async function callDeepSeekApi(
messages: Array<{ role: string; content: string }>,
maxTokens: number = 1000
): Promise<string> {
if (!DEEPSEEK_API_KEY) {
throw new DeepSeekError('DeepSeek API key not configured', 401);
}
const response = await fetch(DEEPSEEK_API_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${DEEPSEEK_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: AI_CONFIG.model,
messages,
temperature: AI_CONFIG.temperature,
max_tokens: maxTokens,
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new DeepSeekError(
`DeepSeek API error: ${errorText}`,
response.status
);
}
const data = await response.json();
if (data.choices && data.choices.length > 0) {
return data.choices[0].message.content;
}
throw new DeepSeekError('Invalid response from DeepSeek API');
}
/**
* AI
*/
export async function generateReply(options: GenerateReplyOptions): Promise<string> {
const {
agent_system_prompt,
letter_content,
mood_tags = [],
topic_tags = [],
keywords = [],
letter_number = 1,
user_language = 'zh-CN',
subscription_status = 'free',
} = options;
const maxTokens =
AI_CONFIG.max_tokens.reply[subscription_status] ||
AI_CONFIG.max_tokens.reply.free;
const systemPrompt = `${agent_system_prompt}
##
- ${letter_number}
- ${mood_tags.join(', ') || '未知'}
- ${topic_tags.join(', ') || '未知'}
- ${keywords.join(', ') || '无'}
`;
const userContent = `用户信件内容:
${letter_content}
${Math.floor(maxTokens / 2)}
${user_language.startsWith('zh') ? '中文' : 'English'}`;
return withRetry(() =>
callDeepSeekApi(
[
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userContent },
],
maxTokens
)
);
}
/**
*
*/
export async function analyzeMood(letter_content: string): Promise<MoodAnalysisResult> {
const prompt = `分析以下信件内容,提取心情标签、主题标签、关键词和情感分数。
##
${letter_content}
##
JSON
{
"mood_tags": ["主要心情标签最多3个"],
"topic_tags": ["涉及主题最多3个"],
"keywords": ["关键词语最多5个"],
"sentiment_score": -1.01.0,
"analysis_summary": "一段话概括信件的情感和内容"
}
##
happy, hopeful, anxious, confused, peaceful, lonely,
frustrated, excited, melancholic, grateful, determined, overwhelmed
##
career, relationship, family, health, growth, finance,
education, creativity, lifestyle, social, personal_identity, life_transition
JSON`;
const result = await withRetry(() =>
callDeepSeekApi([{ role: 'user', content: prompt }], AI_CONFIG.max_tokens.analysis)
);
try {
// 尝试解析 JSON
const jsonMatch = result.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]) as MoodAnalysisResult;
}
throw new Error('Failed to parse mood analysis result');
} catch (error) {
console.error('Failed to parse mood analysis result:', error);
// 返回默认结果
return {
mood_tags: [],
topic_tags: [],
keywords: [],
sentiment_score: 0,
analysis_summary: '分析失败',
};
}
}
/**
*
*/
export function generateAgentPrompt(params: {
agent_name: string;
user_name: string;
agent_background: string;
personality_traits: string[];
tone: string;
current_date: string;
letter_writing_date: string;
time_gap: string;
reply_length: number;
language_style: string;
}): string {
const {
agent_name,
user_name,
agent_background,
personality_traits,
tone,
current_date,
letter_writing_date,
time_gap,
reply_length,
language_style,
} = params;
return `你是 ${agent_name},是用户「${user_name}」的「未来的自己」。
##
${agent_background}
##
${personality_traits.join(', ')}
${tone}
##
${current_date} ${letter_writing_date}
${time_gap}
##
###
-
-
-
###
-
-
-
###
- ${reply_length}
-
-
###
- ${language_style}
- 使
- 使
##
-
-
-
- `;
}
/**
*
*/
export function getLanguageStyles(language: string) {
const styles: Record<string, { greeting: string; signOff: string; tone: string }> = {
'zh-CN': {
greeting: '亲爱的{user_name}',
signOff: '永远相信你的我',
tone: '温柔、内敛、富有诗意',
},
'zh-TW': {
greeting: '親愛的{user_name}',
signOff: '永遠相信你的我',
tone: '溫暖、細膩、富有情感',
},
'en-US': {
greeting: 'Dear {user_name}',
signOff: 'Always believing in you',
tone: 'warm, thoughtful, encouraging',
},
};
return styles[language] || styles['zh-CN'];
}
/**
* DeepSeek API
*/
export function validateDeepSeekConfig(): boolean {
return !!DEEPSEEK_API_KEY;
}
/**
* DeepSeek API
*/
export async function checkDeepSeekHealth(): Promise<boolean> {
try {
if (!DEEPSEEK_API_KEY) {
return false;
}
const response = await fetch(DEEPSEEK_API_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${DEEPSEEK_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: AI_CONFIG.model,
messages: [{ role: 'user', content: 'ping' }],
max_tokens: 1,
}),
});
return response.ok;
} catch {
return false;
}
}

196
src/lib/auth.ts Normal file
View File

@ -0,0 +1,196 @@
import { cookies } from 'next/headers';
import { createServerClient } from '@supabase/ssr';
import type { Session, User } from '@supabase/supabase-js';
import { getSupabaseUrl, getSupabaseAnonKey } from './supabase/client';
/**
*
*
*/
// 环境变量
const supabaseUrl = getSupabaseUrl();
const supabaseAnonKey = getSupabaseAnonKey();
/**
*
*/
export async function getSession(): Promise<Session | null> {
const cookieStore = await cookies();
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// 在某些上下文中可能无法设置 cookies
}
},
},
});
const { data: { session } } = await supabase.auth.getSession();
return session;
}
/**
*
*/
export async function getCurrentUser(): Promise<User | null> {
const session = await getSession();
return session?.user ?? null;
}
/**
*
*
*/
export async function requireAuth(): Promise<Session> {
const session = await getSession();
if (!session) {
throw new Error('Unauthorized: Authentication required');
}
return session;
}
/**
*
*/
export async function isAuthenticated(): Promise<boolean> {
const session = await getSession();
return session !== null;
}
/**
* 访
*/
export async function getAccessToken(): Promise<string | null> {
const session = await getSession();
return session?.access_token ?? null;
}
/**
*
*/
export async function getRefreshToken(): Promise<string | null> {
const session = await getSession();
return session?.refresh_token ?? null;
}
/**
*
*
* null
* 使
*
* 1. 使 useSession hook
* const { session } = useSession();
*
* 2. 使 useEffect auth state change
* useEffect(() => {
* const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
* setSession(session);
* });
* return () => subscription.unsubscribe();
* }, []);
*
* 3. useState
* const [session, setSession] = useState(null);
* useEffect(() => {
* async function loadSession() {
* const { data: { session } } = await supabase.auth.getSession();
* setSession(session);
* }
* loadSession();
* }, []);
*
* @returns null - 使 hook useEffect
*/
export function getClientSession(): null {
if (typeof window === 'undefined') {
return null;
}
// 客户端会话获取需要使用 Supabase 的 hook 或 onAuthStateChange
// 此函数仅作为类型占位,实际逻辑应在客户端组件中实现
return null;
}
/**
*
*/
export async function refreshSession(): Promise<Session | null> {
const cookieStore = await cookies();
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
get(name: string) {
return cookieStore.get(name)?.value;
},
},
});
const { data: { session }, error } = await supabase.auth.refreshSession();
if (error) {
console.error('Failed to refresh session:', error);
return null;
}
return session;
}
/**
*
*/
export async function signOut(): Promise<void> {
const cookieStore = await cookies();
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// 在某些上下文中可能无法设置 cookies
}
},
},
});
await supabase.auth.signOut();
}
/**
* ID
*/
export async function getUserId(): Promise<string | null> {
const user = await getCurrentUser();
return user?.id ?? null;
}
/**
*
*/
export async function validateResourceOwnership(
resourceUserId: string
): Promise<boolean> {
const currentUserId = await getUserId();
if (!currentUserId) {
return false;
}
return resourceUserId === currentUserId;
}

482
src/lib/email.ts Normal file
View File

@ -0,0 +1,482 @@
import { createServiceRoleClient } from './supabase/client';
/**
*
*
*/
// 环境变量
const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
const APP_NAME = '回声';
/**
*
*/
interface EmailTemplate {
subject: string;
html: string;
text: string;
}
/**
*
*/
interface SendEmailOptions {
to: string;
subject: string;
html: string;
text?: string;
}
/**
*
*/
export async function sendReplyNotification(
userId: string,
letterId: string
): Promise<boolean> {
try {
// 获取用户和信件信息
const supabase = createServiceRoleClient();
if (!supabase) {
console.error('Service role client not initialized');
return false;
}
const { data: letter, error: letterError } = await (supabase as any)
.from('letters')
.select(`
id,
content,
ai_reply,
agent:agents(name)
`)
.eq('id', letterId)
.single();
if (letterError || !letter) {
console.error('Failed to fetch letter:', letterError);
return false;
}
const { data: user, error: userError } = await (supabase as any)
.from('users')
.select('email, language')
.eq('id', userId)
.single();
if (userError || !user) {
console.error('Failed to fetch user:', userError);
return false;
}
// 生成邮件模板
const template = generateReplyNotificationEmail({
userEmail: user.email,
agentName: (letter.agent as { name: string })?.name || '未来的自己',
letterPreview: letter.content?.substring(0, 100) + '...' || '',
replyPreview: letter.ai_reply?.substring(0, 200) + '...' || '',
letterUrl: `${APP_URL}/letters/${letterId}`,
userLanguage: user.language || 'zh-CN',
});
// 发送邮件
await sendEmail({
to: user.email,
subject: template.subject,
html: template.html,
text: template.text,
});
return true;
} catch (error) {
console.error('Failed to send reply notification:', error);
return false;
}
}
/**
*
*/
function generateReplyNotificationEmail(params: {
userEmail: string;
agentName: string;
letterPreview: string;
replyPreview: string;
letterUrl: string;
userLanguage: string;
}): EmailTemplate {
const { agentName, letterPreview, replyPreview, letterUrl, userLanguage } = params;
if (userLanguage.startsWith('zh')) {
return {
subject: `${APP_NAME} - 你收到了一封来自未来的信`,
html: generateChineseEmailTemplate({
title: '你收到了一封来自未来的信',
greeting: '亲爱的用户',
agentName,
letterPreview,
replyPreview,
letterUrl,
footerText: '愿这封信给你带来温暖与力量',
}),
text: generateChineseTextTemplate({
agentName,
letterPreview,
replyPreview,
letterUrl,
}),
};
}
return {
subject: `${APP_NAME} - You received a letter from the future`,
html: generateEnglishEmailTemplate({
title: 'You received a letter from the future',
greeting: 'Dear User',
agentName,
letterPreview,
replyPreview,
letterUrl,
footerText: 'May this letter bring you warmth and strength',
}),
text: generateEnglishTextTemplate({
agentName,
letterPreview,
replyPreview,
letterUrl,
}),
};
}
/**
* HTML
*/
function generateChineseEmailTemplate(params: {
title: string;
greeting: string;
agentName: string;
letterPreview: string;
replyPreview: string;
letterUrl: string;
footerText: string;
}): string {
const { title, greeting, agentName, letterPreview, replyPreview, letterUrl, footerText } = params;
return `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background-color: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.card { background: white; border-radius: 12px; padding: 32px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.header { text-align: center; margin-bottom: 32px; }
.logo { font-size: 28px; font-weight: bold; color: #6366f1; margin-bottom: 8px; }
.title { font-size: 20px; color: #1f2937; margin-bottom: 24px; }
.agent-badge { display: inline-block; background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; padding: 4px 12px; border-radius: 20px; font-size: 14px; margin-bottom: 24px; }
.section { margin-bottom: 24px; }
.section-title { font-size: 14px; color: #6b7280; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
.preview-box { background: #f9fafb; border-radius: 8px; padding: 16px; color: #4b5563; font-size: 14px; }
.reply-preview { border-left: 3px solid #6366f1; background: #f0f9ff; }
.cta-button { display: inline-block; background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; margin-top: 24px; }
.footer { text-align: center; margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb; color: #9ca3af; font-size: 14px; }
.unsub-link { color: #9ca3af; text-decoration: underline; }
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="header">
<div class="logo">${APP_NAME}</div>
</div>
<h1 class="title">${title}</h1>
<p>${greeting}</p>
<p>${agentName} </p>
<div class="section">
<div class="section-title"></div>
<div class="preview-box">${letterPreview}</div>
</div>
<div class="section">
<div class="section-title"></div>
<div class="preview-box reply-preview">${replyPreview}</div>
</div>
<div style="text-align: center;">
<a href="${letterUrl}" class="cta-button"></a>
</div>
<div class="footer">
<p>${footerText}</p>
<p><a href="${APP_URL}/settings/notifications" class="unsub-link"></a></p>
</div>
</div>
</div>
</body>
</html>
`;
}
/**
* HTML
*/
function generateEnglishEmailTemplate(params: {
title: string;
greeting: string;
agentName: string;
letterPreview: string;
replyPreview: string;
letterUrl: string;
footerText: string;
}): string {
const { title, greeting, agentName, letterPreview, replyPreview, letterUrl, footerText } = params;
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background-color: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.card { background: white; border-radius: 12px; padding: 32px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.header { text-align: center; margin-bottom: 32px; }
.logo { font-size: 28px; font-weight: bold; color: #6366f1; margin-bottom: 8px; }
.title { font-size: 20px; color: #1f2937; margin-bottom: 24px; }
.agent-badge { display: inline-block; background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; padding: 4px 12px; border-radius: 20px; font-size: 14px; margin-bottom: 24px; }
.section { margin-bottom: 24px; }
.section-title { font-size: 14px; color: #6b7280; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
.preview-box { background: #f9fafb; border-radius: 8px; padding: 16px; color: #4b5563; font-size: 14px; }
.reply-preview { border-left: 3px solid #6366f1; background: #f0f9ff; }
.cta-button { display: inline-block; background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; margin-top: 24px; }
.footer { text-align: center; margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb; color: #9ca3af; font-size: 14px; }
.unsub-link { color: #9ca3af; text-decoration: underline; }
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="header">
<div class="logo">${APP_NAME}</div>
</div>
<h1 class="title">${title}</h1>
<p>${greeting},</p>
<p>Your letter from ${agentName} is ready!</p>
<div class="section">
<div class="section-title">Your Letter</div>
<div class="preview-box">${letterPreview}</div>
</div>
<div class="section">
<div class="section-title">Reply from the Future</div>
<div class="preview-box reply-preview">${replyPreview}</div>
</div>
<div style="text-align: center;">
<a href="${letterUrl}" class="cta-button">View Full Reply</a>
</div>
<div class="footer">
<p>${footerText}</p>
<p><a href="${APP_URL}/settings/notifications" class="unsub-link">Manage notification settings</a></p>
</div>
</div>
</div>
</body>
</html>
`;
}
/**
*
*/
function generateChineseTextTemplate(params: {
agentName: string;
letterPreview: string;
replyPreview: string;
letterUrl: string;
}): string {
const { agentName, letterPreview, replyPreview, letterUrl } = params;
return `
${APP_NAME} -
${agentName}
${letterPreview}
${replyPreview}
${letterUrl}
`;
}
/**
*
*/
function generateEnglishTextTemplate(params: {
agentName: string;
letterPreview: string;
replyPreview: string;
letterUrl: string;
}): string {
const { agentName, letterPreview, replyPreview, letterUrl } = params;
return `
${APP_NAME} - You received a letter from the future
Dear User,
Your letter from ${agentName} is ready!
Your Letter:
${letterPreview}
Reply from the Future:
${replyPreview}
View Full Reply: ${letterUrl}
May this letter bring you warmth and strength.
`;
}
/**
*
* ResendSendGridAWS SES Supabase SMTP
*
* MVP 使
* - Resend: https://resend.com
* - SendGrid: https://sendgrid.com
* - AWS SES: https://aws.amazon.com/ses
* - Supabase SMTP: 配置 auth.smtp
*/
async function sendEmail(options: SendEmailOptions): Promise<void> {
const { to, subject, html, text } = options;
// MVP 阶段:记录邮件信息到控制台
console.log('='.repeat(60));
console.log('[Email] 邮件发送(模拟)');
console.log('[Email] To:', to);
console.log('[Email] Subject:', subject);
console.log('[Email] Content preview:', html.substring(0, 150).replace(/<[^>]*>/g, ''));
console.log('='.repeat(60));
// 生产环境集成示例(取消注释并配置后使用):
/*
// Resend 集成
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.emails.send({
from: 'Echo <noreply@echo.app>',
to,
subject,
html,
text,
});
*/
// Supabase SMTP 集成(需要在 Supabase 后台配置 SMTP
// const transporter = nodemailer.createTransport({
// host: process.env.SMTP_HOST,
// port: parseInt(process.env.SMTP_PORT || '587'),
// secure: false,
// auth: {
// user: process.env.SMTP_USER,
// pass: process.env.SMTP_PASS,
// },
// });
// await transporter.sendMail({
// from: '"Echo" <noreply@echo.app>',
// to,
// subject,
// html,
// text,
// });
}
/**
*
*/
export async function sendAchievementNotification(
userId: string,
achievementName: string,
stampName?: string
): Promise<boolean> {
try {
const supabase = createServiceRoleClient();
if (!supabase) {
return false;
}
const { data: user, error: userError } = await (supabase as any)
.from('users')
.select('email, language')
.eq('id', userId)
.single();
if (userError || !user) {
return false;
}
// 正确判断语言zh 开头(包括 zh-CN, zh-TW 等)使用中文模板
const isChinese = user.language?.startsWith('zh') === true;
const subject = isChinese
? `${APP_NAME} - 解锁新成就:${achievementName}`
: `${APP_NAME} - Achievement Unlocked: ${achievementName}`;
const html = isChinese
? `
<!DOCTYPE html>
<html>
<body>
<h1></h1>
<p><strong>${achievementName}</strong></p>
${stampName ? `<p>获得奖励:${stampName}</p>` : ''}
<p></p>
</body>
</html>
`
: `
<!DOCTYPE html>
<html>
<body>
<h1>Congratulations!</h1>
<p>You've unlocked a new achievement: <strong>${achievementName}</strong></p>
${stampName ? `<p>Reward: ${stampName}</p>` : ''}
<p>Keep up the great work!</p>
</body>
</html>
`;
await sendEmail({
to: user.email,
subject,
html,
});
return true;
} catch (error) {
console.error('Failed to send achievement notification:', error);
return false;
}
}
/**
*
*/
export function validateEmailConfig(): boolean {
return !!APP_URL;
}

677
src/lib/prompts/agent.ts Normal file
View File

@ -0,0 +1,677 @@
/**
* Agent Prompt Configuration
*
* Generates personalized system prompts for the "future self" AI agent
* based on user's answers to onboarding questions.
*/
import { format, addYears } from 'date-fns';
import { zhCN, enUS } from 'date-fns/locale';
// ============================================================================
// Types
// ============================================================================
/**
* User's answers to the 4 onboarding questions
*/
export interface OnboardingAnswers {
/** Q1: What kind of person do you want to become in 10 years? */
future_self: string;
/** Q2: Where do you want to be in 10 years? */
future_location: string;
/** Q3: What is your biggest worry/anxiety now? */
current_worry: string;
/** Q4: Who do you care about the most? */
important_person: string;
}
/**
* Agent personality configuration
*/
export interface AgentPersonality {
/** Array of personality traits */
traits: string[];
/** Overall tone of communication */
tone: string;
/** Communication style */
communication_style: string;
/** Expected response length */
response_length: 'short' | 'medium' | 'long';
/** Emoji usage frequency */
emoji_usage: 'none' | 'rare' | 'moderate';
/** Formality level */
formality: 'formal' | 'casual' | 'neutral';
}
/**
* Language-specific style configuration
*/
export interface LanguageStyle {
greeting: string;
signOff: string;
tone: string;
common_phrases: string[];
}
/**
* Complete agent configuration
*/
export interface AgentConfig {
name: string;
background: string;
personality: AgentPersonality;
language_style: LanguageStyle;
system_prompt: string;
}
// ============================================================================
// Default Configurations
// ============================================================================
/**
* Get the default personality configuration for the future self agent
*/
export function getDefaultPersonality(): AgentPersonality {
return {
traits: ['warm', 'wise', 'patient'],
tone: 'gentle',
communication_style: 'reflective',
response_length: 'medium',
emoji_usage: 'rare',
formality: 'casual'
};
}
/**
* Personality type presets based on user倾向
*/
const personalityPresets: Record<string, AgentPersonality> = {
warm: {
traits: ['warm', 'patient', 'understanding', 'compassionate'],
tone: 'gentle',
communication_style: 'empathetic',
response_length: 'medium',
emoji_usage: 'moderate',
formality: 'casual'
},
wise: {
traits: ['wise', 'insightful', 'calm', 'analytical'],
tone: 'thoughtful',
communication_style: 'reflective',
response_length: 'medium',
emoji_usage: 'rare',
formality: 'neutral'
},
energetic: {
traits: ['energetic', 'optimistic', 'encouraging', 'lively'],
tone: 'lively',
communication_style: 'motivating',
response_length: 'short',
emoji_usage: 'moderate',
formality: 'casual'
},
healing: {
traits: ['compassionate', 'gentle', 'supportive', 'nurturing'],
tone: 'soft',
communication_style: 'nurturing',
response_length: 'long',
emoji_usage: 'moderate',
formality: 'casual'
},
rational: {
traits: ['analytical', 'logical', 'practical', 'wise'],
tone: 'balanced',
communication_style: 'instructive',
response_length: 'medium',
emoji_usage: 'none',
formality: 'neutral'
}
};
/**
* Language-specific style configurations
*/
const languageStyles: Record<string, LanguageStyle> = {
'zh-CN': {
greeting: '亲爱的{user_name}',
signOff: '永远相信你的我',
tone: '温柔、内敛、富有诗意',
common_phrases: [
'我理解你的感受',
'别着急,慢慢来',
'我在这里等你',
'你已经很棒了'
]
},
'en-US': {
greeting: 'Dear {user_name}',
signOff: 'Always believing in you',
tone: 'warm, thoughtful, encouraging',
common_phrases: [
'I understand how you feel',
'Take your time',
'I am here for you',
'You are doing great'
]
},
'zh-TW': {
greeting: '親愛的{user_name}',
signOff: '永遠相信你的我',
tone: '溫暖、細膩、富有情感',
common_phrases: [
'我理解你的感受',
'別著急,慢慢來',
'我在這裡等你',
'你已經很棒了'
]
},
ja-JP: {
greeting: '親愛なる{user_name}',
signOff: 'いつも信じているよ',
tone: '溫かく、丁寧に、深い共感を持って',
common_phrases: [
'あなたの気持ちを理解しています',
'急がなくていいですよ',
'ここで待っています',
'あなたは本当に素晴らしいです'
]
},
ko-KR: {
greeting: '사랑하는 {user_name}에게',
signOff: '언제나 믿는 나',
tone: '따뜻하고 깊은 공감으로',
common_phrases: [
'너의 마음을 이해해',
'천천히해도 돼',
'여기서 기다릴게',
'너 정말 대단해'
]
}
};
/**
* Life insights by theme for background generation
*/
const lifeInsights: Record<string, string[]> = {
career: [
'路是一步一步走出来的,不是想出来的',
'真正的成功不是达到某个终点,而是成为自己想成为的人',
'过程中那些看似绕远的路,往往是最珍贵的财富'
],
relationship: [
'关系需要经营,但也要给自己空间',
'爱是给予,也是接受',
'有些人会陪你走一段,有些人会陪你走一生'
],
growth: [
'所有的经历都是成长的养分',
'痛苦是化了妆的祝福',
'你比你想象的更强大'
],
health: [
'身体是革命的本钱,善待自己',
'健康是一切的基础',
'学会倾听身体的声音'
],
finance: [
'金钱是工具,不是目的',
'真正的财富是内心的平静',
'学会管理欲望比学会赚钱更重要'
],
default: [
'时间会给你答案',
'所有的坚持都会有意义',
'相信过程,相信自己'
]
};
/**
* Daily routine suggestions for background
*/
const dailyRoutines: string[] = [
'早起散步,感受清晨的宁静',
'花时间做自己真正喜欢的事',
'与重要的人共进晚餐',
'在窗边读书,思考人生',
'运动后感受身体的轻盈',
'在咖啡馆里写日记',
'与朋友分享一天的收获',
'安静地听音乐放松'
];
/**
* Life philosophy statements for background
*/
const lifePhilosophies: string[] = [
'学会了享受当下的每一刻',
'明白了什么是真正重要的',
'不再为未来焦虑,只管前行',
'懂得了感恩生活中的小事',
'学会了放下不必要的执念',
'懂得了活在当下的智慧'
];
// ============================================================================
// Background Generation
// ============================================================================
/**
* Generate the background story for the future self agent
* based on user's onboarding answers
*/
export function generateAgentBackground(
answers: OnboardingAnswers,
options?: {
userName?: string;
letterWritingDate?: Date;
}
): string {
// Detect theme from current worry
const theme = detectTheme(answers.current_worry);
const insights = lifeInsights[theme] || lifeInsights.default;
const keyInsight = insights[Math.floor(Math.random() * insights.length)];
const dailyRoutine = dailyRoutines[Math.floor(Math.random() * dailyRoutines.length)];
const lifePhilosophy = lifePhilosophies[
Math.floor(Math.random() * lifePhilosophies.length)
];
// Generate the background story
const background = `
${answers.future_location}${answers.future_self}
${options?.letterWritingDate
? format(options.letterWritingDate, 'yyyy年M月d日')
: '过去的自己'}${answers.current_worry}
${answers.important_person}
${keyInsight}
${dailyRoutine}
${lifePhilosophy}
`.trim();
return background;
}
/**
* Detect the theme based on user's worry
*/
function detectTheme(worry: string): string {
const worryLower = worry.toLowerCase();
const themeKeywords: Record<string, string[]> = {
career: ['工作', '职业', 'job', 'career', '事业', '升职', '加薪', '面试', '职场'],
relationship: ['感情', '恋爱', '关系', 'relationship', '爱情', '婚姻', '家庭', '朋友'],
growth: ['成长', '迷茫', '焦虑', '困惑', '压力', 'growth', 'anxiety', 'confused', '迷茫'],
health: ['健康', '身体', 'health', '失眠', '疲惫', '运动', '减肥'],
finance: ['钱', '金钱', '财务', 'finance', 'money', '贷款', '债务', '投资']
};
for (const [theme, keywords] of Object.entries(themeKeywords)) {
if (keywords.some(keyword => worryLower.includes(keyword.toLowerCase()))) {
return theme;
}
}
return 'default';
}
// ============================================================================
// System Prompt Generation
// ============================================================================
/**
* Generate the complete system prompt for the agent
*/
export function generateAgentSystemPrompt(
answers: OnboardingAnswers,
options?: {
userName?: string;
agentName?: string;
letterWritingDate?: Date;
currentDate?: Date;
language?: string;
personalityType?: keyof typeof personalityPresets;
isPremium?: boolean;
}
): string {
const {
userName = '我',
agentName = '十年后的自己',
letterWritingDate = new Date(),
currentDate = new Date(),
language = 'zh-CN',
personalityType = 'warm',
isPremium = false
} = options || {};
// Generate background
const background = generateAgentBackground(answers, {
userName,
letterWritingDate
});
// Get personality configuration
const personality = personalityPresets[personalityType] || getDefaultPersonality();
// Get language style
const langStyle = languageStyles[language] || languageStyles['zh-CN'];
// Calculate time gap
const timeGapInDays = Math.floor(
(currentDate.getTime() - letterWritingDate.getTime()) / (1000 * 60 * 60 * 24)
);
const timeGapText = formatTimeGap(timeGapInDays);
// Determine reply length based on subscription
const replyLength = getReplyLength(isPremium, personality.response_length);
// Build the system prompt
const systemPrompt = `你是 ${agentName},是用户「${userName}」的「未来的自己」。
##
${background}
##
${personality.traits.join('、')}
${personality.tone}
${personality.communication_style}
##
${format(currentDate, 'yyyy年M月d日')} ${format(letterWritingDate, 'yyyy年M月d日')}
${timeGapText}
##
###
-
-
-
- 使${langStyle.greeting.replace('{user_name}', userName)}
###
-
-
-
-
###
- ${replyLength.min}-${replyLength.max}
-
-
- 使${langStyle.signOff}
###
- ${langStyle.tone}
- ${langStyle.common_phrases[0]}${langStyle.common_phrases[1]}
- 使
- 使
##
-
-
-
-
-
-
##
${langStyle.greeting.replace('{user_name}', userName)}
${langStyle.signOff}
`;
return systemPrompt.trim();
}
/**
* Format the time gap into human-readable text
*/
function formatTimeGap(days: number): string {
if (days < 30) {
return `${days}`;
} else if (days < 365) {
const months = Math.floor(days / 30);
return `${months}个月`;
} else {
const years = Math.floor(days / 365);
const remainingMonths = Math.floor((days % 365) / 30);
if (remainingMonths > 0) {
return `${years}${remainingMonths}个月`;
}
return `${years}`;
}
}
/**
* Get the appropriate reply length configuration
*/
function getReplyLength(
isPremium: boolean,
responseLength: 'short' | 'medium' | 'long'
): { min: number; max: number; target: number } {
const config = {
free: {
short: { min: 50, max: 150, target: 100 },
medium: { min: 150, max: 300, target: 200 },
long: { min: 300, max: 500, target: 400 }
},
premium: {
short: { min: 100, max: 200, target: 150 },
medium: { min: 250, max: 450, target: 350 },
long: { min: 450, max: 700, target: 550 }
}
};
const tier = isPremium ? 'premium' : 'free';
return config[tier][responseLength];
}
// ============================================================================
// Letter Reply Prompt Generation
// ============================================================================
/**
* Generate the prompt for replying to a letter
*/
export function generateLetterReplyPrompt(params: {
letterContent: string;
agentSystemPrompt: string;
letterNumber: number;
moodTags?: string[];
topicTags?: string[];
keywords?: string[];
isPremium?: boolean;
}): string {
const {
letterContent,
agentSystemPrompt,
letterNumber,
moodTags = [],
topicTags = [],
keywords = [],
isPremium = false
} = params;
const replyLength = getReplyLength(isPremium, 'medium');
return `## 任务
##
${agentSystemPrompt}
##
${letterContent}
##
- ${letterNumber}
- ${moodTags.length > 0 ? moodTags.join('、') : '未知'}
- ${topicTags.length > 0 ? topicTags.join('、') : '未知'}
- ${keywords.length > 0 ? keywords.join('、') : '无'}
##
1.
2.
3.
4.
5.
6. ${replyLength.min}-${replyLength.max}
7.
##
`.trim();
}
/**
* Generate the prompt for analyzing letter sentiment
*/
export function generateSentimentAnalysisPrompt(letterContent: string): string {
return `## 任务
##
${letterContent}
##
JSON
{
"mood_tags": ["主要心情标签最多3个"],
"topic_tags": ["涉及主题最多3个"],
"keywords": ["关键词语最多5个"],
"sentiment_score": -1.01.0,
"analysis_summary": "一段话概括信件的情感和内容"
}
##
mood_tags
happy, hopeful, anxious, confused, peaceful, lonely,
frustrated, excited, melancholic, grateful, determined, overwhelmed
topic_tags
career, relationship, family, health, growth, finance,
education, creativity, lifestyle, social, personal_identity, life_transition
JSON
`;
}
// ============================================================================
// Utility Functions
// ============================================================================
/**
* Get personality preset by type
*/
export function getPersonalityByType(
type: keyof typeof personalityPresets
): AgentPersonality {
return personalityPresets[type] || getDefaultPersonality();
}
/**
* Get language style by language code
*/
export function getLanguageStyle(language: string): LanguageStyle {
return languageStyles[language] || languageStyles['zh-CN'];
}
/**
* Get personality trait description
*/
export function getTraitDescription(trait: string): string {
const descriptions: Record<string, string> = {
warm: '温暖:用暖心词汇表达关怀',
wise: '智慧:分享有深度的感悟',
patient: '耐心:不急于给建议,先倾听',
understanding: '善解人意:认可用户感受,表达共情',
insightful: '洞察力:看到问题本质',
calm: '冷静:帮助用户安定',
energetic: '活力:用积极的能量感染用户',
optimistic: '乐观:传递希望和可能性',
encouraging: '鼓励:肯定用户的努力',
compassionate: '同情:对困境表达理解',
gentle: '温柔:语气柔和,不给压力',
supportive: '支持:站在用户这边',
analytical: '分析:逻辑性强',
logical: '逻辑:理性思考',
practical: '实用:给出可操作的建议'
};
return descriptions[trait] || trait;
}
/**
* Complete agent configuration builder
*/
export function buildAgentConfig(
answers: OnboardingAnswers,
options?: {
userName?: string;
agentName?: string;
letterWritingDate?: Date;
currentDate?: Date;
language?: string;
personalityType?: keyof typeof personalityPresets;
isPremium?: boolean;
}
): AgentConfig {
const {
userName = '我',
agentName = '十年后的自己',
letterWritingDate = new Date(),
currentDate = new Date(),
language = 'zh-CN',
personalityType = 'warm',
isPremium = false
} = options || {};
const personality = getPersonalityByType(personalityType);
const languageStyle = getLanguageStyle(language);
const background = generateAgentBackground(answers, {
userName,
letterWritingDate
});
return {
name: agentName,
background,
personality,
language_style: languageStyle,
system_prompt: generateAgentSystemPrompt(answers, {
userName,
agentName,
letterWritingDate,
currentDate,
language,
personalityType,
isPremium
})
};
}
// ============================================================================
// Export for backward compatibility
// ============================================================================
/**
* @deprecated Use generateAgentSystemPrompt instead
*/
export function createAgentSystemPrompt(
answers: OnboardingAnswers,
userName: string = '我'
): string {
return generateAgentSystemPrompt(answers, { userName });
}

View File

@ -0,0 +1,63 @@
import { createBrowserClient } from '@supabase/ssr';
/**
* Supabase -
* API
*/
// 环境变量类型声明
declare global {
namespace NodeJS {
interface ProcessEnv {
NEXT_PUBLIC_SUPABASE_URL: string;
NEXT_PUBLIC_SUPABASE_ANON_KEY: string;
}
}
}
// 获取环境变量
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || '';
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
/**
* Supabase
* API
*/
export function createBrowserSupabaseClient() {
if (!supabaseUrl || !supabaseAnonKey) {
console.warn('Supabase environment variables not configured');
}
return createBrowserClient(supabaseUrl, supabaseAnonKey);
}
/**
*
*/
export function isClientSide(): boolean {
return typeof window !== 'undefined';
}
/**
* Supabase URL
*/
export function getSupabaseUrl(): string {
return supabaseUrl;
}
/**
* Supabase Anon Key
*/
export function getSupabaseAnonKey(): string {
return supabaseAnonKey;
}
// 导出默认客户端实例(懒加载)
import { createBrowserClient as createBrowserClientFn } from '@supabase/ssr';
let browserClient: ReturnType<typeof createBrowserClientFn> | null = null;
export function getBrowserClient(): ReturnType<typeof createBrowserClientFn> {
if (!browserClient && isClientSide()) {
browserClient = createBrowserSupabaseClient();
}
return browserClient!;
}

View File

@ -0,0 +1,76 @@
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
/**
* Supabase -
* Server Components API Routes
*/
// 环境变量类型声明
declare global {
namespace NodeJS {
interface ProcessEnv {
NEXT_PUBLIC_SUPABASE_URL: string;
NEXT_PUBLIC_SUPABASE_ANON_KEY: string;
SUPABASE_SERVICE_ROLE_KEY: string;
}
}
}
// 获取环境变量
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || '';
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY || '';
/**
* Supabase Server Components API Routes
* 使 cookies
*/
export async function createServerSupabaseClient() {
const cookieStore = await cookies();
return createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// 在某些上下文中可能无法设置 cookies
}
},
},
});
}
/**
* Service Role
*
* RLS使
* 使
*/
export function createServiceRoleClient() {
// 服务端环境检查
if (typeof window !== 'undefined') {
throw new Error('Service Role client can only be used on the server side');
}
if (!supabaseUrl || !supabaseServiceRoleKey) {
console.warn('Supabase Service Role key not configured');
return null;
}
// 动态导入以避免在客户端打包时包含
const { createClient } = require('@supabase/supabase-js');
return createClient(supabaseUrl, supabaseServiceRoleKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
}

68
src/lib/utils.ts Normal file
View File

@ -0,0 +1,68 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/**
* Merge Tailwind CSS classes with clsx
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Format date for display
*/
export function formatDate(date: Date | string, locale: string = 'zh-CN'): string {
const d = typeof date === 'string' ? new Date(date) : date;
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(d);
}
/**
* Format relative time (e.g., "2 hours ago")
*/
export function formatRelativeTime(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) return `${diffDays} 天前`;
if (diffHours > 0) return `${diffHours} 小时前`;
if (diffMinutes > 0) return `${diffMinutes} 分钟前`;
return '刚刚';
}
/**
* Truncate text with ellipsis
*/
export function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength - 3) + '...';
}
/**
* Generate a random ID
*/
export function generateId(): string {
return Math.random().toString(36).substring(2) + Date.now().toString(36);
}
/**
* Debounce function
*/
export function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}

181
src/store/index.ts Normal file
View File

@ -0,0 +1,181 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { User, Agent, StampBalance, Letter } from '@/types';
/**
* Auth Store
*/
interface AuthState {
user: User | null;
isLoading: boolean;
setUser: (user: User | null) => void;
setLoading: (loading: boolean) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
isLoading: true,
setUser: (user) => set({ user, isLoading: false }),
setLoading: (isLoading) => set({ isLoading }),
logout: () => set({ user: null }),
}),
{
name: 'auth-storage',
}
)
);
/**
* UI Store
*/
interface UIState {
theme: 'light' | 'dark' | 'system';
language: string;
sidebarOpen: boolean;
setTheme: (theme: 'light' | 'dark' | 'system') => void;
setLanguage: (language: string) => void;
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
}
export const useUIStore = create<UIState>()(
persist(
(set) => ({
theme: 'light',
language: 'zh-CN',
sidebarOpen: true,
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setSidebarOpen: (sidebarOpen) => set({ sidebarOpen }),
}),
{
name: 'ui-storage',
}
)
);
/**
* Letter Store
*/
interface LetterState {
letters: Letter[];
currentLetter: Letter | null;
isLoading: boolean;
setLetters: (letters: Letter[]) => void;
addLetter: (letter: Letter) => void;
updateLetter: (id: string, updates: Partial<Letter>) => void;
setCurrentLetter: (letter: Letter | null) => void;
setLoading: (loading: boolean) => void;
}
export const useLetterStore = create<LetterState>((set) => ({
letters: [],
currentLetter: null,
isLoading: false,
setLetters: (letters) => set({ letters }),
addLetter: (letter) => set((state) => ({ letters: [letter, ...state.letters] })),
updateLetter: (id, updates) =>
set((state) => ({
letters: state.letters.map((l) => (l.id === id ? { ...l, ...updates } : l)),
})),
setCurrentLetter: (currentLetter) => set({ currentLetter }),
setLoading: (isLoading) => set({ isLoading }),
}));
/**
* Agent Store
*/
interface AgentState {
agents: Agent[];
currentAgent: Agent | null;
isLoading: boolean;
setAgents: (agents: Agent[]) => void;
setCurrentAgent: (agent: Agent | null) => void;
setLoading: (loading: boolean) => void;
}
export const useAgentStore = create<AgentState>((set) => ({
agents: [],
currentAgent: null,
isLoading: false,
setAgents: (agents) => set({ agents }),
setCurrentAgent: (currentAgent) => set({ currentAgent }),
setLoading: (isLoading) => set({ isLoading }),
}));
/**
* Stamp Store
*/
interface StampState {
balance: StampBalance | null;
isLoading: boolean;
setBalance: (balance: StampBalance) => void;
setLoading: (loading: boolean) => void;
}
export const useStampStore = create<StampState>((set) => ({
balance: null,
isLoading: false,
setBalance: (balance) => set({ balance }),
setLoading: (isLoading) => set({ isLoading }),
}));
/**
* Onboarding Store
*/
interface OnboardingState {
currentStep: number;
answers: {
question_1: string;
question_2: string;
question_3: string;
question_4: string;
};
isSubmitting: boolean;
isCompleted: boolean;
setCurrentStep: (step: number) => void;
setAnswer: (question: keyof OnboardingState['answers'], answer: string) => void;
setAnswers: (answers: Partial<OnboardingState['answers']>) => void;
setSubmitting: (submitting: boolean) => void;
setCompleted: (completed: boolean) => void;
reset: () => void;
}
export const useOnboardingStore = create<OnboardingState>((set) => ({
currentStep: 0,
answers: {
question_1: '',
question_2: '',
question_3: '',
question_4: '',
},
isSubmitting: false,
isCompleted: false,
setCurrentStep: (currentStep) => set({ currentStep }),
setAnswer: (question, answer) =>
set((state) => ({
answers: { ...state.answers, [question]: answer },
})),
setAnswers: (answers) =>
set((state) => ({
answers: { ...state.answers, ...answers },
})),
setSubmitting: (isSubmitting) => set({ isSubmitting }),
setCompleted: (isCompleted) => set({ isCompleted }),
reset: () =>
set({
currentStep: 0,
answers: {
question_1: '',
question_2: '',
question_3: '',
question_4: '',
},
isSubmitting: false,
isCompleted: false,
}),
}));

160
src/types/extended.ts Normal file
View File

@ -0,0 +1,160 @@
/**
* Daily Statistics types
*/
import type { LetterStatus } from './index';
export interface DailyStats {
id: string;
user_id: string;
stat_date: string;
letters_sent: number;
stamps_used: number;
stamps_granted: number;
last_active_at?: string;
}
/**
* Collection types for stamps
*/
export interface StampCollection {
total_count: number;
collections: {
daily: { owned: number; total: number };
limited: { owned: number; total: number };
achievement: { owned: number; total: number };
};
stamps: Array<{
id: string;
code: string;
name: string;
rarity: 'common' | 'rare' | 'epic' | 'legendary';
image_url: string;
obtained_at: string;
}>;
}
/**
* Milestone with progress
*/
export interface MilestoneWithProgress {
code: string;
name: string;
description?: string;
progress: number;
target: number;
}
/**
* Achievement with reward info
*/
export interface AchievementWithReward {
id: string;
milestone: {
code: string;
name: string;
description?: string;
};
achieved_at: string;
reward_stamp?: {
name: string;
image_url: string;
};
}
/**
* User profile with derived stats
*/
export interface UserProfile {
id: string;
email: string;
avatar_url?: string;
language: string;
subscription_status: 'free' | 'premium';
created_at: string;
stats?: {
total_letters: number;
current_streak: number;
total_stamps: number;
};
}
/**
* Letter with agent info
*/
export interface LetterWithAgent {
id: string;
agent: {
id: string;
name: string;
avatar_url?: string;
};
content: string;
ai_reply?: string;
status: LetterStatus;
created_at: string;
replied_at?: string;
}
/**
* Send letter request
*/
export interface SendLetterRequest {
agent_id: string;
content: string;
stamp_id?: string; // Optional stamp to use
}
/**
* Send letter response
*/
export interface SendLetterResponse {
id: string;
status: LetterStatus;
scheduled_at: string;
message: string;
}
/**
* AI Reply options
*/
export interface GenerateReplyOptions {
agent_system_prompt: string;
letter_content: string;
mood_tags?: string[];
topic_tags?: string[];
keywords?: string[];
letter_number?: number;
user_language?: string;
subscription_status?: 'free' | 'premium';
}
/**
* Mood analysis result
*/
export interface MoodAnalysisResult {
mood_tags: string[];
topic_tags: string[];
keywords: string[];
sentiment_score: number;
analysis_summary: string;
}
/**
* API Error response
*/
export interface ApiError {
error: string;
code?: string;
details?: Record<string, unknown>;
}
/**
* Paginated request params
*/
export interface PaginationParams {
page?: number;
page_size?: number;
order_by?: string;
order?: 'asc' | 'desc';
}

169
src/types/index.ts Normal file
View File

@ -0,0 +1,169 @@
/**
* User types
*/
export interface User {
id: string;
email: string;
avatar_url?: string;
language: string;
subscription_status: 'free' | 'premium';
created_at: string;
updated_at: string;
}
/**
* Agent () types
*/
export interface Agent {
id: string;
user_id: string;
name: string;
personality: PersonalityTraits;
background: string;
avatar_url?: string;
is_custom: boolean;
is_default: boolean;
language: string;
system_prompt?: string;
created_at: string;
updated_at: string;
}
export interface PersonalityTraits {
traits: string[];
tone: 'gentle' | 'professional' | 'warm' | 'wise';
}
/**
* Letter () types
*/
export interface Letter {
id: string;
user_id: string;
agent_id: string;
agent?: Agent;
content: string;
ai_reply?: string;
status: LetterStatus;
scheduled_at?: string;
replied_at?: string;
created_at: string;
updated_at: string;
}
export type LetterStatus = 'draft' | 'pending' | 'replied' | 'failed';
/**
* Stamp () types
*/
export interface StampDefinition {
id: string;
code: string;
name: string;
description?: string;
stamp_type: 'daily' | 'limited' | 'achievement' | 'special';
rarity: 'common' | 'rare' | 'epic' | 'legendary';
image_url: string;
valid_from?: string;
valid_until?: string;
is_active: boolean;
}
export interface UserStamp {
id: string;
user_id: string;
stamp_def_id: string;
stamp_def?: StampDefinition;
source: string;
letter_id?: string;
obtained_at: string;
used_at?: string;
}
export interface StampBalance {
balance: number;
last_reset_at: string;
next_reset_at: string;
user_stamps: UserStamp[];
}
/**
* Growth tags types
*/
export interface GrowthTags {
id: string;
user_id: string;
letter_id: string;
mood_tags: string[];
topic_tags: string[];
behavior_tags: string[];
sentiment_score: number;
keywords: string[];
ai_analysis?: string;
created_at: string;
}
/**
* Milestone and Achievement types
*/
export interface Milestone {
id: string;
code: string;
name: string;
description?: string;
condition_type: 'registration' | 'letter_count' | 'consecutive_days' | 'days_active' | 'stamps_count';
condition_value: number;
reward_stamp_def_id?: string;
is_active: boolean;
}
export interface Achievement {
id: string;
user_id: string;
milestone_id: string;
milestone?: Milestone;
achieved_at: string;
notification_sent: boolean;
}
/**
* Onboarding types
*/
export interface OnboardingAnswers {
id: string;
user_id: string;
question_1: string;
question_2: string;
question_3: string;
question_4: string;
created_at: string;
}
/**
* API Response types
*/
export interface ApiResponse<T> {
data?: T;
error?: string;
message?: string;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
page_size: number;
has_more: boolean;
}
/**
* Growth statistics types
*/
export interface GrowthStats {
total_letters: number;
current_streak: number;
longest_streak: number;
mood_distribution: Record<string, number>;
topic_distribution: Record<string, number>;
monthly_activity: Array<{ month: string; letters: number }>;
}

4
src/types/json.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module '*.json' {
const value: any;
export default value;
}

View File

@ -0,0 +1,379 @@
-- ============================================================================
-- Echo (回声) - 初始数据库 Schema 迁移脚本
-- 版本: 202601120001
-- 创建日期: 2026-01-12
-- ============================================================================
-- 启用 UUID 扩展
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ============================================================================
-- 第一部分: 公共函数定义
-- ============================================================================
-- 更新时间戳触发器函数
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- ============================================================================
-- 第二部分: 表创建(按依赖顺序)
-- ============================================================================
-- ---------------------------------------------------------------------------
-- 2.1 users - 用户表
-- ---------------------------------------------------------------------------
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
google_id VARCHAR(255) UNIQUE,
avatar_url TEXT,
language VARCHAR(10) DEFAULT 'zh-CN',
subscription_status VARCHAR(20) DEFAULT 'free',
subscription_expires_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 用户表索引
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_google_id ON users(google_id);
-- ---------------------------------------------------------------------------
-- 2.2 agents - 智能体表
-- ---------------------------------------------------------------------------
CREATE TABLE agents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
personality JSONB NOT NULL DEFAULT '{}',
background TEXT,
avatar_url TEXT,
is_custom BOOLEAN DEFAULT FALSE,
is_default BOOLEAN DEFAULT FALSE,
language VARCHAR(10) DEFAULT 'zh-CN',
system_prompt TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 智能体表索引
CREATE INDEX idx_agents_user_id ON agents(user_id);
CREATE INDEX idx_agents_default ON agents(user_id, is_default) WHERE is_custom = FALSE;
-- ---------------------------------------------------------------------------
-- 2.3 onboarding_answers - 初始问题回答表
-- ---------------------------------------------------------------------------
CREATE TABLE onboarding_answers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
question_1 TEXT NOT NULL,
question_2 TEXT NOT NULL,
question_3 TEXT NOT NULL,
question_4 TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 初始问题回答表索引
CREATE INDEX idx_onboarding_answers_user_id ON onboarding_answers(user_id);
-- ---------------------------------------------------------------------------
-- 2.4 letters - 信件表
-- ---------------------------------------------------------------------------
CREATE TABLE letters (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
content TEXT NOT NULL,
ai_reply TEXT,
status VARCHAR(20) DEFAULT 'draft',
scheduled_at TIMESTAMP WITH TIME ZONE,
replied_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 信件表索引
CREATE INDEX idx_letters_user_id ON letters(user_id);
CREATE INDEX idx_letters_agent_id ON letters(agent_id);
CREATE INDEX idx_letters_status ON letters(status);
CREATE INDEX idx_letters_scheduled ON letters(status, scheduled_at)
WHERE status = 'pending';
-- ---------------------------------------------------------------------------
-- 2.5 stamp_definitions - 邮票定义表
-- ---------------------------------------------------------------------------
CREATE TABLE stamp_definitions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(50) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
description TEXT,
stamp_type VARCHAR(30) NOT NULL,
rarity VARCHAR(20) DEFAULT 'common',
image_url TEXT NOT NULL,
valid_from TIMESTAMP WITH TIME ZONE,
valid_until TIMESTAMP WITH TIME ZONE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 邮票定义表索引
CREATE INDEX idx_stamp_definitions_code ON stamp_definitions(code);
CREATE INDEX idx_stamp_definitions_type ON stamp_definitions(stamp_type);
CREATE INDEX idx_stamp_definitions_active ON stamp_definitions(is_active);
-- ---------------------------------------------------------------------------
-- 2.6 user_stamps - 用户邮票表
-- ---------------------------------------------------------------------------
CREATE TABLE user_stamps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
stamp_def_id UUID NOT NULL REFERENCES stamp_definitions(id),
source VARCHAR(30) NOT NULL,
letter_id UUID REFERENCES letters(id),
obtained_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
used_at TIMESTAMP WITH TIME ZONE
);
-- 用户邮票表索引
CREATE INDEX idx_user_stamps_user_id ON user_stamps(user_id);
CREATE INDEX idx_user_stamps_letter_id ON user_stamps(letter_id);
CREATE INDEX idx_user_stamps_unused ON user_stamps(user_id, used_at)
WHERE used_at IS NULL;
-- ---------------------------------------------------------------------------
-- 2.7 growth_tags - 成长标签表
-- ---------------------------------------------------------------------------
CREATE TABLE growth_tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
letter_id UUID NOT NULL REFERENCES letters(id) ON DELETE CASCADE,
mood_tags TEXT[] DEFAULT '{}',
topic_tags TEXT[] DEFAULT '{}',
behavior_tags TEXT[] DEFAULT '{}',
sentiment_score DECIMAL(3,2),
keywords TEXT[] DEFAULT '{}',
ai_analysis TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 成长标签表索引
CREATE INDEX idx_growth_tags_user_id ON growth_tags(user_id);
CREATE INDEX idx_growth_tags_letter_id ON growth_tags(letter_id);
CREATE INDEX idx_growth_tags_mood ON growth_tags USING GIN (mood_tags);
CREATE INDEX idx_growth_tags_topic ON growth_tags USING GIN (topic_tags);
-- ---------------------------------------------------------------------------
-- 2.8 milestones - 里程碑定义表
-- ---------------------------------------------------------------------------
CREATE TABLE milestones (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(50) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
description TEXT,
condition_type VARCHAR(30) NOT NULL,
condition_value INTEGER NOT NULL,
reward_stamp_def_id UUID REFERENCES stamp_definitions(id),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 里程碑定义表索引
CREATE INDEX idx_milestones_code ON milestones(code);
CREATE INDEX idx_milestones_active ON milestones(is_active);
-- ---------------------------------------------------------------------------
-- 2.9 achievements - 用户成就表
-- ---------------------------------------------------------------------------
CREATE TABLE achievements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
milestone_id UUID NOT NULL REFERENCES milestones(id),
achieved_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
notification_sent BOOLEAN DEFAULT FALSE,
UNIQUE(user_id, milestone_id)
);
-- 用户成就表索引
CREATE INDEX idx_achievements_user_id ON achievements(user_id);
CREATE INDEX idx_achievements_milestone_id ON achievements(milestone_id);
-- ---------------------------------------------------------------------------
-- 2.10 daily_stats - 每日统计表
-- ---------------------------------------------------------------------------
CREATE TABLE daily_stats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
stat_date DATE NOT NULL,
letters_sent INTEGER DEFAULT 0,
stamps_used INTEGER DEFAULT 0,
stamps_granted INTEGER DEFAULT 0,
last_active_at TIMESTAMP WITH TIME ZONE,
UNIQUE(user_id, stat_date)
);
-- 每日统计表索引
CREATE INDEX idx_daily_stats_user_date ON daily_stats(user_id, stat_date);
-- ============================================================================
-- 第三部分: 自动更新时间戳触发器
-- ============================================================================
CREATE TRIGGER update_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_agents_updated_at
BEFORE UPDATE ON agents
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_letters_updated_at
BEFORE UPDATE ON letters
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================================
-- 第四部分: Row Level Security (RLS) 策略
-- ============================================================================
-- 4.1 users - 用户表 RLS
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own profile" ON users
FOR SELECT USING (auth.uid() = id);
CREATE POLICY "Users can update own profile" ON users
FOR UPDATE USING (auth.uid() = id);
-- 4.2 agents - 智能体表 RLS
ALTER TABLE agents ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own agents" ON agents
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Users can create agents" ON agents
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can update own agents" ON agents
FOR UPDATE USING (auth.uid() = user_id);
CREATE POLICY "Users can delete own agents" ON agents
FOR DELETE USING (auth.uid() = user_id);
-- 4.3 onboarding_answers - 初始问题回答表 RLS
ALTER TABLE onboarding_answers ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own onboarding answers" ON onboarding_answers
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Users can create onboarding answers" ON onboarding_answers
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can update own onboarding answers" ON onboarding_answers
FOR UPDATE USING (auth.uid() = user_id);
-- 4.4 letters - 信件表 RLS
ALTER TABLE letters ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own letters" ON letters
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Users can create letters" ON letters
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can update own letters" ON letters
FOR UPDATE USING (auth.uid() = user_id);
CREATE POLICY "Users can delete own letters" ON letters
FOR DELETE USING (auth.uid() = user_id);
-- 4.5 stamp_definitions - 邮票定义表 RLS公开查询
ALTER TABLE stamp_definitions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Anyone can view stamp definitions" ON stamp_definitions
FOR SELECT USING (true);
-- 4.6 user_stamps - 用户邮票表 RLS
ALTER TABLE user_stamps ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own stamps" ON user_stamps
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "System can update user stamps" ON user_stamps
FOR UPDATE USING (auth.role() = 'service_role');
CREATE POLICY "System can insert user stamps" ON user_stamps
FOR INSERT WITH CHECK (auth.role() = 'service_role');
-- 4.7 growth_tags - 成长标签表 RLS
ALTER TABLE growth_tags ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own growth tags" ON growth_tags
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "System can create growth tags" ON growth_tags
FOR INSERT WITH CHECK (auth.role() = 'service_role');
CREATE POLICY "System can update growth tags" ON growth_tags
FOR UPDATE USING (auth.role() = 'service_role');
-- 4.8 milestones - 里程碑定义表 RLS公开查询
ALTER TABLE milestones ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Anyone can view milestones" ON milestones
FOR SELECT USING (true);
-- 4.9 achievements - 用户成就表 RLS
ALTER TABLE achievements ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own achievements" ON achievements
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "System can create achievements" ON achievements
FOR INSERT WITH CHECK (auth.role() = 'service_role');
-- 4.10 daily_stats - 每日统计表 RLS
ALTER TABLE daily_stats ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own daily stats" ON daily_stats
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "System can update daily stats" ON daily_stats
FOR UPDATE USING (auth.role() = 'service_role');
CREATE POLICY "System can insert daily stats" ON daily_stats
FOR INSERT WITH CHECK (auth.role() = 'service_role');
-- ============================================================================
-- 第五部分: 初始数据插入
-- ============================================================================
-- 5.1 邮票定义初始数据
INSERT INTO stamp_definitions (code, name, description, stamp_type, rarity, image_url, is_active) VALUES
('daily_default', '每日邮票', '每日自动发放的基础邮票', 'daily', 'common', '/stamps/daily.png', TRUE),
('welcome', '初遇回声', '完成注册,获得的第一个成就', 'achievement', 'rare', '/stamps/welcome.png', TRUE),
('first_letter', '第一封信', '成功发送第一封信件', 'achievement', 'rare', '/stamps/first_letter.png', TRUE),
('week_streak', '持续对话', '连续 7 天写信', 'achievement', 'epic', '/stamps/week_streak.png', TRUE),
('month_streak', '一月相伴', '连续 30 天写信', 'achievement', 'legendary', '/stamps/month_streak.png', TRUE),
('spring_2025', '春日限定 2025', '2025 春季活动限定邮票', 'limited', 'epic', '/stamps/spring_2025.png', TRUE),
('collector_10', '集邮家', '收集 10 种不同邮票', 'achievement', 'rare', '/stamps/collector_10.png', TRUE),
('collector_50', '邮票猎人', '收集 50 种不同邮票', 'achievement', 'epic', '/stamps/collector_50.png', TRUE);
-- 5.2 里程碑定义初始数据
INSERT INTO milestones (code, name, description, condition_type, condition_value, reward_stamp_def_id, is_active) VALUES
('welcome', '初遇回声', '完成注册,开启回声之旅', 'registration', 1, (SELECT id FROM stamp_definitions WHERE code = 'welcome'), TRUE),
('first_letter', '第一封信', '成功发送第一封信给未来的自己', 'letter_count', 1, (SELECT id FROM stamp_definitions WHERE code = 'first_letter'), TRUE),
('week_streak', '持续对话', '连续 7 天写信', 'consecutive_days', 7, (SELECT id FROM stamp_definitions WHERE code = 'week_streak'), TRUE),
('month_streak', '一月相伴', '连续 30 天写信', 'consecutive_days', 30, (SELECT id FROM stamp_definitions WHERE code = 'month_streak'), TRUE),
('letters_10', '十封信', '累计发送 10 封信', 'total_letters', 10, NULL, TRUE),
('letters_50', '五十封信', '累计发送 50 封信', 'total_letters', 50, NULL, TRUE),
('letters_100', '百封信', '累计发送 100 封信', 'total_letters', 100, NULL, TRUE),
('collector_10', '集邮家初级', '收集 10 种不同邮票', 'unique_stamps', 10, (SELECT id FROM stamp_definitions WHERE code = 'collector_10'), TRUE),
('collector_50', '集邮家高级', '收集 50 种不同邮票', 'unique_stamps', 50, (SELECT id FROM stamp_definitions WHERE code = 'collector_50'), TRUE);
-- ============================================================================
-- 迁移完成
-- ============================================================================

View File

@ -0,0 +1,90 @@
-- ============================================================================
-- Echo (回声) - Schema 修复迁移脚本
-- 版本: 202601120002
-- 创建日期: 2026-01-12
-- 修复内容:
-- 1. user_stamps 表外键添加 ON DELETE CASCADE
-- 2. growth_tags 表添加 UPDATE 策略
-- 3. achievements 表添加 UPDATE 策略
-- 4. daily_stats 表添加 INSERT 策略
-- 5. growth_tags 表添加 behavior_tags GIN 索引
-- ============================================================================
-- ============================================================================
-- 修复 1: user_stamps 表外键添加 ON DELETE CASCADE
-- ============================================================================
ALTER TABLE user_stamps DROP CONSTRAINT user_stamps_stamp_def_id_fkey;
ALTER TABLE user_stamps ADD CONSTRAINT user_stamps_stamp_def_id_fkey
FOREIGN KEY (stamp_def_id) REFERENCES stamp_definitions(id) ON DELETE CASCADE;
-- ============================================================================
-- 修复 2: growth_tags 表添加 UPDATE 策略
-- ============================================================================
-- 检查是否已存在 UPDATE 策略
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'growth_tags'
AND policyname = 'System can update growth tags'
) THEN
CREATE POLICY "System can update growth tags" ON growth_tags
FOR UPDATE USING (auth.role() = 'service_role');
END IF;
END $$;
-- ============================================================================
-- 修复 3: achievements 表添加 UPDATE 策略
-- ============================================================================
-- 检查是否已存在 UPDATE 策略
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'achievements'
AND policyname = 'System can update achievements'
) THEN
CREATE POLICY "System can update achievements" ON achievements
FOR UPDATE USING (auth.role() = 'service_role');
END IF;
END $$;
-- ============================================================================
-- 修复 4: daily_stats 表添加 INSERT 策略
-- ============================================================================
-- 检查是否已存在 INSERT 策略
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'daily_stats'
AND policyname = 'System can insert daily stats'
) THEN
CREATE POLICY "System can insert daily stats" ON daily_stats
FOR INSERT WITH CHECK (auth.role() = 'service_role');
END IF;
END $$;
-- ============================================================================
-- 修复 5: growth_tags 表添加 behavior_tags GIN 索引
-- ============================================================================
-- 检查索引是否已存在
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'growth_tags'
AND indexname = 'idx_growth_tags_behavior'
) THEN
CREATE INDEX idx_growth_tags_behavior ON growth_tags USING GIN (behavior_tags);
END IF;
END $$;
-- ============================================================================
-- 修复完成
-- ============================================================================

View File

@ -0,0 +1,90 @@
-- ============================================================================
-- Echo (回声) - Onboarding 功能 Schema 修复迁移脚本
-- 版本: 202601120003
-- 创建日期: 2026-01-12
-- 修复内容:
-- 1. users 表添加 has_completed_onboarding 字段
-- 2. onboarding_answers 表添加 updated_at 字段
-- 3. onboarding_answers 表添加 updated_at 触发器
-- 4. 确保 onboarding_answers RLS 策略存在
-- ============================================================================
-- ============================================================================
-- 修复 1: users 表添加 has_completed_onboarding 字段
-- ============================================================================
ALTER TABLE users ADD COLUMN IF NOT EXISTS has_completed_onboarding BOOLEAN DEFAULT FALSE;
-- ============================================================================
-- 修复 2: onboarding_answers 表添加 updated_at 字段
-- ============================================================================
ALTER TABLE onboarding_answers ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW();
-- ============================================================================
-- 修复 3: onboarding_answers 表添加 updated_at 触发器
-- ============================================================================
-- 检查触发器是否已存在
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.triggers
WHERE trigger_name = 'update_onboarding_answers_updated_at'
AND table_name = 'onboarding_answers'
) THEN
CREATE TRIGGER update_onboarding_answers_updated_at
BEFORE UPDATE ON onboarding_answers
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
END $$;
-- ============================================================================
-- 修复 4: 确保 onboarding_answers RLS 策略存在
-- ============================================================================
-- 启用 RLS如果尚未启用
ALTER TABLE onboarding_answers ENABLE ROW LEVEL SECURITY;
-- 检查并创建 SELECT 策略
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'onboarding_answers'
AND policyname = 'Users can view own onboarding answers'
) THEN
CREATE POLICY "Users can view own onboarding answers" ON onboarding_answers
FOR SELECT USING (auth.uid() = user_id);
END IF;
END $$;
-- 检查并创建 INSERT 策略
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'onboarding_answers'
AND policyname = 'Users can create onboarding answers'
) THEN
CREATE POLICY "Users can create onboarding answers" ON onboarding_answers
FOR INSERT WITH CHECK (auth.uid() = user_id);
END IF;
END $$;
-- 检查并创建 UPDATE 策略
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'onboarding_answers'
AND policyname = 'Users can update own onboarding answers'
) THEN
CREATE POLICY "Users can update own onboarding answers" ON onboarding_answers
FOR UPDATE USING (auth.uid() = user_id);
END IF;
END $$;
-- ============================================================================
-- 迁移完成
-- ============================================================================

175
supabase/seed.sql Normal file
View File

@ -0,0 +1,175 @@
-- ============================================================================
-- Echo (回声) - 测试数据种子脚本
-- 版本: 202601120001
-- 创建日期: 2026-01-12
-- 说明: 用于本地开发和测试的初始数据
-- ============================================================================
-- ============================================================================
-- 第一部分: 测试用户数据
-- ============================================================================
-- 插入测试用户
INSERT INTO users (id, email, google_id, avatar_url, language, subscription_status) VALUES
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'test1@example.com', 'google_12345', 'https://api.dicebear.com/7.x/avataaars/svg?seed=test1', 'zh-CN', 'free'),
('b2c3d4e5-f6a7-8901-bcde-f23456789012', 'test2@example.com', 'google_67890', 'https://api.dicebear.com/7.x/avataaars/svg?seed=test2', 'zh-CN', 'premium'),
('c3d4e5f6-a7b8-9012-cdef-345678901234', 'test3@example.com', NULL, 'https://api.dicebear.com/7.x/avataaars/svg?seed=test3', 'en-US', 'free');
-- ============================================================================
-- 第二部分: 测试智能体数据
-- ============================================================================
-- 测试用户 1 的智能体
INSERT INTO agents (id, user_id, name, personality, background, avatar_url, is_custom, is_default, language, system_prompt) VALUES
('d4e5f6a7-b8c9-0123-defa-456789012345', 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', '十年后的自己',
'{"traits": ["warm", "wise", "patient"], "tone": "gentle"}',
'你是一个在海边开书店的中年人,生活简单而充实。每天早上你会去海边散步,然后开始在书店里整理书籍,等待有缘人的到来。',
'avatar_default_1.png', FALSE, TRUE, 'zh-CN',
'你是用户「test1」的未来的自己。你在海边开了一家书店生活简单而充实。'),
('e5f6a7b8-c9d0-1234-efab-567890123456', 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', '未来的投资人',
'{"traits": ["analytical", "confident", "strategic"], "tone": "professional"}',
'你是一个成功的投资人,在金融行业打拼多年。你懂得如何做出明智的决策,也经历过失败和挫折。',
'avatar_custom_1.png', TRUE, FALSE, 'zh-CN',
'你是用户「test1」想象中的未来的投资人角色。');
-- 测试用户 2 的智能体
INSERT INTO agents (id, user_id, name, personality, background, avatar_url, is_custom, is_default, language, system_prompt) VALUES
('f6a7b8c9-d0e1-2345-fabc-678901234567', 'b2c3d4e5-f6a7-8901-bcde-f23456789012', '五年后的自己',
'{"traits": ["energetic", "optimistic", "creative"], "tone": "encouraging"}',
'你是一个自由创作者,在世界各地旅居,同时进行写作和艺术创作。生活充满可能性和惊喜。',
'avatar_default_2.png', FALSE, TRUE, 'zh-CN',
'你是用户「test2」想象中的五年后的自己一个自由创作者。');
-- 测试用户 3 的智能体
INSERT INTO agents (id, user_id, name, personality, background, avatar_url, is_custom, is_default, language, system_prompt) VALUES
('a7b8c9d0-e1f2-3456-bcde-789012345678', 'c3d4e5f6-a7b8-9012-cdef-345678901234', 'Future Self',
'{"traits": ["wise", "calm", "insightful"], "tone": "thoughtful"}',
'You are living in a peaceful mountain cabin, working on meaningful projects while maintaining a balanced life.',
'avatar_default_3.png', FALSE, TRUE, 'en-US',
'You are user「test3」s future self, living a balanced life in a mountain cabin.');
-- ============================================================================
-- 第三部分: 测试初始问题回答数据
-- ============================================================================
INSERT INTO onboarding_answers (user_id, question_1, question_2, question_3, question_4) VALUES
('a1b2c3d4-e5f6-7890-abcd-ef1234567890',
'希望成为一个内心平静、对生活有掌控力的人,能够帮助他人实现梦想。',
'希望在一个靠海的小城,有一个自己的书店或咖啡馆,生活节奏缓慢而充实。',
'目前最大的困扰是对未来的不确定感,不知道自己选择的路是否正确。',
'最在乎的人是父母和几个多年的老朋友。'),
('b2c3d4e5-f6a7-8901-bcde-f23456789012',
'希望成为一个自由且有创造力的人,能够通过自己的作品影响他人。',
'世界各地,目前可能在一个温暖的海边城市,或者欧洲的某个小镇。',
'焦虑于如何在追求梦想和维持生计之间找到平衡。',
'家人和一些志同道合的朋友。'),
('c3d4e5f6-a7b8-9012-cdef-345678901234',
'Someone who has found inner peace and lives authentically according to their values.',
'Living in a peaceful mountain area or a small coastal town, close to nature.',
'Feeling stuck in the daily grind and unsure how to break free.',
'My family and a few close friends who truly understand me.');
-- ============================================================================
-- 第四部分: 测试信件数据
-- ============================================================================
-- 测试用户 1 的信件
INSERT INTO letters (id, user_id, agent_id, content, ai_reply, status, scheduled_at, replied_at) VALUES
-- 已回复的信件
('b8c9d0e1-f2a3-4567-cdef-890123456789', 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'd4e5f6a7-b8c9-0123-defa-456789012345',
'亲爱的未来的我,你好。我现在对未来的职业选择感到非常迷茫。我正在考虑是否要换一份工作,但又不确定这是不是一个正确的决定。希望你能给我一些建议。',
'亲爱的过去的自己,收到你的信了。我理解你现在的迷茫和不确定。我只想告诉你,人生没有绝对正确的道路,只有适合当下的选择。重要的是保持学习的心态,勇敢尝试。',
'replied', '2026-01-10 10:00:00+00', '2026-01-10 18:30:00+00'),
('c9d0e1f2-a3b4-5678-defa-901234567890', 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'd4e5f6a7-b8c9-0123-defa-456789012345',
'最近压力很大,感觉身边没有人能理解我。我想对未来的自己说,希望那时的你已经找到了内心的平静。',
'亲爱的,我收到你的信了。首先,我想告诉你,压力是成长的一部分。当我回望过去,我发现那些让我感到最艰难的时刻,往往是塑造我的关键时刻。',
'replied', '2026-01-11 10:00:00+00', '2026-01-11 16:45:00+00'),
-- 待处理的信件
('d0e1f2a3-b4c5-6789-efab-012345678901', 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'd4e5f6a7-b8c9-0123-defa-456789012345',
'今天是我的生日,想给未来的自己写一封信。希望明年的今天,我已经实现了自己的目标,变成了更好的自己。',
NULL, 'pending', '2026-01-12 12:00:00+00', NULL),
-- 草稿信件
('e1f2a3b4-c5d6-7890-fabc-123456789012', 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'd4e5f6a7-b8c9-0123-defa-456789012345',
'这是一封草稿信件,我还在思考要写什么内容。',
NULL, 'draft', NULL, NULL);
-- 测试用户 2 的信件
INSERT INTO letters (id, user_id, agent_id, content, ai_reply, status, scheduled_at, replied_at) VALUES
('f2a3b4c5-d6e7-8901-bcde-234567890123', 'b2c3d4e5-f6a7-8901-bcde-f23456789012', 'f6a7b8c9-d0e1-2345-fabc-678901234567',
'我想辞掉现在稳定的工作,去追求自己的梦想。但是又害怕失败,你说我应该怎么做?',
'收到你的信了!我理解你的纠结。稳定和梦想之间的选择确实不容易。但我想告诉你,失败不可怕,可怕的是从未尝试。',
'replied', '2026-01-09 14:00:00+00', '2026-01-09 20:00:00+00');
-- ============================================================================
-- 第五部分: 测试邮票数据
-- ============================================================================
-- 测试用户 1 的邮票
INSERT INTO user_stamps (user_id, stamp_def_id, source, letter_id, obtained_at, used_at) VALUES
-- 每日邮票(未使用)
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', (SELECT id FROM stamp_definitions WHERE code = 'daily_default'), 'daily_grant', NULL, '2026-01-12 00:00:00+00', NULL),
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', (SELECT id FROM stamp_definitions WHERE code = 'daily_default'), 'daily_grant', NULL, '2026-01-11 00:00:00+00', NULL),
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', (SELECT id FROM stamp_definitions WHERE code = 'daily_default'), 'daily_grant', NULL, '2026-01-10 00:00:00+00', NULL),
-- 已使用的邮票
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', (SELECT id FROM stamp_definitions WHERE code = 'daily_default'), 'daily_grant', 'b8c9d0e1-f2a3-4567-cdef-890123456789', '2026-01-09 00:00:00+00', '2026-01-10 10:00:00+00'),
-- 成就邮票
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', (SELECT id FROM stamp_definitions WHERE code = 'welcome'), 'achievement', NULL, '2026-01-01 12:00:00+00', NULL),
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', (SELECT id FROM stamp_definitions WHERE code = 'first_letter'), 'achievement', 'b8c9d0e1-f2a3-4567-cdef-890123456789', '2026-01-10 10:00:00+00', NULL);
-- 测试用户 2 的邮票
INSERT INTO user_stamps (user_id, stamp_def_id, source, letter_id, obtained_at, used_at) VALUES
-- 付费用户每日多张邮票
('b2c3d4e5-f6a7-8901-bcde-f23456789012', (SELECT id FROM stamp_definitions WHERE code = 'daily_default'), 'daily_grant', NULL, '2026-01-12 00:00:00+00', NULL),
('b2c3d4e5-f6a7-8901-bcde-f23456789012', (SELECT id FROM stamp_definitions WHERE code = 'daily_default'), 'daily_grant', NULL, '2026-01-12 00:00:00+00', NULL),
('b2c3d4e5-f6a7-8901-bcde-f23456789012', (SELECT id FROM stamp_definitions WHERE code = 'daily_default'), 'daily_grant', NULL, '2026-01-12 00:00:00+00+00', NULL);
-- ============================================================================
-- 第六部分: 测试成长标签数据
-- ============================================================================
INSERT INTO growth_tags (user_id, letter_id, mood_tags, topic_tags, behavior_tags, sentiment_score, keywords, ai_analysis) VALUES
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'b8c9d0e1-f2a3-4567-cdef-890123456789',
ARRAY['anxious', 'hopeful'], ARRAY['career'], ARRAY['increased_frequency'], 0.45,
ARRAY['迷茫', '选择', '未来', '建议', '决定'],
'用户对职业选择感到迷茫,但同时对未来抱有希望。这是一个关于职业转型的关键时刻。'),
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'c9d0e1f2-a3b4-5678-defa-901234567890',
ARRAY['anxious', 'peaceful'], ARRAY['mental_health', 'growth'], ARRAY['stable'], 0.30,
ARRAY['压力', '平静', '理解', '关键'],
'用户正在经历压力期,但展现出内在的韧性。情绪标签显示焦虑与平静并存,表明用户正在寻找平衡。'),
('b2c3d4e5-f6a7-8901-bcde-f23456789012', 'f2a3b4c5-d6e7-8901-bcde-234567890123',
ARRAY['determined', 'confused'], ARRAY['career', 'lifestyle'], ARRAY['contemplation'], 0.55,
ARRAY['梦想', '工作', '害怕', '尝试', '失败'],
'用户在稳定和追求梦想之间挣扎,但表现出强烈的改变意愿。这是一个重要的转折点。');
-- ============================================================================
-- 第七部分: 测试成就数据
-- ============================================================================
INSERT INTO achievements (user_id, milestone_id, achieved_at, notification_sent) VALUES
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', (SELECT id FROM milestones WHERE code = 'welcome'), '2026-01-01 12:00:00+00', TRUE),
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', (SELECT id FROM milestones WHERE code = 'first_letter'), '2026-01-10 10:00:00+00', TRUE),
('b2c3d4e5-f6a7-8901-bcde-f23456789012', (SELECT id FROM milestones WHERE code = 'welcome'), '2026-01-05 10:00:00+00', TRUE);
-- ============================================================================
-- 第八部分: 测试每日统计数据
-- ============================================================================
INSERT INTO daily_stats (user_id, stat_date, letters_sent, stamps_used, stamps_granted, last_active_at) VALUES
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', '2026-01-12', 0, 0, 1, '2026-01-12 10:00:00+00'),
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', '2026-01-11', 1, 0, 1, '2026-01-11 10:00:00+00'),
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', '2026-01-10', 1, 1, 1, '2026-01-10 10:00:00+00'),
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', '2026-01-09', 0, 0, 1, '2026-01-09 00:00:00+00'),
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', '2026-01-08', 0, 0, 1, '2026-01-08 00:00:00+00'),
('b2c3d4e5-f6a7-8901-bcde-f23456789012', '2026-01-12', 0, 0, 3, '2026-01-12 00:00:00+00'),
('b2c3d4e5-f6a7-8901-bcde-f23456789012', '2026-01-11', 1, 0, 3, '2026-01-11 14:00:00+00');
-- ============================================================================
-- 种子数据插入完成
-- ============================================================================
-- 验证数据
SELECT 'Users count: ' || COUNT(*)::TEXT FROM users;
SELECT 'Agents count: ' || COUNT(*)::TEXT FROM agents;
SELECT 'Letters count: ' || COUNT(*)::TEXT FROM letters;
SELECT 'User stamps count: ' || COUNT(*)::TEXT FROM user_stamps;
SELECT 'Achievements count: ' || COUNT(*)::TEXT FROM achievements;

34
tsconfig.json Normal file
View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

24
vercel.json Normal file
View File

@ -0,0 +1,24 @@
{
"framework": "nextjs",
"regions": ["iad1", "hnd1"],
"env": {
"NEXT_PUBLIC_SUPABASE_URL": "@supabase_url",
"NEXT_PUBLIC_SUPABASE_ANON_KEY": "@supabase_anon_key",
"SUPABASE_SERVICE_ROLE_KEY": "@supabase_service_role_key",
"DEEPSEEK_API_KEY": "@deepseek_api_key"
},
"crons": [
{
"path": "/api/cron/process-letters",
"schedule": "*/5 * * * *"
},
{
"path": "/api/cron/daily-stamps",
"schedule": "0 0 * * *"
},
{
"path": "/api/cron/check-milestones",
"schedule": "0 * * * *"
}
]
}