Compare commits

Author SHA1 Message Date
jackyu66git 35f1b7ffb3 feat: 接入 Google Analytics (G-LVVXH3TL04) 2026-07-02 15:57:36 +08:00
jackyu66gitandClaude Opus 4.7 6530b20172 feat: guided light meditation + Edge TTS neural voice audio
- Add continuous guided light-body-scan meditation (16 segments, 97% coverage)
- Edge TTS API endpoint with Xiaoxiao neural voice at -40% rate
- Pre-generated chime WAVs and guidance MP3s in public/audio/
- Mute toggle in practice page, sequenced non-overlapping audio
- Fix middleware to serve /audio and /api/audio/tts without auth
- All LLM output localized to Chinese; fix blank home/settings pages
- DeepSeek API compat: json_object, increased max_tokens
- Analytics, cron reminders, onboarding flow fixes

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-17 20:08:29 +08:00
38 changed files with 1386 additions and 182 deletions
+30 -30
View File
@@ -3,105 +3,105 @@
"type": "attention_training",
"difficulty": 1,
"duration": 3,
"description": "Breath counting: Count each exhale from 1 to 10, then start over. If your mind wanders, gently return to 1.",
"reason": "Foundational attention exercise. Builds the skill of noticing when attention drifts and bringing it back without judgment."
"description": "数呼吸:从1数到10,每次呼气数一个数,然后重新开始。如果走神了,温柔地回到1。",
"reason": "基础的注意力练习。培养觉察注意力游离并温柔带回的能力,不批判自己。"
},
{
"type": "attention_training",
"difficulty": 2,
"duration": 5,
"description": "Breath at the nostrils: Focus on the sensation of air passing the tip of your nose. Notice temperature, texture, and rhythm without changing anything.",
"reason": "Narrower focus point than breath counting. Trains precision of attention by anchoring to a subtle physical sensation."
"description": "鼻端呼吸:专注感受空气经过鼻尖的感觉。留意温度、质感和节奏,不需要改变任何东西。",
"reason": "比数呼吸更精细的专注锚点。通过聚焦微妙的生理感受来训练注意力的精准度。"
},
{
"type": "attention_training",
"difficulty": 3,
"duration": 10,
"description": "Open awareness: Sit quietly and let your attention rest on whatever arises — sounds, body sensations, thoughts. Don't cling or reject. Just notice.",
"reason": "Advanced attention exercise. Builds meta-awareness — the ability to observe the mind's movements without being carried away by them."
"description": "开放觉知:安静地坐着,让注意力自然地停留在任何浮现的事物上——声音、身体感受、念头。不抓取,不排斥,只是观察。",
"reason": "进阶注意力练习。培养元觉知——观察心智活动而不被其带走的能力。"
},
{
"type": "emotion_awareness",
"difficulty": 1,
"duration": 3,
"description": "Name the weather: Close your eyes and ask 'What's the emotional weather right now?' Label it simply — sunny, cloudy, stormy, still. Don't explain why. Just name it.",
"reason": "Builds the vocabulary and habit of checking in with emotional state. Naming emotions reduces their intensity and creates space for choice."
"description": "情绪气象站:闭上眼睛,问自己「此刻的情绪天气是什么?」简单地命名——晴朗、多云、暴风雨、平静。不用解释为什么,只需命名。",
"reason": "建立情绪觉察的习惯和词汇。命名情绪可以降低其强度,为选择创造空间。"
},
{
"type": "emotion_awareness",
"difficulty": 2,
"duration": 5,
"description": "Body scan: Slowly move attention from the top of your head to your toes. At each body part, notice any sensation — tightness, warmth, buzzing, emptiness. Don't try to change anything.",
"reason": "Emotions live in the body before they reach conscious awareness. Body scanning builds the sensory vocabulary that makes emotional signals legible."
"description": "身体扫描:将注意力从头顶慢慢移到脚尖。在每个身体部位停留,留意任何感觉——紧绷、温暖、麻刺、空洞。不试图改变任何东西。",
"reason": "情绪在进入意识之前首先在身体中呈现。身体扫描建立了让情绪信号变得可读的感官词汇。"
},
{
"type": "emotion_awareness",
"difficulty": 3,
"duration": 7,
"description": "Emotion tracking: Set a timer for 7 minutes. Every 60 seconds, pause and write one word describing your current emotional state. Notice how emotions shift moment to moment.",
"reason": "Reveals the impermanent nature of emotions. Watching emotions change undermines the belief that 'this feeling will last forever.'"
"description": "情绪追踪:设定7分钟计时器。每60秒暂停一次,写下一个描述你当前情绪状态的词。注意情绪如何每时每刻都在变化。",
"reason": "揭示情绪的无常本质。观察情绪的变化会瓦解「这种感觉会永远持续下去」的信念。"
},
{
"type": "stress_regulation",
"difficulty": 1,
"duration": 3,
"description": "4-4-6 breathing: Inhale for 4 counts, hold for 4, exhale for 6. The long exhale activates the parasympathetic nervous system.",
"reason": "Physiological stress reduction through breath pattern. The extended exhale is one of the fastest ways to signal safety to the nervous system."
"description": "4-4-6呼吸法:吸气4秒,屏息4秒,呼气6秒。延长的呼气激活副交感神经系统。",
"reason": "通过呼吸模式进行生理层面的压力缓解。延长呼气是向神经系统发出安全信号最快速的方法之一。"
},
{
"type": "stress_regulation",
"difficulty": 2,
"duration": 5,
"description": "Progressive relaxation: Tense each muscle group for 5 seconds, then release for 10. Start with feet, work up to face. Notice the contrast between tension and release.",
"reason": "Teaches the difference between tension and relaxation at the body level. Many people don't realize they're holding tension until they deliberately release it."
"description": "渐进式放松:每个肌肉群收紧5秒,然后放松10秒。从脚开始,逐步到脸部。注意紧张与放松之间的对比。",
"reason": "在身体层面区分紧张与放松的感觉。很多人在刻意释放之前并不知道自己在紧绷着。"
},
{
"type": "stress_regulation",
"difficulty": 3,
"duration": 8,
"description": "Grounding through senses: Name 5 things you can see, 4 things you can touch, 3 things you can hear, 2 things you can smell, 1 thing you can taste.",
"reason": "Full sensory grounding anchors you in the present moment. Effective for acute stress by redirecting attention from internal worry to external reality."
"description": "五感锚定:找出你能看到的5样东西、能触摸的4样东西、能听到的3种声音、能闻到的2种气味、能尝到的1种味道。",
"reason": "全感官锚定让你回到当下。通过将注意力从内在担忧转向外部现实,有效应对急性压力。"
},
{
"type": "cognitive_clarity",
"difficulty": 1,
"duration": 3,
"description": "Thought labeling: Sit quietly and watch thoughts arise. Label each one silently — 'planning,' 'worrying,' 'remembering,' 'judging.' Don't engage the thought. Just label and release.",
"reason": "Creates distance between you and your thoughts. Labeling activates the prefrontal cortex and reduces amygdala reactivity."
"description": "念头标签:安静地坐着,观察升起的念头。默默地为每个念头贴上标签——「计划」「担忧」「回忆」「评判」。不参与念头,只标记和放下。",
"reason": "在你和你的念头之间创造距离。贴标签激活前额叶皮层,降低杏仁核的反应性。"
},
{
"type": "cognitive_clarity",
"difficulty": 2,
"duration": 5,
"description": "Reframing practice: Think of a mildly frustrating situation today. Write down your automatic thought, then write three alternative interpretations that are equally plausible.",
"reason": "Cognitive reframing is the core skill of cognitive flexibility. Practicing with mild frustrations builds the muscle for harder situations."
"description": "重构练习:回想今天一个让你略感沮丧的情境。写下你的自动想法,然后写出三个同样合理的替代解释。",
"reason": "认知重构是认知灵活性的核心技能。用轻微的挫折来练习,为更大的挑战建立心理肌肉。"
},
{
"type": "cognitive_clarity",
"difficulty": 3,
"duration": 10,
"description": "Inner dialogue observation: For 10 minutes, write down every thought that passes through your mind as if transcribing a conversation. Don't edit or judge. Then read it back. Notice which 'voice' is speaking — the critic? The planner? The child?",
"reason": "Reveals the multiplicity of the inner world. Recognizing your inner voices is the first step toward choosing which ones to amplify."
"description": "内在对话观察:用10分钟时间,像记录对话一样写下经过你脑海的每一个念头。不要编辑或评判。然后回头阅读。注意是哪个「声音」在说话——批评者?计划者?内在小孩?",
"reason": "揭示内心世界的多元性。认识你的内在声音是选择放大哪些声音的第一步。"
},
{
"type": "integration",
"difficulty": 1,
"duration": 3,
"description": "One-minute pause: Three times today, pause for 60 seconds. Take three conscious breaths. Notice what's happening in your body and mind. Then continue.",
"reason": "Integrates practice into daily life. Short, frequent pauses build the habit of mindful awareness without requiring dedicated sitting time."
"description": "一分钟暂停:今天三次,每次暂停60秒。做三次有意识的呼吸。留意你的身体和心里正在发生什么。然后继续做你的事。",
"reason": "将练习融入日常生活。短暂而频繁的暂停建立了正念觉察的习惯,无需专门的静坐时间。"
},
{
"type": "integration",
"difficulty": 2,
"duration": 5,
"description": "Mindful routine: Pick one daily activity (brushing teeth, washing dishes, walking to your desk). Do it with full attention today. Notice every sensation, movement, and sound.",
"reason": "Turns everyday activities into practice opportunities. The goal isn't more practice time — it's weaving practice into time you already have."
"description": "正念日常:选择一个日常活动(刷牙、洗碗、走到办公桌)。今天全程全心投入地做这件事。关注每一个感受、动作和声音。",
"reason": "将日常活动转化为练习机会。目标不是增加练习时间——而是将练习织入你已经拥有的时间中。"
},
{
"type": "integration",
"difficulty": 3,
"duration": 10,
"description": "Difficult conversation practice: Before a conversation that might be challenging, sit for 5 minutes and set an intention. During the conversation, notice your reactions without acting on them. After, reflect for 5 minutes on what you noticed.",
"reason": "The ultimate integration — bringing awareness into interpersonal dynamics. This is where practice meets real life."
"description": "困难对话练习:在一场可能具有挑战性的对话之前,静坐5分钟设定一个意图。对话过程中,觉察自己的反应但不立即行动。结束后,用5分钟反思你注意到的事情。",
"reason": "终极整合——将觉察带入人际互动中。这是练习与真实生活相遇的地方。"
}
]
+8
View File
@@ -93,6 +93,10 @@ const en: Dict = {
begin: "Begin practice",
why_prefix: "", // unused in English, kept for key parity with zh
},
session: {
completed: "Practice complete! Keep it going.",
},
},
// -----------------------------------------------------------------------
@@ -185,6 +189,10 @@ const en: Dict = {
finish_early: "Finish early",
complete: "Practice complete",
reflect: "Reflect on your practice",
mute: "Mute",
unmute: "Unmute",
mid_guidance: "Stay focused, you're doing well.",
end_guidance: "Practice complete. Take a moment to reflect.",
},
// -----------------------------------------------------------------------
+8
View File
@@ -93,6 +93,10 @@ const zh: Dict = {
begin: "开始练习",
why_prefix: "",
},
session: {
completed: "练习完成!继续保持。",
},
},
// -----------------------------------------------------------------------
@@ -184,6 +188,10 @@ const zh: Dict = {
finish_early: "提前结束",
complete: "练习完成",
reflect: "记录感受",
mute: "静音",
unmute: "取消静音",
mid_guidance: "继续专注,你做得很好。",
end_guidance: "练习完成。请记录你的感受。",
},
// -----------------------------------------------------------------------
@@ -0,0 +1,111 @@
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"password_hash" TEXT,
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "user_states" (
"id" TEXT NOT NULL PRIMARY KEY,
"user_id" TEXT NOT NULL,
"attention_level" INTEGER NOT NULL DEFAULT 50,
"emotion_stability" INTEGER NOT NULL DEFAULT 50,
"stress_level" INTEGER NOT NULL DEFAULT 50,
"practice_stage" INTEGER NOT NULL DEFAULT 0,
"consistency_score" INTEGER NOT NULL DEFAULT 0,
"streak_days" INTEGER NOT NULL DEFAULT 0,
"total_sessions_completed" INTEGER NOT NULL DEFAULT 0,
"onboarding_completed" BOOLEAN NOT NULL DEFAULT false,
"onboarding_variant" TEXT,
"timezone" TEXT NOT NULL DEFAULT 'Asia/Shanghai',
"last_session_date" DATETIME,
"updated_at" DATETIME NOT NULL,
CONSTRAINT "user_states_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "analytics_events" (
"id" TEXT NOT NULL PRIMARY KEY,
"user_id" TEXT NOT NULL,
"event" TEXT NOT NULL,
"metadata" TEXT NOT NULL DEFAULT '{}',
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "sessions" (
"id" TEXT NOT NULL PRIMARY KEY,
"user_id" TEXT NOT NULL,
"task_type" TEXT NOT NULL,
"task_description" TEXT NOT NULL,
"duration_seconds" INTEGER NOT NULL DEFAULT 300,
"completed" BOOLEAN NOT NULL DEFAULT false,
"focus_score" INTEGER,
"emotion_score" INTEGER,
"notes" TEXT,
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "tasks" (
"id" TEXT NOT NULL PRIMARY KEY,
"user_id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"difficulty" INTEGER NOT NULL DEFAULT 1,
"duration" INTEGER NOT NULL DEFAULT 5,
"description" TEXT NOT NULL,
"reason_json" TEXT NOT NULL DEFAULT '{}',
"status" TEXT NOT NULL DEFAULT 'pending',
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tasks_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "reward_events" (
"id" TEXT NOT NULL PRIMARY KEY,
"user_id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"message" TEXT NOT NULL,
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "reward_events_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "weekly_summaries" (
"id" TEXT NOT NULL PRIMARY KEY,
"user_id" TEXT NOT NULL,
"summary" TEXT,
"week_start" DATETIME NOT NULL,
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "weekly_summaries_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "user_states_user_id_key" ON "user_states"("user_id");
-- CreateIndex
CREATE INDEX "analytics_events_user_id_created_at_idx" ON "analytics_events"("user_id", "created_at");
-- CreateIndex
CREATE INDEX "analytics_events_event_created_at_idx" ON "analytics_events"("event", "created_at");
-- CreateIndex
CREATE INDEX "sessions_user_id_created_at_idx" ON "sessions"("user_id", "created_at");
-- CreateIndex
CREATE INDEX "tasks_user_id_created_at_idx" ON "tasks"("user_id", "created_at");
-- CreateIndex
CREATE INDEX "tasks_user_id_status_idx" ON "tasks"("user_id", "status");
-- CreateIndex
CREATE INDEX "reward_events_user_id_created_at_idx" ON "reward_events"("user_id", "created_at");
-- CreateIndex
CREATE UNIQUE INDEX "weekly_summaries_user_id_key" ON "weekly_summaries"("user_id");
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
+14
View File
@@ -57,6 +57,8 @@ model UserState {
streakDays Int @default(0) @map("streak_days")
totalSessionsCompleted Int @default(0) @map("total_sessions_completed")
onboardingCompleted Boolean @default(false) @map("onboarding_completed")
onboardingVariant String? @map("onboarding_variant")
timezone String @default("Asia/Shanghai")
lastSessionDate DateTime? @map("last_session_date")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -65,6 +67,18 @@ model UserState {
@@map("user_states")
}
model AnalyticsEvent {
id String @id @default(uuid())
userId String @map("user_id")
event String
metadata String @default("{}")
createdAt DateTime @default(now()) @map("created_at")
@@index([userId, createdAt])
@@index([event, createdAt])
@@map("analytics_events")
}
model Session {
id String @id @default(uuid())
userId String @map("user_id")
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+109
View File
@@ -0,0 +1,109 @@
#!/bin/bash
# Generate practice audio assets.
# Prerequisites: Node.js, Python 3, edge-tts (pip install edge-tts)
set -e
AUDIO_DIR="$(cd "$(dirname "$0")/../public/audio" && pwd)"
mkdir -p "$AUDIO_DIR"
echo "=== Generating chime sounds (Node.js) ==="
node -e "
const fs = require('fs');
function writeWav(filename, buildSamples, sampleRate, durationSec) {
const numSamples = Math.floor(sampleRate * durationSec);
const samples = new Float32Array(numSamples);
buildSamples(samples, sampleRate, durationSec);
// Convert to 16-bit PCM
const buffer = Buffer.alloc(44 + numSamples * 2);
buffer.write('RIFF', 0);
buffer.writeUInt32LE(36 + numSamples * 2, 4);
buffer.write('WAVE', 8);
buffer.write('fmt ', 12);
buffer.writeUInt32LE(16, 16); // chunk size
buffer.writeUInt16LE(1, 20); // PCM
buffer.writeUInt16LE(1, 22); // mono
buffer.writeUInt32LE(sampleRate, 24);
buffer.writeUInt32LE(sampleRate * 2, 28); // byte rate
buffer.writeUInt16LE(2, 32); // block align
buffer.writeUInt16LE(16, 34); // bits per sample
buffer.write('data', 36);
buffer.writeUInt32LE(numSamples * 2, 40);
for (let i = 0; i < numSamples; i++) {
const s = Math.max(-1, Math.min(1, samples[i]));
buffer.writeInt16LE(Math.round(s * 32767), 44 + i * 2);
}
fs.writeFileSync(filename, buffer);
console.log(' Created:', filename);
}
// Bell-like chime: two sine harmonics (fundamental + octave) with quick decay
function bellTone(t, freq1, freq2, tStart, duration) {
if (t < tStart || t > tStart + duration) return 0;
const localT = (t - tStart) / duration;
const env = Math.exp(-localT * 6); // quick exponential decay
return 0.35 * env * (
Math.sin(2 * Math.PI * freq1 * t) * 0.6 +
Math.sin(2 * Math.PI * freq2 * t) * 0.4
);
}
// Start chime — welcoming ascending bell, 2s
writeWav('$AUDIO_DIR/start-chime.wav', (samples, sr, dur) => {
for (let i = 0; i < samples.length; i++) {
const t = i / sr;
let v = 0;
v += bellTone(t, 523.25, 1046.5, 0, 0.9); // C5
v += bellTone(t, 659.25, 1318.5, 0.15, 0.8); // E5
v += bellTone(t, 783.99, 1568.0, 0.3, 0.7); // G5
samples[i] = v;
}
}, 44100, 1.8);
// Soft tick — very short click, 0.08s
writeWav('$AUDIO_DIR/tick.wav', (samples, sr) => {
for (let i = 0; i < samples.length; i++) {
const t = i / sr;
const env = Math.exp(-t * 80);
samples[i] = 0.12 * env * Math.sin(2 * Math.PI * 1200 * t);
}
}, 44100, 0.08);
// End chime — resonant closing chord, 3s
writeWav('$AUDIO_DIR/end-chime.wav', (samples, sr, dur) => {
for (let i = 0; i < samples.length; i++) {
const t = i / sr;
let v = 0;
v += bellTone(t, 261.63, 523.25, 0, 1.5); // C4 (deeper)
v += bellTone(t, 329.63, 659.25, 0.12, 1.3); // E4
v += bellTone(t, 392.00, 783.99, 0.24, 1.1); // G4
v += bellTone(t, 523.25, 1046.5, 0.36, 0.9); // C5
samples[i] = v * 0.9;
}
}, 44100, 3.0);
console.log('Chimes done.');
"
echo ""
echo "=== Generating voice guidance (Edge TTS neural voices → mp3) ==="
# Chinese — Xiaoxiao (warm, mature female)
python3 -m edge_tts --voice "zh-CN-XiaoxiaoNeural" --rate="-40%" --text "继续专注,你做得很好。" --write-media "$AUDIO_DIR/mid-guidance-zh.mp3"
echo " Created: mid-guidance-zh.mp3"
python3 -m edge_tts --voice "zh-CN-XiaoxiaoNeural" --rate="-40%" --text "练习完成。请记录你的感受。" --write-media "$AUDIO_DIR/end-guidance-zh.mp3"
echo " Created: end-guidance-zh.mp3"
# English — Aria (neutral, professional female)
python3 -m edge_tts --voice "en-US-AriaNeural" --rate="-40%" --text "Stay focused. You're doing well." --write-media "$AUDIO_DIR/mid-guidance-en.mp3"
echo " Created: mid-guidance-en.mp3"
python3 -m edge_tts --voice "en-US-AriaNeural" --rate="-40%" --text "Practice complete. Take a moment to reflect." --write-media "$AUDIO_DIR/end-guidance-en.mp3"
echo " Created: end-guidance-en.mp3"
echo ""
echo "=== All audio assets generated in $AUDIO_DIR ==="
ls -lh "$AUDIO_DIR"
+79
View File
@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from "next/server";
import { spawn } from "child_process";
import { readFileSync, unlinkSync, existsSync } from "fs";
import { randomUUID } from "crypto";
import path from "path";
import os from "os";
// Uses Microsoft Edge TTS (free, neural voices via edge-tts Python CLI).
// pip install edge-tts
const VOICE: Record<string, string> = {
zh: "zh-CN-XiaoxiaoNeural", // warm, mature female
en: "en-US-AriaNeural", // neutral, professional female
};
function edgeTts(text: string, voice: string, rate: string, outputPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn("python3", [
"-m", "edge_tts",
"--voice", voice,
`--rate=${rate}`,
"--text", text,
"--write-media", outputPath,
], { timeout: 30000, stdio: "pipe" });
let stderr = "";
child.stderr.on("data", (d) => { stderr += d.toString(); });
child.on("close", (code) => {
if (code === 0 && existsSync(outputPath)) {
resolve();
} else {
reject(new Error(stderr || `exit ${code}`));
}
});
child.on("error", reject);
});
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const text = searchParams.get("text");
const lang = searchParams.get("lang") || "zh";
if (!text) {
return NextResponse.json({ error: "Missing `text` param" }, { status: 400 });
}
if (text.length > 1200) {
return NextResponse.json({ error: "Text too long (max 1200 chars)" }, { status: 400 });
}
const rate = searchParams.get("rate") || "-40%";
const voice = VOICE[lang] || VOICE.zh;
const tmpDir = os.tmpdir();
const id = randomUUID();
const mp3Path = path.join(tmpDir, `tts-${id}.mp3`);
try {
await edgeTts(text, voice, rate, mp3Path);
const audioBuffer = readFileSync(mp3Path);
const response = new NextResponse(audioBuffer);
response.headers.set("Content-Type", "audio/mpeg");
response.headers.set("Content-Length", String(audioBuffer.length));
response.headers.set("Cache-Control", "public, max-age=3600");
return response;
} catch (err) {
console.error("TTS generation failed:", err);
return NextResponse.json({ error: "TTS unavailable" }, { status: 501 });
} finally {
try {
if (existsSync(mp3Path)) unlinkSync(mp3Path);
} catch {}
}
}
+89
View File
@@ -0,0 +1,89 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
const CRON_SECRET = process.env.CRON_SECRET || "change-me-to-a-random-string";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const secret = searchParams.get("secret");
if (!secret || secret !== CRON_SECRET) {
return NextResponse.json({ error: "Not Found" }, { status: 404 });
}
const now = new Date();
// Find users who haven't completed a session today
const allUsers = await prisma.userState.findMany({
where: { onboardingCompleted: true },
select: {
userId: true,
timezone: true,
lastSessionDate: true,
},
});
// Check for users who haven't practiced today (using their timezone)
const missedUsers: { userId: string; timezone: string; lastSessionDate: Date | null }[] = [];
for (const user of allUsers) {
const tz = user.timezone || "Asia/Shanghai";
const userTodayStr = now.toLocaleDateString("en-CA", { timeZone: tz });
const userToday = new Date(userTodayStr + "T00:00:00");
// Check if user had a session today
const todaySession = await prisma.session.findFirst({
where: {
userId: user.userId,
completed: true,
createdAt: { gte: userToday },
},
});
if (!todaySession) {
// Check if it's past 8pm local time
const localHour = parseInt(
now.toLocaleTimeString("en-US", { timeZone: tz, hour: "2-digit", hour12: false })
);
if (localHour >= 20) {
missedUsers.push({
userId: user.userId,
timezone: tz,
lastSessionDate: user.lastSessionDate,
});
}
}
}
// Log would-be recipients (email infra deferred per CM-2)
if (missedUsers.length > 0) {
console.log(
"[cron/reminders] Would email:",
missedUsers.map((u) => `${u.userId.slice(0, 8)}… (tz=${u.timezone})`)
);
}
// Also find users who signed up >24h ago but have zero sessions
const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const usersWithNoSessions = await prisma.user.findMany({
where: {
createdAt: { lt: dayAgo },
state: { totalSessionsCompleted: 0, onboardingCompleted: true },
},
select: { id: true },
});
if (usersWithNoSessions.length > 0) {
console.log(
"[cron/reminders] Would send first-session nudge to:",
usersWithNoSessions.map((u) => u.id.slice(0, 8) + "…")
);
}
return NextResponse.json({
reminders: {
missedToday: missedUsers.map((u) => ({ userId: u.userId.slice(0, 8) + "…", timezone: u.timezone })),
noSessions: usersWithNoSessions.map((u) => ({ userId: u.id.slice(0, 8) + "…" })),
},
});
}
+77 -30
View File
@@ -4,7 +4,8 @@ import { openai } from "@/lib/openai";
import { prisma } from "@/lib/prisma";
import { processReflection } from "@/lib/engines/reflection-analyzer";
import { computeStateUpdate } from "@/lib/engines/progress-tracker";
import { generateRewards, persistRewards } from "@/lib/engines/reward-generator";
import { generateRewards } from "@/lib/engines/reward-generator";
import { logEvent } from "@/lib/analytics";
export async function POST(req: Request) {
const session = await getSession();
@@ -54,31 +55,58 @@ export async function POST(req: Request) {
update: {},
});
const trimmedNotes = (notes || "").trim();
const cookieHeader = req.headers.get("cookie") || "";
const locale: "zh" | "en" =
cookieHeader
.split(";")
.map((c) => c.trim())
.find((c) => c.startsWith("inneros-lang="))
?.split("=")[1] === "en"
? "en"
: "zh";
// Determine user's language preference from cookie
const cookieLang = req.headers
.get("cookie")
?.split("; ")
.find((c) => c.startsWith("inneros-lang="))
?.split("=")[1];
const locale: "zh" | "en" = cookieLang === "en" ? "en" : "zh";
// Empty / whitespace-only reflection — short-circuit, skip LLM
let reflection: {
focusScore: number;
emotionScore: number;
insight: string;
attentionDelta: number;
emotionDelta: number;
stressDelta: number;
safety: { safe: boolean };
};
// Process reflection through LLM #2
const reflection = await processReflection(
{
notes: notes || "",
durationSeconds: durationSeconds || task.duration * 60,
taskType: task.type,
taskDescription: task.description,
},
{
attentionLevel: currentState.attentionLevel,
emotionStability: currentState.emotionStability,
stressLevel: currentState.stressLevel,
},
openai,
locale
);
if (!trimmedNotes) {
reflection = {
focusScore: 3,
emotionScore: 3,
insight:
locale === "zh"
? "下次练习后花一点时间写下你的感受。"
: "Take a moment to reflect on your practice next time.",
attentionDelta: 0,
emotionDelta: 0,
stressDelta: 0,
safety: { safe: true },
};
} else {
reflection = await processReflection(
{
notes: trimmedNotes,
durationSeconds: durationSeconds || task.duration * 60,
taskType: task.type,
taskDescription: task.description,
},
{
attentionLevel: currentState.attentionLevel,
emotionStability: currentState.emotionStability,
stressLevel: currentState.stressLevel,
},
openai,
locale
);
}
// Get recent session dates for consistency computation
const recentSessions = await prisma.session.findMany({
@@ -99,6 +127,11 @@ export async function POST(req: Request) {
recentSessions.map((s) => s.createdAt)
);
// First session should start streak at 1
if (currentState.totalSessionsCompleted === 0 && stateUpdate.streakDays === 0) {
stateUpdate.streakDays = 1;
}
// Generate rewards
const rewards = generateRewards({
isFirstSession: currentState.totalSessionsCompleted === 0,
@@ -109,15 +142,19 @@ export async function POST(req: Request) {
totalSessionsCompleted: stateUpdate.totalSessionsCompleted,
});
// Use user-local date for lastSessionDate (UTC noon to avoid DST ambiguity)
const timezone = currentState.timezone || "Asia/Shanghai";
const dateStr = new Date().toLocaleDateString("en-CA", { timeZone: timezone });
const [y, m, d] = dateStr.split("-").map(Number);
const nowLocal = new Date(Date.UTC(y, m - 1, d, 12, 0, 0));
// Perform all DB writes in a transaction
await prisma.$transaction([
// Mark task as completed
prisma.task.update({
where: { id: taskId },
data: { status: "completed" },
}),
// Create session record
prisma.session.create({
data: {
userId,
@@ -127,11 +164,10 @@ export async function POST(req: Request) {
completed: true,
focusScore: reflection.focusScore,
emotionScore: reflection.emotionScore,
notes: notes || null,
notes: trimmedNotes || null,
},
}),
// Update user state
prisma.userState.update({
where: { userId },
data: {
@@ -142,11 +178,10 @@ export async function POST(req: Request) {
consistencyScore: stateUpdate.consistencyScore,
streakDays: stateUpdate.streakDays,
totalSessionsCompleted: stateUpdate.totalSessionsCompleted,
lastSessionDate: new Date(),
lastSessionDate: nowLocal,
},
}),
// Persist rewards
...rewards.map((r) =>
prisma.rewardEvent.create({
data: {
@@ -158,6 +193,17 @@ export async function POST(req: Request) {
),
]);
// Fire-and-forget analytics event (outside transaction, never blocks response)
logEvent(userId, "session_completed", {
focusScore: reflection.focusScore,
emotionScore: reflection.emotionScore,
durationSeconds: durationSeconds || task.duration * 60,
taskType: task.type,
hasNotes: !!trimmedNotes,
});
const streakIncremented = stateUpdate.streakDays > currentState.streakDays;
return NextResponse.json({
reflection: {
focusScore: reflection.focusScore,
@@ -176,5 +222,6 @@ export async function POST(req: Request) {
rewards,
stageAdvanced: stateUpdate.stageAdvanced,
streakMilestone: stateUpdate.streakMilestone,
streakIncremented,
});
}
+9 -3
View File
@@ -7,14 +7,20 @@ export async function GET() {
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const userId = session.userId;
const today = new Date();
today.setHours(0, 0, 0, 0);
// Use user-local date for "today"
const state = await prisma.userState.findUnique({
where: { userId },
select: { timezone: true },
});
const timezone = state?.timezone || "Asia/Shanghai";
const todayStr = new Date().toLocaleDateString("en-CA", { timeZone: timezone });
const todayStart = new Date(todayStr + "T00:00:00");
const task = await prisma.task.findFirst({
where: {
userId,
status: "pending",
createdAt: { gte: today },
createdAt: { gte: todayStart },
},
orderBy: { createdAt: "desc" },
select: {
+8
View File
@@ -2,6 +2,7 @@ import { getSession } from "@/lib/auth";
import { NextResponse } from "next/server";
import { openai } from "@/lib/openai";
import { generateDailyTask } from "@/lib/engines/task-selector";
import { logEvent } from "@/lib/analytics";
export async function POST() {
const session = await getSession();
@@ -11,6 +12,13 @@ export async function POST() {
try {
const result = await generateDailyTask(userId, openai);
logEvent(userId, "task_generated", {
taskType: result.task.type,
difficulty: result.task.difficulty,
duration: result.task.duration,
fromTemplate: result.fromTemplate,
});
return NextResponse.json({
task: result.task,
fromTemplate: result.fromTemplate,
+22 -1
View File
@@ -1,18 +1,33 @@
import { getSession } from "@/lib/auth";
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { logEvent } from "@/lib/analytics";
const VARIANTS = ["3-questions", "narrative"] as const;
export async function POST() {
const session = await getSession();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const userId = session.userId;
// Mark onboarding complete
// Round-robin variant assignment: query the last assigned variant
const lastState = await prisma.userState.findFirst({
where: { onboardingVariant: { not: null } },
orderBy: { updatedAt: "desc" },
select: { onboardingVariant: true },
});
const nextVariant =
lastState?.onboardingVariant === "3-questions"
? "narrative"
: "3-questions";
const state = await prisma.userState.upsert({
where: { userId },
create: {
userId,
onboardingCompleted: true,
onboardingVariant: nextVariant,
},
update: {
onboardingCompleted: true,
@@ -26,8 +41,14 @@ export async function POST() {
streakDays: true,
totalSessionsCompleted: true,
onboardingCompleted: true,
onboardingVariant: true,
timezone: true,
},
});
logEvent(userId, "onboarding_completed", {
variant: state.onboardingVariant,
});
return NextResponse.json({ state });
}
+4
View File
@@ -18,6 +18,8 @@ export async function GET() {
streakDays: true,
totalSessionsCompleted: true,
onboardingCompleted: true,
onboardingVariant: true,
timezone: true,
lastSessionDate: true,
},
});
@@ -65,6 +67,8 @@ export async function PATCH(req: Request) {
streakDays: true,
totalSessionsCompleted: true,
onboardingCompleted: true,
onboardingVariant: true,
timezone: true,
lastSessionDate: true,
},
});
+3
View File
@@ -0,0 +1,3 @@
export default function InternalLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
+294
View File
@@ -0,0 +1,294 @@
import { prisma } from "@/lib/prisma";
import { Prisma } from "@/generated/prisma/client";
const DASHBOARD_TOKEN = process.env.DASHBOARD_TOKEN || "inneros-dashboard-2026";
export default async function InternalPage({
searchParams,
}: {
searchParams: Promise<{ token?: string }>;
}) {
const { token } = await searchParams;
if (!token || token !== DASHBOARD_TOKEN) {
return (
<div style={{ padding: 40, fontFamily: "monospace" }}>
<h1>404 Not Found</h1>
</div>
);
}
const now = new Date();
const todayStr = now.toISOString().slice(0, 10);
const todayStart = new Date(todayStr + "T00:00:00.000Z");
// DAU: users who completed a session today
const dau = await prisma.session.findMany({
where: { completed: true, createdAt: { gte: todayStart } },
select: { userId: true },
distinct: ["userId"],
});
const dauCount = dau.length;
// All users with state
const allStates = await prisma.userState.findMany({
select: {
userId: true,
streakDays: true,
totalSessionsCompleted: true,
lastSessionDate: true,
onboardingCompleted: true,
},
});
// All completed sessions
const allSessions = await prisma.session.findMany({
where: { completed: true },
orderBy: { createdAt: "asc" },
select: { userId: true, createdAt: true, focusScore: true, emotionScore: true },
});
// Per-user session log
const userSessions = new Map<string, typeof allSessions>();
for (const s of allSessions) {
const list = userSessions.get(s.userId) || [];
list.push(s);
userSessions.set(s.userId, list);
}
// Day-3 return rate: users who completed a session on day 3 after first session
let day3Return = 0;
let usersWithFirstSession = 0;
for (const [userId, sessions] of userSessions) {
if (sessions.length === 0) continue;
usersWithFirstSession++;
const firstDate = new Date(sessions[0].createdAt);
const firstDay = new Date(firstDate.getFullYear(), firstDate.getMonth(), firstDate.getDate());
const day3 = new Date(firstDay.getTime() + 3 * 24 * 60 * 60 * 1000);
const hasDay3 = sessions.some((s) => {
const d = new Date(s.createdAt);
return d >= day3;
});
if (hasDay3) day3Return++;
}
// Week-1 completion rate: users with >=5 sessions in first 7 days
let week1Completers = 0;
for (const [userId, sessions] of userSessions) {
if (sessions.length === 0) continue;
const firstDate = new Date(sessions[0].createdAt);
const firstDay = new Date(firstDate.getFullYear(), firstDate.getMonth(), firstDate.getDate());
const day7 = new Date(firstDay.getTime() + 7 * 24 * 60 * 60 * 1000);
const week1Sessions = sessions.filter((s) => {
const d = new Date(s.createdAt);
return d >= firstDay && d < day7;
});
if (week1Sessions.length >= 5) week1Completers++;
}
// Day-7 retention: users who completed a session on day 7 after first session
// Only measurable if at least one user started >= 7 days ago
let day7Retention: string | number = "N/A";
const sevenDaysAgo = new Date(todayStart.getTime() - 7 * 24 * 60 * 60 * 1000);
let day7Eligible = 0;
let day7Returned = 0;
for (const [userId, sessions] of userSessions) {
if (sessions.length === 0) continue;
const firstDate = new Date(sessions[0].createdAt);
const firstDay = new Date(firstDate.getFullYear(), firstDate.getMonth(), firstDate.getDate());
if (firstDay > sevenDaysAgo) continue; // first session was less than 7 days ago
day7Eligible++;
const day7Start = new Date(firstDay.getTime() + 7 * 24 * 60 * 60 * 1000);
const day7End = new Date(day7Start.getTime() + 24 * 60 * 60 * 1000);
const hasDay7 = sessions.some((s) => {
const d = new Date(s.createdAt);
return d >= day7Start && d < day7End;
});
if (hasDay7) day7Returned++;
}
if (day7Eligible > 0) {
day7Retention = Math.round((day7Returned / day7Eligible) * 100);
} else {
// Find earliest first session to show countdown
let earliestFirst: Date | null = null;
for (const [, sessions] of userSessions) {
if (sessions.length === 0) continue;
const d = new Date(sessions[0].createdAt);
if (!earliestFirst || d < earliestFirst) earliestFirst = d;
}
if (earliestFirst) {
const daysSince = Math.floor(
(todayStart.getTime() - earliestFirst.getTime()) / (1000 * 60 * 60 * 24)
);
const daysUntil = 7 - daysSince;
day7Retention = `N/A — ${Math.max(0, daysUntil)} days until measurable`;
} else {
day7Retention = "N/A — no sessions yet";
}
}
// Average focus/emotion scores
let avgFocus = 0;
let avgEmotion = 0;
const scoredSessions = allSessions.filter((s) => s.focusScore != null);
if (scoredSessions.length > 0) {
avgFocus = Math.round(
(scoredSessions.reduce((sum, s) => sum + (s.focusScore || 0), 0) / scoredSessions.length) * 10
) / 10;
avgEmotion = Math.round(
(scoredSessions.reduce((sum, s) => sum + (s.emotionScore || 0), 0) / scoredSessions.length) * 10
) / 10;
}
// Analytics events count
const analyticsCount = await prisma.analyticsEvent.count();
const totalUsers = allStates.length;
const usersWithSessions = usersWithFirstSession;
const td = { border: "1px solid #ccc", padding: "8px 12px", fontSize: 14 };
const th = { ...td, fontWeight: 600, background: "#f5f5f5" };
return (
<div style={{ padding: 40, fontFamily: "system-ui, sans-serif", maxWidth: 960 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
<h1 style={{ fontSize: 24, fontWeight: 700, margin: "0 0 4px" }}>Inner OS Retention Dashboard</h1>
<span style={{ color: "#888", fontSize: 13 }}>
{now.toISOString().slice(0, 10)} · {totalUsers} users · {analyticsCount} events
</span>
</div>
{/* Key metrics */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, margin: "24px 0" }}>
<MetricBox label="DAU" value={dauCount} hint="sessions completed today" />
<MetricBox
label="Day-3 Return"
value={usersWithFirstSession > 0 ? Math.round((day3Return / usersWithFirstSession) * 100) + "%" : "—"}
hint={`${day3Return}/${usersWithFirstSession} eligible`}
/>
<MetricBox
label="Day-7 Retention"
value={typeof day7Retention === "number" ? day7Retention + "%" : String(day7Retention)}
hint={`${day7Returned}/${day7Eligible} eligible`}
/>
<MetricBox
label="Week-1 ≥5"
value={usersWithFirstSession > 0 ? Math.round((week1Completers / usersWithFirstSession) * 100) + "%" : "—"}
hint={`${week1Completers}/${usersWithFirstSession} users`}
/>
</div>
{/* Score averages */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, margin: "0 0 24px" }}>
<MetricBox label="Avg Focus" value={scoredSessions.length > 0 ? avgFocus.toFixed(1) : "—"} hint="1-5 scale" />
<MetricBox label="Avg Emotion" value={scoredSessions.length > 0 ? avgEmotion.toFixed(1) : "—"} hint="1-5 scale" />
<MetricBox label="Total Sessions" value={allSessions.length} hint="completed" />
<MetricBox label="Analytics Events" value={analyticsCount} hint="all event types" />
</div>
{/* Per-user session log */}
<h2 style={{ fontSize: 18, fontWeight: 600, margin: "24px 0 12px" }}>Per-User Session Log</h2>
{totalUsers === 0 ? (
<div style={{ padding: 40, textAlign: "center", color: "#888" }}>No data yet waiting for first user to complete a session.</div>
) : (
<table style={{ borderCollapse: "collapse", width: "100%" }}>
<thead>
<tr>
<th style={th}>User ID</th>
<th style={th}>Sessions</th>
<th style={th}>Streak</th>
<th style={th}>Avg Focus</th>
<th style={th}>Avg Emotion</th>
<th style={th}>Last Active</th>
<th style={th}>Days Ago</th>
</tr>
</thead>
<tbody>
{allStates.map((st) => {
const sessions = userSessions.get(st.userId) || [];
const fScores = sessions.filter((s) => s.focusScore != null);
const uAvgFocus = fScores.length > 0
? Math.round((fScores.reduce((s, x) => s + (x.focusScore || 0), 0) / fScores.length) * 10) / 10
: null;
const uAvgEmotion = fScores.length > 0
? Math.round((fScores.reduce((s, x) => s + (x.emotionScore || 0), 0) / fScores.length) * 10) / 10
: null;
const lastSession = sessions.length > 0 ? sessions[sessions.length - 1].createdAt : null;
const daysAgo = lastSession
? Math.floor((now.getTime() - new Date(lastSession).getTime()) / (1000 * 60 * 60 * 24))
: null;
return (
<tr key={st.userId}>
<td style={td}>{st.userId.slice(0, 8)}</td>
<td style={td}>{st.totalSessionsCompleted}</td>
<td style={td}>{st.streakDays}</td>
<td style={td}>{uAvgFocus?.toFixed(1) || "—"}</td>
<td style={td}>{uAvgEmotion?.toFixed(1) || "—"}</td>
<td style={td}>{lastSession ? new Date(lastSession).toISOString().slice(0, 10) : "never"}</td>
<td style={td}>{daysAgo !== null ? daysAgo : "—"}</td>
</tr>
);
})}
</tbody>
</table>
)}
{/* Analytics events log */}
<h2 style={{ fontSize: 18, fontWeight: 600, margin: "32px 0 12px" }}>Recent Analytics Events</h2>
<AnalyticsLog />
</div>
);
}
function MetricBox({ label, value, hint }: { label: string; value: string | number; hint: string }) {
return (
<div style={{ padding: "16px 20px", background: "#fafaf5", borderRadius: 8, border: "1px solid #e5e5d5" }}>
<div style={{ fontSize: 12, color: "#888", marginBottom: 4, textTransform: "uppercase", letterSpacing: "0.05em" }}>
{label}
</div>
<div style={{ fontSize: 28, fontWeight: 700, fontVariantNumeric: "tabular-nums" }}>
{value}
</div>
<div style={{ fontSize: 12, color: "#aaa", marginTop: 2 }}>{hint}</div>
</div>
);
}
async function AnalyticsLog() {
const events = await prisma.analyticsEvent.findMany({
orderBy: { createdAt: "desc" },
take: 50,
});
const td = { border: "1px solid #ccc", padding: "6px 10px", fontSize: 13 };
const th = { ...td, fontWeight: 600, background: "#f5f5f5" };
if (events.length === 0) {
return <div style={{ padding: 40, textAlign: "center", color: "#888" }}>No events yet.</div>;
}
return (
<table style={{ borderCollapse: "collapse", width: "100%" }}>
<thead>
<tr>
<th style={th}>Time</th>
<th style={th}>Event</th>
<th style={th}>User</th>
<th style={th}>Metadata</th>
</tr>
</thead>
<tbody>
{events.map((e) => (
<tr key={e.id}>
<td style={td}>{new Date(e.createdAt).toISOString().slice(11, 19)}</td>
<td style={td}>{e.event}</td>
<td style={td}>{e.userId.slice(0, 8)}</td>
<td style={{ ...td, fontSize: 12, color: "#666", maxWidth: 300, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{e.metadata}
</td>
</tr>
))}
</tbody>
</table>
);
}
+22 -3
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { cookies, headers } from "next/headers";
import Script from "next/script";
import { DM_Sans, Fraunces } from "next/font/google";
import TabBar from "@/components/TabBar";
import LanguageSwitcher from "@/components/LanguageSwitcher";
@@ -32,18 +33,36 @@ export default async function RootLayout({
const langCookie = cookieStore.get("inneros-lang")?.value;
const lang = langCookie === "zh" || langCookie === "en" ? langCookie : "zh";
const headersList = await headers();
const pathname = headersList.get("x-pathname") || "";
const isInternal = pathname.startsWith("/internal");
return (
<html
lang={lang}
className={`${fraunces.variable} ${dmSans.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col pb-16 bg-cream dark:bg-[#1E1A14] text-ink dark:text-[#F3EFE8]">
<head>
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-LVVXH3TL04"
strategy="afterInteractive"
/>
<Script id="google-analytics" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-LVVXH3TL04');
`}
</Script>
</head>
<body className={`min-h-full flex flex-col ${isInternal ? "" : "pb-16"} bg-cream dark:bg-[#1E1A14] text-ink dark:text-[#F3EFE8]`}>
<I18nProvider>
<main className="flex-1 max-w-2xl mx-auto w-full px-5 pt-8 pb-4 relative">
<LanguageSwitcher />
{children}
</main>
<TabBar />
{!isInternal && <TabBar />}
</I18nProvider>
</body>
</html>
+43 -5
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
import { useAuth } from "@/hooks/use-auth";
import { useRouter } from "next/navigation";
import StatBar from "@/components/StatBar";
@@ -36,9 +36,33 @@ export default function HomePage() {
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [genError, setGenError] = useState<string | null>(null);
const [showAck, setShowAck] = useState(false);
const ackRef = useRef(false);
// Post-session acknowledgment: ?justCompleted=true
useEffect(() => {
if (ackRef.current) return;
if (typeof window !== "undefined" && window.location.search.includes("justCompleted=true")) {
ackRef.current = true;
setShowAck(true);
const timer = setTimeout(() => setShowAck(false), 3000);
// Clean URL
const url = new URL(window.location.href);
url.searchParams.delete("justCompleted");
window.history.replaceState({}, "", url.toString());
return () => clearTimeout(timer);
}
}, []);
// Redirect to sign-in when auth fails after loading
useEffect(() => {
if (userLoaded && !user) {
router.replace("/sign-in");
}
}, [userLoaded, user, router]);
useEffect(() => {
if (!userLoaded) return;
if (!userLoaded || !user) return;
Promise.all([
fetch("/api/user/state").then((r) => r.json()),
fetch("/api/task/daily").then((r) => r.json()),
@@ -77,10 +101,17 @@ export default function HomePage() {
);
}
if (!user) return null;
// Show spinner while redirect effect fires
if (!user) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="animate-pulse text-muted text-sm">{t("shared.loading")}</div>
</div>
);
}
// Redirect to settings for onboarding
if (state && !state.onboardingCompleted) {
// Show onboarding prompt for new users (state can be null)
if (!state || !state.onboardingCompleted) {
return (
<div className="space-y-8">
<Greeting firstName={null} />
@@ -133,6 +164,13 @@ export default function HomePage() {
<div className="space-y-8">
<Greeting firstName={null} />
{/* Post-session acknowledgment */}
{showAck && (
<div className="p-4 rounded-lg bg-amber/10 border border-amber/30 text-center animate-in fade-in">
<p className="text-amber font-medium text-sm">{t("home.session.completed")}</p>
</div>
)}
{/* State overview */}
{state && (
<div className="grid grid-cols-2 gap-4">
+86 -14
View File
@@ -1,9 +1,11 @@
"use client";
import { Suspense, useEffect, useState, useCallback } from "react";
import { Suspense, useEffect, useState, useCallback, useRef } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useI18n } from "@/lib/i18n/context";
import { formatTaskType } from "@/lib/i18n/utils";
import { playStartChime, playTick, playEndChime, speakText } from "@/lib/audio";
import { getMeditationPrompts } from "@/lib/meditation-guide";
interface TaskData {
id: string;
@@ -33,7 +35,7 @@ function LoadingFallback() {
function PracticeContent() {
const searchParams = useSearchParams();
const router = useRouter();
const { t } = useI18n();
const { t, lang } = useI18n();
const taskId = searchParams.get("id");
const [task, setTask] = useState<TaskData | null>(null);
@@ -41,6 +43,12 @@ function PracticeContent() {
const [phase, setPhase] = useState<"ready" | "active" | "done">("ready");
const [secondsRemaining, setSecondsRemaining] = useState(0);
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [muted, setMuted] = useState(false);
const totalSeconds = task ? task.duration * 60 : 0;
const prompts = useRef<{ atSecond: number; text: string }[]>([]);
const nextPromptIdx = useRef(0);
const isSpeaking = useRef(false);
useEffect(() => {
if (!taskId) {
@@ -53,7 +61,9 @@ function PracticeContent() {
.then((json) => {
if (json.task && json.task.id === taskId) {
setTask(json.task);
setSecondsRemaining(json.task.duration * 60);
const secs = json.task.duration * 60;
setSecondsRemaining(secs);
prompts.current = getMeditationPrompts(secs);
} else {
router.push("/");
}
@@ -62,21 +72,54 @@ function PracticeContent() {
.finally(() => setLoading(false));
}, [taskId, router]);
// Timer
// Timer + guided meditation prompts (sequenced, no overlap)
useEffect(() => {
if (phase !== "active") return;
if (secondsRemaining <= 0) {
setPhase("done");
return;
}
// Tick sound for last 10 seconds
if (secondsRemaining <= 10 && !muted) {
playTick();
}
// Fire next meditation prompt when ready (previous has finished)
if (!muted && !isSpeaking.current) {
const idx = nextPromptIdx.current;
if (idx < prompts.current.length && elapsedSeconds >= prompts.current[idx].atSecond) {
nextPromptIdx.current = idx + 1;
isSpeaking.current = true;
speakText(prompts.current[idx].text, lang === "zh" ? "zh" : "en")
.finally(() => { isSpeaking.current = false; });
}
}
const interval = setInterval(() => {
setSecondsRemaining((s) => s - 1);
setElapsedSeconds((s) => s + 1);
}, 1000);
return () => clearInterval(interval);
}, [phase, secondsRemaining]);
}, [phase, secondsRemaining, elapsedSeconds, muted, lang]);
const handleStart = () => setPhase("active");
// Done phase audio
useEffect(() => {
if (phase !== "done") return;
if (!muted) {
playEndChime();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase]);
const handleStart = useCallback(() => {
setPhase("active");
nextPromptIdx.current = 0;
isSpeaking.current = false;
if (!muted) {
playStartChime();
}
}, [muted]);
const handleFinish = () => {
router.push(
@@ -103,13 +146,23 @@ function PracticeContent() {
return (
<div className="space-y-8">
{/* Back */}
<button
onClick={() => router.push("/")}
className="text-sm text-muted hover:text-ink dark:hover:text-[#F3EFE8] transition-colors"
>
{t("shared.back")}
</button>
{/* Top bar: back + mute */}
<div className="flex items-center justify-between">
<button
onClick={() => router.push("/")}
className="text-sm text-muted hover:text-ink dark:hover:text-[#F3EFE8] transition-colors"
>
{t("shared.back")}
</button>
<button
onClick={() => setMuted((m) => !m)}
className="p-2 rounded-lg text-muted hover:text-ink dark:hover:text-[#F3EFE8] transition-colors"
aria-label={muted ? t("practice.unmute") : t("practice.mute")}
>
{muted ? <SpeakerOffIcon /> : <SpeakerOnIcon />}
</button>
</div>
{/* Task display */}
<div className="space-y-6">
@@ -144,7 +197,6 @@ function PracticeContent() {
{phase === "active" && (
<div className="space-y-6 flex flex-col items-center">
<div className="relative w-32 h-32 rounded-full bg-sand dark:bg-[#292418] border-4 border-stone dark:border-[#3D3528] flex items-center justify-center">
{/* Progress ring */}
<svg
className="absolute inset-0 w-full h-full -rotate-90"
viewBox="0 0 128 128"
@@ -206,3 +258,23 @@ function PracticeContent() {
</div>
);
}
function SpeakerOnIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
</svg>
);
}
function SpeakerOffIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<line x1="23" y1="9" x2="17" y2="15" />
<line x1="17" y1="9" x2="23" y2="15" />
</svg>
);
}
+1 -1
View File
@@ -113,7 +113,7 @@ function ReflectForm() {
</div>
<button
onClick={() => router.push("/")}
onClick={() => router.push("/?justCompleted=true")}
className="w-full py-3 px-6 rounded-lg bg-ink dark:bg-[#F3EFE8] text-cream dark:text-[#1E1A14] font-medium text-base transition-all hover:opacity-90 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-amber focus:ring-offset-2"
>
{t("reflect.back_home")}
+1 -1
View File
@@ -67,7 +67,7 @@ export default function SettingsPage() {
if (!user) return null;
const needsOnboarding = state && !state.onboardingCompleted;
const needsOnboarding = !state || !state.onboardingCompleted;
return (
<div className="space-y-8">
+97
View File
@@ -0,0 +1,97 @@
"use client";
import { useCallback, useRef, useState, useEffect } from "react";
// Preferred voices by language — ordered by naturalness.
// Names are substrings that match across macOS / Windows / Chrome / Android.
const PREFERRED_ZH = [
"Tingting", // macOS / iOS
"Huihui", // Windows
"Google 普通话", // Android / Chrome
"Sin-Ji", // macOS Cantonese fallback
];
const PREFERRED_EN = [
"Samantha", // macOS — very natural
"Daniel", // macOS UK
"Google UK English Female",
"Google US English",
"Zira", // Windows
"Mark", // macOS male
];
function pickVoice(lang: string): SpeechSynthesisVoice | null {
const voices = window.speechSynthesis.getVoices();
if (voices.length === 0) return null;
const candidates = lang.startsWith("zh") ? PREFERRED_ZH : PREFERRED_EN;
for (const name of candidates) {
const match = voices.find(
(v) => v.lang.startsWith(lang.split("-")[0]) && v.name.includes(name)
);
if (match) return match;
}
// Fallback: any voice matching the language prefix
const langFallback = voices.find((v) => v.lang.startsWith(lang.split("-")[0]));
if (langFallback) return langFallback;
// Last resort: first available voice
return voices[0] ?? null;
}
export function useSpeech() {
const [voicesReady, setVoicesReady] = useState(false);
const [isSpeaking, setIsSpeaking] = useState(false);
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
const isSupported = typeof window !== "undefined" && "speechSynthesis" in window;
// Voices load asynchronously on some browsers (Chrome). On others (Safari)
// they're available immediately. Cover both.
useEffect(() => {
if (!isSupported) return;
const voices = window.speechSynthesis.getVoices();
if (voices.length > 0) {
setVoicesReady(true);
return;
}
const onVoicesChanged = () => setVoicesReady(true);
window.speechSynthesis.addEventListener("voiceschanged", onVoicesChanged);
return () =>
window.speechSynthesis.removeEventListener("voiceschanged", onVoicesChanged);
}, [isSupported]);
const speak = useCallback(
(text: string, lang: string = "zh-CN") => {
if (!isSupported) return;
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = lang;
utterance.rate = 0.82;
utterance.pitch = 0.95;
utterance.volume = 1.0;
const voice = pickVoice(lang);
if (voice) utterance.voice = voice;
utterance.onstart = () => setIsSpeaking(true);
utterance.onend = () => setIsSpeaking(false);
utterance.onerror = () => setIsSpeaking(false);
utteranceRef.current = utterance;
window.speechSynthesis.speak(utterance);
},
[isSupported]
);
const stop = useCallback(() => {
if (!isSupported) return;
window.speechSynthesis.cancel();
setIsSpeaking(false);
}, [isSupported]);
return { speak, stop, isSpeaking, isSupported, voicesReady };
}
+21
View File
@@ -0,0 +1,21 @@
import { prisma } from "@/lib/prisma";
type EventName = "session_completed" | "task_generated" | "onboarding_completed";
export async function logEvent(
userId: string,
event: EventName,
metadata: Record<string, unknown> = {}
): Promise<void> {
try {
await prisma.analyticsEvent.create({
data: {
userId,
event,
metadata: JSON.stringify(metadata),
},
});
} catch (err) {
console.error("[analytics] Failed to log event:", event, err);
}
}
+50
View File
@@ -0,0 +1,50 @@
// Audio playback using pre-generated files + server-side TTS API.
// All functions silently no-op when audio playback fails.
function playFile(path: string) {
try {
const audio = new Audio(path);
audio.play().catch(() => {});
} catch {}
}
export function playStartChime() {
playFile("/audio/start-chime.wav");
}
export function playTick() {
playFile("/audio/tick.wav");
}
export function playEndChime() {
playFile("/audio/end-chime.wav");
}
export function playMidGuidance(lang: string) {
const file = lang === "zh" ? "mid-guidance-zh.mp3" : "mid-guidance-en.mp3";
playFile(`/audio/${file}`);
}
export function playEndGuidance(lang: string) {
const file = lang === "zh" ? "end-guidance-zh.mp3" : "end-guidance-en.mp3";
playFile(`/audio/${file}`);
}
// Dynamic TTS via server-side Edge TTS (neural voices) API.
// Falls back silently on error (network issue, edge-tts not installed, etc.).
export async function speakText(text: string, lang: string = "zh"): Promise<void> {
try {
const url = `/api/audio/tts?text=${encodeURIComponent(text)}&lang=${lang}&rate=-40%25`;
const response = await fetch(url);
if (!response.ok) return;
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
await new Promise<void>((resolve) => {
const audio = new Audio(blobUrl);
audio.onended = () => { URL.revokeObjectURL(blobUrl); resolve(); };
audio.onerror = () => { URL.revokeObjectURL(blobUrl); resolve(); };
audio.play().catch(() => { URL.revokeObjectURL(blobUrl); resolve(); });
});
} catch {}
}
+24 -16
View File
@@ -38,6 +38,20 @@ export function computeStage(
return { stage, advanced: stage > 0 };
}
// ---------------------------------------------------------------------------
// Timezone helpers
// ---------------------------------------------------------------------------
function getUserToday(timezone: string): Date {
const now = new Date();
const dateStr = now.toLocaleDateString("en-CA", { timeZone: timezone });
return new Date(dateStr + "T00:00:00");
}
function dateToLocaleDateStr(date: Date, timezone: string): string {
return date.toLocaleDateString("en-CA", { timeZone: timezone });
}
// ---------------------------------------------------------------------------
// Streak computation
// ---------------------------------------------------------------------------
@@ -69,16 +83,13 @@ export function computeStreak(
);
if (diffDays === 0) {
// Already completed today — maintain current streak
return { streakDays: previousStreak, streakBroken: false, lastSessionDate };
}
if (diffDays === 1) {
// Yesterday — streak continues
return { streakDays: previousStreak + 1, streakBroken: false, lastSessionDate };
}
// More than 1 day gap — streak broken
return { streakDays: 1, streakBroken: true, lastSessionDate };
}
@@ -88,23 +99,22 @@ export function computeStreak(
/**
* Consistency score (0-100) reflects how regularly the user practices.
* - Each day with a session in the last 14 days contributes ~7.14 points
* - Capped at 100
* Uses user-local dates to count unique practice days in the lookback window.
*/
export function computeConsistency(
sessionDates: Date[],
timezone: string,
lookbackDays: number = 14
): number {
const now = new Date();
const cutoff = new Date(now.getTime() - lookbackDays * 24 * 60 * 60 * 1000);
const todayStr = dateToLocaleDateStr(now, timezone);
const todayLocal = new Date(todayStr + "T00:00:00");
const cutoff = new Date(todayLocal.getTime() - lookbackDays * 24 * 60 * 60 * 1000);
const recentDates = new Set(
sessionDates
.filter((d) => d >= cutoff)
.map((d) => {
const local = new Date(d);
return `${local.getFullYear()}-${local.getMonth()}-${local.getDate()}`;
})
.map((d) => dateToLocaleDateStr(d, timezone))
);
return Math.min(Math.round((recentDates.size / lookbackDays) * 100), 100);
@@ -136,7 +146,6 @@ export function computeStateUpdate(
recentSessionDates: Date[],
now: Date = new Date()
): StateUpdate {
// Apply deltas with clamping
const attentionLevel = Math.max(
0,
Math.min(100, currentState.attentionLevel + deltas.attentionDelta)
@@ -150,22 +159,21 @@ export function computeStateUpdate(
Math.min(100, currentState.stressLevel + deltas.stressDelta)
);
// Compute streak
const streak = computeStreak(
currentState.lastSessionDate,
currentState.streakDays,
now
);
// Compute consistency
const consistencyScore = computeConsistency(recentSessionDates);
const consistencyScore = computeConsistency(
recentSessionDates,
currentState.timezone || "Asia/Shanghai"
);
// Compute stage
const totalSessionsCompleted = currentState.totalSessionsCompleted + 1;
const newStage = computeStage(totalSessionsCompleted, consistencyScore);
const stageAdvanced = newStage.stage > currentState.practiceStage;
// Check streak milestones (3, 7, 14, 30)
const streakMilestone =
streak.streakDays > 0 &&
[3, 7, 14, 30].includes(streak.streakDays) &&
+6 -20
View File
@@ -82,10 +82,12 @@ Score calibration:
- A reflection like "it was fine" with no detail → focus_score 2-3, emotion_score 2-3
- A reflection with specific observations and genuine curiosity → focus_score 4-5, emotion_score 4-5
- A reflection mentioning frustration or struggle → lower focus but don't punish emotion if they're honest about it
- A completely empty or one-word reflection → focus_score 1, emotion_score 1
- A completely empty or one-word reflection → focus_score 3, emotion_score 3
- A reflection that shows they really tried but found it hard → focus_score 3-4, emotion_score 3-4 (effort counts)
Be generous but honest. These scores guide their development path.`;
Be generous but honest. These scores guide their development path.
IMPORTANT: The user interface is Chinese-only. The insight MUST be written in Simplified Chinese (简体中文). Use "你" (not "you").`;
export async function analyzeReflection(
input: ReflectionInput,
@@ -120,25 +122,9 @@ export async function analyzeReflection(
}),
},
],
response_format: {
type: "json_schema",
json_schema: {
name: "reflection",
strict: true,
schema: {
type: "object",
properties: {
focus_score: { type: "number", minimum: 1, maximum: 5 },
emotion_score: { type: "number", minimum: 1, maximum: 5 },
insight: { type: "string" },
},
required: ["focus_score", "emotion_score", "insight"],
additionalProperties: false,
},
},
},
response_format: { type: "json_object" },
temperature: 0.5,
max_tokens: 200,
max_tokens: 800,
});
const raw = response.choices[0]?.message?.content;
+24 -24
View File
@@ -23,56 +23,56 @@ interface RewardContext {
// ---------------------------------------------------------------------------
const FIRST_SESSION_MESSAGES = [
"Your first practice is complete. This is where the path begins — not with a destination, but with a single step.",
"Day one. You didn't just think about it — you did it. That's the difference that makes all the difference.",
"Welcome to your practice. The door is open now. Walk through it again tomorrow.",
"第一次练习完成了。这条路始于足下,不在于终点。",
"第一天。你不是只想了想,你做到了。这就是一切的区别。",
"欢迎开始你的练习。门已经打开了。明天,再走进来。",
];
const STREAK_3_MESSAGES = [
"Three days in a row. A pattern is forming. Your mind is starting to expect this — that's the habit taking root.",
"Day three. The first two days were willpower. Today is the beginning of momentum.",
"连续三天。一个模式正在形成。你的心智开始期待这一刻——习惯正在扎根。",
"第三天。前两次靠的是意志力。今天,是势头的开始。",
];
const STREAK_7_MESSAGES = [
"Seven days. A full week of showing up for yourself. Most people never make it this far. You did.",
"One week of daily practice. Your brain is literally rewiring. Neuroplasticity isn't a metaphor — it's what's happening right now.",
"七天。一整周都在为自己出现。大多数人从未走到这么远。你做到了。",
"一周的每日练习。你的大脑正在实实在在地重塑。神经可塑性不是比喻——它正在发生。",
];
const STREAK_14_MESSAGES = [
"Two weeks. Research says it takes 21 days to form a habit — you're two-thirds there, and it already feels different, doesn't it?",
"Fourteen consecutive days. You're not 'trying to meditate' anymore. You're someone who practices. The identity has shifted.",
"两周。研究表明形成习惯需要21天——你已经走了三分之二,感觉已经不一样了,不是吗?",
"连续十四天。你不再在「尝试冥想」。你是一个在练习的人。身份已经转变。",
];
const STREAK_30_MESSAGES = [
"Thirty days. A month of daily practice. Whatever you were before you started — anxious, scattered, reactive — you're becoming someone else now. Someone who can watch their own mind and choose.",
"One month. This isn't a streak anymore. It's a practice. It's part of who you are. The question is no longer 'will I practice today?' but 'when?'",
"三十天。一个月的每日练习。无论你开始前是什么样子——焦虑、散乱、反应——你正在变成另一个人。一个能观察自己的心并做出选择的人。",
"一个月。这不再是一个连续记录。这是一项练习。它是你的一部分。问题不再是「我今天要不要练习?」,而是「什么时候?」",
];
const STAGE_UP_MESSAGES: Record<number, string[]> = {
1: [
"You've reached the Foundation stage. The basics are in place. Now we build depth — longer sessions, subtler awareness.",
"Stage up: Foundation. You've moved from trying to training. The practice is becoming familiar territory.",
"你已经进入基础阶段。基本功已经到位。现在开始深入——更长的练习,更细微的觉察。",
"阶段提升:基础。你从尝试进入了训练。练习正在成为熟悉的领域。",
],
2: [
"Growing stage reached. Your consistency is solid and your awareness is sharpening. This is where practice starts to change how you move through the world.",
"Stage up: Growing. You're past the awkward beginner phase. Your sessions have texture and depth now.",
"成长阶段已到。你的坚持是稳固的,你的觉知正在敏锐。这就是练习开始改变你如何在这个世界上行走的地方。",
"阶段提升:成长。你度过了笨拙的初学者阶段。你的练习现在有了质感和深度。",
],
3: [
"Established. Fifteen sessions and strong consistency. Your inner world is becoming legible to you. This is emotional literacy in action.",
"Stage up: Established. Your practice has roots. What started as a daily exercise is becoming a way of being.",
"稳固阶段。十五次练习和强劲的坚持度。你的内心世界正在变得清晰可读。这就是情绪素养在行动。",
"阶段提升:稳固。你的练习扎下了根。从每天的练习开始,逐渐成为一种存在方式。",
],
4: [
"Advanced. This is rare territory. Most people never develop this level of inner awareness. Your practice now informs everything you do.",
"Stage up: Advanced. You're not just practicing awareness — you're living it. The boundary between 'practice' and 'life' is dissolving.",
"进阶。这是稀有的领域。大多数人从未发展出这个层次的内心觉知。你的练习现在渗透到你做的每一件事中。",
"阶段提升:进阶。你不只是在练习觉知——你在活出它。「练习」和「生活」之间的界限正在消融。",
],
};
const GENERIC_COMPLETION_MESSAGES = [
"Done. You showed up, you practiced, you reflected. That's the whole game — and you're playing it.",
"Practice complete. Notice how you feel right now compared to before. That shift is the point.",
"Another session in the books. Not every practice will feel profound — but every practice counts.",
"You did the work today. Not tomorrow, not 'when things calm down' — today. That's discipline, and discipline is self-love.",
"Session finished. Your future self — the one with more clarity, more calm, more choice — thanks you.",
"完成。你出现了,你练习了,你反思了。这就是全部——而你在做着。",
"练习完成。留意你现在的感受和之前的区别。那个转变就是意义所在。",
"又一次练习记录在册。不是每一次练习都会感觉深刻——但每一次练习都很重要。",
"你今天完成了功课。不是明天,不是「等事情平静下来的时候」——是今天。这就是自律,自律就是自爱。",
"练习结束。你未来的自己——那个更清晰、更平静、更有选择权的自己——在感谢你。",
];
// ---------------------------------------------------------------------------
+4 -2
View File
@@ -58,7 +58,9 @@ Voice rules:
- Be honest. If it was a light week with only 1-2 sessions, acknowledge that without judgment.
- Never diagnose, never claim medical benefits, never use self-help clichés.
- Write in second person ("you").
- Keep it to 3-4 paragraphs, 200-300 words total.`;
- Keep it to 3-4 paragraphs, 200-300 words total.
IMPORTANT: The user interface is Chinese-only. Write the entire summary in Simplified Chinese (简体中文). Use "你" (not "you").`;
export async function generateWeeklySummary(
userId: string,
@@ -123,7 +125,7 @@ export async function generateWeeklySummary(
},
],
temperature: 0.7,
max_tokens: 500,
max_tokens: 2000,
});
const summary = response.choices[0]?.message?.content?.trim();
+5 -30
View File
@@ -107,7 +107,9 @@ Principles:
- If the user has done the same type 2+ days in a row, vary it
- Match difficulty to practice_stage (0-1 → diff 1, 2-3 → diff 2, 4+ → diff 3)
- Every few days, include an integration task to connect practice to daily life
- Be specific and concrete — never generic platitudes`
- Be specific and concrete — never generic platitudes
IMPORTANT: The user interface is Chinese-only. All user-facing text (description, reason) MUST be written in Simplified Chinese (简体中文). The type field must remain the English enum value.`
interface LLMTaskResponse {
type: TaskType;
@@ -140,36 +142,9 @@ export async function generateTask(
}),
},
],
response_format: {
type: "json_schema",
json_schema: {
name: "task",
strict: true,
schema: {
type: "object",
properties: {
type: {
type: "string",
enum: [
"attention_training",
"emotion_awareness",
"stress_regulation",
"cognitive_clarity",
"integration",
],
},
difficulty: { type: "number", minimum: 1, maximum: 3 },
duration: { type: "number", minimum: 1, maximum: 15 },
description: { type: "string" },
reason: { type: "string" },
},
required: ["type", "difficulty", "duration", "description", "reason"],
additionalProperties: false,
},
},
},
response_format: { type: "json_object" },
temperature: 0.7,
max_tokens: 400,
max_tokens: 1000,
});
const raw = response.choices[0]?.message?.content;
+124
View File
@@ -0,0 +1,124 @@
// Light-based mindfulness meditation guide — continuous, flowing narration.
// Designed for edge-tts zh-CN-XiaoxiaoNeural (warm, mature female) at -40% rate (~2 chars/sec).
// Covers ~98% of session time with near-continuous speech.
interface Prompt {
at: number;
text: string;
}
const PROMPTS: Prompt[] = [
// ── Arrival (014%) ──
{
at: 0.00,
text:
"找一个舒适的姿势坐好,轻轻地闭上眼睛。双手自然地放在膝盖上,脊柱挺直但不僵硬。" +
"做一次深长的吸气,感受空气缓缓流入胸腔和腹部。然后缓慢地呼出去,把紧张都呼出体外。",
},
{
at: 0.11,
text:
"再做两次深呼吸。吸气时,吸进平静和安宁。呼气时,呼出焦虑和杂念。" +
"不需要刻意控制,只是观察。吸气时知道自己在吸气,呼气时知道自己在呼气。把注意力完全放在当下。",
},
{
at: 0.20,
text:
"很好。把注意力从呼吸上移开。在你的内心深处,有一片宁静的空间。" +
"那里没有压力,没有必须完成的事。只有你自己,最真实、最纯粹的自己。此刻,你就是完整的。",
},
// ── Light appears (2234%) ──
{
at: 0.28,
text:
"现在,在头顶上方大约三十厘米的地方,有一团温暖的白光。" +
"它就像一个温柔的太阳,纯净、柔和、温暖,包含了宇宙间所有的善意与平静。它在那里等待着你,从不催促,从不评判。",
},
{
at: 0.36,
text:
"每一次吸气,这团光就变得更加明亮。每一次呼气,这团光就变得更加温暖。" +
"它不是刺眼的光,而是柔和的光芒,就像冬日清晨的第一缕阳光。你能感受到它的温度,感受到它想要与你连接的愿望。",
},
// ── Light body scan — head to heart (3856%) ──
{
at: 0.43,
text:
"让这团光慢慢地降下来,轻轻触碰你的头顶,就像母亲的手温柔地抚摸着你。" +
"光缓缓流进你的头部,放松每一寸皮肤。纷乱的念头像云雾一样慢慢散开。",
},
{
at: 0.49,
text:
"光继续向下,来到你的额头和眉心。让光的温暖融化这里的紧绷。" +
"光流过你的双眼和脸颊,带走疲劳,松开不知不觉咬紧的牙关。整个面部都在光中放松了。",
},
{
at: 0.55,
text:
"光流进脖子和肩膀。脖子连接着身体与心灵,肩膀承受着生活的压力。" +
"让光温柔地包裹它们,紧绷的肌肉像冰块在阳光下慢慢融化。所有的僵硬都在光中消散。",
},
{
at: 0.61,
text:
"光从肩膀向下,流过手臂,一直到手指尖。同时,光充满你的胸腔,包围着心脏。" +
"你感受到一种温暖的悸动,那是生命的节奏。在这光中,心跳平稳而有力,不急不缓。",
},
// ── Light body scan — core to feet (6574%) ──
{
at: 0.67,
text:
"光继续向下,流过腹部,流过脊柱。腹部的器官在温暖中放松。" +
"脊柱一节一节地被光滋养,变得更加柔韧。一切都是那么自然,那么和谐。",
},
{
at: 0.73,
text:
"光流进臀部和大腿。它们承载着你每天行走的重量,现在让光的温暖来回报它们。" +
"光流过膝盖、小腿、脚踝,一直到脚趾尖。所有的沉重都在光中消散。双腿变得轻盈而有力。",
},
// ── Full immersion (7686%) ──
{
at: 0.78,
text:
"现在,你的整个身体,从头顶到脚趾,都被这团温暖的白光充满了。" +
"每一个细胞都在发光。光也在身体外部形成柔和的光晕。你坐在一个温暖的光茧之中,安全、宁静、完整。",
},
{
at: 0.84,
text:
"在这光中,你就是平静本身。不是在寻找,不是追求,你就是平静。" +
"所有的念头和感受,都像天空中的云,来了又走。而你是那片天空,广阔无垠,深邃宁静。",
},
// ── Closing (8899%) ──
{
at: 0.89,
text:
"这份平静,这份光,并不只存在于练习中。它一直在你的内心。" +
"无论今天要面对什么,它都会陪伴着你。任何时候闭上眼睛,做一次深呼吸,就能重新连接。平静就在这里。",
},
{
at: 0.94,
text:
"现在,温柔地回到当下的空间。轻轻动一动手指和脚趾,转动一下脖子。" +
"感受椅子支撑着你的重量。你是踏实的,你是安稳的。带着这份光和这份平静,回到你所在的空间。",
},
{
at: 0.99,
text:
"这次练习结束了,但这份觉察可以一直延续。当你准备好了,慢慢地、温柔地睁开眼睛。欢迎回来。",
},
];
export function getMeditationPrompts(totalSeconds: number): { atSecond: number; text: string }[] {
return PROMPTS.map((p) => ({
atSecond: Math.round(p.at * totalSeconds),
text: p.text,
}));
}
+10 -2
View File
@@ -8,6 +8,10 @@ const PUBLIC_PATHS = [
"/api/auth/register",
"/api/auth/login",
"/api/cron/weekly-summaries",
"/api/cron/reminders",
"/api/audio/tts",
"/audio",
"/internal",
];
function isPublic(pathname: string): boolean {
@@ -17,8 +21,12 @@ function isPublic(pathname: string): boolean {
export default async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
// Set pathname header for layout to use
const requestHeaders = new Headers(req.headers);
requestHeaders.set("x-pathname", pathname);
if (isPublic(pathname)) {
return NextResponse.next();
return NextResponse.next({ request: { headers: requestHeaders } });
}
const token = req.cookies.get("inneros-auth")?.value;
@@ -33,7 +41,7 @@ export default async function middleware(req: NextRequest) {
return res;
}
return NextResponse.next();
return NextResponse.next({ request: { headers: requestHeaders } });
}
export const config = {