refactor: migrate project from Flask to Next.js with TypeScript, Prisma, and Edge TTS integration

This commit is contained in:
2026-09-03 00:16:04 -07:00
parent 84f79dc5f9
commit f033a6045c
60 changed files with 5340 additions and 2179 deletions
+9
View File
@@ -0,0 +1,9 @@
# Environment Variables Example
# Database URL (SQLite default for local development)
DATABASE_URL="file:./dev.db"
# Optional: OpenAI API Key (reserved for Milestone 2)
# OPENAI_API_KEY="sk-..."
# Optional: ElevenLabs API Key (reserved for Milestone 2)
# ELEVENLABS_API_KEY="..."
+34 -10
View File
@@ -1,14 +1,38 @@
# Virtual Environment # Dependencies
venv/ node_modules/
.env .pnp
.pnp.js
# Build artifacts # Testing
*.egg-info/ coverage/
# Next.js build output
.next/
out/
build/ build/
dist/ dist/
# Test artifacts # Environment files
.pytest_cache/ .env
__pycache__/ .env*.local
*.pyc
run/ # Database
prisma/*.db
prisma/*.db-journal
*.sqlite
*.sqlite3
# Audio generated outputs (if local storage)
public/audio/episodes/*
!public/audio/episodes/.gitkeep
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS / Editor
.DS_Store
*.pem
.vscode/
.idea/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-2026 Pengzhan Hao <[email protected]>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+24
View File
@@ -0,0 +1,24 @@
.PHONY: install setup dev build start db-push db-studio clean
install:
npm install
setup: install db-push
db-push:
npx prisma db push
db-studio:
npx prisma studio
dev:
npm run dev
build:
npm run build
start:
npm run start
clean:
rm -rf .next
+50 -5
View File
@@ -1,10 +1,55 @@
# Be my anchor # Be My Anchor 🎙️
Make you a podcast like audio fomr articles you'd like to read but want to listen instead. > Transform articles, tech blogs, and research papers into an engaging, hearing-first multimedia podcast experience.
## How to run it ---
## Features (Milestone 1)
- **High-Fidelity Neural Audio Generation**: Multi-provider TTS architecture powered by Microsoft Edge Neural Voices (free, zero-key) with OpenAI TTS interface ready.
- **Smart Text Chunking & Audio Stitching**: Automatically splits long articles and paragraphs into natural spoken chunks, stitches them into a unified `.mp3`, and calculates precise timing cues.
- **Local File System Audio Storage**: Decoupled audio management with native HTTP Range streaming support for smooth scrubbing.
- **Synchronized Web Player**:
- Interactive player with Play / Pause, Progress scrubbing, Speed controls (0.8x - 2.0x), and ±15s jumps.
- Live synchronized transcript that auto-scrolls and highlights the active spoken paragraph.
- Click-to-seek directly from transcript.
- **Episode Library**: Local database persistence via Prisma & SQLite (easily migratable to MongoDB / PostgreSQL).
---
## Tech Stack
- **Framework**: Next.js 16 (App Router) + React 19 + TypeScript
- **Styling**: Tailwind CSS + Lucide Icons
- **Database & ORM**: Prisma + SQLite
- **Audio Engine**: `msedge-tts` (Neural TTS)
---
## Quick Start
### 1. Install Dependencies
```bash
npm install
``` ```
make setup
python -m src.server.app ### 2. Initialize Database
```bash
npx prisma db push
``` ```
### 3. Run Development Server
```bash
npm run dev
```
Open [http://localhost:3000](http://localhost:3000) in your browser.
---
## Architecture & Roadmap
Refer to [Product Requirements Document (`docs/prd.md`)](docs/prd.md) for full architecture details:
- **Phase 1 (Complete)**: Audio generation core, chunking, stitching, local audio storage, and synchronized web player.
- **Phase 2 (Upcoming)**: Hearing-front LLM transformation (Feynman Deep-Dive Presenter & Dual-Host Banter).
- **Phase 3 (Upcoming)**: Synchronized visual stage (displaying diagrams, figures, and code in sync with audio).
+312
View File
@@ -0,0 +1,312 @@
# Product Requirements Document (PRD): Be My Anchor
**Status**: Approved (Post Grill-Me)
**Target Version**: 2.0 (Full Refactor)
**Architecture**: TypeScript / Node.js Fullstack
**Primary Output**: `docs/prd.md`
---
## 1. Executive Summary & Vision
**Be My Anchor** transforms written materials—technical blogs, research papers, news articles, novels, and documentation—into an engaging, listening-first multimedia experience.
Rather than relying on brittle, heavy web scrapers or monotone text-to-speech readers, Be My Anchor adopts an **API-first ingestion** model combined with:
1. **High-Fidelity Audio Generation**: Seamless multi-provider TTS (Edge TTS, OpenAI, ElevenLabs) with smart text chunking and audio stitching.
2. **Hearing-Front Content Adaptation**: An LLM-powered scriptwriting engine that adapts dense text into conversational spoken formats (e.g., Feynman-style technical breakdown or 2-host conversational dialogue).
3. **Synchronized Visual Companion**: A web player that dynamically surfaces referenced figures, diagrams, and code snippets in sync with the audio narration.
---
## 2. Core Philosophy & Phased Strategy
```
┌─────────────────────────────────────────────────────────────┐
│ Phase 1: Robust Audio Generation Core │
│ - Multi-provider TTS (Edge TTS, OpenAI, ElevenLabs) │
│ - Long-form text chunking & audio stitching │
│ - Basic web player & episode library │
└──────────────────────────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Phase 2: Hearing-Front Content Transformation │
│ - "Feynman" Deep-Dive Presenter Persona │
│ - Dual-Host Dialogue Persona (NotebookLM style) │
│ - Script stage cue tagging │
└──────────────────────────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Phase 3: Interactive Synchronized Multimedia Player │
│ - Timestamped visual cues (figures, code, diagrams) │
│ - Karaoke-style synced transcript │
│ - Stage-aware responsive visual pane │
└─────────────────────────────────────────────────────────────┘
```
---
## 3. User Personas & Modes of Operation
### Persona 1: The Engineer / Researcher ("The Feynman Mode")
- **Content**: Technical architecture posts, RFCs, research papers, algorithm deep-dives.
- **Narrator Style**: A world-class communicator (like Richard Feynman or a top tech lead) explaining concepts using intuitive analogies, breaking down equations, and explaining diagrams.
- **Visual Need**: When the narrator says *"Looking at Figure 2, the ingress traffic routes through..."*, Figure 2 automatically appears on screen.
### Persona 2: The Informed Professional ("The Dual-Host Dialogue")
- **Content**: Industry news, opinion pieces, market shifts, newsletters.
- **Narrator Style**: Two AI co-hosts bantering, asking each other rhetorical questions, and adding contextual background not directly present in the source article.
- **Visual Need**: Highlight cards, quotes, and source citations appearing on the dashboard.
### Persona 3: The Audiobook / Direct Listener ("Verbatim Mode")
- **Content**: Novels, essays, speeches, narrative non-fiction.
- **Narrator Style**: Clear, expressive single-narrator audiobook delivery with sentence-by-sentence paragraph tracking.
---
## 4. System Architecture
```
┌────────────────────────────┐
│ API Ingestion Endpoint │
│ (Markdown / Text / Assets) │
└─────────────┬──────────────┘
│
▼
┌────────────────────────────┐
│ Hearing-Front Script Engine│
│ (LLM: OpenAI / Claude) │
│ - Rewrites text for ear │
│ - Generates Stage Cues │
└─────────────┬──────────────┘
│
Script with Stage Marks
│
▼
┌────────────────────────────┐
│ TTS Generation Core │
│ ┌──────────┐ ┌───────────┐ │
│ │ Edge TTS │ │OpenAI TTS │ │
│ └──────────┘ └───────────┘ │
│ ┌────────────────────────┐ │
│ │ ElevenLabs (Multi-Voice│ │
│ └────────────────────────┘ │
│ - Chunker & Audio Stitcher │
│ - Timing Aligner (VTT/JSON)│
└─────────────┬──────────────┘
│
▼
┌───────────────────────────────────┴───────────────────────────────────┐
│ Data & Storage │
│ - Database: Prisma ORM (MongoDB / PostgreSQL) │
│ - File Storage: Local static / S3-compatible (Audio MP3s & Figures) │
└───────────────────────────────────┬───────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────────┐
│ Interactive Web Player (Next.js / TS) │
│ ┌───────────────────────┐ ┌───────────────────────────────────────┐ │
│ │ Audio Player UI │ │ Synchronized Visual Stage │ │
│ │ - Waveform / Scrubber│ │ - Figures / Diagrams from post │ │
│ │ - Speed / ±15s Skips │ │ - Syntax-highlighted code snippets │ │
│ │ - Live Transcript │ │ - Key takeaway summary cards │ │
│ └───────────────────────┘ └───────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────────┘
```
---
## 5. Functional Requirements
### 5.1 Content Ingestion (API-Driven)
- Provide a clean REST API accepting:
- `title`: String
- `content`: Plain text or Markdown (including image references `![alt](url)`)
- `format_mode`: `'feynman'` | `'dual_host'` | `'verbatim'`
- `voice_config`: Provider preference (`edge` | `openai` | `elevenlabs`), voice IDs, speed
- `supplemental_materials`: Optional array of images/figures, captions, and links
- No requirement for headless Selenium or browser dependencies on the server.
### 5.2 Hearing-Front Content Transformation
- **Verbatim Cleaner**: Strips markdown artifacts, normalizes punctuation, phonetic hints for acronyms.
- **Feynman Script Generator**:
- Rewrites technical content into an oral lecture/walkthrough.
- Generates `[STAGE: figure_id]` or `[STAGE: code_block_id]` inline tags corresponding to visual assets.
- **Dual-Host Dialogue Generator**:
- Turns article content into an interactive conversation between Host A (e.g., inquisitive, relatable) and Host B (e.g., domain expert).
- Emits speaker-tagged lines: `SpeakerA: ...` and `SpeakerB: ...`.
### 5.3 TTS Engine & Audio Processing
- **Provider Abstraction Interface (`ITextToSpeechProvider`)**:
- `EdgeTTSProvider`: Free, instant, high-quality neural voices (`en-US-ChristopherNeural`, `en-US-JennyNeural`, etc.), zero API key required.
- `OpenAITTSProvider`: `alloy`, `echo`, `fable`, `onyx`, `nova`, `shimmer`.
- `ElevenLabsProvider`: High-fidelity multi-voice assignment with voice cloning or distinct character voices for multi-host dialogue.
- **Long-Text Chunker**:
- Breaks script into sentence/clause chunks respecting provider character limits (e.g. 4096 chars).
- Handles speaker switching in dual-host mode without awkward pauses.
- **Audio Stitcher & Aligner**:
- Stitches chunk audio files into a unified master `.mp3`.
- Outputs a timing manifest (`timings.json` or WebVTT) recording the start and end timestamp of each paragraph, speaker turn, and stage marker.
### 5.4 Synchronized Web View & Media Player
- **Episode Library**:
- List of generated episodes with duration, creation date, mode, and status (`processing`, `ready`, `failed`).
- **Audio Player Controls**:
- Play / Pause, Progress scrubber, Current time / Total duration.
- Jump Back 15s (`-15s`), Jump Forward 15s (`+15s`).
- Playback speed selection (0.8x, 1.0x, 1.25x, 1.5x, 2.0x).
- **Synchronized Transcript Pane**:
- Interactive transcript that auto-scrolls and highlights the active paragraph/dialogue turn as audio plays.
- Clicking any paragraph seeks playback directly to that timestamp.
- **Synchronized Stage (Supplemental Visual Information)**:
- Responsive visual viewport alongside or above the player.
- When the audio reaches a tagged stage (e.g. timestamp 02:15):
- Automatically displays the relevant figure, architecture diagram, equation, or code snippet.
- Provides zoom/expand view for detailed inspection.
- Gracefully transitions to summary notes when no specific figure is active.
---
## 6. Data Model (Prisma Schema Specification)
```prisma
datasource db {
provider = "postgresql" // or "mongodb"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
enum ProcessingStatus {
PENDING
SCRIPTING
SYNTHESIZING
READY
FAILED
}
enum ScriptMode {
VERBATIM
FEYNMAN
DUAL_HOST
}
model Episode {
id String @id @default(cuid()) @map("_id")
title String
originalContent String
scriptContent String?
mode ScriptMode @default(FEYNMAN)
status ProcessingStatus @default(PENDING)
durationSeconds Float?
audioUrl String?
timingsJson String? // Serialized timestamp cues
stages StageItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model StageItem {
id String @id @default(cuid()) @map("_id")
episodeId String
timestampSeconds Float
type String // "IMAGE" | "CODE" | "CALLOUT"
title String?
content String // Image URL or Code snippet / Callout text
caption String?
episode Episode @relation(fields: [episodeId], references: [id], onDelete: Cascade)
}
```
---
## 7. API Specification
### `POST /api/episodes`
Creates a new conversion job.
```json
{
"title": "Understanding Kubernetes Ingress Controllers",
"content": "# Markdown content...",
"mode": "FEYNMAN",
"ttsProvider": "edge",
"voiceOptions": {
"primaryVoice": "en-US-ChristopherNeural",
"speed": 1.0
},
"visualAssets": [
{
"id": "fig-1",
"type": "IMAGE",
"url": "https://example.com/arch.png",
"caption": "Figure 1: Ingress routing architecture"
}
]
}
```
**Response**: `202 Accepted` with `{ "episodeId": "clx...", "status": "PENDING" }`.
### `GET /api/episodes/:id`
Retrieves episode details, audio URL, processing status, transcript timings, and stage markers.
### `GET /api/episodes`
Lists all generated episodes for the library view.
---
## 8. Technical Stack
| Component | Technology | Rationale |
| --- | --- | --- |
| **Runtime & Language** | Node.js (v20+) / TypeScript | Rapid prototyping, strong typing, zero Python venv/setup friction. |
| **Framework** | Next.js (App Router) | Unified backend API routes + reactive frontend player in one repo. |
| **Database & ORM** | Prisma + MongoDB or PostgreSQL | Lightweight document/relational storage matching user preference. |
| **Audio Generation** | `msedge-tts`, OpenAI API, ElevenLabs SDK | Free zero-key tier (Edge) + premium commercial options. |
| **Audio Processing** | `fluent-ffmpeg` or Web Audio APIs | Fast server-side audio stitching and silence injection. |
| **LLM Orchestration** | Vercel AI SDK / OpenAI SDK | Streaming script generation and structured stage extraction. |
| **Styling** | Tailwind CSS / Lucide Icons | Clean modern aesthetic, responsive audio controls. |
---
## 9. Implementation Roadmap
### Phase 1: Core Audio Generation & Minimal Player (Week 1)
- [ ] Initialize Next.js + TypeScript project structure and Prisma configuration.
- [ ] Implement `ITextToSpeechProvider` interface with `EdgeTTSProvider` and `OpenAITTSProvider`.
- [ ] Build audio chunking, concatenation, and static file delivery.
- [ ] Implement basic Web Player UI (Play/Pause, Scrubber, Episode list).
### Phase 2: Hearing-Front Script Engine (Week 2)
- [ ] Implement prompt templates for:
- Feynman Master Presenter (single host, conversational breakdown).
- Dual-Host Dialogue (speaker-tagged turns).
- [ ] Parse speaker tags and route chunks to distinct voices in the TTS pipeline.
- [ ] Output synchronized paragraph/sentence timestamp manifests.
### Phase 3: Synchronized Visual Stages (Week 3)
- [ ] Extract or link visual assets (figures/diagrams/code) to script stages.
- [ ] Build the interactive Stage Viewer in the web player:
- Figure transitions locked to audio progress.
- Interactive transcript with click-to-seek.
- [ ] End-to-end testing with technical papers, blog posts, and news feeds.
-25
View File
@@ -1,25 +0,0 @@
download-webdriver:
curl -L https://storage.googleapis.com/chrome-for-testing-public/132.0.6834.110/linux64/chrome-linux64.zip -o chrome-linux64.zip
curl -L https://storage.googleapis.com/chrome-for-testing-public/132.0.6834.110/linux64/chromedriver-linux64.zip -o chromedriver-linux64.zip
prepare-webdriver: download-webdriver
mkdir -p run/webdriver/chrome
unzip chrome-linux64.zip -d run/webdriver/chrome
rm -f chrome-linux64.zip
mkdir -p run/webdriver/chromedriver
unzip -j chromedriver-linux64.zip -d run/webdriver/chromedriver
rm -f chromedriver-linux64.zip
setup: prepare-webdriver
python3 -m venv venv
pip install -e .
clean-webdriver:
rm -rf run/webdriver
clean: clean-webdriver
rm -rf run
rm -rf venv
all: setup
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
serverExternalPackages: ["msedge-tts"],
};
export default nextConfig;
+3090
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "be-my-anchor",
"version": "1.0.0",
"description": "Transform articles, tech blogs, and papers into a personalized listening experience with synchronized visual companion.",
"scripts": {
"dev": "next dev --webpack",
"build": "next build --webpack",
"start": "next start",
"lint": "next lint"
},
"repository": {
"type": "git",
"url": "https://git.pengzhan.dev/haopengzhan/be-my-anchor.git"
},
"keywords": ["podcast", "tts", "nextjs", "audio", "reader"],
"author": "Pengzhan Hao <[email protected]>",
"license": "MIT",
"type": "module",
"dependencies": {
"@prisma/client": "^6.19.3",
"lucide-react": "^1.39.0",
"msedge-tts": "^2.0.7",
"next": "^16.3.4",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@types/node": "^26.4.1",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"autoprefixer": "^10.5.4",
"postcss": "^8.5.26",
"prisma": "^6.19.3",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3"
}
}
+8
View File
@@ -0,0 +1,8 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;
+38
View File
@@ -0,0 +1,38 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model Episode {
id String @id @default(cuid())
title String
originalContent String
scriptContent String?
mode String @default("VERBATIM") // VERBATIM | FEYNMAN | DUAL_HOST
status String @default("PENDING") // PENDING | PROCESSING | READY | FAILED
errorMessage String?
durationSeconds Float?
audioUrl String?
timingsJson String? // Serialized array of cues / timestamps [{text, startTime, endTime, speaker}]
stages StageItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model StageItem {
id String @id @default(cuid())
episodeId String
timestampSeconds Float
type String // IMAGE | CODE | CALLOUT
title String?
content String // Image URL, Code snippet, or Callout text
caption String?
episode Episode @relation(fields: [episodeId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
}
+1
View File
@@ -0,0 +1 @@
-106
View File
@@ -1,106 +0,0 @@
[build-system]
requires = ["setuptools>=42", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "be-my-anchor"
version = "0.1.0"
authors = [
{name="Pengzhan Hao", email="[email protected]"},
]
description = "Web content to podcast converter"
readme = "README.md"
requires-python = ">=3.11"
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
[project.dependencies]
annotated-types==0.7.0
anyio==4.8.0
attrs==25.1.0
beautifulsoup4==4.12.3
blinker==1.9.0
cachetools==5.5.1
certifi==2024.12.14
chardet==5.2.0
charset-normalizer==3.4.1
click==8.1.8
cssselect==1.2.0
distro==1.9.0
feedfinder2==0.0.4
feedparser==6.0.11
filelock==3.17.0
Flask==3.1.0
google-ai-generativelanguage==0.6.15
google-api-core==2.24.0
google-api-python-client==2.159.0
google-auth==2.38.0
google-auth-httplib2==0.2.0
google-cloud-texttospeech==2.24.0
google-generativeai==0.8.4
googleapis-common-protos==1.66.0
greenlet==3.1.1
grpcio==1.70.0
grpcio-status==1.70.0
h11==0.14.0
html2text==2024.2.26
httpcore==1.0.7
httplib2==0.22.0
httpx==0.28.1
idna==3.10
itsdangerous==2.2.0
jieba3k==0.35.1
Jinja2==3.1.5
jiter==0.8.2
joblib==1.4.2
lxml==5.3.0
lxml_html_clean==0.4.1
MarkupSafe==3.0.2
newspaper3k==0.2.8
nltk==3.9.1
openai==1.60.1
outcome==1.3.0.post0
pillow==11.1.0
pip-chill==1.0.3
playwright==1.49.1
proto-plus==1.25.0
protobuf==5.29.3
pyasn1==0.6.1
pyasn1_modules==0.4.1
pydantic==2.10.6
pydantic_core==2.27.2
pyee==12.0.0
pyfranc==0.2.3
pyparsing==3.2.1
PySocks==1.7.1
python-dateutil==2.9.0.post0
python-dotenv==1.0.1
PyYAML==6.0.2
readability-lxml==0.8.1
regex==2024.11.6
requests==2.32.3
requests-file==2.1.0
rsa==4.9
selenium==4.28.1
sgmllib3k==1.0.0
six==1.17.0
sniffio==1.3.1
sortedcontainers==2.4.0
soupsieve==2.6
tinysegmenter==0.3
tldextract==5.1.3
tqdm==4.67.1
trio==0.28.0
trio-websocket==0.11.1
typing_extensions==4.12.2
uritemplate==4.1.1
urllib3==2.3.0
websocket-client==1.8.0
Werkzeug==3.1.3
wsproto==1.2.0
[project.optional-dependencies]
dev = ["pytest", "black"]
-15
View File
@@ -1,15 +0,0 @@
flask
google-cloud-texttospeech
google-generativeai
grpcio-status
html2text
lxml-html-clean
newspaper3k
openai
pip-chill
playwright
pyfranc
pysocks
python-dotenv
readability-lxml
selenium
-5
View File
@@ -1,5 +0,0 @@
-r requirements.txt
pytest>=7.0
black>=23.0
isort>=5.0
pip-chill>=1.0
+66
View File
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { storage } from "@/lib/storage/storage";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const episode = await prisma.episode.findUnique({
where: { id },
include: { stages: true },
});
if (!episode) {
return NextResponse.json(
{ success: false, error: "Episode not found" },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
episode: {
...episode,
timings: episode.timingsJson ? JSON.parse(episode.timingsJson) : [],
},
});
} catch (error: any) {
return NextResponse.json(
{ success: false, error: error.message || "Failed to fetch episode" },
{ status: 500 }
);
}
}
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const episode = await prisma.episode.findUnique({ where: { id } });
if (!episode) {
return NextResponse.json(
{ success: false, error: "Episode not found" },
{ status: 404 }
);
}
// Delete audio file from storage
await storage.deleteAudio(id);
// Delete record from DB
await prisma.episode.delete({ where: { id } });
return NextResponse.json({ success: true, message: "Episode deleted" });
} catch (error: any) {
return NextResponse.json(
{ success: false, error: error.message || "Failed to delete episode" },
{ status: 500 }
);
}
}
+101
View File
@@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { audioService } from "@/lib/tts/audio-service";
import { cleanHtmlTags } from "@/lib/tts/chunker";
export async function GET() {
try {
const episodes = await prisma.episode.findMany({
orderBy: { createdAt: "desc" },
include: { stages: true },
});
return NextResponse.json({ success: true, episodes });
} catch (error: any) {
console.error("Failed to list episodes:", error);
return NextResponse.json(
{ success: false, error: error.message || "Failed to list episodes" },
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { title, content, mode = "VERBATIM", provider = "edge", voice } = body;
if (!title || typeof title !== "string" || !title.trim()) {
return NextResponse.json(
{ success: false, error: "Title is required." },
{ status: 400 }
);
}
if (!content || typeof content !== "string" || !content.trim()) {
return NextResponse.json(
{ success: false, error: "Content is required." },
{ status: 400 }
);
}
const sanitizedTitle = cleanHtmlTags(title.trim()) || "Untitled Episode";
// 1. Create episode record in PENDING state
const episode = await prisma.episode.create({
data: {
title: sanitizedTitle,
originalContent: content.trim(),
mode,
status: "PROCESSING",
},
});
// 2. Synthesize audio asynchronously or inline
try {
const audioResult = await audioService.processAndSaveEpisode(
episode.id,
content.trim(),
{ provider, voice }
);
const updatedEpisode = await prisma.episode.update({
where: { id: episode.id },
data: {
status: "READY",
audioUrl: audioResult.audioUrl,
durationSeconds: audioResult.durationSeconds,
timingsJson: JSON.stringify(audioResult.timings),
},
});
return NextResponse.json({
success: true,
episode: updatedEpisode,
});
} catch (synthError: any) {
console.error(`Audio synthesis failed for episode ${episode.id}:`, synthError);
await prisma.episode.update({
where: { id: episode.id },
data: {
status: "FAILED",
errorMessage: synthError.message || "Audio synthesis failed",
},
});
return NextResponse.json(
{
success: false,
error: synthError.message || "Failed to synthesize audio",
episodeId: episode.id,
},
{ status: 500 }
);
}
} catch (error: any) {
console.error("Episode creation error:", error);
return NextResponse.json(
{ success: false, error: error.message || "Internal server error" },
{ status: 500 }
);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import { POPULAR_EDGE_VOICES } from "@/lib/tts/edge-provider";
export async function GET() {
return NextResponse.json({
success: true,
voices: POPULAR_EDGE_VOICES,
});
}
+37
View File
@@ -0,0 +1,37 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #090d16;
--foreground: #f1f5f9;
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 0;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
/* Custom scrollbars */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #0f172a;
}
::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #475569;
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Be My Anchor - Listen to Any Article",
description:
"Convert written articles, tech blogs, and papers into a personalized listening experience with synchronized visual companion.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="dark">
<body className="bg-slate-950 text-slate-100 min-h-screen flex flex-col antialiased">
{children}
</body>
</html>
);
}
+468
View File
@@ -0,0 +1,468 @@
"use client";
import React, { useEffect, useState } from "react";
import {
Radio,
PlusCircle,
Headphones,
Trash2,
FileText,
Sparkles,
Loader2,
Clock,
ExternalLink,
Volume2,
} from "lucide-react";
import { AudioPlayer } from "@/components/AudioPlayer";
import { TranscriptViewer } from "@/components/TranscriptViewer";
import { TimingCue, VoiceOption } from "@/lib/tts/types";
interface Episode {
id: string;
title: string;
originalContent: string;
mode: string;
status: string;
durationSeconds?: number | null;
audioUrl?: string | null;
timingsJson?: string | null;
createdAt: string;
}
export default function Home() {
const [episodes, setEpisodes] = useState<Episode[]>([]);
const [activeEpisode, setActiveEpisode] = useState<Episode | null>(null);
const [activeTimings, setActiveTimings] = useState<TimingCue[]>([]);
const [currentTime, setCurrentTime] = useState<number>(0);
const [seekTime, setSeekTime] = useState<number | null>(null);
// Form state
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [voice, setVoice] = useState("zh-CN-XiaoxiaoNeural");
const [voices, setVoices] = useState<VoiceOption[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formError, setFormError] = useState("");
const [showCreateForm, setShowCreateForm] = useState(false);
const handleContentChange = (val: string) => {
setContent(val);
if (/[\u4e00-\u9fa5]/.test(val) && voice.startsWith("en-")) {
setVoice("zh-CN-XiaoxiaoNeural");
}
};
// Fetch episodes on mount
const fetchEpisodes = async () => {
try {
const res = await fetch("/api/episodes");
const data = await res.json();
if (data.success && data.episodes) {
setEpisodes(data.episodes);
// Default to first episode if none selected
if (!activeEpisode && data.episodes.length > 0) {
selectEpisode(data.episodes[0]);
}
}
} catch (err) {
console.error("Failed to load episodes:", err);
}
};
// Fetch voices on mount
const fetchVoices = async () => {
try {
const res = await fetch("/api/voices");
const data = await res.json();
if (data.success && data.voices) {
setVoices(data.voices);
}
} catch (err) {
console.error("Failed to load voices:", err);
}
};
useEffect(() => {
fetchEpisodes();
fetchVoices();
}, []);
const selectEpisode = (ep: Episode) => {
setActiveEpisode(ep);
if (ep.timingsJson) {
try {
setActiveTimings(JSON.parse(ep.timingsJson));
} catch {
setActiveTimings([]);
}
} else {
setActiveTimings([]);
}
setCurrentTime(0);
};
const handleCreateEpisode = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim() || !content.trim()) {
setFormError("Please provide both a title and article content.");
return;
}
setIsSubmitting(true);
setFormError("");
try {
const res = await fetch("/api/episodes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title,
content,
mode: "VERBATIM",
provider: "edge",
voice,
}),
});
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.error || "Synthesis failed");
}
// Reset form
setTitle("");
setContent("");
setShowCreateForm(false);
// Refresh list & select newly created episode
await fetchEpisodes();
if (data.episode) {
selectEpisode(data.episode);
}
} catch (err: any) {
setFormError(err.message || "Failed to generate audio.");
} finally {
setIsSubmitting(false);
}
};
const handleDeleteEpisode = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm("Are you sure you want to delete this episode?")) return;
try {
await fetch(`/api/episodes/${id}`, { method: "DELETE" });
if (activeEpisode?.id === id) {
setActiveEpisode(null);
setActiveTimings([]);
}
await fetchEpisodes();
} catch (err) {
console.error("Failed to delete episode:", err);
}
};
const formatDuration = (secs?: number | null) => {
if (!secs) return "0:00";
const m = Math.floor(secs / 60);
const s = Math.floor(secs % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
};
return (
<div className="flex-1 flex flex-col pb-32">
{/* Top Navbar */}
<header className="border-b border-slate-800 bg-slate-900/60 backdrop-blur-md sticky top-0 z-40">
<div className="max-w-7xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-tr from-blue-600 to-indigo-500 flex items-center justify-center text-white shadow-md shadow-blue-500/20">
<Radio className="w-5 h-5" />
</div>
<div>
<span className="font-bold text-lg text-slate-100 tracking-tight">
Be My Anchor
</span>
<span className="ml-2 text-xs px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-400 border border-blue-500/20 font-mono">
v2.0 M1
</span>
</div>
</div>
<button
onClick={() => setShowCreateForm(!showCreateForm)}
className="flex items-center gap-2 px-3.5 py-1.5 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium transition shadow-sm shadow-blue-600/20"
>
<PlusCircle className="w-4 h-4" />
<span>New Episode</span>
</button>
</div>
</header>
{/* Main Content Area */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6 w-full flex-1">
{/* Ingest Form (Collapsible) */}
{showCreateForm && (
<div className="mb-8 p-6 bg-slate-900 border border-slate-800 rounded-2xl shadow-xl animate-in fade-in slide-in-from-top-4 duration-200">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-blue-400" />
<h2 className="text-lg font-semibold text-slate-100">
Convert Content to Podcast Episode
</h2>
</div>
<button
onClick={() => setShowCreateForm(false)}
className="text-xs text-slate-400 hover:text-slate-200"
>
Close
</button>
</div>
{formError && (
<div className="mb-4 p-3 bg-red-950/40 border border-red-800 text-red-300 text-sm rounded-lg">
{formError}
</div>
)}
<form onSubmit={handleCreateEpisode} className="space-y-4">
<div>
<label className="block text-xs font-medium text-slate-400 mb-1">
Article or Episode Title
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Understanding Kubernetes Ingress and Service Meshes"
className="w-full px-3.5 py-2.5 bg-slate-800/80 border border-slate-700 rounded-lg text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-blue-500"
required
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-400 mb-1">
TTS Voice Engine (Edge Neural)
</label>
<select
value={voice}
onChange={(e) => setVoice(e.target.value)}
className="w-full px-3.5 py-2.5 bg-slate-800/80 border border-slate-700 rounded-lg text-sm text-slate-100 focus:outline-none focus:border-blue-500"
>
<optgroup label="🇨🇳 中文 / Chinese (支持中英双语混合)">
{voices
.filter((v) => v.locale.startsWith("zh"))
.map((v) => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</optgroup>
<optgroup label="🇺🇸 English (US & UK)">
{voices
.filter((v) => !v.locale.startsWith("zh"))
.map((v) => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</optgroup>
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-400 mb-1">
Narration Mode
</label>
<select
disabled
className="w-full px-3.5 py-2.5 bg-slate-850 border border-slate-700/50 rounded-lg text-sm text-slate-400 cursor-not-allowed"
>
<option value="VERBATIM">Verbatim Reader (M1 Active)</option>
<option value="FEYNMAN" disabled>Feynman Tutor (M2 Preview)</option>
<option value="DUAL_HOST" disabled>Dual-Host Dialogue (M2 Preview)</option>
</select>
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-400 mb-1">
Article Text or Markdown
</label>
<textarea
value={content}
onChange={(e) => handleContentChange(e.target.value)}
placeholder="Paste your technical article, newsletter, blog post, or notes here..."
rows={6}
className="w-full px-3.5 py-2.5 bg-slate-800/80 border border-slate-700 rounded-lg text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-blue-500 font-mono text-xs leading-relaxed"
required
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={() => setShowCreateForm(false)}
className="px-4 py-2 text-sm text-slate-400 hover:text-slate-200"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="flex items-center gap-2 px-5 py-2 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-800 text-white text-sm font-medium rounded-lg shadow-md shadow-blue-600/30 transition"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>Synthesizing Audio...</span>
</>
) : (
<>
<Headphones className="w-4 h-4" />
<span>Synthesize Episode</span>
</>
)}
</button>
</div>
</form>
</div>
)}
{/* Dashboard Columns */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
{/* Left: Episode Library (5 cols) */}
<section className="lg:col-span-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-2">
<FileText className="w-4 h-4" />
<span>Episodes ({episodes.length})</span>
</h2>
</div>
{episodes.length === 0 ? (
<div className="p-8 text-center bg-slate-900/50 border border-dashed border-slate-800 rounded-2xl">
<Headphones className="w-10 h-10 text-slate-600 mx-auto mb-3" />
<h3 className="text-slate-200 font-medium text-sm mb-1">No episodes generated yet</h3>
<p className="text-xs text-slate-500 mb-4">
Paste a blog post or article to generate your first podcast episode!
</p>
<button
onClick={() => setShowCreateForm(true)}
className="inline-flex items-center gap-2 px-3 py-1.5 text-xs bg-blue-600 text-white rounded-lg hover:bg-blue-500"
>
<PlusCircle className="w-3.5 h-3.5" />
<span>Generate Episode</span>
</button>
</div>
) : (
<div className="space-y-2.5">
{episodes.map((ep) => {
const isSelected = activeEpisode?.id === ep.id;
return (
<div
key={ep.id}
onClick={() => selectEpisode(ep)}
className={`p-4 rounded-xl cursor-pointer transition-all border ${
isSelected
? "bg-slate-850 border-blue-500/60 shadow-md shadow-blue-500/5"
: "bg-slate-900/70 border-slate-800 hover:border-slate-700 hover:bg-slate-850/60"
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h3 className={`text-sm font-medium truncate ${isSelected ? "text-blue-200" : "text-slate-200"}`}>
{ep.title}
</h3>
<div className="flex items-center gap-3 mt-1.5 text-xs text-slate-500">
<span className="flex items-center gap-1 font-mono">
<Clock className="w-3 h-3" />
{formatDuration(ep.durationSeconds)}
</span>
<span>•</span>
<span className="capitalize">{ep.mode.toLowerCase()}</span>
<span>•</span>
<span
className={`px-1.5 py-0.2 rounded text-[10px] font-medium uppercase ${
ep.status === "READY"
? "bg-emerald-500/10 text-emerald-400"
: ep.status === "PROCESSING"
? "bg-amber-500/10 text-amber-400"
: "bg-red-500/10 text-red-400"
}`}
>
{ep.status}
</span>
</div>
</div>
<button
onClick={(e) => handleDeleteEpisode(ep.id, e)}
className="p-1.5 text-slate-500 hover:text-red-400 hover:bg-red-950/30 rounded-lg transition"
title="Delete episode"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
);
})}
</div>
)}
</section>
{/* Right: Active Episode & Transcript (7 cols) */}
<section className="lg:col-span-7 bg-slate-900/80 border border-slate-800 rounded-2xl p-6 min-h-[480px] flex flex-col">
{activeEpisode ? (
<div className="flex flex-col flex-1">
{/* Header */}
<div className="border-b border-slate-800 pb-4 mb-4">
<div className="flex items-center justify-between">
<span className="text-xs font-mono uppercase tracking-wider text-blue-400 bg-blue-950/60 px-2.5 py-1 rounded border border-blue-900">
Now Playing
</span>
<span className="text-xs text-slate-500">
Created {new Date(activeEpisode.createdAt).toLocaleDateString()}
</span>
</div>
<h1 className="text-xl font-bold text-slate-100 mt-2">
{activeEpisode.title}
</h1>
</div>
{/* Synchronized Transcript */}
<div className="flex-1 flex flex-col">
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3 flex items-center gap-1.5">
<Volume2 className="w-3.5 h-3.5 text-blue-400" />
<span>Synchronized Transcript</span>
</h3>
<TranscriptViewer
timings={activeTimings}
currentTime={currentTime}
onSeek={(t) => setSeekTime(t)}
/>
</div>
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-center text-slate-500 py-16">
<Radio className="w-12 h-12 mb-3 opacity-30 text-blue-400" />
<p className="text-sm">Select an episode from the library to listen and follow along.</p>
</div>
)}
</section>
</div>
</main>
{/* Persistent Bottom Audio Player */}
{activeEpisode && activeEpisode.audioUrl && (
<AudioPlayer
title={activeEpisode.title}
audioUrl={activeEpisode.audioUrl}
durationSeconds={activeEpisode.durationSeconds}
onTimeUpdate={(t) => setCurrentTime(t)}
seekTime={seekTime}
/>
)}
</div>
);
}
+253
View File
@@ -0,0 +1,253 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import {
Play,
Pause,
RotateCcw,
RotateCw,
Volume2,
VolumeX,
Gauge,
Radio,
} from "lucide-react";
interface AudioPlayerProps {
title: string;
audioUrl: string;
durationSeconds?: number | null;
onTimeUpdate?: (currentTime: number) => void;
onSeek?: (seekTo: number) => void;
seekTime?: number | null;
}
export function AudioPlayer({
title,
audioUrl,
durationSeconds,
onTimeUpdate,
seekTime,
}: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(durationSeconds || 0);
const [playbackRate, setPlaybackRate] = useState(1);
const [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
// Handle external seek requests (e.g. clicking transcript)
useEffect(() => {
if (
seekTime !== undefined &&
seekTime !== null &&
audioRef.current &&
Math.abs(audioRef.current.currentTime - seekTime) > 0.5
) {
audioRef.current.currentTime = seekTime;
setCurrentTime(seekTime);
if (!isPlaying) {
audioRef.current.play().catch(console.error);
setIsPlaying(true);
}
}
}, [seekTime]);
// Handle source changes
useEffect(() => {
if (audioRef.current) {
audioRef.current.pause();
setIsPlaying(false);
setCurrentTime(0);
audioRef.current.load();
}
}, [audioUrl]);
const togglePlay = () => {
if (!audioRef.current) return;
if (isPlaying) {
audioRef.current.pause();
setIsPlaying(false);
} else {
audioRef.current.play().then(() => setIsPlaying(true)).catch(console.error);
}
};
const jump = (seconds: number) => {
if (!audioRef.current) return;
const newTime = Math.max(0, Math.min(audioRef.current.currentTime + seconds, duration || 999999));
audioRef.current.currentTime = newTime;
setCurrentTime(newTime);
};
const handleTimeUpdate = () => {
if (!audioRef.current) return;
const time = audioRef.current.currentTime;
setCurrentTime(time);
onTimeUpdate?.(time);
};
const handleLoadedMetadata = () => {
if (!audioRef.current) return;
if (audioRef.current.duration && isFinite(audioRef.current.duration)) {
setDuration(audioRef.current.duration);
}
};
const handleScrub = (e: React.ChangeEvent<HTMLInputElement>) => {
const time = parseFloat(e.target.value);
setCurrentTime(time);
if (audioRef.current) {
audioRef.current.currentTime = time;
}
};
const togglePlaybackRate = () => {
const rates = [0.8, 1.0, 1.25, 1.5, 2.0];
const nextIdx = (rates.indexOf(playbackRate) + 1) % rates.length;
const newRate = rates[nextIdx];
setPlaybackRate(newRate);
if (audioRef.current) {
audioRef.current.playbackRate = newRate;
}
};
const toggleMute = () => {
if (!audioRef.current) return;
const newMuted = !isMuted;
setIsMuted(newMuted);
audioRef.current.muted = newMuted;
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseFloat(e.target.value);
setVolume(val);
if (audioRef.current) {
audioRef.current.volume = val;
if (val === 0) {
setIsMuted(true);
} else if (isMuted) {
setIsMuted(false);
}
}
};
const formatTime = (secs: number) => {
if (isNaN(secs) || !isFinite(secs)) return "00:00";
const minutes = Math.floor(secs / 60);
const seconds = Math.floor(secs % 60);
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
};
return (
<div className="fixed bottom-0 left-0 right-0 z-50 bg-slate-900/95 backdrop-blur-md border-t border-slate-800 shadow-2xl px-4 py-3 sm:px-6">
<audio
ref={audioRef}
src={audioUrl}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={() => setIsPlaying(false)}
preload="metadata"
/>
<div className="max-w-6xl mx-auto flex flex-col gap-2">
{/* Scrubber and Timestamps */}
<div className="flex items-center gap-3 w-full">
<span className="text-xs font-mono text-slate-400 w-12 text-right">
{formatTime(currentTime)}
</span>
<div className="relative flex-1 group">
<input
type="range"
min="0"
max={duration || 100}
step="0.1"
value={currentTime}
onChange={handleScrub}
className="w-full h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-500 hover:h-2 transition-all"
/>
</div>
<span className="text-xs font-mono text-slate-400 w-12">
{formatTime(duration)}
</span>
</div>
{/* Controls Bar */}
<div className="flex items-center justify-between">
{/* Track Info */}
<div className="flex items-center gap-3 w-1/3 min-w-0">
<div className="w-10 h-10 rounded-lg bg-blue-600/20 border border-blue-500/30 flex items-center justify-center text-blue-400 shrink-0">
<Radio className="w-5 h-5 animate-pulse" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-slate-200 truncate">{title}</p>
<p className="text-xs text-slate-400 truncate">Be My Anchor • Episode</p>
</div>
</div>
{/* Main Playback Buttons */}
<div className="flex items-center gap-3 sm:gap-4 justify-center">
<button
onClick={() => jump(-15)}
className="p-2 text-slate-400 hover:text-slate-200 transition hover:bg-slate-800 rounded-full"
title="Jump backward 15s"
>
<RotateCcw className="w-4 h-4" />
</button>
<button
onClick={togglePlay}
className="p-3 bg-blue-600 hover:bg-blue-500 text-white rounded-full shadow-lg shadow-blue-600/30 transition-transform active:scale-95"
title={isPlaying ? "Pause" : "Play"}
>
{isPlaying ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5 ml-0.5" />}
</button>
<button
onClick={() => jump(15)}
className="p-2 text-slate-400 hover:text-slate-200 transition hover:bg-slate-800 rounded-full"
title="Jump forward 15s"
>
<RotateCw className="w-4 h-4" />
</button>
</div>
{/* Aux Controls: Speed & Volume */}
<div className="flex items-center gap-3 w-1/3 justify-end">
<button
onClick={togglePlaybackRate}
className="flex items-center gap-1 text-xs font-medium px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded border border-slate-700 transition"
title="Adjust Speed"
>
<Gauge className="w-3.5 h-3.5 text-blue-400" />
<span>{playbackRate}x</span>
</button>
<div className="hidden sm:flex items-center gap-2">
<button
onClick={toggleMute}
className="text-slate-400 hover:text-slate-200 transition"
title={isMuted ? "Unmute" : "Mute"}
>
{isMuted || volume === 0 ? (
<VolumeX className="w-4 h-4 text-red-400" />
) : (
<Volume2 className="w-4 h-4" />
)}
</button>
<input
type="range"
min="0"
max="1"
step="0.05"
value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
className="w-16 h-1 bg-slate-700 rounded appearance-none cursor-pointer accent-blue-500"
/>
</div>
</div>
</div>
</div>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import React, { useEffect, useRef } from "react";
import { TimingCue } from "@/lib/tts/types";
import { Clock, PlayCircle } from "lucide-react";
interface TranscriptViewerProps {
timings: TimingCue[];
currentTime: number;
onSeek: (time: number) => void;
}
export function TranscriptViewer({
timings,
currentTime,
onSeek,
}: TranscriptViewerProps) {
const activeRef = useRef<HTMLDivElement | null>(null);
// Auto-scroll active cue into view smoothly
useEffect(() => {
if (activeRef.current) {
activeRef.current.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}, [currentTime]);
const formatSeconds = (secs: number) => {
const m = Math.floor(secs / 60);
const s = Math.floor(secs % 60);
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
};
if (!timings || timings.length === 0) {
return (
<div className="flex flex-col items-center justify-center p-8 text-slate-500 border border-dashed border-slate-800 rounded-xl">
<Clock className="w-8 h-8 mb-2 opacity-50" />
<p className="text-sm">No synchronized transcript available for this episode.</p>
</div>
);
}
return (
<div className="space-y-2 max-h-[500px] overflow-y-auto pr-2">
{timings.map((cue, idx) => {
const isActive = currentTime >= cue.startTime && currentTime <= cue.endTime;
return (
<div
key={idx}
ref={isActive ? activeRef : null}
onClick={() => onSeek(cue.startTime)}
className={`group flex items-start gap-3 p-3 rounded-lg cursor-pointer transition-all duration-200 border ${
isActive
? "bg-blue-950/40 border-blue-500/40 text-blue-100 shadow-sm"
: "bg-slate-900/30 border-slate-800/60 hover:bg-slate-850 hover:border-slate-700 text-slate-300"
}`}
>
{/* Timestamp badge */}
<span
className={`text-xs font-mono px-2 py-0.5 rounded shrink-0 flex items-center gap-1 ${
isActive
? "bg-blue-600 text-white font-semibold"
: "bg-slate-800 text-slate-400 group-hover:bg-slate-700 group-hover:text-slate-200"
}`}
>
<PlayCircle className="w-3 h-3" />
{formatSeconds(cue.startTime)}
</span>
{/* Cue text */}
<p className="text-sm leading-relaxed flex-1">
{cue.text}
</p>
</div>
);
})}
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
export default prisma;
+66
View File
@@ -0,0 +1,66 @@
import fs from "fs";
import path from "path";
export interface IStorageProvider {
saveAudio(key: string, buffer: Buffer): Promise<string>;
getAudioUrl(key: string): string;
deleteAudio(key: string): Promise<boolean>;
exists(key: string): Promise<boolean>;
}
/**
* Local filesystem storage provider.
* Saves audio files directly inside public/audio/episodes so Next.js
* can serve them with native HTTP Range requests (scrubbing/seeking).
*/
export class LocalStorageProvider implements IStorageProvider {
private baseDir: string;
private publicUrlPrefix: string;
constructor() {
this.baseDir = path.join(process.cwd(), "public", "audio", "episodes");
this.publicUrlPrefix = "/audio/episodes";
this.ensureDirectory();
}
private ensureDirectory(): void {
if (!fs.existsSync(this.baseDir)) {
fs.mkdirSync(this.baseDir, { recursive: true });
}
}
async saveAudio(key: string, buffer: Buffer): Promise<string> {
this.ensureDirectory();
const filename = key.endsWith(".mp3") ? key : `${key}.mp3`;
const filepath = path.join(this.baseDir, filename);
await fs.promises.writeFile(filepath, buffer);
return this.getAudioUrl(filename);
}
getAudioUrl(key: string): string {
const filename = key.endsWith(".mp3") ? key : `${key}.mp3`;
return `${this.publicUrlPrefix}/${filename}`;
}
async deleteAudio(key: string): Promise<boolean> {
const filename = key.endsWith(".mp3") ? key : `${key}.mp3`;
const filepath = path.join(this.baseDir, filename);
try {
if (fs.existsSync(filepath)) {
await fs.promises.unlink(filepath);
}
return true;
} catch {
return false;
}
}
async exists(key: string): Promise<boolean> {
const filename = key.endsWith(".mp3") ? key : `${key}.mp3`;
const filepath = path.join(this.baseDir, filename);
return fs.existsSync(filepath);
}
}
// Global default storage provider instance
export const storage: IStorageProvider = new LocalStorageProvider();
+148
View File
@@ -0,0 +1,148 @@
import { EdgeTTSProvider } from "./edge-provider";
import { OpenAITTSProvider } from "./openai-provider";
import { chunkText } from "./chunker";
import { ITextToSpeechProvider, SynthesizeResult, TimingCue, TTSOptions } from "./types";
import { storage } from "../storage/storage";
/**
* Accurately estimate duration of an MP3 buffer based on frame count or 48kbps bitrate fallback.
*/
export function estimateMp3Duration(buffer: Buffer): number {
if (!buffer || buffer.length === 0) return 0;
let frames = 0;
let sampleRate = 24000;
let foundHeaders = false;
for (let i = 0; i < buffer.length - 4; i++) {
// Look for MP3 frame sync: 11 bits set (0xFF followed by 0xE0 or higher)
if (buffer[i] === 0xff && (buffer[i + 1] & 0xe0) === 0xe0) {
const versionBits = (buffer[i + 1] >> 3) & 0x03; // 0=MPEG2.5, 2=MPEG2, 3=MPEG1
const layerBits = (buffer[i + 1] >> 1) & 0x03; // 1=Layer3
const sampleRateBits = (buffer[i + 2] >> 2) & 0x03;
if (layerBits === 1) { // Layer III
foundHeaders = true;
frames++;
// Determine sample rate
if (versionBits === 3) {
// MPEG 1
sampleRate = [44100, 48000, 32000, 44100][sampleRateBits];
} else {
// MPEG 2 or 2.5
sampleRate = [22050, 24000, 16000, 24000][sampleRateBits];
}
i += 100; // Skip ahead inside frame
}
}
}
if (foundHeaders && frames > 0) {
// MPEG2/2.5 Layer 3 has 576 samples per frame; MPEG1 Layer 3 has 1152 samples per frame
const samplesPerFrame = sampleRate <= 24000 ? 576 : 1152;
return (frames * samplesPerFrame) / sampleRate;
}
// Fallback based on 48 kbps mono MP3 (48000 bits/s = 6000 bytes/s)
return buffer.length / 6000;
}
export class AudioService {
private providers: Map<string, ITextToSpeechProvider>;
constructor() {
this.providers = new Map();
this.providers.set("edge", new EdgeTTSProvider());
this.providers.set("openai", new OpenAITTSProvider());
}
getProvider(name: string = "edge"): ITextToSpeechProvider {
const provider = this.providers.get(name.toLowerCase());
if (!provider) {
throw new Error(`Unsupported TTS provider: ${name}`);
}
return provider;
}
async getAvailableVoices(providerName: string = "edge") {
const provider = this.getProvider(providerName);
return provider.getAvailableVoices();
}
/**
* Synthesize long text into a single stitched MP3 with timing cues
*/
async synthesizeEpisode(
content: string,
options?: {
provider?: string;
voice?: string;
rate?: string;
}
): Promise<SynthesizeResult> {
const provider = this.getProvider(options?.provider || "edge");
const chunks = chunkText(content);
if (chunks.length === 0) {
throw new Error("No readable text content found to synthesize.");
}
const audioBuffers: Buffer[] = [];
const timings: TimingCue[] = [];
let currentTime = 0;
const ttsOptions: TTSOptions = {
voice: options?.voice,
rate: options?.rate,
};
for (const chunk of chunks) {
const chunkBuffer = await provider.synthesizeChunk(chunk.text, ttsOptions);
const chunkDuration = estimateMp3Duration(chunkBuffer);
timings.push({
text: chunk.text,
startTime: Number(currentTime.toFixed(2)),
endTime: Number((currentTime + chunkDuration).toFixed(2)),
stageCue: chunk.stageCue,
});
audioBuffers.push(chunkBuffer);
currentTime += chunkDuration;
}
const fullAudioBuffer = Buffer.concat(audioBuffers);
const totalDurationSeconds = Number(currentTime.toFixed(2));
return {
audioBuffer: fullAudioBuffer,
totalDurationSeconds,
timings,
};
}
/**
* Synthesizes and saves audio to storage, returning audioUrl and timings
*/
async processAndSaveEpisode(
episodeId: string,
content: string,
options?: {
provider?: string;
voice?: string;
rate?: string;
}
) {
const result = await this.synthesizeEpisode(content, options);
const audioUrl = await storage.saveAudio(episodeId, result.audioBuffer);
return {
audioUrl,
durationSeconds: result.totalDurationSeconds,
timings: result.timings,
};
}
}
export const audioService = new AudioService();
+161
View File
@@ -0,0 +1,161 @@
export interface TextChunk {
index: number;
text: string;
originalIndex?: number;
speaker?: string;
stageCue?: string;
}
/**
* Remove HTML tags from any string.
* When preserveNewlines is true, paragraph structure and linebreaks are maintained.
*/
export function cleanHtmlTags(raw: string, preserveNewlines: boolean = false): string {
if (!raw) return "";
let text = raw
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n\n")
.replace(/<\/div>/gi, "\n")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&quot;/gi, '"')
.replace(/&#39;|&apos;/gi, "'")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&#\d+;/g, "");
if (preserveNewlines) {
return text
.split("\n")
.map((line) => line.replace(/[ \t]+/g, " ").trim())
.join("\n");
} else {
return text.replace(/\s+/g, " ").trim();
}
}
/**
* Detects if text contains Chinese characters (CJK unified ideographs)
*/
export function hasChineseCharacters(text: string): boolean {
return /[\u4e00-\u9fa5]/.test(text);
}
/**
* Common web boilerplate / navigation noise lines to discard
*/
const NOISE_LINE_PATTERNS = [
/^(previous|next|share|tweet|follow|subscribe|read more|continue reading|back to top|related)$/i,
/^(上一页|下一页|上一篇|下一篇|分享|点赞|在看|关注|阅读原文|返回顶部|相关推荐|版权声明|目录)$/,
/^https?:\/\/\S+$/i, // lone URLs
];
/**
* Clean markdown and HTML formatting to make text speech-friendly
*/
export function cleanTextForSpeech(raw: string): string {
if (!raw) return "";
// 1. Remove HTML tags while preserving line breaks for noise filtering
let text = cleanHtmlTags(raw, true);
// 2. Remove markdown image syntax ![alt](url)
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, "");
// 3. Convert markdown links [text](url) to just text
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1");
// 4. Remove headers (# Header)
text = text.replace(/^#{1,6}\s+/gm, "");
// 5. Remove bold/italic (* or _)
text = text.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, "$1");
// 6. Remove inline code ticks & code blocks
text = text.replace(/```[\s\S]*?```/g, "");
text = text.replace(/`([^`]+)`/g, "$1");
// 7. Remove blockquotes and list bullet markers
text = text.replace(/^\s*>\s+/gm, "");
text = text.replace(/^[\s*-•–]+\s+/gm, "");
// 8. Filter out common boilerplate / noise lines
const lines = text.split("\n").map((l) => l.trim());
const filteredLines = lines.filter((line) => {
if (!line) return false;
for (const pattern of NOISE_LINE_PATTERNS) {
if (pattern.test(line)) return false;
}
return true;
});
// 9. Normalize excessive newlines
return filteredLines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
}
/**
* Splits long text into natural sentence or paragraph chunks.
* Handles English (. ? !) with spaces, AND Chinese / CJK (。!?;) without spaces.
* Chinese defaults to ~120 chars; English defaults to ~350 chars.
*/
export function chunkText(rawText: string, maxChunkLength?: number): TextChunk[] {
const cleaned = cleanTextForSpeech(rawText);
if (!cleaned) return [];
const isChinese = hasChineseCharacters(cleaned);
const targetLength = maxChunkLength || (isChinese ? 120 : 350);
// Split into raw paragraphs first
const rawParagraphs = cleaned
.split(/\n\s*\n/)
.map((p) => p.trim())
.filter(Boolean);
const chunks: TextChunk[] = [];
let chunkIndex = 0;
for (const para of rawParagraphs) {
if (para.length <= targetLength) {
chunks.push({
index: chunkIndex++,
text: para,
});
continue;
}
// Split large paragraph into sentences:
// Matches CJK sentence enders (。!?;) with optional space
// OR English sentence enders (.?!;) followed by required space or end of string
const sentences = para
.split(/(?<=[。!?;])\s*|(?<=[.?!;])\s+/)
.map((s) => s.trim())
.filter(Boolean);
let currentChunk = "";
for (const sentence of sentences) {
const combined = currentChunk ? `${currentChunk} ${sentence}` : sentence;
if (combined.length <= targetLength) {
currentChunk = combined;
} else {
if (currentChunk) {
chunks.push({
index: chunkIndex++,
text: currentChunk,
});
}
currentChunk = sentence;
}
}
if (currentChunk) {
chunks.push({
index: chunkIndex++,
text: currentChunk,
});
}
}
return chunks;
}
+151
View File
@@ -0,0 +1,151 @@
import { MsEdgeTTS, OUTPUT_FORMAT, ProsodyOptions } from "msedge-tts";
import { ITextToSpeechProvider, TTSOptions, VoiceOption } from "./types";
import { hasChineseCharacters } from "./chunker";
export const DEFAULT_ENGLISH_VOICE = "en-US-ChristopherNeural";
export const DEFAULT_CHINESE_VOICE = "zh-CN-XiaoxiaoNeural";
export const POPULAR_EDGE_VOICES: VoiceOption[] = [
// Chinese / Multilingual (Mixed Chinese + English)
{
id: "zh-CN-XiaoxiaoNeural",
name: "晓晓 Xiaoxiao (CN Female - 温暖亲切 / 中英混合流利)",
gender: "female",
locale: "zh-CN",
},
{
id: "zh-CN-YunxiNeural",
name: "云希 Yunxi (CN Male - 生动活泼 / 科技与播客)",
gender: "male",
locale: "zh-CN",
},
{
id: "zh-CN-YunjianNeural",
name: "云健 Yunjian (CN Male - 沉稳大气 / 新闻专栏)",
gender: "male",
locale: "zh-CN",
},
{
id: "zh-CN-XiaoyiNeural",
name: "晓伊 Xiaoyi (CN Female - 温柔自然)",
gender: "female",
locale: "zh-CN",
},
{
id: "zh-TW-HsiaoChenNeural",
name: "曉臻 HsiaoChen (TW Female - 繁體中文)",
gender: "female",
locale: "zh-TW",
},
{
id: "zh-HK-HiuMaanNeural",
name: "曉曼 HiuMaan (HK Female - 廣東話 / 粵語)",
gender: "female",
locale: "zh-HK",
},
// English Voices
{
id: "en-US-ChristopherNeural",
name: "Christopher (US Male - Clear & Authoritative)",
gender: "male",
locale: "en-US",
},
{
id: "en-US-JennyNeural",
name: "Jenny (US Female - Natural & Engaging)",
gender: "female",
locale: "en-US",
},
{
id: "en-US-GuyNeural",
name: "Guy (US Male - Conversational)",
gender: "male",
locale: "en-US",
},
{
id: "en-US-AriaNeural",
name: "Aria (US Female - Expressive)",
gender: "female",
locale: "en-US",
},
{
id: "en-GB-RyanNeural",
name: "Ryan (UK Male - British Accent)",
gender: "male",
locale: "en-GB",
},
{
id: "en-GB-SoniaNeural",
name: "Sonia (UK Female - British Accent)",
gender: "female",
locale: "en-GB",
},
];
/**
* Resolves the optimal voice based on text language and user preference
*/
export function resolveVoiceForText(text: string, requestedVoice?: string): string {
const isChinese = hasChineseCharacters(text);
if (!requestedVoice) {
return isChinese ? DEFAULT_CHINESE_VOICE : DEFAULT_ENGLISH_VOICE;
}
// If the text has Chinese characters, but an English-only voice (en-*) was selected,
// automatically route to a Chinese neural voice so synthesis does NOT skip Chinese characters!
if (isChinese && requestedVoice.startsWith("en-")) {
const isMale = requestedVoice.includes("Christopher") || requestedVoice.includes("Guy") || requestedVoice.includes("Ryan");
return isMale ? "zh-CN-YunxiNeural" : DEFAULT_CHINESE_VOICE;
}
return requestedVoice;
}
export class EdgeTTSProvider implements ITextToSpeechProvider {
readonly name = "edge";
async getAvailableVoices(): Promise<VoiceOption[]> {
return POPULAR_EDGE_VOICES;
}
async synthesizeChunk(text: string, options?: TTSOptions): Promise<Buffer> {
const voice = resolveVoiceForText(text, options?.voice);
const tts = new MsEdgeTTS();
try {
await tts.setMetadata(
voice,
OUTPUT_FORMAT.AUDIO_24KHZ_48KBITRATE_MONO_MP3
);
const prosody: ProsodyOptions = {};
if (options?.rate) prosody.rate = options.rate;
if (options?.pitch) prosody.pitch = options.pitch;
if (options?.volume) prosody.volume = options.volume;
const { audioStream } = tts.toStream(text, prosody);
const chunks: Buffer[] = [];
return await new Promise<Buffer>((resolve, reject) => {
audioStream.on("data", (chunk: Buffer) => {
chunks.push(chunk);
});
audioStream.on("end", () => {
resolve(Buffer.concat(chunks));
});
audioStream.on("error", (err: Error) => {
reject(err);
});
});
} finally {
try {
tts.close();
} catch {
// ignore close error
}
}
}
}
+23
View File
@@ -0,0 +1,23 @@
import { ITextToSpeechProvider, TTSOptions, VoiceOption } from "./types";
export class OpenAITTSProvider implements ITextToSpeechProvider {
readonly name = "openai";
async getAvailableVoices(): Promise<VoiceOption[]> {
return [
{ id: "alloy", name: "Alloy (Neutral)", gender: "neutral", locale: "en-US" },
{ id: "echo", name: "Echo (Male)", gender: "male", locale: "en-US" },
{ id: "fable", name: "Fable (British Accent)", gender: "neutral", locale: "en-GB" },
{ id: "onyx", name: "Onyx (Deep Male)", gender: "male", locale: "en-US" },
{ id: "nova", name: "Nova (Warm Female)", gender: "female", locale: "en-US" },
{ id: "shimmer", name: "Shimmer (Clear Female)", gender: "female", locale: "en-US" },
];
}
async synthesizeChunk(text: string, options?: TTSOptions): Promise<Buffer> {
// Placeholder for Milestone 1
throw new Error(
"OpenAI TTS provider is reserved for Milestone 2. Please use 'edge' provider for Milestone 1."
);
}
}
+40
View File
@@ -0,0 +1,40 @@
export interface TimingCue {
text: string;
startTime: number; // in seconds
endTime: number; // in seconds
speaker?: string;
stageCue?: string; // Optional stage / figure cue ID e.g. "fig-1"
}
export interface TTSOptions {
voice?: string;
rate?: string; // e.g. "+0%", "-10%"
pitch?: string; // e.g. "+0Hz"
volume?: string; // e.g. "+0%"
}
export interface TTSChunkResult {
chunkIndex: number;
text: string;
audioBuffer: Buffer;
durationSeconds: number;
}
export interface SynthesizeResult {
audioBuffer: Buffer;
totalDurationSeconds: number;
timings: TimingCue[];
}
export interface VoiceOption {
id: string;
name: string;
gender: "male" | "female" | "neutral";
locale: string;
}
export interface ITextToSpeechProvider {
readonly name: string;
getAvailableVoices(): Promise<VoiceOption[]>;
synthesizeChunk(text: string, options?: TTSOptions): Promise<Buffer>;
}
View File
-266
View File
@@ -1,266 +0,0 @@
"""
Web Content Extractor Module
Provides functionalities to fetch web content using headless Chrome,
sanitize HTML, and save results with comprehensive logging and error handling.
"""
import logging
import os
import time
import random
import string
from typing import Optional
from lxml.html.clean import Cleaner
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from bs4 import BeautifulSoup
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[logging.StreamHandler()]
)
logger = logging.getLogger(__name__)
# Configuration constants
DEFAULT_CHROME_PATH = "run/webdriver/chrome"
DEFAULT_WEB_DRIVER_PATH = "run/webdriver/chromedriver"
DEFAULT_WAIT_TIME = 10
DEFAULT_OUTPUT_DIR = "run/tmp"
CHROME_OPTIONS = [
"--headless",
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-extensions",
"--disable-gpu",
"window-size=1920,1080"
]
CLEANER_CONFIG = {
'page_structure': True,
'meta': True,
'embedded': True,
'links': True,
'style': True,
'processing_instructions': True,
'inline_style': True,
'scripts': True,
'javascript': True,
'comments': True,
'frames': True,
'forms': True,
'annoying_tags': True,
'remove_unknown_tags': True,
'safe_attrs_only': True,
'safe_attrs': frozenset(['src', 'color', 'href', 'title', 'class', 'name', 'id']),
'remove_tags': ('span', 'font', 'div')
}
ALLOWED_TAGS = {
'img': ['src', 'alt', 'width', 'height'],
'a': ['href', 'title'],
'video': ['src', 'controls', 'width', 'height'],
'source': ['src', 'type']
}
UNWANTED_TAGS = [
'script', 'style', 'nav', 'footer', 'header', 'aside',
'form', 'iframe', 'noscript', 'svg', 'canvas', 'applet',
'object', 'embed'
]
def configure_chrome_driver() -> webdriver.Chrome:
"""Configure and return a Chrome WebDriver instance."""
try:
chrome_options = Options()
for option in CHROME_OPTIONS:
chrome_options.add_argument(option)
chrome_path = os.path.join(
DEFAULT_CHROME_PATH, 'chrome-linux64', 'chrome'
)
chrome_options.binary_location = chrome_path
driver_path = os.path.join(
DEFAULT_WEB_DRIVER_PATH, 'chromedriver'
)
chrome_service = Service(driver_path)
logger.info("Initializing Chrome WebDriver")
return webdriver.Chrome(
service=chrome_service,
options=chrome_options
)
except Exception as e:
logger.error("Chrome driver configuration failed")
raise RuntimeError("WebDriver initialization failed") from e
def extract_html_with_chromedriver(
url: str,
wait_time: int = DEFAULT_WAIT_TIME
) -> Optional[str]:
"""
Extract HTML content from a URL using headless Chrome.
Args:
url: Target URL to scrape
wait_time: Maximum wait time for page load (seconds)
Returns:
str: Raw HTML content or None if extraction fails
"""
driver = None
try:
driver = configure_chrome_driver()
logger.info(f"Fetching URL: {url}")
driver.get(url)
WebDriverWait(driver, wait_time).until(
EC.presence_of_element_located((By.TAG_NAME, 'body'))
)
logger.debug("Page load completed successfully")
return driver.page_source
except Exception as e:
logger.error(f"Failed to extract HTML: {str(e)}")
return None
finally:
if driver:
logger.debug("Closing WebDriver instance")
driver.quit()
def sanitize_html(dirty_html: str) -> str:
"""
Sanitize HTML content using lxml cleaner.
Args:
dirty_html: Raw HTML input
Returns:
str: Sanitized HTML output
"""
try:
logger.info("Sanitizing HTML content")
cleaner = Cleaner(**CLEANER_CONFIG)
return cleaner.clean_html(dirty_html)
except Exception as e:
logger.error(f"HTML sanitization failed: {str(e)}")
raise
def extract_content(html_content: str) -> Optional[str]:
"""
Extract and clean main content from HTML.
Args:
html_content: Raw HTML input
Returns:
str: Cleaned HTML content or None if processing fails
"""
try:
if not html_content:
logger.warning("Empty HTML content received")
return None
logger.info("Processing HTML content")
soup = BeautifulSoup(html_content, "html.parser")
# Remove unwanted tags
for tag in soup.find_all(UNWANTED_TAGS):
tag.decompose()
# Clean tag attributes
for tag in soup.find_all():
allowed = ALLOWED_TAGS.get(tag.name, [])
attrs = list(tag.attrs.keys())
for attr in attrs:
if attr not in allowed:
del tag[attr]
# Remove empty tags
for tag in soup.find_all():
if not tag.get_text(strip=True):
tag.decompose()
return str(soup)
except Exception as e:
logger.error(f"Content extraction failed: {str(e)}")
return None
def save_html_to_file(
html_content: str,
output_dir: str = DEFAULT_OUTPUT_DIR
) -> Optional[str]:
"""
Save HTML content to timestamped file.
Args:
html_content: Content to save
output_dir: Target directory path
Returns:
str: Path to saved file or None if save fails
"""
try:
os.makedirs(output_dir, exist_ok=True)
timestamp = time.strftime("%Y%m%d%H%M%S")
rand_str = ''.join(random.choices(string.ascii_letters + string.digits, k=8))
filename = f"output_{timestamp}_{rand_str}.html"
filepath = os.path.join(output_dir, filename)
logger.info(f"Saving output to: {filepath}")
with open(filepath, "w", encoding="utf-8") as f:
f.write(html_content)
return filepath
except Exception as e:
logger.error(f"File save failed: {str(e)}")
return None
def get_content_from_url(url: str) -> Optional[str]:
"""
Main pipeline: Fetch URL -> Extract Content -> Sanitize HTML
Args:
url: Target URL to process
Returns:
str: Processed HTML content or None if any step fails
"""
try:
raw_html = extract_html_with_chromedriver(url)
if not raw_html:
return None
sanitized = sanitize_html(raw_html)
return extract_content(sanitized)
except Exception as e:
logger.error(f"Processing pipeline failed: {str(e)}")
return None
if __name__ == "__main__":
target_url = "https://www.cncf.io/blog/2025/01/22/kubernetes-in-2025-are-you-ready-for-these-top-5-trends-and-predictions/"
if (result := get_content_from_url(target_url)) is not None:
file_path = save_html_to_file(result)
if file_path:
logger.info(f"Successfully saved output to: {file_path}")
else:
logger.error("Failed to save output file")
else:
logger.error("Content extraction failed for target URL")
View File
-192
View File
@@ -1,192 +0,0 @@
"""
LLM-based Podcast Script Converter
将HTML内容转换为播客风格的稿件,支持多种LLM模型接口。
"""
import os
import logging
from abc import ABC, abstractmethod
from typing import Optional, Dict, Literal
import html2text
# 第三方库导入(需要额外安装)
try:
from openai import OpenAI # OpenAI官方库
import google.generativeai as genai # Google Gemini
except ImportError:
pass # 运行时检查
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class LLMConverter(ABC):
"""LLM转换器抽象基类"""
@abstractmethod
def convert_to_podcast(self, html_content: str) -> Optional[str]:
"""将HTML内容转换为播客稿件"""
pass
class OpenAIConverter(LLMConverter):
"""ChatGPT转换器实现"""
def __init__(self, model: str = "gpt-4-turbo", temperature: float = 0.7):
self.client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
self.model = model
self.temperature = temperature
def convert_to_podcast(self, html_content: str) -> Optional[str]:
try:
# 将HTML转换为Markdown格式
markdown_content = html2text.html2text(html_content)
response = self.client.chat.completions.create(
model=self.model,
temperature=self.temperature,
messages=[
{
"role": "system",
"content": """你是一位专业播客编剧,需要将技术文章转换为生动有趣的播客稿件。要求:
1. 使用口语化表达,避免专业术语
2. 结构清晰,包含引言、主体和总结
3. 突出文章的核心观点和技术亮点
4. 适当添加过渡语和互动提示
5. 总长度控制在1500字左右"""
},
{
"role": "user",
"content": markdown_content
}
]
)
return response.choices[0].message.content
except Exception as e:
logger.error(f"OpenAI转换失败: {str(e)}")
return None
class GeminiConverter(LLMConverter):
"""Google Gemini转换器实现"""
def __init__(self, model: str = "gemini-pro", temperature: float = 0.7):
genai.configure(api_key=os.getenv("GEMINI_API_KEY"))
self.model = genai.GenerativeModel(model)
self.temperature = temperature
def convert_to_podcast(self, html_content: str) -> Optional[str]:
try:
markdown_content = html2text.html2text(html_content)
response = self.model.generate_content(
f"[播客稿件转换要求同OpenAI版本]\n\n{markdown_content}",
generation_config=genai.types.GenerationConfig(
temperature=self.temperature
)
)
return response.text
except Exception as e:
logger.error(f"Gemini转换失败: {str(e)}")
return None
class DeepSeekConverter(LLMConverter):
"""深度求索转换器实现"""
def __init__(self, model: str = "deepseek-chat", temperature: float = 0.7):
self.client = OpenAI(
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url="https://api.deepseek.com/v1"
)
self.model = model
self.temperature = temperature
def convert_to_podcast(self, html_content: str) -> Optional[str]:
try:
markdown_content = html2text.html2text(html_content)
response = self.client.chat.completions.create(
model=self.model,
temperature=self.temperature,
messages=[
{"role": "system", "content": "[播客稿件转换要求同OpenAI版本]"},
{"role": "user", "content": markdown_content}
]
)
return response.choices[0].message.content
except Exception as e:
logger.error(f"DeepSeek转换失败: {str(e)}")
return None
class PodcastConverter:
"""播客稿件转换管理器"""
def __init__(
self,
provider: Literal["openai", "gemini", "deepseek"] = "openai",
model: Optional[str] = None,
temperature: float = 0.7
):
self.provider = provider
self.model = model
self.temperature = temperature
self.converter = self._init_converter()
def _init_converter(self) -> LLMConverter:
"""初始化指定的LLM转换器"""
try:
if self.provider == "openai":
return OpenAIConverter(
model=self.model or "gpt-4-turbo",
temperature=self.temperature
)
elif self.provider == "gemini":
return GeminiConverter(
model=self.model or "gemini-pro",
temperature=self.temperature
)
elif self.provider == "deepseek":
return DeepSeekConverter(
model=self.model or "deepseek-chat",
temperature=self.temperature
)
else:
raise ValueError("不支持的LLM提供商")
except Exception as e:
logger.error(f"转换器初始化失败: {str(e)}")
raise
def convert(self, html_content: str) -> Optional[str]:
"""执行转换流程"""
if not html_content:
logger.warning("输入内容为空")
return None
logger.info(f"开始转换,使用模型: {self.provider}/{self.model}")
return self.converter.convert_to_podcast(html_content)
# 使用示例
if __name__ == "__main__":
# 测试用HTML内容(实际应从文件或网络获取)
sample_html = """
<html>
<body>
<h1>人工智能的未来发展</h1>
<p>近年来,AI技术取得了突破性进展...</p>
<ul>
<li>自然语言处理的进步</li>
<li>计算机视觉的应用扩展</li>
<li>伦理问题的重要性</li>
</ul>
</body>
</html>
"""
# 使用OpenAI转换
openai_converter = PodcastConverter(provider="openai")
result = openai_converter.convert(sample_html)
if result:
print("转换结果示例:")
print(result[:500] + "...") # 打印前500字符
else:
print("转换失败")
View File
-87
View File
@@ -1,87 +0,0 @@
from flask import Flask, request, render_template, redirect, url_for
from ..modules.html_extractor.extract import get_content_from_url
from flask_dance.contrib.google import make_google_blueprint, google
app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key-here'
app.config['PREFIX'] = '/proxy/5000'
app.config['GOOGLE_OAUTH_CLIENT_ID'] = 'your-client-id'
app.config['GOOGLE_OAUTH_CLIENT_SECRET'] = 'your-client-secret'
@app.route("/")
def index():
print("index!")
return render_template("index.html", prefix=app.config['PREFIX'],)
@app.route("/page")
def article_page():
url = request.args.get("url")
if not url:
return redirect(url_for('index'))
try:
content = get_content_from_url(url)
return render_template('page.html',
prefix=app.config['PREFIX'],
url=url,
summary=content,
audio_url="/static/audio/sample.mp3")
except Exception as e:
app.logger.error(f"Error processing {url}: {str(e)}")
return render_template('error.html',
error_message="Failed to process the URL"), 500
@app.route("/login", methods=['GET', 'POST'])
def login():
if request.method == 'POST':
# Add authentication logic here
return redirect(url_for('index'))
return render_template("login.html", prefix=app.config['PREFIX'])
@app.route("/register", methods=['GET', 'POST'])
def register():
if request.method == 'POST':
# Add registration logic here
return redirect(url_for('login'))
return render_template("register.html", prefix=app.config['PREFIX'])
@app.errorhandler(404)
def page_not_found(e):
return render_template('error.html', prefix=app.config['PREFIX'],
error_message="Page not found"), 404
@app.errorhandler(500)
def internal_error(e):
return render_template('error.html', prefix=app.config['PREFIX'],
error_message="Internal server error"), 500
google_bp = make_google_blueprint(
scope=["profile", "email"],
redirect_to="auth_callback"
)
app.register_blueprint(google_bp, url_prefix="/auth")
@app.route("/auth/callback")
def auth_callback():
if not google.authorized:
return redirect(url_for("login"))
resp = google.get("/oauth2/v2/userinfo")
if resp.ok:
user_info = resp.json()
# Handle user authentication logic here
return redirect(url_for("index"))
return redirect(url_for("login"))
@app.route("/auth/google")
def google_auth():
return redirect(url_for("google.login"))
if __name__ == "__main__":
app.run(port=5000, debug=True)
-818
View File
@@ -1,818 +0,0 @@
:root {
--primary: #6366f1;
--surface: #f8fafc;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
touch-action: manipulation;
}
body {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
font-family: system-ui, sans-serif;
background: #f8fafc;
}
/* 头部样式 */
header {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding: 12px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
z-index: 2000;
}
.branding {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin: 0 12px;
}
.brand-logo {
width: 32px;
height: 32px;
object-fit: contain;
}
.site-name {
font-weight: 600;
color: #1e293b;
font-size: 1.2rem;
white-space: nowrap;
}
.nav-group {
display: flex;
gap: 8px;
justify-self: end;
/* 右侧菜单保持右对齐 */
}
.history-button {
padding: 8px;
border: none;
background: none;
cursor: pointer;
justify-self: start;
}
.history-button img {
width: 32px;
height: 32px;
}
/* 菜单系统 */
.menu {
position: relative;
}
.menu-button {
padding: 8px;
border: none;
background: none;
cursor: pointer;
}
.menu-button img {
width: 32px;
height: 32px;
}
.menu-dropdown {
position: absolute;
right: 0;
top: 100%;
z-index: 100;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
display: none;
min-width: 160px;
}
.menu-dropdown.active {
display: block;
}
.menu-item {
padding: 12px 16px;
color: #334155;
text-decoration: none;
display: block;
border-bottom: 1px solid #f1f5f9;
}
/* 主容器设置 */
main {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 20px;
overflow: hidden;
}
/* 图片容器 */
.image-container {
position: relative;
width: min(60vw, 300px);
height: min(60vw, 300px);
/* 明确设置高度 */
margin: 0 auto 20px;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 90;
}
/* 图片绝对居中方案 */
.image-container img {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
/* 文字内容容器 */
.text-content {
width: 80%;
max-width: 800px;
text-align: center;
}
/* 主容器设置 */
main {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 20px;
overflow: hidden;
}
/* 图片容器 */
.image-container {
position: relative;
width: min(60vw, 300px);
height: min(60vw, 300px);
/* 明确设置高度 */
margin: 0 auto 20px;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* 图片绝对居中方案 */
.image-container img {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
/* 文字内容容器 */
.text-content {
width: 80%;
max-width: 800px;
text-align: center;
}
.url {
font-size: 1.1em;
color: #3b82f6;
margin-bottom: 12px;
word-break: break-all;
}
.summary {
line-height: 1.5;
color: #64748b;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
/* 默认显示4行 */
overflow: hidden;
text-overflow: ellipsis;
--lines: 4;
/* 默认显示行数 */
--mobile-lines: 3;
/* 移动端行数 */
}
/* 响应式调整 */
@media (max-width: 768px) {
.text-content {
width: 90%;
/* 小屏幕更宽 */
}
.summary {
-webkit-line-clamp: 3;
/* 减少显示行数 */
}
}
/* 播放控制栏 */
footer {
display: flex;
align-items: center;
padding: 16px;
gap: 4px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
z-index: 2000;
}
.progress-bar {
flex-grow: 1;
height: 6px;
background: #e2e8f0;
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--primary);
width: 0%;
transition: width 0.1s linear;
}
.control-button {
padding: 8px;
border: none;
background: none;
cursor: pointer;
}
.control-button img {
width: 40px;
height: 40px;
}
/* 历史记录面板样式 */
.history-panel {
position: fixed;
top: -100%;
left: 0;
right: 0;
height: 100vh;
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(30px);
z-index: 2000;
transition: top 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
padding: 60px 16px 16px;
}
.history-panel.active {
top: 0;
}
/* 关闭按钮位置调整 */
.history-close {
position: absolute;
top: 12px;
left: 12px;
/* 改为左上角 */
padding: 8px;
background: none;
border: none;
cursor: pointer;
}
.history-close img {
width: 32px;
height: 32px;
}
/* 滚动容器 */
.history-content {
height: calc(100vh - 100px);
overflow-y: auto;
padding: 0 8px;
}
/* 历史记录项样式 */
.history-item {
padding: 12px;
margin: 8px 0;
background: rgba(0, 0, 0, 0.03);
border-radius: 8px;
transition: all 0.2s;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
}
.history-item:hover {
background: rgba(0, 0, 0, 0.05);
}
/* 滚动条美化 */
.history-content::-webkit-scrollbar {
width: 6px;
}
.history-content::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05);
border-radius: 3px;
}
.history-content::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.search-form {
display: flex;
align-items: center;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.search-input {
flex-grow: 1;
border: none;
padding: 15px;
font-size: 16px;
outline: none;
color: #333;
}
.search-button {
background-color: #007bff;
/* Blue button color */
color: #fff;
border: none;
padding: 15px 20px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.search-button:hover {
background-color: #0069d9;
}
/* Optional: Add a magnifying glass icon */
.search-input::placeholder {
color: #999;
}
/* Responsive adjustments (optional) */
@media (max-width: 768px) {
.search-form {
width: 100%;
}
}
/* Add to main.css */
/* Auth System */
.auth-main {
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
min-height: calc(100dvh - 120px);
}
.auth-container {
background: white;
padding: 2.5rem;
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 420px;
margin: 0 auto;
}
.auth-title {
text-align: center;
color: #1e293b;
font-size: 1.5rem;
margin-bottom: 2rem;
}
.input-group {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.input-group input {
width: 100%;
padding: 12px 16px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.2s;
}
.input-group input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
.primary-button {
width: 100%;
padding: 14px;
background-color: var(--primary);
color: white;
border: none;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.primary-button:hover {
opacity: 0.9;
}
.oauth-divider {
display: flex;
align-items: center;
gap: 1rem;
margin: 1.5rem 0;
color: #64748b;
}
.oauth-divider::before,
.oauth-divider::after {
content: '';
flex: 1;
height: 1px;
background: #e2e8f0;
}
.google-login {
width: 100%;
padding: 12px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer;
transition: background 0.2s;
}
.google-login:hover {
background: #f8fafc;
}
.google-login img {
width: 20px;
height: 20px;
}
.auth-links {
margin-top: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
text-align: center;
}
.auth-links a {
color: var(--primary);
text-decoration: none;
font-size: 0.9rem;
}
.auth-links a:hover {
text-decoration: underline;
}
/* 统一搜索页面样式 */
.search-main {
display: flex;
justify-content: center;
align-items: center;
min-height: calc(100dvh - 120px);
padding: 2rem;
}
.search-container {
width: 100%;
max-width: 720px;
text-align: center;
}
.search-title {
font-size: 2rem;
color: #1e293b;
margin-bottom: 2rem;
}
.search-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.search-input {
padding: 16px 24px;
border: 2px solid #e2e8f0;
border-radius: 12px;
font-size: 1.1rem;
transition: all 0.3s ease;
}
.search-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}
/* 统一按钮悬停效果 */
.primary-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
}
/* 历史面板动画优化 */
.history-panel {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transform: translateY(-100%);
}
.history-panel.active {
transform: translateY(0);
}
/* 新增CSS样式 */
.search-textarea:focus {
outline: none;
box-shadow: 0 0 0 2px var(--primary);
}
.search-icon-button {
position: absolute;
right: 16px;
bottom: 16px;
background: var(--primary);
border: none;
border-radius: 12px;
padding: 12px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
}
.search-icon-button:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.search-icon-button img {
width: 24px;
height: 24px;
filter: brightness(0) invert(1);
}
.search-options {
margin-top: 1.5rem;
display: flex;
gap: 1.5rem;
justify-content: center;
flex-wrap: wrap;
}
.option-checkbox {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: #475569;
}
.voice-selection select {
padding: 8px 12px;
border-radius: 8px;
border: 1px solid #e2e8f0;
background: white;
font-size: 0.95rem;
}
/* 加载动画 */
.loading-indicator {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 16px 24px;
background: rgba(255, 255, 255, 0.95);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
align-items: center;
gap: 12px;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 3px solid #e2e8f0;
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 验证状态提示 */
.invalid-input {
border-color: #ef4444 !important;
background: #fef2f2;
}
.validation-error {
color: #ef4444;
font-size: 0.9rem;
margin-top: 4px;
display: none;
}
/* 新增/修改的CSS */
.search-group {
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.search-textarea {
width: 100%;
padding: 20px;
border: none;
resize: none;
font-size: 1.1rem;
line-height: 1.6;
min-height: 150px;
background: #fff;
}
.search-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
background: #f8fafc;
border-top: 1px solid #e2e8f0;
}
.advanced-toggle {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
color: var(--primary);
cursor: pointer;
padding: 8px 12px;
border-radius: 8px;
transition: all 0.2s;
}
.advanced-toggle:hover {
background: rgba(99, 102, 241, 0.1);
}
.advanced-toggle img {
width: 14px;
height: 14px;
transition: transform 0.2s;
}
.search-icon-button {
background: var(--primary);
border: none;
border-radius: 12px;
padding: 10px 16px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
color: white;
transition: all 0.2s;
}
.search-icon-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.search-icon-button img {
width: 20px;
height: 20px;
filter: brightness(0) invert(1);
}
/* 高级选项面板 */
.advanced-options {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease-out;
background: #fff;
border-radius: 0 0 16px 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.advanced-options.active {
max-height: 400px; /* 根据内容调整 */
}
.option-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
padding: 20px;
}
.option-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.option-group label {
font-size: 0.9rem;
color: #475569;
font-weight: 500;
}
.option-group select,
.option-group input {
width: 100%;
padding: 8px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: white;
}
/* 开关样式 */
.switch {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.slider {
position: relative;
width: 40px;
height: 20px;
background: #e2e8f0;
border-radius: 10px;
transition: 0.4s;
}
.slider:before {
content: "";
position: absolute;
height: 16px;
width: 16px;
left: 2px;
bottom: 2px;
background: white;
border-radius: 50%;
transition: 0.4s;
}
input:checked + .slider {
background: var(--primary);
}
input:checked + .slider:before {
transform: translateX(20px);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

-29
View File
@@ -1,29 +0,0 @@
// history.js
const HistoryManager = {
MAX_ITEMS: 20,
init: function() {
if (!localStorage.history) {
localStorage.history = JSON.stringify([]);
}
},
add: function(item) {
const history = JSON.parse(localStorage.history);
history.unshift({
timestamp: new Date().toISOString(),
...item
});
localStorage.history = JSON.stringify(history.slice(0, this.MAX_ITEMS));
},
render: function(container) {
const history = JSON.parse(localStorage.history);
container.innerHTML = history.map(item => `
<div class="history-item">
<time>${new Date(item.timestamp).toLocaleString()}</time>
<p>${item.title}</p>
</div>
`).join('');
}
};
-216
View File
@@ -1,216 +0,0 @@
// static/js/shared.js
const App = {
init: function () {
this.bindMenuToggle();
this.bindGlobalClick();
this.initHistoryPanel();
this.initAdvancedOptions();
this.checkLoginStatus();
this.loadSettings();
},
// 菜单切换功能
bindMenuToggle: function () {
document.querySelectorAll('.menu-button').forEach(button => {
button.addEventListener('click', (e) => {
e.stopPropagation();
const menu = e.currentTarget.closest('.menu').querySelector('.menu-dropdown');
menu.classList.toggle('active');
});
});
},
// 全局点击处理
bindGlobalClick: function () {
document.addEventListener('click', (e) => {
// 关闭所有菜单
document.querySelectorAll('.menu-dropdown.active').forEach(menu => {
if (!menu.contains(e.target)) {
menu.classList.remove('active');
}
});
// 关闭高级选项
const advancedPanel = document.getElementById('advancedOptions');
if (advancedPanel &&
!e.target.closest('.advanced-options') &&
!e.target.closest('.advanced-toggle')) {
advancedPanel.classList.remove('active');
const toggleImg = document.querySelector('.advanced-toggle img');
if (toggleImg) toggleImg.style.transform = 'rotate(0deg)';
}
});
},
// 历史记录面板
initHistoryPanel: function () {
const historyButton = document.querySelector('.history-button');
const historyPanel = document.getElementById('historyPanel');
if (historyButton && historyPanel) {
historyButton.addEventListener('click', () => {
historyPanel.classList.toggle('active');
});
// 触摸滑动处理
let touchStartY = 0;
historyPanel.addEventListener('touchstart', e => {
touchStartY = e.touches[0].clientY;
}, false);
historyPanel.addEventListener('touchmove', e => {
const touchY = e.touches[0].clientY;
if (touchY - touchStartY > 50) {
historyPanel.classList.remove('active');
}
}, false);
}
},
// 高级选项功能
initAdvancedOptions: function () {
const toggleButton = document.querySelector('.advanced-toggle');
if (toggleButton) {
toggleButton.addEventListener('click', () => {
const optionsPanel = document.getElementById('advancedOptions');
optionsPanel.classList.toggle('active');
const img = toggleButton.querySelector('img');
img.style.transform = optionsPanel.classList.contains('active') ?
'rotate(180deg)' : 'rotate(0deg)';
});
}
},
// 登录状态检查
checkLoginStatus: function () {
const loginItem = document.getElementById('loginItem');
if (loginItem) {
// 实际应通过API检查登录状态
const isLoggedIn = false;
if (isLoggedIn) {
loginItem.textContent = 'Profile';
loginItem.href = '/profile';
}
}
},
// 设置管理
loadSettings: function () {
if (document.querySelector('[name="voice"]')) {
const settings = JSON.parse(localStorage.getItem('userSettings')) || {};
document.querySelector('[name="voice"]').value = settings.voice || 'male';
document.querySelector('[name="style"]').value = settings.style || 'news';
document.querySelector('[name="experimental"]').checked = settings.experimental || false;
}
},
saveSettings: function () {
if (document.querySelector('[name="voice"]')) {
const settings = {
voice: document.querySelector('[name="voice"]').value,
style: document.querySelector('[name="style"]').value,
experimental: document.querySelector('[name="experimental"]').checked
};
localStorage.setItem('userSettings', JSON.stringify(settings));
}
},
// 通用表单验证
validateRegistration: function (form) {
const password = form.querySelector('#password').value;
const confirmPassword = form.querySelector('#confirmPassword').value;
if (password !== confirmPassword) {
alert('Passwords do not match!');
return false;
}
return true;
},
// 播放器控制
initAudioPlayer: function () {
const audio = document.getElementById('audioPlayer');
if (!audio) return;
// 设置有效音频源
if (window.audioSource) {
audio.src = window.audioSource;
}
const progress = document.querySelector('.progress-fill');
const playButton = document.getElementById('playButton');
// 安全更新播放按钮状态
const updatePlayButton = () => {
if (!audio.paused && !isFinite(audio.duration)) {
return; // 防止无效状态
}
const iconPath = audio.paused ? 'play.png' : 'pause.png';
playButton.querySelector('img').src =
`${window.prefix}/static/icons/${iconPath}`;
};
// 音频元数据加载完成后再启用控制
audio.addEventListener('loadedmetadata', () => {
// 初始化进度条
progress.style.width = '0%';
// 绑定控制事件
document.querySelectorAll('#playButton').forEach(btn => {
btn.addEventListener('click', () => {
audio.paused ? audio.play().catch(console.error) : audio.pause();
});
});
document.querySelectorAll('.jump-back').forEach(btn => {
btn.addEventListener('click', () => this.jumpAudio(-15, audio));
});
document.querySelectorAll('.jump-forward').forEach(btn => {
btn.addEventListener('click', () => this.jumpAudio(15, audio));
});
});
// 错误处理
audio.addEventListener('error', (e) => {
console.error('Audio error:', e.target.error);
alert('Error loading audio file');
});
},
jumpAudio: function (seconds, audio) {
try {
if (!isFinite(audio.duration) || audio.readyState < 2) {
console.warn('Audio not ready');
return;
}
const newTime = Math.max(0, Math.min(
audio.currentTime + seconds,
audio.duration
));
audio.currentTime = newTime;
} catch (error) {
console.error('Jump error:', error);
}
}
};
// 初始化应用
document.addEventListener('DOMContentLoaded', () => {
App.init();
App.initAudioPlayer();
// 注册表单验证
const regForm = document.querySelector('.auth-form');
if (regForm) {
regForm.addEventListener('submit', (e) => {
if (!App.validateRegistration(regForm)) {
e.preventDefault();
}
});
}
});
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.
-69
View File
@@ -1,69 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-zoom=user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Error - Be my Anchor</title>
<link rel="stylesheet" href="{{ prefix }}/{{ url_for('static', filename='css/main.css') }}">
<script src="{{ prefix }}/{{ url_for('static', filename='js/main.js') }}"></script>
</head>
<body>
<header>
<!-- 左侧历史按钮 -->
<button class="history-button" onclick="toggleHistory()">
<img src="/proxy/5000/static/icons/clock.png" alt="History">
</button>
<!-- 中间Logo和标题 -->
<div class="branding">
<img src="/proxy/5000/static/logo.png" alt="Logo" class="brand-logo">
<span class="site-name">Be my Anchor</span>
</div>
<!-- 右侧菜单组 -->
<div class="nav-group">
<div class="menu">
<button class="menu-button" onclick="toggleMenu(event)">
<img src="/proxy/5000/static/icons/people.png" alt="Menu">
</button>
<!-- 菜单下拉内容 -->
<div class="menu-dropdown" id="mainMenu">
<a href="/" class="menu-item">返回主页</a>
<a href="/report" class="menu-item">报告问题</a>
<a href="/login" class="menu-item" id="loginItem">登录</a>
</div>
</div>
</div>
</header>
<!-- 历史记录面板结构 -->
<div class="history-panel" id="historyPanel">
<button class="history-close" onclick="toggleHistory()">
<img src="/proxy/5000/static/icons/play-pause.png" alt="Close">
</button>
<div class="history-content">
<div class="history-item">
<span class="timestamp">10:30</span>
<span class="title">文章标题示例 1</span>
</div>
<!-- 更多历史记录项... -->
<div class="history-item">
<span class="timestamp">11:45</span>
<span class="title">文章标题示例 20</span>
</div>
</div>
</div>
<main>
<div class="error-container">
<h2>Oops! Something went wrong</h2>
<p class="error-message">{{ error_message }}</p>
<a href="/" class="primary-button">Return Home</a>
</div>
</main>
</body>
</html>
-108
View File
@@ -1,108 +0,0 @@
<!-- ./src/server/templates/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-zoom=user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Be my Anchor</title>
<link rel="stylesheet" href="{{ prefix }}/{{ url_for('static', filename='css/main.css') }}">
<script src="{{ prefix }}/{{ url_for('static', filename='js/main.js') }}"></script>
</head>
<body>
<header>
<button class="history-button" onclick="toggleHistory()">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/clock.png') }}" alt="History">
</button>
<div class="branding">
<img src="{{ prefix }}/{{ url_for('static', filename='logo.png') }}" alt="Logo" class="brand-logo">
<span class="site-name">Be my Anchor</span>
</div>
<div class="nav-group">
<div class="menu">
<button class="menu-button" onclick="toggleMenu(event)">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/people.png') }}" alt="Menu">
</button>
<div class="menu-dropdown" id="mainMenu">
<a href="{{ prefix }}/login" class="menu-item">Login</a>
<a href="{{ prefix }}/register" class="menu-item">Register</a>
<a href="{{ prefix }}/report" class="menu-item">Report Issue</a>
</div>
</div>
</div>
</header>
<!-- index.html 修改后的搜索部分 -->
<main class="search-main">
<div class="search-container">
<h1 class="search-title">Convert Content to Podcast</h1>
<form class="search-form" action="{{ prefix }}/page" method="get">
<!-- 搜索输入区 -->
<div class="search-group">
<textarea name="content" class="search-textarea" placeholder="Paste your text or enter URL..."
rows="4" required></textarea>
<div class="search-actions">
<button type="button" class="advanced-toggle" onclick="toggleAdvancedOptions()">
Advanced Options
<img src="{{ prefix }}/static/icons/chevron-down.svg" alt="▼">
</button>
<button type="submit" class="search-icon-button">
<img src="{{ prefix }}/static/icons/convert.svg" alt="Convert">
</button>
</div>
</div>
<!-- 高级选项下拉 -->
<div class="advanced-options" id="advancedOptions">
<div class="option-grid">
<div class="option-group">
<label>Voice Gender</label>
<select name="voice">
<option value="male">Male</option>
<option value="female">Female</option>
<option value="neutral">Neutral</option>
</select>
</div>
<div class="option-group">
<label>Style</label>
<select name="style">
<option value="news">News Report</option>
<option value="blog">Casual Blog</option>
<option value="story">Story Telling</option>
<option value="academic">Academic</option>
</select>
</div>
<div class="option-group">
<label>Custom Prompt</label>
<input type="text" name="prompt" placeholder="Additional instructions...">
</div>
<div class="option-group">
<label class="switch">
<input type="checkbox" name="experimental">
<span class="slider"></span>
<span>Experimental Mode</span>
</label>
</div>
</div>
</div>
</form>
</div>
</main>
<!-- 在body末尾添加 -->
<div class="loading-indicator">
<div class="loading-spinner"></div>
<span>Processing...</span>
</div>
</body>
</html>
-65
View File
@@ -1,65 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-zoom=user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Login - Be my Anchor</title>
<link rel="stylesheet" href="{{ prefix }}/{{ url_for('static', filename='css/main.css') }}">
<script src="{{ prefix }}/{{ url_for('static', filename='js/main.js') }}"></script>
</head>
<body>
<header>
<button class="history-button" onclick="toggleHistory()">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/clock.png') }}" alt="History">
</button>
<div class="branding">
<img src="{{ prefix }}/{{ url_for('static', filename='logo.png') }}" alt="Logo" class="brand-logo">
<span class="site-name">Be my Anchor</span>
</div>
<div class="nav-group">
<div class="menu">
<button class="menu-button" onclick="toggleMenu(event)">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/people.png') }}" alt="Menu">
</button>
<div class="menu-dropdown" id="mainMenu">
<a href="{{ prefix }}/" class="menu-item">Home</a>
<a href="{{ prefix }}/report" class="menu-item">Report Issue</a>
<a href="{{ prefix }}/register" class="menu-item">Register</a>
</div>
</div>
</div>
</header>
<main class="auth-main">
<div class="auth-container">
<h2 class="auth-title">Welcome Back</h2>
<form class="auth-form" method="POST" action="/login">
<div class="input-group">
<input type="text" name="username" placeholder="Username" required>
<input type="password" name="password" placeholder="Password" required>
</div>
<button type="submit" class="primary-button">Login</button>
<div class="oauth-divider">
<span>or continue with</span>
</div>
<button type="button" class="google-login" onclick="loginWithGoogle()">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/google.png') }}" alt="Google">
Continue with Google
</button>
</form>
<div class="auth-links">
<a href="{{ prefix }}/register">Create new account</a>
<a href="{{ prefix }}/forgot-password">Forgot password?</a>
</div>
</div>
</main>
</body>
</html>
-102
View File
@@ -1,102 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-zoom=user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Be my Anchor - {{ url }} </title>
<link rel="stylesheet" href="{{ prefix }}/{{ url_for('static', filename='css/main.css') }}">
<script>
// 定义全局路径前缀
window.prefix = "{{ prefix }}";
// 初始化音频源
window.audioSource = "{{ prefix }}/{{ url_for('static', filename='test.mp3') }}";
</script>
<script src="{{ prefix }}/{{ url_for('static', filename='js/main.js') }}"></script>
</head>
<body>
<header>
<!-- 左侧历史按钮 -->
<button class="history-button" onclick="toggleHistory()">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/clock.png') }}" alt="History">
</button>
<!-- 中间Logo和标题 -->
<div class="branding">
<img src="{{ prefix }}/{{ url_for('static', filename='logo.png') }}" alt="Logo" class="brand-logo">
<span class="site-name">Be my Anchor</span>
</div>
<!-- 右侧菜单组 -->
<div class="nav-group">
<div class="menu">
<button class="menu-button" onclick="toggleMenu(event)">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/people.png') }}" alt="Menu">
</button>
<!-- 菜单下拉内容 -->
<div class="menu-dropdown" id="mainMenu">
<a href="{{ prefix }}/" class="menu-item">Home</a>
<a href="{{ prefix }}/report" class="menu-item">Report Issue</a>
<a href="{{ prefix }}/login" class="menu-item" id="loginItem">Login</a>
</div>
</div>
</div>
</header>
<!-- 历史记录面板结构 -->
<div class="history-panel" id="historyPanel">
<button class="history-close" onclick="toggleHistory()">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/play-pause.png') }}" alt="Close">
</button>
<div class="history-content">
<div class="history-item">
<span class="timestamp">10:30</span>
<span class="title">文章标题示例 1</span>
</div>
<!-- 更多历史记录项... -->
<div class="history-item">
<span class="timestamp">11:45</span>
<span class="title">文章标题示例 20</span>
</div>
</div>
</div>
<main>
<div class="image-container">
<img src="{{ prefix }}/{{ url_for('static', filename='logo.png') }}" alt="Cover">
</div>
<!-- 独立文字容器 -->
<div class="text-content">
<div class="url">{{url}}</div>
<div class="summary">{{summary}}</div>
</div>
</main>
<!-- page.html 修改后的按钮部分 -->
<footer>
<button class="control-button jump-back">
<img src="{{ prefix }}/static/icons/back.png" alt="-15s">
</button>
<button class="control-button" id="playButton">
<img src="{{ prefix }}/static/icons/play.png" alt="Play/Pause">
</button>
<div class="progress-bar">
<div class="progress-fill"></div>
</div>
<button class="control-button jump-forward">
<img src="{{ prefix }}/static/icons/fast.png" alt="+15s">
</button>
</footer>
<audio id="audioPlayer" src="{{ prefix }}/{{ url_for('static', filename='test.mp3') }}"></audio>
</body>
</html>
-61
View File
@@ -1,61 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-zoom=user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Register - Be my Anchor</title>
<link rel="stylesheet" href="{{ prefix }}/{{ url_for('static', filename='css/main.css') }}">
<script src="{{ prefix }}/{{ url_for('static', filename='js/main.js') }}"></script>
</head>
<body>
<header>
<button class="history-button" onclick="handleHistoryButton()">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/clock.png') }}" alt="History">
</button>
<div class="branding">
<img src="{{ prefix }}/{{ url_for('static', filename='logo.png') }}" alt="Logo" class="brand-logo">
<span class="site-name">Be my Anchor</span>
</div>
<div class="nav-group">
<div class="menu">
<button class="menu-button" onclick="toggleMenu(event)">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/people.png') }}" alt="Menu">
</button>
<div class="menu-dropdown" id="mainMenu">
<a href="{{ prefix }}/" class="menu-item">Home</a>
<a href="{{ prefix }}/login" class="menu-item">Login</a>
<a href="{{ prefix }}/report" class="menu-item">Report Issue</a>
</div>
</div>
</div>
</header>
<main class="auth-main">
<div class="auth-container">
<h2 class="auth-title">Create Account</h2>
<form class="auth-form" onsubmit="return validateRegistration(event)">
<div class="input-group">
<input type="text" id="username" placeholder="Username" required>
<input type="email" id="email" placeholder="Email" required>
<input type="password" id="password" placeholder="Password" required>
<input type="password" id="confirmPassword" placeholder="Confirm Password" required>
</div>
<button type="submit" class="primary-button">Register</button>
<div class="auth-links">
<span>Already have an account? <a href="{{ prefix }}/login">Login here</a></span>
</div>
</form>
</div>
</main>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: "class",
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
slate: {
850: "#151f32",
},
},
},
},
plugins: [],
};
export default config;
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2022",
"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"
],
"exclude": [
"node_modules"
]
}