duqing2026 commited on
Commit
9ed89c8
·
1 Parent(s): 522a44a

同步 hf

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.dockerignore CHANGED
@@ -7,3 +7,5 @@ npm-debug.log
7
  Dockerfile
8
  .dockerignore
9
  .DS_Store
 
 
 
7
  Dockerfile
8
  .dockerignore
9
  .DS_Store
10
+ !rag-kb.db
11
+ !vector_store
.gitattributes CHANGED
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.db filter=lfs diff=lfs merge=lfs -text
37
+ *.sqlite filter=lfs diff=lfs merge=lfs -text
38
+ *.index filter=lfs diff=lfs merge=lfs -text
.gitignore CHANGED
@@ -45,3 +45,8 @@ rag-kb.db
45
  vector_store/hnswlib.index
46
  vector_store/docstore.json
47
  vector_store/args.json
 
 
 
 
 
 
45
  vector_store/hnswlib.index
46
  vector_store/docstore.json
47
  vector_store/args.json
48
+
49
+ # exported dataset
50
+ hf_dataset/
51
+ 备份-语雀数据-JSON/
52
+ .git/
Dockerfile CHANGED
@@ -21,8 +21,7 @@ ENV NEXT_TELEMETRY_DISABLED 1
21
  # Dummy key for build time to prevent getEmbeddings from throwing
22
  ENV GOOGLE_GENERATIVE_AI_API_KEY "dummy-key-for-build"
23
 
24
- # Force Demo Mode for Hugging Face or similar deployments
25
- ENV NEXT_PUBLIC_DEMO_MODE "true"
26
 
27
  RUN npm run build
28
 
@@ -33,8 +32,10 @@ WORKDIR /app
33
  ENV NODE_ENV production
34
  ENV NEXT_TELEMETRY_DISABLED 1
35
 
36
- # Install runtime dependencies for ONNX Runtime and others
37
- RUN apt-get update && apt-get install -y libgomp1 && rm -rf /var/lib/apt/lists/*
 
 
38
 
39
  RUN addgroup --system --gid 1001 nodejs
40
  RUN adduser --system --uid 1001 nextjs
@@ -53,11 +54,11 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
53
  COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
54
 
55
  # Copy data files needed for RAG
56
- # Create directory if it doesn't exist in the image (it shouldn't)
57
  # We copy existing stores so the demo works out of the box
58
- # COPY --from=builder --chown=nextjs:nodejs /app/vector_store ./vector_store
59
- # Copy database if it exists, otherwise the app might create it (but likely fail due to permissions if strictly read-only filesystem, though HF Spaces usually has ephemeral writeable FS)
60
- # COPY --from=builder --chown=nextjs:nodejs /app/rag-kb.db ./rag-kb.db
61
  # Copy source documents
62
  COPY --from=builder --chown=nextjs:nodejs /app/data ./data
63
 
 
21
  # Dummy key for build time to prevent getEmbeddings from throwing
22
  ENV GOOGLE_GENERATIVE_AI_API_KEY "dummy-key-for-build"
23
 
24
+ ENV NEXT_PUBLIC_DEMO_MODE "false"
 
25
 
26
  RUN npm run build
27
 
 
32
  ENV NODE_ENV production
33
  ENV NEXT_TELEMETRY_DISABLED 1
34
 
35
+ # Install runtime dependencies for ONNX Runtime and optional Python scripts
36
+ RUN apt-get update && apt-get install -y libgomp1 python3 python3-pip && rm -rf /var/lib/apt/lists/*
37
+
38
+ RUN pip3 install --no-cache-dir huggingface_hub httpx
39
 
40
  RUN addgroup --system --gid 1001 nodejs
41
  RUN adduser --system --uid 1001 nextjs
 
54
  COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
55
 
56
  # Copy data files needed for RAG
57
+ # Create directory if it doesn't exist
58
  # We copy existing stores so the demo works out of the box
59
+ COPY --from=builder --chown=nextjs:nodejs /app/vector_store ./vector_store
60
+ # Copy database if it exists
61
+ COPY --from=builder --chown=nextjs:nodejs /app/rag-kb.db ./rag-kb.db
62
  # Copy source documents
63
  COPY --from=builder --chown=nextjs:nodejs /app/data ./data
64
 
README.md CHANGED
@@ -11,7 +11,42 @@ short_description: RAG 知识库系统
11
 
12
  # RAG Knowledge Base System
13
 
14
- A full-stack RAG (Retrieval-Augmented Generation) Q&A system built with Next.js, LangChain, and Gemini. Designed to handle large-scale documentation (4.2k+ files simulated) with high-accuracy retrieval using Local Vector Store.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  ## Tech Stack
17
 
 
11
 
12
  # RAG Knowledge Base System
13
 
14
+ A full-stack RAG (Retrieval-Augmented Generation) Q&A system built with Next.js, LangChain, and Gemini. Designed to handle large-scale documentation with high-accuracy retrieval using Local Vector Store.
15
+
16
+ ## 🚀 Deployment on Hugging Face Spaces
17
+
18
+ This project is configured for easy deployment on Hugging Face Spaces using Docker.
19
+
20
+ ### Prerequisites
21
+
22
+ 1. **Create a Space**: Go to [Hugging Face Spaces](https://huggingface.co/spaces) and create a new Space.
23
+ * **SDK**: Select `Docker`.
24
+ * **Hardware**: Default CPU (Free) is sufficient, but 2 vCPU is recommended for faster embedding.
25
+
26
+ 2. **Environment Variables**:
27
+ Go to your Space's **Settings** tab and add the following secrets:
28
+ * `GOOGLE_GENERATIVE_AI_API_KEY`: Your Google Gemini API Key (Required for embeddings).
29
+ * `DEEPSEEK_API_KEY`: Your DeepSeek API Key (Recommended for chat).
30
+ * `NEXT_PUBLIC_DEMO_MODE`: Set to `false` to use the real database.
31
+
32
+ ### Syncing Code
33
+
34
+ You can upload the code directly via Git:
35
+
36
+ ```bash
37
+ # Initialize git if not already done
38
+ git init
39
+ git remote add space https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME
40
+
41
+ # Add all files (including rag-kb.db and vector_store)
42
+ git add .
43
+ git commit -m "Initial commit with DB and Vector Store"
44
+
45
+ # Push to Hugging Face
46
+ git push space main
47
+ ```
48
+
49
+ **Note**: The `rag-kb.db` and `vector_store/` files are included in the upload to ensure the knowledge base is pre-populated.
50
 
51
  ## Tech Stack
52
 
analyze_bulk_creation.js ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ const db = require('better-sqlite3')('rag-kb.db');
3
+
4
+ const startTime = new Date('2025-01-01').getTime();
5
+ const endTime = new Date('2026-01-01').getTime();
6
+
7
+ const docs = db.prepare(`
8
+ SELECT
9
+ created_at,
10
+ updated_at,
11
+ word_count
12
+ FROM documents
13
+ WHERE
14
+ yuque_id != 0
15
+ AND namespace != 'NOTES'
16
+ AND (slug IS NULL OR slug NOT LIKE 'dir-%')
17
+ AND created_at >= ?
18
+ AND created_at < ?
19
+ `).all(startTime, endTime);
20
+
21
+ let sameTimeCount = 0;
22
+ let sameTimeWords = 0;
23
+ let diffTimeCount = 0;
24
+ let diffTimeWords = 0;
25
+
26
+ // Threshold for "same time" (e.g., 60 seconds)
27
+ const THRESHOLD_MS = 60 * 1000;
28
+
29
+ docs.forEach(doc => {
30
+ const diff = Math.abs(doc.updated_at - doc.created_at);
31
+ if (diff <= THRESHOLD_MS) {
32
+ sameTimeCount++;
33
+ sameTimeWords += doc.word_count || 0;
34
+ } else {
35
+ diffTimeCount++;
36
+ diffTimeWords += doc.word_count || 0;
37
+ }
38
+ });
39
+
40
+ console.log(`\nAnalysis of ${docs.length} docs created in 2025:`);
41
+ console.log(`Updated ~= Created (<= 60s):`);
42
+ console.log(` Count: ${sameTimeCount}`);
43
+ console.log(` Words: ${(sameTimeWords / 10000).toFixed(1)}w`);
44
+
45
+ console.log(`\nUpdated > Created (> 60s):`);
46
+ console.log(` Count: ${diffTimeCount}`);
47
+ console.log(` Words: ${(diffTimeWords / 10000).toFixed(1)}w`);
check_db.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ import os
3
+
4
+ files = [f for f in os.listdir('.') if f.endswith('.db') or f.endswith('.sqlite')]
5
+
6
+ for db_file in files:
7
+ print(f"--- {db_file} ---")
8
+ try:
9
+ conn = sqlite3.connect(db_file)
10
+ cursor = conn.cursor()
11
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
12
+ tables = cursor.fetchall()
13
+ for table in tables:
14
+ table_name = table[0]
15
+ print(f"Table: {table_name}")
16
+ cursor.execute(f"PRAGMA table_info({table_name})")
17
+ columns = cursor.fetchall()
18
+ col_names = [col[1] for col in columns]
19
+ print(f" Columns: {col_names}")
20
+ if 'content' in col_names:
21
+ print(" *** HAS CONTENT COLUMN ***")
22
+ conn.close()
23
+ except Exception as e:
24
+ print(f"Error: {e}")
25
+ print("\n")
debug-db-tags.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import db from './src/lib/db';
3
+
4
+ const docs = db.prepare(`
5
+ SELECT id, title, tags
6
+ FROM documents
7
+ WHERE namespace = 'NOTES' AND tags IS NOT NULL
8
+ LIMIT 5
9
+ `).all();
10
+
11
+ console.log('Raw DB Tags Content:');
12
+ docs.forEach((d: any) => {
13
+ console.log(`Title: ${d.title}`);
14
+ console.log(`Tags (Raw): ${d.tags}`);
15
+ try {
16
+ const parsed = JSON.parse(d.tags);
17
+ console.log('Tags (Parsed):', JSON.stringify(parsed, null, 2));
18
+ console.log('Is Array?', Array.isArray(parsed));
19
+ if (Array.isArray(parsed)) {
20
+ console.log('Element types:', parsed.map((x: any) => typeof x));
21
+ }
22
+ } catch (e) {
23
+ console.log('Parse Error:', e);
24
+ }
25
+ console.log('---');
26
+ });
debug-verify-tags.ts ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import db from './src/lib/db';
3
+
4
+ const docs = db.prepare(`
5
+ SELECT id, title, tags
6
+ FROM documents
7
+ WHERE namespace = 'NOTES'
8
+ `).all();
9
+
10
+ const allTags = new Map<string, number>();
11
+ let untaggedCount = 0;
12
+
13
+ docs.forEach((d: any) => {
14
+ let tags: string[] = [];
15
+ if (typeof d.tags === 'string' && d.tags.length > 0) {
16
+ try {
17
+ const parsed = JSON.parse(d.tags);
18
+ if (Array.isArray(parsed)) {
19
+ tags = parsed.map((x: any) => {
20
+ if (typeof x === 'string') return x;
21
+ if (typeof x === 'object' && x !== null) {
22
+ return x.title || x.name || '';
23
+ }
24
+ return '';
25
+ }).filter((x: string) => x.length > 0);
26
+ }
27
+ } catch {
28
+ tags = [];
29
+ }
30
+ }
31
+
32
+ if (tags.length > 0) {
33
+ tags.forEach(t => allTags.set(t, (allTags.get(t) || 0) + 1));
34
+ } else {
35
+ untaggedCount++;
36
+ }
37
+ });
38
+
39
+ console.log('=== Tag List Verification ===');
40
+ const sortedTags = Array.from(allTags.entries())
41
+ .map(([name, count]) => ({ name, count }))
42
+ .sort((a, b) => b.count - a.count);
43
+
44
+ if (untaggedCount > 0) {
45
+ sortedTags.push({ name: '无标签', count: untaggedCount });
46
+ }
47
+
48
+ sortedTags.forEach(t => {
49
+ console.log(`${t.name}: ${t.count}`);
50
+ });
51
+ console.log('=============================');
debug-yuque-notes.ts ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import * as dotenv from "dotenv";
3
+ import fetch from "node-fetch";
4
+
5
+ dotenv.config({ path: ".env.local" });
6
+ dotenv.config();
7
+
8
+ const TOKEN = process.env.YUQUE_TOKEN;
9
+ const BASE_URL = "https://www.yuque.com/api/v2";
10
+
11
+ interface Note {
12
+ tags?: string[];
13
+ [key: string]: unknown;
14
+ }
15
+
16
+ interface YuqueResponse {
17
+ data: {
18
+ notes: Note[];
19
+ };
20
+ }
21
+
22
+ async function checkNotes() {
23
+ if (!TOKEN) {
24
+ console.error("No YUQUE_TOKEN found in env");
25
+ return;
26
+ }
27
+
28
+ console.log("Fetching notes...");
29
+ const url = `${BASE_URL}/notes?offset=0&limit=50`;
30
+ const headers = {
31
+ "X-Auth-Token": TOKEN,
32
+ "User-Agent": "debug-script",
33
+ "Content-Type": "application/json",
34
+ };
35
+
36
+ try {
37
+ const res = await fetch(url, { headers });
38
+ if (!res.ok) {
39
+ console.error(`Error: ${res.status} ${res.statusText}`);
40
+ const text = await res.text();
41
+ console.error(text);
42
+ return;
43
+ }
44
+
45
+ const data = await res.json() as unknown as YuqueResponse;
46
+ const notes = data.data.notes || [];
47
+
48
+ console.log(`Found ${notes.length} notes.`);
49
+
50
+ const notesWithTags = notes.filter((n) => n.tags && n.tags.length > 0);
51
+ console.log(`Notes with tags: ${notesWithTags.length}`);
52
+
53
+ if (notesWithTags.length > 0) {
54
+ console.log("Example note with tags:");
55
+ console.log(JSON.stringify(notesWithTags[0], null, 2));
56
+ } else {
57
+ console.log("No tags found in the first 50 notes.");
58
+ }
59
+ } catch (e) {
60
+ console.error("Failed:", e);
61
+ }
62
+ }
63
+
64
+ checkNotes();
debug_2025_stats.js ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ const db = require('better-sqlite3')('rag-kb.db');
3
+
4
+ // 1. Check table info to see available columns
5
+ const columns = db.prepare("PRAGMA table_info(documents)").all();
6
+ console.log("Columns:", columns.map(c => c.name).join(', '));
7
+
8
+ // 2. Analyze 2025 creation distribution
9
+ // Convert unix timestamp to YYYY-MM-DD
10
+ const query = `
11
+ SELECT
12
+ date(created_at / 1000, 'unixepoch', 'localtime') as created_date,
13
+ COUNT(*) as count
14
+ FROM documents
15
+ WHERE
16
+ yuque_id != 0
17
+ AND namespace != 'NOTES'
18
+ AND (slug IS NULL OR slug NOT LIKE 'dir-%')
19
+ AND created_at >= ?
20
+ AND created_at < ?
21
+ GROUP BY created_date
22
+ ORDER BY count DESC
23
+ LIMIT 20;
24
+ `;
25
+
26
+ const startTime = new Date('2025-01-01').getTime();
27
+ const endTime = new Date('2026-01-01').getTime();
28
+
29
+ const results = db.prepare(query).all(startTime, endTime);
30
+
31
+ console.log("\nTop creation dates in 2025 (non-NOTES):");
32
+ results.forEach(r => {
33
+ console.log(`${r.created_date}: ${r.count} docs`);
34
+ });
35
+
36
+ // 3. Analyze 2025 update distribution for comparison
37
+ const updateQuery = `
38
+ SELECT
39
+ date(updated_at / 1000, 'unixepoch', 'localtime') as updated_date,
40
+ COUNT(*) as count
41
+ FROM documents
42
+ WHERE
43
+ yuque_id != 0
44
+ AND namespace != 'NOTES'
45
+ AND (slug IS NULL OR slug NOT LIKE 'dir-%')
46
+ AND updated_at >= ?
47
+ AND updated_at < ?
48
+ GROUP BY updated_date
49
+ ORDER BY count DESC
50
+ LIMIT 10;
51
+ `;
52
+
53
+ const updateResults = db.prepare(updateQuery).all(startTime, endTime);
54
+ console.log("\nTop update dates in 2025 (non-NOTES):");
55
+ updateResults.forEach(r => {
56
+ console.log(`${r.updated_date}: ${r.count} docs`);
57
+ });
deploy.log ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [2026-01-09 12:42:37] Configuring Git LFS settings...
2
+ [2026-01-09 12:42:37] Checking network connectivity...
3
+ [2026-01-09 12:42:48] Warning: Could not ping hf.co, but proceeding with push attempt...
4
+ [2026-01-09 12:42:48] Starting push attempt 1 of 3...
5
+ [2026-01-09 12:44:19] Upload failed with exit code 1.
6
+ [2026-01-09 12:44:19] Waiting 5 seconds before retrying...
7
+ [2026-01-09 12:44:24] Starting push attempt 2 of 3...
8
+ [2026-01-09 12:44:38] Upload failed with exit code 1.
9
+ [2026-01-09 12:44:38] Waiting 10 seconds before retrying...
10
+ [2026-01-09 12:44:48] Starting push attempt 3 of 3...
11
+ [2026-01-09 12:45:02] Upload failed with exit code 1.
12
+ [2026-01-09 12:45:02] All 3 attempts failed.
13
+ [2026-01-09 12:45:02] Please check deploy.log for details.
14
+ [2026-01-09 12:45:27] Configuring Git LFS settings...
15
+ [2026-01-09 12:45:27] Checking network connectivity...
16
+ [2026-01-09 12:45:38] Warning: Could not ping hf.co, but proceeding with push attempt...
17
+ [2026-01-09 12:45:38] Starting push attempt 1 of 3...
18
+ [2026-01-09 12:45:52] Upload failed with exit code 1.
19
+ [2026-01-09 12:45:52] Waiting 5 seconds before retrying...
20
+ [2026-01-09 12:45:57] Starting push attempt 2 of 3...
21
+ [2026-01-09 12:46:11] Upload failed with exit code 1.
22
+ [2026-01-09 12:46:11] Waiting 10 seconds before retrying...
23
+ [2026-01-09 12:46:21] Starting push attempt 3 of 3...
24
+ [2026-01-09 12:46:36] Upload failed with exit code 1.
25
+ [2026-01-09 12:46:36] All 3 attempts failed.
26
+ [2026-01-09 12:46:36] Executing fallback: Marking as pending upload in local cache...
27
+ [2026-01-09 12:46:36] Status saved to upload_status.json
28
+ [2026-01-09 12:46:36] Deployment process completed with pending status.
documents.db DELETED
File without changes
eslint.config.mjs CHANGED
@@ -5,6 +5,18 @@ import nextTs from "eslint-config-next/typescript";
5
  const eslintConfig = defineConfig([
6
  ...nextVitals,
7
  ...nextTs,
 
 
 
 
 
 
 
 
 
 
 
 
8
  // Override default ignores of eslint-config-next.
9
  globalIgnores([
10
  // Default ignores of eslint-config-next:
 
5
  const eslintConfig = defineConfig([
6
  ...nextVitals,
7
  ...nextTs,
8
+ {
9
+ files: ["**/*.js", "**/*.cjs", "**/*.mjs"],
10
+ rules: {
11
+ "@typescript-eslint/no-require-imports": "off",
12
+ },
13
+ },
14
+ {
15
+ files: ["scripts/**/*.{ts,tsx}", "debug*.ts", "debug-*.ts"],
16
+ rules: {
17
+ "@typescript-eslint/no-explicit-any": "off",
18
+ },
19
+ },
20
  // Override default ignores of eslint-config-next.
21
  globalIgnores([
22
  // Default ignores of eslint-config-next:
package.json CHANGED
@@ -8,7 +8,8 @@
8
  "start": "next start",
9
  "lint": "eslint",
10
  "ingest:yuque": "npx tsx scripts/yuque-ingest.ts",
11
- "query": "npx tsx scripts/query.ts"
 
12
  },
13
  "dependencies": {
14
  "@ai-sdk/google": "^0.0.55",
 
8
  "start": "next start",
9
  "lint": "eslint",
10
  "ingest:yuque": "npx tsx scripts/yuque-ingest.ts",
11
+ "query": "npx tsx scripts/query.ts",
12
+ "export:hf": "npx tsx scripts/export-hf-dataset.ts"
13
  },
14
  "dependencies": {
15
  "@ai-sdk/google": "^0.0.55",
scripts/benchmark-notes-speed.ts ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as dotenv from "dotenv";
2
+ import { SimpleYuqueLoader, YuqueDoc } from "../src/lib/yuque-service";
3
+
4
+ dotenv.config({ path: ".env.local" });
5
+ dotenv.config();
6
+
7
+ async function asyncPool<T, R>(poolLimit: number, array: T[], iteratorFn: (item: T, array: T[]) => Promise<R>): Promise<R[]> {
8
+ const ret: Promise<R>[] = [];
9
+ const executing: Promise<void>[] = [];
10
+ for (const item of array) {
11
+ const p = Promise.resolve().then(() => iteratorFn(item, array));
12
+ ret.push(p);
13
+ if (poolLimit <= array.length) {
14
+ const e: Promise<void> = p.then(() => {
15
+ executing.splice(executing.indexOf(e), 1);
16
+ });
17
+ executing.push(e);
18
+ if (executing.length >= poolLimit) {
19
+ await Promise.race(executing);
20
+ }
21
+ }
22
+ }
23
+ return Promise.all(ret);
24
+ }
25
+
26
+ async function run() {
27
+ const token = process.env.YUQUE_TOKEN;
28
+ if (!token) {
29
+ console.error("缺少环境变量 YUQUE_TOKEN");
30
+ process.exit(1);
31
+ }
32
+ const concurrency = parseInt(process.env.BENCH_NOTES_CONCURRENCY ?? "2");
33
+ const limit = parseInt(process.env.BENCH_NOTES_LIMIT ?? "50");
34
+ const loader = new SimpleYuqueLoader(token, "NOTES");
35
+
36
+ const listRes = await loader.fetchAPI(`/notes?offset=0&limit=${Math.min(limit, 50)}`);
37
+ const notes: YuqueDoc[] = Array.isArray(listRes?.data?.notes)
38
+ ? listRes.data.notes.map((n: any) => ({
39
+ id: n.id,
40
+ slug: n.slug,
41
+ title: n.content?.abstract ?? `小记-${n.id}`,
42
+ uuid: n.slug,
43
+ }))
44
+ : [];
45
+ console.log(`准备抓取 ${notes.length} 条小记详情,并发=${concurrency}`);
46
+
47
+ const start = Date.now();
48
+ let ok = 0;
49
+ await asyncPool(concurrency, notes, async (note) => {
50
+ const doc = await loader.fetchNoteDetail(note);
51
+ if (doc) ok++;
52
+ });
53
+ const elapsed = (Date.now() - start) / 1000;
54
+ const rps = ok / elapsed;
55
+ console.log(`完成 ${ok}/${notes.length} 条,用时 ${elapsed.toFixed(2)}s,平均 ${rps.toFixed(2)} req/s`);
56
+ }
57
+
58
+ run().catch((e) => {
59
+ console.error("基准测试失败:", e);
60
+ process.exit(1);
61
+ });
scripts/deploy_to_hf.sh ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ # Configuration
4
+ MAX_RETRIES=3
5
+ LOG_FILE="deploy.log"
6
+ REMOTE_URL="git@hf.co:spaces/duqing2026/rag-kb-demo"
7
+
8
+ # Function to log messages
9
+ log_message() {
10
+ local timestamp=$(date "+%Y-%m-%d %H:%M:%S")
11
+ echo "[$timestamp] $1" | tee -a "$LOG_FILE"
12
+ }
13
+
14
+ # 1. Configure Git LFS to skip lock verification (Fixes the specific error)
15
+ log_message "Configuring Git LFS settings..."
16
+ git config lfs.locksverify false
17
+ git config lfs.https://hf.co/spaces/duqing2026/rag-kb-demo.git/info/lfs.locksverify false
18
+
19
+ # 2. Check network connectivity (Simple check)
20
+ log_message "Checking network connectivity..."
21
+ if ping -c 1 hf.co &> /dev/null; then
22
+ log_message "Network connection to hf.co confirmed."
23
+ else
24
+ log_message "Warning: Could not ping hf.co, but proceeding with push attempt..."
25
+ fi
26
+
27
+ # 3. Push with retry logic
28
+ attempt=1
29
+ while [ $attempt -le $MAX_RETRIES ]; do
30
+ log_message "Starting push attempt $attempt of $MAX_RETRIES..."
31
+
32
+ # Try to push both LFS objects and git refs
33
+ # Using -u origin main to ensure upstream tracking
34
+ if git push -u origin main; then
35
+ log_message "Upload successful!"
36
+ exit 0
37
+ else
38
+ exit_code=$?
39
+ log_message "Upload failed with exit code $exit_code."
40
+
41
+ if [ $attempt -lt $MAX_RETRIES ]; then
42
+ wait_time=$((attempt * 5))
43
+ log_message "Waiting $wait_time seconds before retrying..."
44
+ sleep $wait_time
45
+ ((attempt++))
46
+ else
47
+ log_message "All $MAX_RETRIES attempts failed."
48
+
49
+ # 4. Fallback: Save to local cache status (User Option 3)
50
+ log_message "Executing fallback: Marking as pending upload in local cache..."
51
+
52
+ # Create status file
53
+ cat > upload_status.json <<EOF
54
+ {
55
+ "status": "pending_upload",
56
+ "last_attempt": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
57
+ "retry_count": $MAX_RETRIES,
58
+ "error_log": "$LOG_FILE",
59
+ "reason": "Network connection refused after multiple retries"
60
+ }
61
+ EOF
62
+ log_message "Status saved to upload_status.json"
63
+ log_message "Deployment process completed with pending status."
64
+ exit 0 # Exit cleanly as we handled the error gracefully
65
+ fi
66
+ fi
67
+ done
scripts/export-hf-dataset.ts ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Database from 'better-sqlite3';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+
5
+ type DocRow = {
6
+ id: string;
7
+ yuque_id?: number;
8
+ title: string;
9
+ slug: string;
10
+ url?: string | null;
11
+ namespace?: string | null;
12
+ content_preview?: string | null;
13
+ word_count?: number | null;
14
+ updated_at?: number | null;
15
+ created_at?: number | null;
16
+ tags?: string | null;
17
+ sort_order?: number | null;
18
+ };
19
+
20
+ type KbRow = {
21
+ namespace: string;
22
+ name: string;
23
+ description?: string | null;
24
+ synced_at: number;
25
+ last_offset?: number | null;
26
+ };
27
+
28
+ function safeSlug(s: string) {
29
+ return s.replace(/[\\/]/g, '_').replace(/\s+/g, '-');
30
+ }
31
+
32
+ function ensureDir(p: string) {
33
+ if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
34
+ }
35
+
36
+ function writeJson(filePath: string, data: unknown) {
37
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
38
+ }
39
+
40
+ async function main() {
41
+ const cwd = process.cwd();
42
+ const dbPath = path.join(cwd, 'rag-kb.db');
43
+ const outDir = path.join(cwd, '..', 'hf_dataset_rag-kb-system');
44
+ const filesDir = path.join(outDir, 'files');
45
+ const metaDir = path.join(outDir, 'metadata');
46
+
47
+ ensureDir(outDir);
48
+ ensureDir(filesDir);
49
+ ensureDir(metaDir);
50
+
51
+ const db = new Database(dbPath);
52
+
53
+ const docs = db
54
+ .prepare(
55
+ `SELECT id, yuque_id, title, slug, url, namespace, content_preview, word_count, updated_at, created_at, tags, sort_order
56
+ FROM documents
57
+ ORDER BY namespace ASC, sort_order ASC, synced_at DESC`
58
+ )
59
+ .all() as DocRow[];
60
+
61
+ const kbs = db
62
+ .prepare(
63
+ `SELECT namespace, name, description, synced_at, last_offset
64
+ FROM knowledge_bases
65
+ ORDER BY synced_at DESC`
66
+ )
67
+ .all() as KbRow[];
68
+
69
+ const index: Array<Omit<DocRow, 'content_preview'>> = [];
70
+
71
+ for (const d of docs) {
72
+ const ns = d.namespace || 'UNKNOWN';
73
+ const nsDir = path.join(filesDir, ns);
74
+ ensureDir(nsDir);
75
+ const slug = safeSlug(d.slug);
76
+ const filePath = path.join(nsDir, `${slug}.md`);
77
+ const content = d.content_preview || '';
78
+ fs.writeFileSync(filePath, content, 'utf8');
79
+
80
+ index.push({
81
+ id: d.id,
82
+ yuque_id: d.yuque_id,
83
+ title: d.title,
84
+ slug: d.slug,
85
+ url: d.url,
86
+ namespace: d.namespace,
87
+ word_count: d.word_count,
88
+ updated_at: d.updated_at,
89
+ created_at: d.created_at,
90
+ tags: d.tags,
91
+ sort_order: d.sort_order,
92
+ });
93
+ }
94
+
95
+ writeJson(path.join(metaDir, 'documents.json'), {
96
+ count: index.length,
97
+ documents: index,
98
+ });
99
+
100
+ writeJson(path.join(metaDir, 'knowledge_bases.json'), {
101
+ count: kbs.length,
102
+ knowledge_bases: kbs,
103
+ });
104
+
105
+ const summary = {
106
+ generated_at: new Date().toISOString(),
107
+ files_dir: 'files',
108
+ metadata_dir: 'metadata',
109
+ namespaces: Array.from(new Set(index.map((d) => d.namespace || 'UNKNOWN'))),
110
+ };
111
+ writeJson(path.join(outDir, 'dataset_summary.json'), summary);
112
+
113
+ console.log(`Exported ${index.length} documents to: ${outDir}`);
114
+ }
115
+
116
+ main().catch((e) => {
117
+ console.error(e);
118
+ process.exit(1);
119
+ });
scripts/import_hf_metadata.ts ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import Database from 'better-sqlite3';
4
+
5
+ type DocRow = {
6
+ id: string;
7
+ yuque_id?: number;
8
+ title: string;
9
+ slug: string;
10
+ url?: string | null;
11
+ namespace?: string | null;
12
+ content_preview?: string | null;
13
+ synced_at?: number;
14
+ parent_uuid?: string | null;
15
+ uuid?: string | null;
16
+ sort_order?: number | null;
17
+ word_count?: number | null;
18
+ updated_at?: number | null;
19
+ created_at?: number | null;
20
+ tags?: string | null;
21
+ };
22
+
23
+ type KbRow = {
24
+ namespace: string;
25
+ name: string;
26
+ description?: string | null;
27
+ synced_at: number;
28
+ last_offset?: number | null;
29
+ };
30
+
31
+ function readJson(p: string) {
32
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
33
+ }
34
+
35
+ function ensureDir(p: string) {
36
+ if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
37
+ }
38
+
39
+ function main() {
40
+ const cwd = process.cwd();
41
+ const datasetRoot = process.env.HF_DATASET_ROOT || path.join(cwd, '..', 'hf_dataset_rag');
42
+ const metaDir = path.join(datasetRoot, 'metadata');
43
+ const docsDir = path.join(metaDir, 'documents');
44
+ const indexPath = path.join(docsDir, 'index.json');
45
+ const kbPath = path.join(metaDir, 'knowledge_bases.json');
46
+
47
+ if (!fs.existsSync(indexPath)) {
48
+ console.error('Missing index.json:', indexPath);
49
+ process.exit(1);
50
+ }
51
+ if (!fs.existsSync(kbPath)) {
52
+ console.error('Missing knowledge_bases.json:', kbPath);
53
+ process.exit(1);
54
+ }
55
+
56
+ const dbPath = path.join(cwd, 'rag-kb.db');
57
+ ensureDir(cwd);
58
+ const db = new Database(dbPath);
59
+
60
+ db.exec(`
61
+ CREATE TABLE IF NOT EXISTS sessions (
62
+ id TEXT PRIMARY KEY,
63
+ title TEXT NOT NULL,
64
+ created_at INTEGER NOT NULL,
65
+ type TEXT DEFAULT 'chat'
66
+ );
67
+
68
+ CREATE TABLE IF NOT EXISTS messages (
69
+ id TEXT PRIMARY KEY,
70
+ session_id TEXT NOT NULL,
71
+ role TEXT NOT NULL,
72
+ content TEXT NOT NULL,
73
+ created_at INTEGER NOT NULL,
74
+ FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE
75
+ );
76
+
77
+ CREATE TABLE IF NOT EXISTS documents (
78
+ id TEXT PRIMARY KEY,
79
+ yuque_id INTEGER,
80
+ title TEXT NOT NULL,
81
+ slug TEXT NOT NULL,
82
+ url TEXT,
83
+ namespace TEXT,
84
+ content_preview TEXT,
85
+ synced_at INTEGER NOT NULL,
86
+ parent_uuid TEXT,
87
+ uuid TEXT,
88
+ sort_order INTEGER DEFAULT 0,
89
+ word_count INTEGER DEFAULT 0,
90
+ updated_at INTEGER,
91
+ created_at INTEGER,
92
+ tags TEXT
93
+ );
94
+
95
+ CREATE TABLE IF NOT EXISTS knowledge_bases (
96
+ namespace TEXT PRIMARY KEY,
97
+ name TEXT NOT NULL,
98
+ description TEXT,
99
+ synced_at INTEGER NOT NULL,
100
+ last_offset INTEGER DEFAULT 0
101
+ );
102
+ `);
103
+
104
+ const reset = (process.env.IMPORT_RESET || '').toLowerCase();
105
+ if (reset === '1' || reset === 'true') {
106
+ db.exec('DELETE FROM documents; DELETE FROM knowledge_bases;');
107
+ }
108
+
109
+ const kbData = readJson(kbPath) as { knowledge_bases?: KbRow[] };
110
+ const kbInsert = db.prepare(`
111
+ INSERT OR REPLACE INTO knowledge_bases (namespace, name, description, synced_at, last_offset)
112
+ VALUES (@namespace, @name, @description, @synced_at, COALESCE(@last_offset, 0))
113
+ `);
114
+ for (const kb of kbData.knowledge_bases || []) {
115
+ kbInsert.run(kb);
116
+ }
117
+
118
+ const index = readJson(indexPath) as { parts?: string[] };
119
+ const docInsert = db.prepare(`
120
+ INSERT OR REPLACE INTO documents
121
+ (id, yuque_id, title, slug, url, namespace, content_preview, synced_at, parent_uuid, uuid, sort_order, word_count, updated_at, created_at, tags)
122
+ VALUES (@id, @yuque_id, @title, @slug, @url, @namespace, '', COALESCE(@updated_at, strftime('%s','now')*1000), @parent_uuid, @uuid, COALESCE(@sort_order, 0), COALESCE(@word_count, 0), @updated_at, @created_at, @tags)
123
+ `);
124
+
125
+ let total = 0;
126
+ for (const part of index.parts || []) {
127
+ const partPath = path.join(docsDir, part);
128
+ const arr = readJson(partPath) as DocRow[];
129
+ for (const d of arr) {
130
+ docInsert.run(d);
131
+ total += 1;
132
+ }
133
+ }
134
+
135
+ console.log(JSON.stringify({
136
+ inserted_documents: total,
137
+ inserted_kbs: (kbData.knowledge_bases || []).length,
138
+ db_path: dbPath
139
+ }, null, 2));
140
+ }
141
+
142
+ try {
143
+ main();
144
+ } catch (e) {
145
+ console.error(e);
146
+ process.exit(1);
147
+ }
scripts/push_hf_dataset.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import json
4
+ import pathlib
5
+ from typing import List
6
+ import httpx
7
+ from contextlib import suppress
8
+
9
+ def _is_ascii(s: str) -> bool:
10
+ try:
11
+ s.encode("ascii")
12
+ return True
13
+ except Exception:
14
+ return False
15
+
16
+ def list_initial_files(root: pathlib.Path, start: int, limit: int, include_license: bool, include_readme: bool) -> List[pathlib.Path]:
17
+ meta = root / "metadata"
18
+ docs_dir = meta / "documents"
19
+ files = [
20
+ docs_dir / "index.json",
21
+ meta / "knowledge_bases.json",
22
+ ]
23
+ if include_license and (root / "LICENSE").exists():
24
+ files.append(root / "LICENSE")
25
+ if include_readme and (root / "README.md").exists():
26
+ files.append(root / "README.md")
27
+ index = json.loads((docs_dir / "index.json").read_text("utf8"))
28
+ parts = index.get("parts", [])
29
+ slice_parts = parts[start:start + limit]
30
+ for p in slice_parts:
31
+ files.append(docs_dir / p)
32
+ return files
33
+
34
+ def create_commit(repo_id: str, token: str, root: pathlib.Path, paths: List[pathlib.Path], message: str):
35
+ from huggingface_hub import HfApi, CommitOperationAdd
36
+ api = HfApi(token=token)
37
+ api.create_repo(repo_id=repo_id, repo_type="dataset", private=True, exist_ok=True)
38
+ ops = []
39
+ for p in paths:
40
+ rel = p.relative_to(root)
41
+ ops.append(CommitOperationAdd(path_in_repo=str(rel), path_or_fileobj=str(p)))
42
+ def _commit(ops_slice: List[CommitOperationAdd], msg: str):
43
+ try:
44
+ api.create_commit(repo_id=repo_id, repo_type="dataset", operations=ops_slice, commit_message=msg)
45
+ except Exception as e:
46
+ s = str(e)
47
+ timeout_like = isinstance(e, httpx.ReadTimeout) or "ReadTimeout" in s or "Timeout" in s
48
+ if timeout_like and len(ops_slice) > 1:
49
+ mid = len(ops_slice) // 2
50
+ left = ops_slice[:mid]
51
+ right = ops_slice[mid:]
52
+ _commit(left, msg + " [chunk A]")
53
+ _commit(right, msg + " [chunk B]")
54
+ else:
55
+ raise
56
+ chunk_size_env = os.getenv("HF_COMMIT_CHUNK_SIZE", "")
57
+ chunk_size = int(chunk_size_env) if chunk_size_env.isdigit() else 0
58
+ if chunk_size and chunk_size > 0:
59
+ for i in range(0, len(ops), chunk_size):
60
+ _commit(ops[i:i + chunk_size], message + f" [batch {i//chunk_size}]")
61
+ else:
62
+ _commit(ops, message)
63
+
64
+ def dry_run_summary(paths: List[pathlib.Path]):
65
+ total = 0
66
+ items = []
67
+ for p in paths:
68
+ size = p.stat().st_size
69
+ total += size
70
+ items.append((str(p), size))
71
+ print(json.dumps({"files": [{"path": i[0], "size": i[1]} for i in items], "total_bytes": total}, ensure_ascii=False, indent=2))
72
+
73
+ def main():
74
+ root_env = os.getenv("HF_DATASET_ROOT", "")
75
+ repo_id = os.getenv("HF_REPO_ID", "")
76
+ token = os.getenv("HF_TOKEN", "") or os.getenv("HUGGINGFACE_TOKEN", "")
77
+ offset_env = os.getenv("HF_PARTS_OFFSET", "")
78
+ limit_env = os.getenv("HF_PARTS_LIMIT", "") or os.getenv("HF_INITIAL_PARTS", "")
79
+ include_license = os.getenv("HF_INCLUDE_LICENSE", "1") not in ("0", "false", "False")
80
+ include_readme = os.getenv("HF_INCLUDE_README", "1") not in ("0", "false", "False")
81
+ offset = int(offset_env) if offset_env.isdigit() else 0
82
+ limit = int(limit_env) if limit_env.isdigit() else 2
83
+
84
+ root = pathlib.Path(root_env or pathlib.Path(__file__).resolve().parents[2] / "hf_dataset_rag")
85
+ paths = list_initial_files(root, offset, limit, include_license, include_readme)
86
+
87
+ if not repo_id or not token:
88
+ print("Missing HF_REPO_ID or HF_TOKEN; performing dry-run")
89
+ dry_run_summary(paths)
90
+ sys.exit(0)
91
+ if not _is_ascii(token) or not _is_ascii(repo_id):
92
+ print("HF_TOKEN or HF_REPO_ID contains non-ASCII characters")
93
+ sys.exit(2)
94
+ os.environ["HF_HUB_USER_AGENT"] = "rag-kb-uploader"
95
+ os.environ.setdefault("HF_HUB_TIMEOUT", "60")
96
+ os.environ.setdefault("HF_HUB_READ_TIMEOUT", "60")
97
+ with suppress(Exception):
98
+ from huggingface_hub import HfApi
99
+ HfApi(token=token).whoami()
100
+
101
+ create_commit(repo_id, token, root, paths, f"Upload: metadata + parts [{offset}, {offset + limit})")
102
+ print("Commit completed")
103
+
104
+ if __name__ == "__main__":
105
+ main()
scripts/test-notes-pagination.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as dotenv from "dotenv";
2
+ import { SimpleYuqueLoader } from "../src/lib/yuque-service";
3
+
4
+ dotenv.config({ path: ".env.local" });
5
+ dotenv.config();
6
+
7
+ async function test() {
8
+ const token = process.env.YUQUE_TOKEN;
9
+ if (!token) {
10
+ console.error("缺少环境变量 YUQUE_TOKEN");
11
+ process.exit(1);
12
+ }
13
+ const loader = new SimpleYuqueLoader(token, "NOTES");
14
+
15
+ const limits = [30, 50];
16
+ for (const limit of limits) {
17
+ try {
18
+ const res = await loader.fetchAPI(`/notes?offset=0&limit=${limit}`);
19
+ const list = Array.isArray(res?.data?.notes) ? res.data.notes : [];
20
+ console.log(`请求 limit=${limit} -> 返回 ${list.length} 条`);
21
+ if (list.length > 0) {
22
+ console.log(`示例ID范围: ${list[0]?.id} ... ${list[list.length - 1]?.id}`);
23
+ }
24
+ } catch (e) {
25
+ console.error(`请求 limit=${limit} 失败:`, e instanceof Error ? e.message : String(e));
26
+ }
27
+ }
28
+ }
29
+
30
+ test().catch((e) => {
31
+ console.error("测试失败:", e);
32
+ process.exit(1);
33
+ });
src/app/api/backup/route.ts ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import Database from 'better-sqlite3';
5
+
6
+ export async function POST() {
7
+ try {
8
+ const cwd = process.cwd();
9
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
10
+ const backupDirName = '备份-语雀数据-JSON';
11
+ const backupDir = path.join(cwd, backupDirName);
12
+
13
+ if (!fs.existsSync(backupDir)) {
14
+ fs.mkdirSync(backupDir, { recursive: true });
15
+ } else {
16
+ // Clear existing files
17
+ const files = fs.readdirSync(backupDir);
18
+ for (const file of files) {
19
+ fs.unlinkSync(path.join(backupDir, file));
20
+ }
21
+ }
22
+
23
+ // Connect to the main database
24
+ const dbPath = path.join(cwd, 'rag-kb.db');
25
+ if (!fs.existsSync(dbPath)) {
26
+ return NextResponse.json(
27
+ { message: 'Main database (rag-kb.db) not found', success: false },
28
+ { status: 404 }
29
+ );
30
+ }
31
+
32
+ const db = new Database(dbPath, { readonly: true });
33
+
34
+ // Get all namespaces (knowledge bases)
35
+ // Try to get from knowledge_bases table first, if not, distinct from documents
36
+ let kbs: { namespace: string, name: string }[] = [];
37
+ try {
38
+ const rows = db.prepare('SELECT namespace, name FROM knowledge_bases').all() as { namespace: string, name: string }[];
39
+ kbs = rows;
40
+ } catch (e) {
41
+ console.warn('Could not read knowledge_bases table, falling back to documents', e);
42
+ const rows = db.prepare('SELECT DISTINCT namespace FROM documents WHERE namespace IS NOT NULL').all() as { namespace: string }[];
43
+ kbs = rows.map(r => ({ namespace: r.namespace, name: r.namespace }));
44
+ }
45
+
46
+ if (kbs.length === 0) {
47
+ return NextResponse.json(
48
+ { message: 'No knowledge bases found to backup', success: false },
49
+ { status: 404 }
50
+ );
51
+ }
52
+
53
+ const backupsCreated: string[] = [];
54
+
55
+ // Dataset root for reading content files
56
+ const hfDatasetRoot = process.env.HF_DATASET_ROOT || path.join(cwd, '..', 'hf_dataset_rag');
57
+
58
+ for (const kb of kbs) {
59
+ const ns = kb.namespace;
60
+ // Query documents for this namespace
61
+ const docs = db.prepare(`
62
+ SELECT title, slug, created_at, updated_at, tags
63
+ FROM documents
64
+ WHERE namespace = ?
65
+ `).all(ns) as { title: string, slug: string, created_at: number, updated_at: number, tags: string }[];
66
+
67
+ const filteredDocs = docs.filter(doc => {
68
+ if (!doc.tags) return true;
69
+ try {
70
+ const tags = JSON.parse(doc.tags);
71
+ if (Array.isArray(tags)) {
72
+ return !tags.includes('个人资料');
73
+ }
74
+ } catch (e) {
75
+ // If tags is not JSON, check as string (fallback)
76
+ return !doc.tags.includes('个人资料');
77
+ }
78
+ return true;
79
+ });
80
+
81
+ const exportData = filteredDocs.map(doc => {
82
+ // Try to read content from file
83
+ let content = '';
84
+ try {
85
+ // Construct path: hf_dataset_rag/files/namespace/slug.md
86
+ // Note: slug might contain subdirectories? usually slug is just filename base.
87
+ // Based on grep: files/lianmt/jm/ehzgn5-624997.md
88
+ // So structure is files/namespace/slug.md
89
+
90
+ // Handle namespace with slashes? e.g. lianmt/cq
91
+ // The grep showed: files/lianmt/jm/...
92
+ // So if ns is "lianmt/jm", then path is files/lianmt/jm/...
93
+
94
+ const filePath = path.join(hfDatasetRoot, 'files', ns, `${doc.slug}.md`);
95
+ if (fs.existsSync(filePath)) {
96
+ content = fs.readFileSync(filePath, 'utf8');
97
+ } else {
98
+ // Try looking for it without namespace structure if simple?
99
+ // But grep confirmed structure.
100
+ // content = `(File not found: ${filePath})`;
101
+ }
102
+ } catch (err) {
103
+ console.error(`Error reading file for ${doc.slug}:`, err);
104
+ }
105
+
106
+ return {
107
+ title: doc.title,
108
+ content: content,
109
+ created_at: new Date(doc.created_at).toISOString(),
110
+ updated_at: doc.updated_at ? new Date(doc.updated_at).toISOString() : null,
111
+ tags: doc.tags
112
+ };
113
+ });
114
+
115
+ // Create sanitized filename
116
+ // Use kb.name (Chinese name) for filename
117
+ const safeName = (kb.name || ns).replace(/[\/\\:]/g, '_');
118
+ const fileName = `${safeName}_${timestamp}.json`;
119
+ const filePath = path.join(backupDir, fileName);
120
+
121
+ fs.writeFileSync(filePath, JSON.stringify(exportData));
122
+ backupsCreated.push(fileName);
123
+ }
124
+
125
+ db.close();
126
+
127
+ return NextResponse.json({
128
+ message: `JSON export created successfully in folder: ${backupDirName}`,
129
+ files: backupsCreated,
130
+ success: true
131
+ });
132
+ } catch (error) {
133
+ console.error('Export failed:', error);
134
+ return NextResponse.json(
135
+ { message: 'Export failed', error: String(error), success: false },
136
+ { status: 500 }
137
+ );
138
+ }
139
+ }
src/app/api/chat/route.ts CHANGED
@@ -40,9 +40,30 @@ const google = createGoogleGenerativeAI({
40
  // Allow streaming responses up to 300 seconds
41
  export const maxDuration = 300;
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  export async function POST(req: Request) {
44
  try {
45
- const { messages, model, useRAG } = await req.json();
 
46
 
47
  // Log the incoming request details for debugging
48
  console.log(`[API] Received chat request. Model: ${model}, useRAG: ${useRAG}`);
@@ -50,6 +71,12 @@ export async function POST(req: Request) {
50
  // Get the last message to use as the query for RAG
51
  const lastMessage = messages[messages.length - 1];
52
  const query = lastMessage.content;
 
 
 
 
 
 
53
 
54
  // Mock Streaming Response for testing
55
  // Trigger if query is exactly 'mock-test' OR contains keywords for test generation
@@ -152,18 +179,31 @@ Here is a test quiz to verify the rendering:
152
  try {
153
  const docCount = db.prepare('SELECT COUNT(*) as count FROM documents').get() as { count: number };
154
  const totalWords = db.prepare('SELECT SUM(word_count) as total FROM documents').get() as { total: number };
155
- const kbs = db.prepare('SELECT name, namespace FROM knowledge_bases').all() as { name: string, namespace: string }[];
156
  const lastSync = db.prepare('SELECT MAX(synced_at) as last_sync FROM documents').get() as { last_sync: number };
157
 
158
- const kbList = kbs.map(k => `${k.name} (${k.namespace})`).join(', ');
 
 
 
 
 
 
 
 
 
 
 
 
159
  const lastSyncDate = lastSync.last_sync ? new Date(lastSync.last_sync).toLocaleString('zh-CN') : 'Never';
160
 
161
  kbStats = `
162
  Knowledge Base Statistics:
163
  - Total Documents: ${docCount.count}
164
  - Total Word Count: ${totalWords.total || 0}
165
- - Knowledge Bases: ${kbList}
166
  - Last Synced: ${lastSyncDate}
 
 
 
167
  `;
168
  } catch (e) {
169
  console.warn("Failed to fetch KB stats:", e);
@@ -179,8 +219,20 @@ Knowledge Base Statistics:
179
  const vectorStore = await getVectorStore();
180
 
181
  // Perform similarity search
182
- console.log(`[RAG] Searching for context: "${query.substring(0, 50)}..."`);
183
- const results = await vectorStore.similaritySearch(query, 5); // Retrieve top 5 chunks
 
 
 
 
 
 
 
 
 
 
 
 
184
 
185
  if (results.length > 0) {
186
  console.log(`[RAG] Found ${results.length} relevant context chunks.`);
@@ -200,21 +252,41 @@ Knowledge Base Statistics:
200
  // Construct the system prompt with the retrieved context
201
  let systemPrompt = "";
202
 
 
 
 
203
  if (useRAG !== false) {
204
- systemPrompt = `You are an intelligent knowledge base assistant.
205
-
206
- ${kbStats}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
 
208
- Context from the knowledge base:
209
- ${context}
210
-
211
- Instructions:
212
- 1. Answer the user's question based on the provided context if relevant.
213
- 2. If the user asks about the knowledge base itself (e.g., how many documents, statistics), use the "Knowledge Base Statistics" provided above.
214
- 3. If the context is empty or not relevant, use your general knowledge to answer the question helpfully.
215
- 4. You can engage in general conversation, creative writing, or coding tasks if requested.
216
- 5. Provide clear, accurate, and friendly responses.
217
- `;
 
218
  } else {
219
  systemPrompt = `You are a helpful AI assistant.
220
 
@@ -227,6 +299,11 @@ Knowledge Base Statistics:
227
  `;
228
  }
229
 
 
 
 
 
 
230
  // Define available models and fallback strategy
231
  // We prioritize the user-selected model, then fall back to others if it fails
232
 
 
40
  // Allow streaming responses up to 300 seconds
41
  export const maxDuration = 300;
42
 
43
+ const QUIZ_GENERATION_PROMPT = `请基于知识库内容生成 5 道单选题。**直接返回 JSON 代码块,不要废话。**
44
+
45
+ 要求:
46
+ 1. 结果必须封装在 \`\`\`quiz 代码块中。
47
+ 2. 选项数组 options 中只包含内容,不要 A/B/C/D 前缀。
48
+ 3. explanation 解析需简练(50字以内)。
49
+
50
+ 格式:
51
+ \`\`\`quiz
52
+ [
53
+ {
54
+ "id": 1,
55
+ "question": "...",
56
+ "options": ["A", "B", "C", "D"],
57
+ "correctAnswer": 0,
58
+ "explanation": "..."
59
+ }
60
+ ]
61
+ \`\`\``;
62
+
63
  export async function POST(req: Request) {
64
  try {
65
+ const body = await req.json();
66
+ const { messages, model, useRAG, fileInfo } = body;
67
 
68
  // Log the incoming request details for debugging
69
  console.log(`[API] Received chat request. Model: ${model}, useRAG: ${useRAG}`);
 
71
  // Get the last message to use as the query for RAG
72
  const lastMessage = messages[messages.length - 1];
73
  const query = lastMessage.content;
74
+
75
+ // For quiz generation, use a broader query to retrieve relevant context
76
+ let ragQuery = query;
77
+ if (query.trim() === '对话试题' || query.trim() === 'Generate Quiz') {
78
+ ragQuery = "summary 摘要 concept 概念 main point 核心观点";
79
+ }
80
 
81
  // Mock Streaming Response for testing
82
  // Trigger if query is exactly 'mock-test' OR contains keywords for test generation
 
179
  try {
180
  const docCount = db.prepare('SELECT COUNT(*) as count FROM documents').get() as { count: number };
181
  const totalWords = db.prepare('SELECT SUM(word_count) as total FROM documents').get() as { total: number };
 
182
  const lastSync = db.prepare('SELECT MAX(synced_at) as last_sync FROM documents').get() as { last_sync: number };
183
 
184
+ // Get per-KB stats
185
+ const kbStatsDetails = db.prepare(`
186
+ SELECT
187
+ kb.name,
188
+ kb.namespace,
189
+ COUNT(d.id) as doc_count,
190
+ SUM(d.word_count) as word_count
191
+ FROM knowledge_bases kb
192
+ LEFT JOIN documents d ON kb.namespace = d.namespace
193
+ GROUP BY kb.namespace
194
+ `).all() as { name: string, namespace: string, doc_count: number, word_count: number }[];
195
+
196
+ const kbList = kbStatsDetails.map(k => `- ${k.name} (${k.namespace}): ${k.doc_count} documents, ${k.word_count || 0} characters`).join('\n');
197
  const lastSyncDate = lastSync.last_sync ? new Date(lastSync.last_sync).toLocaleString('zh-CN') : 'Never';
198
 
199
  kbStats = `
200
  Knowledge Base Statistics:
201
  - Total Documents: ${docCount.count}
202
  - Total Word Count: ${totalWords.total || 0}
 
203
  - Last Synced: ${lastSyncDate}
204
+
205
+ Knowledge Base Distribution:
206
+ ${kbList}
207
  `;
208
  } catch (e) {
209
  console.warn("Failed to fetch KB stats:", e);
 
219
  const vectorStore = await getVectorStore();
220
 
221
  // Perform similarity search
222
+ // We fetch more results (k=20) and filter in memory to avoid HNSWLib filter crashes
223
+ console.log(`[RAG] Searching for context: "${ragQuery.substring(0, 50)}..."${fileInfo?.name ? ` (Raw search for file: ${fileInfo.name})` : ''}`);
224
+
225
+ // Retrieve more candidates if we need to filter by file
226
+ const searchK = fileInfo?.name ? 20 : 5;
227
+ let results = await vectorStore.similaritySearch(ragQuery, searchK);
228
+
229
+ // Post-filtering in memory
230
+ if (fileInfo?.name) {
231
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
232
+ results = results.filter((doc: any) => doc?.metadata?.source === fileInfo.name);
233
+ // Take top 5 after filtering
234
+ results = results.slice(0, 5);
235
+ }
236
 
237
  if (results.length > 0) {
238
  console.log(`[RAG] Found ${results.length} relevant context chunks.`);
 
252
  // Construct the system prompt with the retrieved context
253
  let systemPrompt = "";
254
 
255
+ // Check if we are in file-specific mode
256
+ const isFileMode = !!fileInfo?.name;
257
+
258
  if (useRAG !== false) {
259
+ if (isFileMode) {
260
+ // File-specific mode system prompt
261
+ systemPrompt = `You are a helpful assistant analyzing a specific file named "${fileInfo.name}".
262
+
263
+ Context from the file "${fileInfo.name}":
264
+ ${context ? context : "(No relevant content found in this file for the current query)"}
265
+
266
+ Instructions:
267
+ 1. Answer the user's question ONLY based on the provided context from the file "${fileInfo.name}".
268
+ 2. If the context is empty or does not contain the answer, explicitly state that you cannot find the information in the file "${fileInfo.name}".
269
+ 3. Do NOT use outside knowledge or information about other files/knowledge bases unless explicitly asked.
270
+ 4. Do NOT mention "Knowledge Base Statistics" or other documents.
271
+ 5. Provide clear, accurate responses based strictly on the file content.
272
+ `;
273
+ } else {
274
+ // General Knowledge Base mode system prompt
275
+ systemPrompt = `You are an intelligent knowledge base assistant.
276
+
277
+ ${kbStats}
278
 
279
+ Context from the knowledge base:
280
+ ${context}
281
+
282
+ Instructions:
283
+ 1. Answer the user's question based on the provided context if relevant.
284
+ 2. If the user asks about the knowledge base itself (e.g., how many documents, statistics), use the "Knowledge Base Statistics" provided above.
285
+ 3. If the context is empty or not relevant, use your general knowledge to answer the question helpfully.
286
+ 4. You can engage in general conversation, creative writing, or coding tasks if requested.
287
+ 5. Provide clear, accurate, and friendly responses.
288
+ `;
289
+ }
290
  } else {
291
  systemPrompt = `You are a helpful AI assistant.
292
 
 
299
  `;
300
  }
301
 
302
+ // Check for quiz generation request
303
+ if (query.trim() === '对话试题' || query.trim() === 'Generate Quiz') {
304
+ systemPrompt += `\n\nIMPORTANT INSTRUCTION: ${QUIZ_GENERATION_PROMPT}`;
305
+ }
306
+
307
  // Define available models and fallback strategy
308
  // We prioritize the user-selected model, then fall back to others if it fails
309
 
src/app/api/documents/route.ts CHANGED
@@ -1,7 +1,29 @@
1
 
2
  import { NextRequest, NextResponse } from 'next/server';
3
  import db from '@/lib/db';
4
- import { startYuqueSync, getSyncStatus } from '@/lib/yuque-service';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  export async function GET(req: NextRequest) {
7
  try {
@@ -11,11 +33,60 @@ export async function GET(req: NextRequest) {
11
 
12
  // Select all columns EXCEPT content_preview to reduce payload size, but include length for stats
13
  // Use word_count if available (more accurate), otherwise fallback to 0 (will be updated on next sync)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  const docs = db.prepare(`
15
- SELECT id, yuque_id, title, slug, url, namespace, synced_at, parent_uuid, uuid, sort_order, word_count as content_length, updated_at
16
  FROM documents
17
  ORDER BY namespace ASC, sort_order ASC, synced_at DESC
18
- `).all();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  const kbs = db.prepare(`
21
  SELECT * FROM knowledge_bases ORDER BY synced_at DESC
@@ -27,7 +98,7 @@ export async function GET(req: NextRequest) {
27
  // If database is empty or connection fails, return static demo data for HuggingFace/Demo purposes
28
  // We check docs.length === 0 because even if KBs exist (e.g. from partial sync), if there are no documents,
29
  // we should show demo data to provide a better initial experience (especially for HF deployments where sync might fail).
30
- if (isDemoMode || docs.length === 0) {
31
  return NextResponse.json({
32
  documents: [
33
  {
@@ -95,8 +166,13 @@ export async function GET(req: NextRequest) {
95
  });
96
  }
97
 
 
 
 
 
 
98
  return NextResponse.json({
99
- documents: docs,
100
  knowledgeBases: kbs,
101
  status: status,
102
  isDemo: false
@@ -172,24 +248,24 @@ export async function GET(req: NextRequest) {
172
  }
173
  }
174
 
175
- export async function POST() {
176
  try {
177
  const status = getSyncStatus();
178
 
179
- if (status.status === 'running') {
180
- return NextResponse.json({ message: 'Sync already in progress', status });
181
  }
182
 
183
- // Start background sync
184
- startYuqueSync();
185
 
186
  return NextResponse.json({
187
- message: 'Sync started',
188
  status: getSyncStatus()
189
  });
190
 
191
  } catch (error) {
192
- console.error('Failed to start sync:', error);
193
- return NextResponse.json({ error: 'Failed to start sync' }, { status: 500 });
194
  }
195
  }
 
1
 
2
  import { NextRequest, NextResponse } from 'next/server';
3
  import db from '@/lib/db';
4
+ import { startYuqueSync, getSyncStatus, stopYuqueSync, backfillNoteTags } from '@/lib/yuque-service';
5
+
6
+ export async function POST() {
7
+ try {
8
+ const status = getSyncStatus();
9
+
10
+ if (status.status === 'running') {
11
+ return NextResponse.json({ message: 'Sync already running', status });
12
+ }
13
+
14
+ // Start background sync
15
+ startYuqueSync();
16
+
17
+ return NextResponse.json({
18
+ message: 'Sync started',
19
+ status: getSyncStatus()
20
+ });
21
+
22
+ } catch (error) {
23
+ console.error('Failed to start sync:', error);
24
+ return NextResponse.json({ error: 'Failed to start sync' }, { status: 500 });
25
+ }
26
+ }
27
 
28
  export async function GET(req: NextRequest) {
29
  try {
 
33
 
34
  // Select all columns EXCEPT content_preview to reduce payload size, but include length for stats
35
  // Use word_count if available (more accurate), otherwise fallback to 0 (will be updated on next sync)
36
+ type DocRow = {
37
+ id: string;
38
+ yuque_id: number;
39
+ title: string;
40
+ slug: string;
41
+ url: string;
42
+ namespace: string;
43
+ synced_at: number;
44
+ parent_uuid?: string | null;
45
+ uuid?: string | null;
46
+ sort_order?: number | null;
47
+ content_length?: number | null;
48
+ updated_at?: number | null;
49
+ tags?: string | string[] | null;
50
+ };
51
+
52
  const docs = db.prepare(`
53
+ SELECT id, yuque_id, title, slug, url, namespace, synced_at, parent_uuid, uuid, sort_order, word_count as content_length, updated_at, tags
54
  FROM documents
55
  ORDER BY namespace ASC, sort_order ASC, synced_at DESC
56
+ `).all() as DocRow[];
57
+
58
+ const normalizedDocs = docs.map((d: DocRow) => {
59
+ let tags: string[] = [];
60
+ if (typeof d.tags === 'string' && d.tags.length > 0) {
61
+ try {
62
+ const parsed = JSON.parse(d.tags);
63
+ if (Array.isArray(parsed)) {
64
+ tags = parsed.map((x: unknown) => {
65
+ if (typeof x === 'string') return x;
66
+ if (typeof x === 'object' && x !== null) {
67
+ const obj = x as Record<string, unknown>;
68
+ const val = obj.title || obj.name;
69
+ return typeof val === 'string' ? val : '';
70
+ }
71
+ return '';
72
+ }).filter((x: string) => x.length > 0);
73
+ }
74
+ } catch {
75
+ tags = [];
76
+ }
77
+ } else if (Array.isArray(d.tags)) {
78
+ tags = d.tags.map((x: unknown) => {
79
+ if (typeof x === 'string') return x;
80
+ if (typeof x === 'object' && x !== null) {
81
+ const obj = x as Record<string, unknown>;
82
+ const val = obj.title || obj.name;
83
+ return typeof val === 'string' ? val : '';
84
+ }
85
+ return '';
86
+ }).filter((x: string) => x.length > 0);
87
+ }
88
+ return { ...d, tags };
89
+ });
90
 
91
  const kbs = db.prepare(`
92
  SELECT * FROM knowledge_bases ORDER BY synced_at DESC
 
98
  // If database is empty or connection fails, return static demo data for HuggingFace/Demo purposes
99
  // We check docs.length === 0 because even if KBs exist (e.g. from partial sync), if there are no documents,
100
  // we should show demo data to provide a better initial experience (especially for HF deployments where sync might fail).
101
+ if (isDemoMode) {
102
  return NextResponse.json({
103
  documents: [
104
  {
 
166
  });
167
  }
168
 
169
+ const notesDocs = normalizedDocs.filter((d) => d.namespace === 'NOTES');
170
+ const hasNoteTags = notesDocs.some((d) => d.tags && d.tags.length > 0);
171
+ if (!hasNoteTags && process.env.YUQUE_TOKEN) {
172
+ backfillNoteTags(200, 3).catch(() => {});
173
+ }
174
  return NextResponse.json({
175
+ documents: normalizedDocs,
176
  knowledgeBases: kbs,
177
  status: status,
178
  isDemo: false
 
248
  }
249
  }
250
 
251
+ export async function DELETE() {
252
  try {
253
  const status = getSyncStatus();
254
 
255
+ if (status.status !== 'running') {
256
+ return NextResponse.json({ message: 'Sync not running', status });
257
  }
258
 
259
+ // Stop background sync
260
+ stopYuqueSync();
261
 
262
  return NextResponse.json({
263
+ message: 'Sync stop requested',
264
  status: getSyncStatus()
265
  });
266
 
267
  } catch (error) {
268
+ console.error('Failed to stop sync:', error);
269
+ return NextResponse.json({ error: 'Failed to stop sync' }, { status: 500 });
270
  }
271
  }
src/app/api/health/route.ts ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import db from '@/lib/db';
3
+ import { indexExists } from '@/lib/vector-store';
4
+
5
+ export async function GET() {
6
+ const startedAt = Date.now();
7
+ try {
8
+ const docCountRow = db.prepare('SELECT COUNT(*) as count FROM documents').get() as { count: number };
9
+ const kbCountRow = db.prepare('SELECT COUNT(*) as count FROM knowledge_bases').get() as { count: number };
10
+
11
+ return NextResponse.json({
12
+ ok: true,
13
+ db: {
14
+ ok: true,
15
+ documents: docCountRow.count,
16
+ knowledgeBases: kbCountRow.count,
17
+ },
18
+ vectorStore: {
19
+ ok: indexExists(),
20
+ },
21
+ meta: {
22
+ durationMs: Date.now() - startedAt,
23
+ timestamp: new Date().toISOString(),
24
+ uptimeSeconds: Math.floor(process.uptime()),
25
+ },
26
+ });
27
+ } catch (error) {
28
+ console.error('[Health] Failed:', error);
29
+ return NextResponse.json(
30
+ {
31
+ ok: false,
32
+ db: { ok: false },
33
+ vectorStore: { ok: indexExists() },
34
+ meta: {
35
+ durationMs: Date.now() - startedAt,
36
+ timestamp: new Date().toISOString(),
37
+ uptimeSeconds: Math.floor(process.uptime()),
38
+ },
39
+ },
40
+ { status: 500 }
41
+ );
42
+ }
43
+ }
src/app/api/history/sessions/route.ts CHANGED
@@ -65,10 +65,10 @@ export async function GET(req: NextRequest) {
65
  // POST: Create a new session
66
  export async function POST(req: NextRequest) {
67
  try {
68
- const { id, title, createdAt } = await req.json();
69
 
70
- const stmt = db.prepare('INSERT INTO sessions (id, title, created_at) VALUES (?, ?, ?)');
71
- stmt.run(id, title, createdAt);
72
 
73
  return NextResponse.json({ success: true });
74
  } catch (error) {
 
65
  // POST: Create a new session
66
  export async function POST(req: NextRequest) {
67
  try {
68
+ const { id, title, createdAt, type = 'chat' } = await req.json();
69
 
70
+ const stmt = db.prepare('INSERT INTO sessions (id, title, created_at, type) VALUES (?, ?, ?, ?)');
71
+ stmt.run(id, title, createdAt, type);
72
 
73
  return NextResponse.json({ success: true });
74
  } catch (error) {
src/app/api/stats/route.ts ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import { NextRequest, NextResponse } from 'next/server';
3
+ import db from '@/lib/db';
4
+
5
+ export const dynamic = 'force-dynamic';
6
+
7
+ export async function GET(req: NextRequest) {
8
+ try {
9
+ const { searchParams } = new URL(req.url);
10
+ const yearParam = searchParams.get('year');
11
+
12
+ // 1. Get available years (Union of created and updated years)
13
+ const yearsResult = db.prepare(`
14
+ SELECT DISTINCT strftime('%Y', datetime(ts / 1000, 'unixepoch', 'localtime')) as year
15
+ FROM (
16
+ SELECT updated_at as ts FROM documents WHERE yuque_id != 0 AND updated_at IS NOT NULL
17
+ UNION
18
+ SELECT created_at as ts FROM documents WHERE yuque_id != 0 AND created_at IS NOT NULL
19
+ )
20
+ ORDER BY year DESC
21
+ `).all() as { year: string }[];
22
+
23
+ const years = yearsResult.map(y => parseInt(y.year)).filter(y => !isNaN(y));
24
+
25
+ // 2. Prepare filter
26
+ let unionTimeFilterClause = '';
27
+ let createdTimeFilterClause = '';
28
+ const paramsUnion: (number | string)[] = [];
29
+ const paramsCreated: (number | string)[] = [];
30
+
31
+ let startTime = 0;
32
+ let endTime = 0;
33
+ let isYearFilter = false;
34
+
35
+ if (yearParam && yearParam !== 'all') {
36
+ const year = parseInt(yearParam);
37
+ if (!isNaN(year)) {
38
+ startTime = new Date(year, 0, 1).getTime();
39
+ endTime = new Date(year + 1, 0, 1).getTime();
40
+ isYearFilter = true;
41
+
42
+ // Union filter for activity heatmap (Created OR Updated)
43
+ unionTimeFilterClause = `AND (
44
+ (updated_at >= ? AND updated_at < ?)
45
+ OR
46
+ (created_at >= ? AND created_at < ?)
47
+ )`;
48
+ paramsUnion.push(startTime, endTime, startTime, endTime);
49
+ // Created-only filter for annual totals
50
+ createdTimeFilterClause = `AND (created_at >= ? AND created_at < ?)`;
51
+ paramsCreated.push(startTime, endTime);
52
+ }
53
+ }
54
+
55
+ // 3. Get Totals
56
+ // Get all-time total documents count for context
57
+ // If specific year selected, count accumulated docs up to the end of that year
58
+ let allTimeQuery = `
59
+ SELECT COUNT(*) as count FROM documents
60
+ WHERE yuque_id != 0 AND (slug IS NULL OR slug NOT LIKE 'dir-%')
61
+ `;
62
+ const allTimeParams: (number | string)[] = [];
63
+
64
+ if (isYearFilter) {
65
+ allTimeQuery += ` AND created_at < ?`;
66
+ allTimeParams.push(endTime);
67
+ }
68
+
69
+ const allTimeStats = db.prepare(allTimeQuery).get(...allTimeParams) as { count: number };
70
+
71
+ // 定义:年度字数仅统计「小记」在该年份内创建或更新过的内容的字数之和(去重)
72
+ // 这样更贴近“今年写了多少字”的直觉,不把历史长文的字数一次性算入当年。
73
+ let totalStats: { count: number; words: number } = { count: 0, words: 0 };
74
+ let docsStats: { count: number; words: number } = { count: 0, words: 0 };
75
+ let notesStats: { count: number; words: number } = { count: 0, words: 0 };
76
+ if (isYearFilter) {
77
+ // 年份视图下,“新增文档数/小记数”遵循“新增”语义:以 created_at 计算
78
+ const docsCreated = db.prepare(`
79
+ SELECT COUNT(*) as count, SUM(word_count) as words
80
+ FROM documents
81
+ WHERE yuque_id != 0 AND namespace != 'NOTES' AND (slug IS NULL OR slug NOT LIKE 'dir-%') ${createdTimeFilterClause}
82
+ `).get(...paramsCreated) as { count: number, words: number };
83
+
84
+ // 小记按“创建或更新”的活跃口径,满足“小记数应最多”的运营预期
85
+ // 小记通常短小,且更新代表补充,适合算入年度产出
86
+ const notesActive = db.prepare(`
87
+ SELECT COUNT(DISTINCT id) as count, SUM(word_count) as words
88
+ FROM documents
89
+ WHERE yuque_id != 0 AND namespace = 'NOTES' AND (slug IS NULL OR slug NOT LIKE 'dir-%') ${unionTimeFilterClause}
90
+ `).get(...paramsUnion) as { count: number, words: number };
91
+
92
+ docsStats = docsCreated;
93
+ notesStats = notesActive;
94
+
95
+ // 年度字数:
96
+ // - 小记:统计活跃字数(创建或更新)
97
+ // - 其他:统计新增字数(仅创建),避免长文修改导致字数统计虚高
98
+ // 注意:这里仍然使用 created_at。因为语雀 API 的 first_published_at 经常与 created_at 非常接近(仅差几秒),
99
+ // 无法有效区分“搬运”和“原创”。搬运的文档在语雀系统中确实被视为“在搬运时刻创建”。
100
+ //
101
+ // 针对“过往”和“旧码”两个库在 2025 年有大量“新建”记录(实为搬运)的情况,
102
+ // 目前最稳妥的逻辑依然是:只算 Created,不算 Updated。
103
+ // 这样至少剔除了 1500 万字的“旧文修改”水分。
104
+ // 剩下的 1400 万字“搬运/整理”数据,客观上确实是 2025 年“进入”语雀系统的,
105
+ // 程序无法区分“我 2025 年写的 3 万字”和“我 2025 年搬运进来的 3 万字”。
106
+ totalStats = {
107
+ count: (docsStats.count || 0) + (notesStats.count || 0),
108
+ words: (docsStats.words || 0) + (notesStats.words || 0),
109
+ };
110
+ } else {
111
+ // 所有年份视图:总字数为所有文档的字数之和
112
+ const allWords = db.prepare(`
113
+ SELECT SUM(word_count) as words FROM documents
114
+ WHERE yuque_id != 0 AND (slug IS NULL OR slug NOT LIKE 'dir-%')
115
+ `).get() as { words: number };
116
+ const docsAll = db.prepare(`
117
+ SELECT COUNT(*) as count, SUM(word_count) as words FROM documents
118
+ WHERE yuque_id != 0 AND namespace != 'NOTES' AND (slug IS NULL OR slug NOT LIKE 'dir-%')
119
+ `).get() as { count: number, words: number };
120
+ const notesAll = db.prepare(`
121
+ SELECT COUNT(*) as count, SUM(word_count) as words FROM documents
122
+ WHERE yuque_id != 0 AND namespace = 'NOTES' AND (slug IS NULL OR slug NOT LIKE 'dir-%')
123
+ `).get() as { count: number, words: number };
124
+ docsStats = docsAll;
125
+ notesStats = notesAll;
126
+ totalStats = {
127
+ count: (docsStats.count || 0) + (notesStats.count || 0),
128
+ words: allWords.words || 0
129
+ };
130
+ }
131
+
132
+ // 4. Get Per-KB Stats
133
+ // 年份筛选下:小记使用“创建或更新”,其他使用“创建”
134
+ const kbStats = isYearFilter
135
+ ? (db.prepare(`
136
+ SELECT
137
+ namespace,
138
+ SUM(CASE
139
+ WHEN namespace = 'NOTES' THEN 1
140
+ WHEN created_at >= ? AND created_at < ? THEN 1
141
+ ELSE 0
142
+ END) as count,
143
+ SUM(CASE
144
+ WHEN namespace = 'NOTES' THEN word_count
145
+ WHEN created_at >= ? AND created_at < ? THEN word_count
146
+ ELSE 0
147
+ END) as words
148
+ FROM documents
149
+ WHERE yuque_id != 0
150
+ AND (slug IS NULL OR slug NOT LIKE 'dir-%')
151
+ ${unionTimeFilterClause}
152
+ GROUP BY namespace
153
+ `).all(startTime, endTime, startTime, endTime, ...paramsUnion) as { namespace: string, count: number, words: number }[])
154
+ : (db.prepare(`
155
+ SELECT
156
+ namespace,
157
+ COUNT(*) as count,
158
+ SUM(word_count) as words
159
+ FROM documents
160
+ WHERE yuque_id != 0 AND (slug IS NULL OR slug NOT LIKE 'dir-%')
161
+ GROUP BY namespace
162
+ `).all() as { namespace: string, count: number, words: number }[]);
163
+
164
+ // Get KB Names
165
+ const kbs = db.prepare('SELECT namespace, name FROM knowledge_bases').all() as { namespace: string, name: string }[];
166
+ const kbNameMap = new Map(kbs.map(k => [k.namespace, k.name]));
167
+
168
+ const kbStatsWithNames = kbStats.map(s => ({
169
+ ...s,
170
+ name: kbNameMap.get(s.namespace) || s.namespace
171
+ }));
172
+
173
+ // 5. Get Heatmap Data (Daily counts of Creation AND Update events)
174
+ // We want to show activity dots for both creation and updates.
175
+
176
+ let heatmapQuery = '';
177
+ const heatmapParams: (number | string)[] = [];
178
+
179
+ if (isYearFilter) {
180
+ // Filtered by year,排除目录节点
181
+ heatmapQuery = `
182
+ SELECT date, COUNT(*) as count FROM (
183
+ SELECT strftime('%Y-%m-%d', datetime(updated_at / 1000, 'unixepoch', 'localtime')) as date
184
+ FROM documents
185
+ WHERE yuque_id != 0 AND updated_at >= ? AND updated_at < ? AND (slug IS NULL OR slug NOT LIKE 'dir-%')
186
+
187
+ UNION ALL
188
+
189
+ SELECT strftime('%Y-%m-%d', datetime(created_at / 1000, 'unixepoch', 'localtime')) as date
190
+ FROM documents
191
+ WHERE yuque_id != 0 AND created_at >= ? AND created_at < ? AND (slug IS NULL OR slug NOT LIKE 'dir-%')
192
+ )
193
+ GROUP BY date
194
+ `;
195
+ heatmapParams.push(startTime, endTime, startTime, endTime);
196
+ } else {
197
+ // All years - Union all events,排除目录节点
198
+ heatmapQuery = `
199
+ SELECT date, COUNT(*) as count FROM (
200
+ SELECT strftime('%Y-%m-%d', datetime(updated_at / 1000, 'unixepoch', 'localtime')) as date
201
+ FROM documents
202
+ WHERE yuque_id != 0 AND updated_at IS NOT NULL AND (slug IS NULL OR slug NOT LIKE 'dir-%')
203
+
204
+ UNION ALL
205
+
206
+ SELECT strftime('%Y-%m-%d', datetime(created_at / 1000, 'unixepoch', 'localtime')) as date
207
+ FROM documents
208
+ WHERE yuque_id != 0 AND created_at IS NOT NULL AND (slug IS NULL OR slug NOT LIKE 'dir-%')
209
+ )
210
+ GROUP BY date
211
+ `;
212
+ }
213
+
214
+ const heatmapData = db.prepare(heatmapQuery).all(...heatmapParams) as { date: string, count: number }[];
215
+
216
+ // 6. Annual Stats (For the bottom table verification)
217
+ // Strictly group by Created At to ensure Sum of Parts == Whole
218
+ const annualStats = db.prepare(`
219
+ SELECT
220
+ strftime('%Y', datetime(COALESCE(created_at, updated_at) / 1000, 'unixepoch', 'localtime')) as year,
221
+ COUNT(*) as count,
222
+ SUM(CASE WHEN namespace = 'NOTES' THEN 1 ELSE 0 END) as notes_count,
223
+ SUM(word_count) as words
224
+ FROM documents
225
+ WHERE yuque_id != 0 AND (slug IS NULL OR slug NOT LIKE 'dir-%')
226
+ GROUP BY year
227
+ ORDER BY year DESC
228
+ `).all() as { year: string; count: number; notes_count: number; words: number }[];
229
+
230
+ return NextResponse.json({
231
+ years,
232
+ stats: {
233
+ totalDocs: totalStats.count || 0,
234
+ totalWords: totalStats.words || 0,
235
+ allTimeDocs: allTimeStats.count,
236
+ docsCount: docsStats.count || 0,
237
+ notesCount: notesStats.count || 0,
238
+ kbStats: kbStatsWithNames,
239
+ heatmap: heatmapData,
240
+ annualStats // Add this field
241
+ }
242
+ });
243
+
244
+ } catch (error) {
245
+ console.error('Failed to fetch stats:', error);
246
+ return NextResponse.json({ error: 'Failed to fetch statistics' }, { status: 500 });
247
+ }
248
+ }
src/app/knowledge/page.tsx CHANGED
@@ -3,7 +3,7 @@
3
 
4
  import { useState, useEffect, useMemo, Suspense, ReactNode, useRef } from 'react';
5
  import { useLanguage } from '@/contexts/LanguageContext';
6
- import { ArrowLeft, RefreshCw, Search, Database, ExternalLink, ChevronRight, ChevronDown, Home, Palette, List } from 'lucide-react';
7
  import ReactMarkdown from 'react-markdown';
8
  import rehypeRaw from 'rehype-raw';
9
  import remarkGfm from 'remark-gfm';
@@ -129,6 +129,7 @@ interface Document {
129
  sort_order?: number;
130
  content_length?: number;
131
  updated_at?: number;
 
132
  }
133
 
134
  interface TreeNode {
@@ -178,17 +179,26 @@ const TreeNodeView = ({
178
  node,
179
  level = 0,
180
  onSelect,
181
- selectedUuid
 
182
  }: {
183
  node: TreeNode,
184
  level?: number,
185
  onSelect: (node: TreeNode) => void,
186
- selectedUuid?: string
 
187
  }) => {
188
  const [isOpen, setIsOpen] = useState(false);
189
  const hasChildren = node.children.length > 0;
190
  const isSelected = node.doc.uuid === selectedUuid;
191
 
 
 
 
 
 
 
 
192
  // Use refs to track mounted state and last processed selection
193
  // This prevents auto-expansion when data refreshes but selection hasn't changed
194
  const isMounted = useRef(false);
@@ -252,9 +262,18 @@ const TreeNodeView = ({
252
  {isOpen ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
253
  </div>
254
 
255
- <div className="flex-1 flex items-center gap-2 min-w-0">
256
- <span className="truncate">{node.doc.title}</span>
257
  </div>
 
 
 
 
 
 
 
 
 
258
  </div>
259
 
260
  {isOpen && hasChildren && (
@@ -266,6 +285,7 @@ const TreeNodeView = ({
266
  level={level + 1}
267
  onSelect={onSelect}
268
  selectedUuid={selectedUuid}
 
269
  />
270
  ))}
271
  </div>
@@ -318,7 +338,40 @@ function KnowledgePageContent() {
318
  // State initialization flag
319
  const [isInitialized, setIsInitialized] = useState(false);
320
  const isManualNav = useRef(false);
 
 
 
321
  const [treeVersion, setTreeVersion] = useState(0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
 
323
  const handleCollapseAll = (e: React.MouseEvent) => {
324
  e.stopPropagation();
@@ -340,17 +393,22 @@ function KnowledgePageContent() {
340
  setIsDemoMode(data.isDemo);
341
  }
342
 
343
- // Initial KB selection logic
344
- if (data.knowledgeBases && data.knowledgeBases.length > 0) {
345
- // Check URL first
346
- const kbParam = searchParams.get('kb');
347
- // Verify if the kbParam actually exists in the fetched knowledge bases
348
- const kbExists = kbParam && data.knowledgeBases.some((k: KnowledgeBase) => k.namespace === kbParam);
349
-
350
- if (kbExists) {
351
- if (!currentKbNamespace) setCurrentKbNamespace(kbParam);
352
- } else {
353
- // If no KB selected, or selected KB doesn't exist (e.g. switching modes), select the first one
 
 
 
 
 
354
  setCurrentKbNamespace(data.knowledgeBases[0].namespace);
355
  }
356
  }
@@ -368,6 +426,65 @@ function KnowledgePageContent() {
368
 
369
  const treeRoots = useMemo(() => buildTree(filteredDocuments), [filteredDocuments]);
370
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
  // Restore state from URL on load and when documents are ready
372
  useEffect(() => {
373
  if (documents.length > 0 && !isInitialized) {
@@ -480,6 +597,16 @@ function KnowledgePageContent() {
480
  return () => clearInterval(interval);
481
  }, [syncStatus.status, searchParams.get('demo')]);
482
 
 
 
 
 
 
 
 
 
 
 
483
  const handleSync = async () => {
484
  try {
485
  await fetch('/api/documents', { method: 'POST' });
@@ -517,28 +644,58 @@ function KnowledgePageContent() {
517
  </div>
518
  )}
519
  <button
520
- onClick={handleSync}
521
- disabled={syncStatus.status === 'running'}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
522
  className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-md font-medium transition-all
523
  ${syncStatus.status === 'running'
524
- ? 'text-gray-400 cursor-not-allowed'
525
  : 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'
526
  }`}
527
  >
528
- <RefreshCw className={`w-4 h-4 ${syncStatus.status === 'running' ? 'animate-spin' : ''}`} />
529
- {syncStatus.status === 'running' ? '同步中' : '同步'}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
530
  </button>
531
  </div>
532
  </div>
533
 
534
  {/* Sync Progress Bar (Slim) and Status Message */}
535
- {syncStatus.status === 'running' && (
536
  <div className="w-full border-t border-gray-100 dark:border-gray-800">
537
- <div className="px-4 py-2 bg-primary-50/50 dark:bg-primary-900/10">
538
- <div className="flex items-center justify-center text-xs text-primary-700 dark:text-primary-300 mb-1.5 gap-2">
539
  <span className="relative flex h-2 w-2">
540
- <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary-400 opacity-75"></span>
541
- <span className="relative inline-flex rounded-full h-2 w-2 bg-primary-500"></span>
542
  </span>
543
  <span className="font-medium">{syncStatus.message || '正在同步...'}</span>
544
  <span className="mx-1 opacity-50">|</span>
@@ -549,7 +706,7 @@ function KnowledgePageContent() {
549
 
550
  <div className="h-2 w-full bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
551
  <div
552
- className="h-full bg-primary-600 transition-all duration-500 ease-out rounded-full"
553
  style={{ width: `${(syncStatus.processed / (syncStatus.total || 1)) * 100}%` }}
554
  />
555
  </div>
@@ -561,7 +718,7 @@ function KnowledgePageContent() {
561
  {/* Main Content Area */}
562
  <div className="flex flex-1 overflow-hidden">
563
  {/* Sidebar */}
564
- <div className="w-64 border-r border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-900/50 flex flex-col overflow-hidden shrink-0">
565
  <div className="px-3 pt-3 pb-1">
566
  {/* Knowledge Base Switcher */}
567
  <div className="relative mb-2 px-3">
@@ -578,7 +735,40 @@ function KnowledgePageContent() {
578
  }}
579
  >
580
  <ChevronDown className="w-4 h-4 text-gray-500" />
581
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
582
  </div>
583
 
584
  {isKbDropdownOpen && (
@@ -655,12 +845,22 @@ function KnowledgePageContent() {
655
  <span className="font-medium">{t('outline')}</span>
656
  </div>
657
  </div>
658
- <div className="flex-1 overflow-y-auto px-3 pb-4 scrollbar-thin">
 
 
 
 
 
 
 
 
 
 
659
  {isLoading ? (
660
  <div className="p-4 text-center text-sm text-gray-500">{t('loading')}</div>
661
  ) : (
662
  <div className="flex flex-col gap-0.5">
663
- {treeRoots.map(node => (
664
  <TreeNodeView
665
  key={`${node.doc.uuid}-${treeVersion}`}
666
  node={node}
@@ -668,13 +868,39 @@ function KnowledgePageContent() {
668
  selectedUuid={selectedNode?.doc.uuid}
669
  />
670
  ))}
 
 
 
 
 
671
  </div>
672
  )}
673
  </div>
674
  </div>
675
 
676
  {/* Right Panel */}
677
- <div className="flex-1 overflow-y-auto bg-white dark:bg-gray-900 p-8">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
678
  {searchTerm ? (
679
  <div className="max-w-4xl mx-auto">
680
  <h2 className="text-xl font-bold mb-6">搜索结果: &quot;{searchTerm}&quot;</h2>
@@ -715,13 +941,15 @@ function KnowledgePageContent() {
715
  <div className="flex-1 min-w-0 max-w-3xl">
716
  {/* Breadcrumb / Header */}
717
  <div className="mb-8 pb-6 border-b border-gray-100 dark:border-gray-800">
718
- <div className="flex items-center gap-3 mb-10">
719
- <div>
720
- <h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100">
721
- {selectedNode.doc.title}
722
- </h1>
 
 
723
  </div>
724
- </div>
725
 
726
  {/* Content */}
727
  {isLoadingContent ? (
@@ -750,6 +978,19 @@ function KnowledgePageContent() {
750
  <ExternalLink className="w-3 h-3" /> 语雀链接
751
  </a>
752
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
753
  </div>
754
  </>
755
  ) : (
@@ -796,12 +1037,125 @@ function KnowledgePageContent() {
796
  </div>
797
  ) : (
798
  /* Home View */
799
- <div className="max-w-4xl mx-auto pt-2">
800
  <div className="mb-8">
801
- <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-3">
802
- {knowledgeBases.find(kb => kb.namespace === currentKbNamespace)?.name || '知识库'}
803
- </h1>
804
- <div className="flex items-center gap-4 text-sm text-gray-500">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
805
  <span>{filteredDocuments.filter(d => d.yuque_id !== 0).length} {t('documents')}</span>
806
  <span className="text-gray-300 dark:text-gray-700">|</span>
807
  <span>{(filteredDocuments.filter(d => d.yuque_id !== 0).reduce((acc, d) => acc + (d.content_length || 0), 0) / 10000).toFixed(1)} {language === 'zh' ? '万字' : '0k chars'}</span>
@@ -809,16 +1163,31 @@ function KnowledgePageContent() {
809
  </div>
810
 
811
  <div className="flex flex-col gap-0.5 mt-6">
812
- {treeRoots.map(node => (
813
  <TreeNodeView
814
  key={`${node.doc.uuid}-${treeVersion}`}
815
  node={node}
816
  onSelect={setSelectedNode}
 
817
  />
818
  ))}
 
 
 
 
 
819
  </div>
820
  </div>
821
  )}
 
 
 
 
 
 
 
 
 
822
  </div>
823
  </div>
824
  </div>
 
3
 
4
  import { useState, useEffect, useMemo, Suspense, ReactNode, useRef } from 'react';
5
  import { useLanguage } from '@/contexts/LanguageContext';
6
+ import { ArrowLeft, RefreshCw, Search, Database, ExternalLink, ChevronRight, ChevronDown, Home, List, Square, ArrowUp, MoreVertical, BarChart2, Save, Tag, X } from 'lucide-react';
7
  import ReactMarkdown from 'react-markdown';
8
  import rehypeRaw from 'rehype-raw';
9
  import remarkGfm from 'remark-gfm';
 
129
  sort_order?: number;
130
  content_length?: number;
131
  updated_at?: number;
132
+ tags?: string[];
133
  }
134
 
135
  interface TreeNode {
 
179
  node,
180
  level = 0,
181
  onSelect,
182
+ selectedUuid,
183
+ variant
184
  }: {
185
  node: TreeNode,
186
  level?: number,
187
  onSelect: (node: TreeNode) => void,
188
+ selectedUuid?: string,
189
+ variant?: 'sidebar' | 'main'
190
  }) => {
191
  const [isOpen, setIsOpen] = useState(false);
192
  const hasChildren = node.children.length > 0;
193
  const isSelected = node.doc.uuid === selectedUuid;
194
 
195
+ // Helper to format date
196
+ const formatDate = (timestamp?: number) => {
197
+ if (!timestamp) return '';
198
+ const date = new Date(timestamp);
199
+ return `${date.getFullYear()}-${(date.getMonth()+1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
200
+ };
201
+
202
  // Use refs to track mounted state and last processed selection
203
  // This prevents auto-expansion when data refreshes but selection hasn't changed
204
  const isMounted = useRef(false);
 
262
  {isOpen ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
263
  </div>
264
 
265
+ <div className={`flex items-center gap-2 min-w-0 ${variant === 'main' ? '' : 'flex-1'}`}>
266
+ <span className={variant === 'main' ? 'truncate text-sm/relaxed' : 'truncate'} title={node.doc.title}>{node.doc.title}</span>
267
  </div>
268
+
269
+ {variant === 'main' && node.doc.yuque_id !== 0 && (
270
+ <>
271
+ <div className="flex-1 min-w-[1rem] mx-2 border-b border-dotted border-gray-300 dark:border-gray-700 h-[1px] relative top-[2px] opacity-50" />
272
+ <span className="text-xs text-gray-400 shrink-0 font-mono">
273
+ {formatDate(node.doc.updated_at || node.doc.synced_at)}
274
+ </span>
275
+ </>
276
+ )}
277
  </div>
278
 
279
  {isOpen && hasChildren && (
 
285
  level={level + 1}
286
  onSelect={onSelect}
287
  selectedUuid={selectedUuid}
288
+ variant={variant}
289
  />
290
  ))}
291
  </div>
 
338
  // State initialization flag
339
  const [isInitialized, setIsInitialized] = useState(false);
340
  const isManualNav = useRef(false);
341
+ const mainContentRef = useRef<HTMLDivElement>(null);
342
+ const tagInputRef = useRef<HTMLInputElement>(null);
343
+ const [showScrollTop, setShowScrollTop] = useState(false);
344
  const [treeVersion, setTreeVersion] = useState(0);
345
+ const [visibleCount, setVisibleCount] = useState(30);
346
+ const [sidebarVisibleCount, setSidebarVisibleCount] = useState(30);
347
+ const [isHoveringSync, setIsHoveringSync] = useState(false);
348
+ const [isStatsMenuOpen, setIsStatsMenuOpen] = useState(false);
349
+ const [isBackingUp, setIsBackingUp] = useState(false);
350
+
351
+ const handleBackup = async () => {
352
+ if (isBackingUp) return;
353
+ setIsBackingUp(true);
354
+ try {
355
+ const res = await fetch('/api/backup', { method: 'POST' });
356
+ const data = await res.json();
357
+ if (res.ok) {
358
+ alert(`导出 JSON 成功!\n文件已保存到「备份-语雀数据-JSON」文件夹:\n${data.files.join('\n')}`);
359
+ } else {
360
+ alert(`导出失败: ${data.message}`);
361
+ }
362
+ } catch (error) {
363
+ console.error('Export failed:', error);
364
+ alert('导出请求失败,请检查控制台。');
365
+ } finally {
366
+ setIsBackingUp(false);
367
+ }
368
+ };
369
+
370
+ useEffect(() => {
371
+ setVisibleCount(30);
372
+ setSidebarVisibleCount(30);
373
+ }, [currentKbNamespace]);
374
+
375
 
376
  const handleCollapseAll = (e: React.MouseEvent) => {
377
  e.stopPropagation();
 
393
  setIsDemoMode(data.isDemo);
394
  }
395
 
396
+ // Knowledge base selection logic
397
+ // Stabilize current selection during sync or after initialization to prevent flicker
398
+ if (!isInitialized) {
399
+ if (data.knowledgeBases && data.knowledgeBases.length > 0) {
400
+ const kbParam = searchParams.get('kb');
401
+ const kbExists = kbParam && data.knowledgeBases.some((k: KnowledgeBase) => k.namespace === kbParam);
402
+ if (kbExists) {
403
+ if (!currentKbNamespace) setCurrentKbNamespace(kbParam);
404
+ } else if (!currentKbNamespace) {
405
+ setCurrentKbNamespace(data.knowledgeBases[0].namespace);
406
+ }
407
+ }
408
+ } else {
409
+ // Do not auto-switch KB while syncing even if current is temporarily missing
410
+ // Only set default if nothing is selected and we have KBs
411
+ if (!currentKbNamespace && data.knowledgeBases && data.knowledgeBases.length > 0) {
412
  setCurrentKbNamespace(data.knowledgeBases[0].namespace);
413
  }
414
  }
 
426
 
427
  const treeRoots = useMemo(() => buildTree(filteredDocuments), [filteredDocuments]);
428
 
429
+ // Tag filter state
430
+ const [selectedTag, setSelectedTag] = useState<string>('');
431
+ const [isTagDropdownOpen, setIsTagDropdownOpen] = useState(false);
432
+ const [isInputFocused, setIsInputFocused] = useState(false);
433
+ const [tagSearchTerm, setTagSearchTerm] = useState('');
434
+
435
+ const allTags = useMemo(() => {
436
+ if (!documents || documents.length === 0) return [];
437
+
438
+ const tagMap = new Map<string, number>();
439
+ let untaggedCount = 0;
440
+
441
+ // Use filteredDocuments to only show tags relevant to current KB
442
+ filteredDocuments.forEach(doc => {
443
+ if (doc.tags && Array.isArray(doc.tags) && doc.tags.length > 0) {
444
+ doc.tags.forEach(t => {
445
+ if (t) tagMap.set(t, (tagMap.get(t) || 0) + 1);
446
+ });
447
+ } else {
448
+ untaggedCount++;
449
+ }
450
+ });
451
+
452
+ const tagsList = Array.from(tagMap.entries())
453
+ .map(([name, count]) => ({ name, count }));
454
+
455
+ if (untaggedCount > 0) {
456
+ tagsList.push({ name: '无标签', count: untaggedCount });
457
+ }
458
+
459
+ return tagsList.sort((a, b) => b.count - a.count);
460
+ }, [filteredDocuments, documents]);
461
+
462
+ const mainAreaRoots = useMemo(() => {
463
+ if (!selectedTag) return treeRoots;
464
+
465
+ let taggedDocs;
466
+ if (selectedTag === '无标签') {
467
+ taggedDocs = filteredDocuments.filter(doc =>
468
+ !doc.tags || !Array.isArray(doc.tags) || doc.tags.length === 0
469
+ );
470
+ } else {
471
+ taggedDocs = filteredDocuments.filter(doc =>
472
+ doc.tags && Array.isArray(doc.tags) && doc.tags.includes(selectedTag)
473
+ );
474
+ }
475
+
476
+ // Rebuild tree for the filtered view
477
+ // Note: If a child matches but parent doesn't, it becomes a root in this new tree
478
+ return buildTree(taggedDocs);
479
+ }, [treeRoots, filteredDocuments, selectedTag]);
480
+
481
+ useEffect(() => {
482
+ setSelectedTag('');
483
+ setIsTagDropdownOpen(false);
484
+ setTagSearchTerm('');
485
+ }, [currentKbNamespace]);
486
+
487
+
488
  // Restore state from URL on load and when documents are ready
489
  useEffect(() => {
490
  if (documents.length > 0 && !isInitialized) {
 
597
  return () => clearInterval(interval);
598
  }, [syncStatus.status, searchParams.get('demo')]);
599
 
600
+ const handleStopSync = async () => {
601
+ try {
602
+ await fetch('/api/documents', { method: 'DELETE' });
603
+ // Trigger immediate refresh to get updated status
604
+ fetchDocuments();
605
+ } catch (error) {
606
+ console.error('Failed to stop sync:', error);
607
+ }
608
+ };
609
+
610
  const handleSync = async () => {
611
  try {
612
  await fetch('/api/documents', { method: 'POST' });
 
644
  </div>
645
  )}
646
  <button
647
+ onClick={handleBackup}
648
+ disabled={isBackingUp}
649
+ className="flex items-center gap-2 px-3 py-1.5 text-sm rounded-md font-medium transition-all hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
650
+ title="备份语雀数据到本地"
651
+ >
652
+ {isBackingUp ? (
653
+ <RefreshCw className="w-4 h-4 animate-spin" />
654
+ ) : (
655
+ <Save className="w-4 h-4" />
656
+ )}
657
+ <span>导出 JSON</span>
658
+ </button>
659
+ <button
660
+ onClick={syncStatus.status === 'running' ? handleStopSync : handleSync}
661
+ onMouseEnter={() => setIsHoveringSync(true)}
662
+ onMouseLeave={() => setIsHoveringSync(false)}
663
  className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-md font-medium transition-all
664
  ${syncStatus.status === 'running'
665
+ ? (isHoveringSync ? 'bg-red-50 text-red-600 dark:bg-red-900/20 dark:text-red-400' : 'text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/20')
666
  : 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'
667
  }`}
668
  >
669
+ {syncStatus.status === 'running' ? (
670
+ isHoveringSync ? (
671
+ <>
672
+ <Square className="w-4 h-4 fill-current" />
673
+ <span>停止同步</span>
674
+ </>
675
+ ) : (
676
+ <>
677
+ <RefreshCw className="w-4 h-4 animate-spin" />
678
+ <span>同步中</span>
679
+ </>
680
+ )
681
+ ) : (
682
+ <>
683
+ <RefreshCw className="w-4 h-4" />
684
+ <span>{syncStatus.message?.includes('停止') ? '继续同步' : '同步'}</span>
685
+ </>
686
+ )}
687
  </button>
688
  </div>
689
  </div>
690
 
691
  {/* Sync Progress Bar (Slim) and Status Message */}
692
+ {(syncStatus.status === 'running' || syncStatus.message?.includes('停止') || syncStatus.status === 'error') && (
693
  <div className="w-full border-t border-gray-100 dark:border-gray-800">
694
+ <div className={`px-4 py-2 ${syncStatus.status === 'error' ? 'bg-red-50/50 dark:bg-red-900/10' : 'bg-primary-50/50 dark:bg-primary-900/10'}`}>
695
+ <div className={`flex items-center justify-center text-xs ${syncStatus.status === 'error' ? 'text-red-700 dark:text-red-300' : 'text-primary-700 dark:text-primary-300'} mb-1.5 gap-2`}>
696
  <span className="relative flex h-2 w-2">
697
+ <span className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${syncStatus.status === 'error' ? 'bg-red-400' : 'bg-primary-400'}`}></span>
698
+ <span className={`relative inline-flex rounded-full h-2 w-2 ${syncStatus.status === 'error' ? 'bg-red-500' : 'bg-primary-500'}`}></span>
699
  </span>
700
  <span className="font-medium">{syncStatus.message || '正在同步...'}</span>
701
  <span className="mx-1 opacity-50">|</span>
 
706
 
707
  <div className="h-2 w-full bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
708
  <div
709
+ className={`h-full transition-all duration-500 ease-out rounded-full ${syncStatus.status === 'error' ? 'bg-red-600' : 'bg-primary-600'}`}
710
  style={{ width: `${(syncStatus.processed / (syncStatus.total || 1)) * 100}%` }}
711
  />
712
  </div>
 
718
  {/* Main Content Area */}
719
  <div className="flex flex-1 overflow-hidden">
720
  {/* Sidebar */}
721
+ <div className="w-64 border-r border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-900/50 flex flex-col z-20 shrink-0">
722
  <div className="px-3 pt-3 pb-1">
723
  {/* Knowledge Base Switcher */}
724
  <div className="relative mb-2 px-3">
 
735
  }}
736
  >
737
  <ChevronDown className="w-4 h-4 text-gray-500" />
738
+ </div>
739
+
740
+ <div className="relative ml-auto">
741
+ <div
742
+ className="p-1 rounded-sm hover:bg-gray-200 dark:hover:bg-gray-700 cursor-pointer transition-colors"
743
+ onClick={(e) => {
744
+ e.stopPropagation();
745
+ setIsStatsMenuOpen(!isStatsMenuOpen);
746
+ }}
747
+ >
748
+ <MoreVertical className="w-4 h-4 text-gray-500" />
749
+ </div>
750
+ {isStatsMenuOpen && (
751
+ <>
752
+ <div
753
+ className="fixed inset-0 z-10"
754
+ onClick={(e) => {
755
+ e.stopPropagation();
756
+ setIsStatsMenuOpen(false);
757
+ }}
758
+ />
759
+ <div className="absolute left-full top-0 ml-2 w-32 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-20 py-1">
760
+ <Link
761
+ href="/knowledge/stats"
762
+ className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 w-full"
763
+ onClick={() => setIsStatsMenuOpen(false)}
764
+ >
765
+ <BarChart2 className="w-4 h-4" />
766
+ <span>统计</span>
767
+ </Link>
768
+ </div>
769
+ </>
770
+ )}
771
+ </div>
772
  </div>
773
 
774
  {isKbDropdownOpen && (
 
845
  <span className="font-medium">{t('outline')}</span>
846
  </div>
847
  </div>
848
+ <div
849
+ className="flex-1 overflow-y-auto px-3 pb-4 scrollbar-thin"
850
+ onScroll={(e) => {
851
+ const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
852
+ if (scrollHeight - scrollTop - clientHeight < 100) {
853
+ if (sidebarVisibleCount < treeRoots.length) {
854
+ setSidebarVisibleCount(prev => Math.min(prev + 30, treeRoots.length));
855
+ }
856
+ }
857
+ }}
858
+ >
859
  {isLoading ? (
860
  <div className="p-4 text-center text-sm text-gray-500">{t('loading')}</div>
861
  ) : (
862
  <div className="flex flex-col gap-0.5">
863
+ {(currentKbNamespace === 'NOTES' ? treeRoots.slice(0, sidebarVisibleCount) : treeRoots).map(node => (
864
  <TreeNodeView
865
  key={`${node.doc.uuid}-${treeVersion}`}
866
  node={node}
 
868
  selectedUuid={selectedNode?.doc.uuid}
869
  />
870
  ))}
871
+ {currentKbNamespace === 'NOTES' && sidebarVisibleCount < treeRoots.length && (
872
+ <div className="py-2 text-center text-xs text-gray-400">
873
+ ...
874
+ </div>
875
+ )}
876
  </div>
877
  )}
878
  </div>
879
  </div>
880
 
881
  {/* Right Panel */}
882
+ <div
883
+ ref={mainContentRef}
884
+ className="flex-1 overflow-y-auto bg-white dark:bg-gray-900 p-8 scroll-smooth"
885
+ onScroll={(e) => {
886
+ const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
887
+
888
+ // Toggle Back to Top button
889
+ if (scrollTop > 300) {
890
+ if (!showScrollTop) setShowScrollTop(true);
891
+ } else {
892
+ if (showScrollTop) setShowScrollTop(false);
893
+ }
894
+
895
+ if (scrollHeight - scrollTop - clientHeight < 100) {
896
+ const targetRoots = currentKbNamespace === 'NOTES' ? mainAreaRoots : treeRoots;
897
+ if (visibleCount < targetRoots.length) {
898
+ setVisibleCount(prev => Math.min(prev + 30, targetRoots.length));
899
+ }
900
+ }
901
+ }}
902
+ >
903
+
904
  {searchTerm ? (
905
  <div className="max-w-4xl mx-auto">
906
  <h2 className="text-xl font-bold mb-6">搜索结果: &quot;{searchTerm}&quot;</h2>
 
941
  <div className="flex-1 min-w-0 max-w-3xl">
942
  {/* Breadcrumb / Header */}
943
  <div className="mb-8 pb-6 border-b border-gray-100 dark:border-gray-800">
944
+ {selectedNode.doc.namespace !== 'NOTES' && (
945
+ <div className="flex items-center gap-3 mb-10">
946
+ <div>
947
+ <h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100">
948
+ {selectedNode.doc.title}
949
+ </h1>
950
+ </div>
951
  </div>
952
+ )}
953
 
954
  {/* Content */}
955
  {isLoadingContent ? (
 
978
  <ExternalLink className="w-3 h-3" /> 语雀链接
979
  </a>
980
  )}
981
+ {Array.isArray(selectedNode.doc.tags) && selectedNode.doc.tags.length > 0 && (
982
+ <div className="flex items-center gap-2">
983
+ <Tag className="w-3 h-3" />
984
+ {selectedNode.doc.tags.map(tag => (
985
+ <span
986
+ key={tag}
987
+ className="px-1.5 py-0.5 text-[11px] rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 border border-gray-200 dark:border-gray-700"
988
+ >
989
+ #{tag}
990
+ </span>
991
+ ))}
992
+ </div>
993
+ )}
994
  </div>
995
  </>
996
  ) : (
 
1037
  </div>
1038
  ) : (
1039
  /* Home View */
1040
+ <div className="max-w-5xl mx-auto px-4 pt-2">
1041
  <div className="mb-8">
1042
+ <div className="flex items-center justify-between">
1043
+ <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
1044
+ {knowledgeBases.find(kb => kb.namespace === currentKbNamespace)?.name || '知识库'}
1045
+ </h1>
1046
+ {currentKbNamespace === 'NOTES' && (
1047
+ <div className="relative w-64">
1048
+ <Tag className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 z-10 pointer-events-none" />
1049
+ <input
1050
+ ref={tagInputRef}
1051
+ type="text"
1052
+ className={`w-full pl-9 pr-10 py-1.5 text-sm rounded-md border transition-colors outline-none
1053
+ ${isTagDropdownOpen
1054
+ ? 'border-primary-500 ring-1 ring-primary-500 bg-white dark:bg-gray-800'
1055
+ : 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700'}
1056
+ ${selectedTag ? 'text-primary-600 dark:text-primary-400 font-medium' : 'text-gray-700 dark:text-gray-300'}
1057
+ `}
1058
+ placeholder={allTags.length > 0 ? "输入筛选标签..." : "暂无标签"}
1059
+ value={tagSearchTerm}
1060
+ onChange={(e) => {
1061
+ setTagSearchTerm(e.target.value);
1062
+ setIsTagDropdownOpen(true);
1063
+ }}
1064
+ onFocus={() => {
1065
+ setIsTagDropdownOpen(true);
1066
+ setIsInputFocused(true);
1067
+ }}
1068
+ onBlur={() => setIsInputFocused(false)}
1069
+ onClick={(e) => {
1070
+ e.stopPropagation();
1071
+ setIsTagDropdownOpen(true);
1072
+ }}
1073
+ disabled={allTags.length === 0}
1074
+ />
1075
+ <div
1076
+ className="absolute right-1 top-1/2 -translate-y-1/2 p-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md text-gray-400 hover:text-gray-600 transition-colors z-10"
1077
+ onMouseDown={(e) => e.preventDefault()}
1078
+ onClick={(e) => {
1079
+ e.stopPropagation();
1080
+ if (tagSearchTerm) {
1081
+ setTagSearchTerm('');
1082
+ setSelectedTag('');
1083
+ setIsTagDropdownOpen(true);
1084
+ tagInputRef.current?.focus();
1085
+ } else {
1086
+ if (isTagDropdownOpen) {
1087
+ setIsTagDropdownOpen(false);
1088
+ } else {
1089
+ setIsTagDropdownOpen(true);
1090
+ tagInputRef.current?.focus();
1091
+ }
1092
+ }
1093
+ }}
1094
+ >
1095
+ {tagSearchTerm ? (
1096
+ <X className="w-3 h-3" />
1097
+ ) : (
1098
+ <ChevronDown className={`w-3 h-3 transition-transform ${isTagDropdownOpen ? 'rotate-180' : ''}`} />
1099
+ )}
1100
+ </div>
1101
+
1102
+ {isTagDropdownOpen && (
1103
+ <>
1104
+ <div
1105
+ className="fixed inset-0 z-0"
1106
+ onClick={(e) => {
1107
+ e.stopPropagation();
1108
+ setIsTagDropdownOpen(false);
1109
+ setTagSearchTerm(selectedTag || '');
1110
+ }}
1111
+ />
1112
+ <div className="absolute left-0 right-0 top-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-20 py-1 max-h-60 overflow-y-auto">
1113
+ {(!tagSearchTerm || '全部标签'.includes(tagSearchTerm)) && (
1114
+ <button
1115
+ className={`w-full text-left px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-700/50 flex items-center justify-between
1116
+ ${!selectedTag ? 'text-primary-600 bg-primary-50 dark:bg-primary-900/20' : 'text-gray-700 dark:text-gray-300'}
1117
+ `}
1118
+ onClick={() => {
1119
+ setSelectedTag('');
1120
+ setTagSearchTerm('');
1121
+ setIsTagDropdownOpen(false);
1122
+ }}
1123
+ >
1124
+ <span>全部标签</span>
1125
+ {!selectedTag && <div className="w-2 h-2 rounded-full bg-primary-600" />}
1126
+ </button>
1127
+ )}
1128
+ {allTags
1129
+ .filter(tag => tag.name.toLowerCase().includes(tagSearchTerm.toLowerCase()))
1130
+ .map(tag => (
1131
+ <button
1132
+ key={tag.name}
1133
+ className={`w-full text-left px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-700/50 flex items-center justify-between gap-2
1134
+ ${selectedTag === tag.name ? 'text-primary-600 bg-primary-50 dark:bg-primary-900/20' : 'text-gray-700 dark:text-gray-300'}
1135
+ `}
1136
+ onClick={() => {
1137
+ setSelectedTag(tag.name);
1138
+ setTagSearchTerm(tag.name);
1139
+ setIsTagDropdownOpen(false);
1140
+ }}
1141
+ >
1142
+ <span className="truncate flex-1">{tag.name}</span>
1143
+ <span className="text-xs text-gray-400 shrink-0 tabular-nums">{tag.count}</span>
1144
+ {selectedTag === tag.name && <div className="w-2 h-2 rounded-full bg-primary-600 shrink-0" />}
1145
+ </button>
1146
+ ))}
1147
+ {allTags.filter(tag => tag.name.toLowerCase().includes(tagSearchTerm.toLowerCase())).length === 0 && !('全部标签'.includes(tagSearchTerm)) && (
1148
+ <div className="px-3 py-2 text-xs text-gray-400 text-center">
1149
+ 未找到相关标签
1150
+ </div>
1151
+ )}
1152
+ </div>
1153
+ </>
1154
+ )}
1155
+ </div>
1156
+ )}
1157
+ </div>
1158
+ <div className="mt-3 flex items-center gap-4 text-sm text-gray-500">
1159
  <span>{filteredDocuments.filter(d => d.yuque_id !== 0).length} {t('documents')}</span>
1160
  <span className="text-gray-300 dark:text-gray-700">|</span>
1161
  <span>{(filteredDocuments.filter(d => d.yuque_id !== 0).reduce((acc, d) => acc + (d.content_length || 0), 0) / 10000).toFixed(1)} {language === 'zh' ? '万字' : '0k chars'}</span>
 
1163
  </div>
1164
 
1165
  <div className="flex flex-col gap-0.5 mt-6">
1166
+ {(currentKbNamespace === 'NOTES' ? mainAreaRoots.slice(0, visibleCount) : mainAreaRoots).map(node => (
1167
  <TreeNodeView
1168
  key={`${node.doc.uuid}-${treeVersion}`}
1169
  node={node}
1170
  onSelect={setSelectedNode}
1171
+ variant="main"
1172
  />
1173
  ))}
1174
+ {currentKbNamespace === 'NOTES' && visibleCount < mainAreaRoots.length && (
1175
+ <div className="py-4 text-center text-sm text-gray-400">
1176
+ 加载更多...
1177
+ </div>
1178
+ )}
1179
  </div>
1180
  </div>
1181
  )}
1182
+
1183
+ {/* Back to Top Button */}
1184
+ <button
1185
+ onClick={() => mainContentRef.current?.scrollTo({ top: 0, behavior: 'smooth' })}
1186
+ className={`fixed bottom-8 right-8 p-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg rounded-full text-gray-500 hover:text-primary-600 dark:text-gray-400 dark:hover:text-primary-400 transition-all z-50 duration-300 ${showScrollTop ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10 pointer-events-none'}`}
1187
+ title="回到顶部"
1188
+ >
1189
+ <ArrowUp className="w-5 h-5" />
1190
+ </button>
1191
  </div>
1192
  </div>
1193
  </div>
src/app/knowledge/stats/page.tsx ADDED
@@ -0,0 +1,1104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ "use client";
3
+
4
+ import { useState, useEffect, useRef, useCallback } from 'react';
5
+ import { ArrowLeft, Calendar, FileText, Type, BarChart2, X, Sparkles, Share2, Download, Copy } from 'lucide-react';
6
+ import { useRouter } from 'next/navigation';
7
+
8
+ interface HeatmapData {
9
+ date: string;
10
+ count: number;
11
+ }
12
+
13
+ interface KbStat {
14
+ namespace: string;
15
+ name: string;
16
+ count: number;
17
+ words: number;
18
+ }
19
+
20
+ interface StatsData {
21
+ years: number[];
22
+ stats: {
23
+ totalDocs: number;
24
+ totalWords: number;
25
+ allTimeDocs?: number;
26
+ docsCount?: number;
27
+ notesCount?: number;
28
+ kbStats: KbStat[];
29
+ heatmap: HeatmapData[];
30
+ annualStats?: { year: string; count: number; notes_count: number; words: number }[];
31
+ };
32
+ }
33
+
34
+ const AnnualReportModal = ({
35
+ isOpen,
36
+ onClose,
37
+ year,
38
+ data
39
+ }: {
40
+ isOpen: boolean;
41
+ onClose: () => void;
42
+ year: number | 'all';
43
+ data: StatsData['stats'];
44
+ }) => {
45
+ const [sharePreviewOpen, setSharePreviewOpen] = useState(false);
46
+ const [shareImageUrl, setShareImageUrl] = useState<string | null>(null);
47
+ const [generating, setGenerating] = useState(false);
48
+ const [copyStatus, setCopyStatus] = useState<'idle' | 'success' | 'error'>('idle');
49
+
50
+ // Calculate insights
51
+ const totalDocs = data.totalDocs;
52
+ const totalWords = data.totalWords;
53
+
54
+ // Sort heatmap data by date
55
+ const sortedHeatmap = [...data.heatmap].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
56
+
57
+ // 1. Active Days
58
+ const activeDays = sortedHeatmap.length;
59
+ const isLeapYear = (y: number) => (y % 4 === 0 && y % 100 !== 0) || (y % 400 === 0);
60
+ const daysInYear = year === 'all' ? 365 : (typeof year === 'number' && isLeapYear(year) ? 366 : 365);
61
+ const isFullAttendance = year !== 'all' && activeDays >= daysInYear;
62
+
63
+ // 3. Day of Week (removed cards below no longer use this)
64
+
65
+ const displayYear = year === 'all' ? '全部年份' : `${year}年`;
66
+ if (!isOpen) return null;
67
+
68
+ const generateSvg = () => {
69
+ const w = 1080;
70
+ const topKb = [...data.kbStats]
71
+ .sort((a, b) => b.words - a.words)
72
+ .slice(0, 6);
73
+ const totalWordsW = (totalWords / 10000).toFixed(1) + '万';
74
+ const headerTitle = `${displayYear} 年度报告`;
75
+ const subTitle = '知识库年度回顾';
76
+ const activeDaysText = `${activeDays} 天`;
77
+ const totalDocsText = String(totalDocs);
78
+ const bg = `
79
+ <defs>
80
+ <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
81
+ <stop offset="0%" stop-color="#7c3aed"/>
82
+ <stop offset="100%" stop-color="#2563eb"/>
83
+ </linearGradient>
84
+ <linearGradient id="card" x1="0" y1="0" x2="0" y2="1">
85
+ <stop offset="0%" stop-color="#ffffff"/>
86
+ <stop offset="100%" stop-color="#f8fafc"/>
87
+ </linearGradient>
88
+ </defs>
89
+ `;
90
+ const kbBars = topKb.map((kb, i) => {
91
+ const barMax = 740;
92
+ const percent = totalWords > 0 ? Math.max(2, Math.round((kb.words / totalWords) * barMax)) : 2;
93
+ const y = 820 + i * 90;
94
+ const name = kb.name.replace(/&/g, '&amp;').replace(/</g, '&lt;');
95
+ const words = (kb.words / 10000).toFixed(1) + '万';
96
+ return `
97
+ <g>
98
+ <text x="120" y="${y - 26}" font-size="36" fill="#334155" font-weight="700">${name}</text>
99
+ <rect x="120" y="${y}" width="${barMax}" height="32" rx="16" fill="#e5e7eb"/>
100
+ <rect x="120" y="${y}" width="${percent}" height="32" rx="16" fill="#22c55e"/>
101
+ <text x="${w - 160}" y="${y + 26}" font-size="30" fill="#64748b" text-anchor="end">${words}</text>
102
+ </g>
103
+ `;
104
+ }).join('');
105
+ const lastBarY = 820 + Math.max(0, topKb.length - 1) * 90 + 32;
106
+ const h = lastBarY + 160;
107
+ const footerText = 'RAG Knowledge Base';
108
+ const svg = `
109
+ <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}">
110
+ ${bg}
111
+ <rect x="0" y="0" width="${w}" height="${h}" fill="url(#g)"/>
112
+ <rect x="40" y="40" width="${w - 80}" height="${h - 80}" rx="40" fill="url(#card)"/>
113
+ <text x="${w / 2}" y="200" font-size="64" font-weight="700" fill="#111827" text-anchor="middle">${headerTitle}</text>
114
+ <text x="${w / 2}" y="260" font-size="32" fill="#6b7280" text-anchor="middle">${subTitle}</text>
115
+
116
+ <g>
117
+ <rect x="120" y="320" width="${w - 240}" height="140" rx="24" fill="#eef2ff" />
118
+ <text x="180" y="370" font-size="28" fill="#6366f1" font-weight="600">年度活跃天数</text>
119
+ <text x="${w - 180}" y="390" font-size="56" fill="#4f46e5" font-weight="800" text-anchor="end">${activeDaysText}</text>
120
+ ${isFullAttendance ? `<text x="${w - 180}" y="430" font-size="22" fill="#f59e0b" font-weight="700" text-anchor="end">全勤达成</text>` : ''}
121
+ </g>
122
+
123
+ <g>
124
+ <rect x="120" y="500" width="${(w - 300) / 2}" height="160" rx="24" fill="#ecfeff" />
125
+ <text x="180" y="570" font-size="56" fill="#0ea5e9" font-weight="800">${totalDocsText}</text>
126
+ <text x="180" y="620" font-size="28" fill="#14b8a6" font-weight="600">总文档数</text>
127
+ </g>
128
+
129
+ <g>
130
+ <rect x="${120 + (w - 300) / 2 + 60}" y="500" width="${(w - 300) / 2}" height="160" rx="24" fill="#f0fdf4" />
131
+ <text x="${120 + (w - 300) / 2 + 120}" y="570" font-size="56" fill="#16a34a" font-weight="800">${totalWordsW}</text>
132
+ <text x="${120 + (w - 300) / 2 + 120}" y="620" font-size="28" fill="#22c55e" font-weight="600">总字数</text>
133
+ </g>
134
+
135
+ <text x="120" y="730" font-size="40" fill="#111827" font-weight="700">知识库贡献</text>
136
+ ${kbBars}
137
+ <text x="${w - 120}" y="${h - 60}" font-size="24" fill="#9ca3af" text-anchor="end">${footerText}</text>
138
+ </svg>
139
+ `;
140
+ return { svg, w, h };
141
+ };
142
+
143
+ const svgToPngDataUrl = async (svg: string, w: number, h: number) => {
144
+ return new Promise<string>((resolve, reject) => {
145
+ try {
146
+ const blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' });
147
+ const url = URL.createObjectURL(blob);
148
+ const img = new Image();
149
+ img.onload = () => {
150
+ const canvas = document.createElement('canvas');
151
+ const scale = 2;
152
+ canvas.width = w * scale;
153
+ canvas.height = h * scale;
154
+ const ctx = canvas.getContext('2d');
155
+ if (!ctx) {
156
+ URL.revokeObjectURL(url);
157
+ reject(new Error('Canvas not supported'));
158
+ return;
159
+ }
160
+ ctx.scale(scale, scale);
161
+ ctx.fillStyle = '#ffffff';
162
+ ctx.fillRect(0, 0, w, h);
163
+ ctx.drawImage(img, 0, 0, w, h);
164
+ URL.revokeObjectURL(url);
165
+ resolve(canvas.toDataURL('image/png'));
166
+ };
167
+ img.onerror = () => {
168
+ URL.revokeObjectURL(url);
169
+ reject(new Error('Failed to load SVG'));
170
+ };
171
+ img.src = url;
172
+ } catch (e) {
173
+ reject(e as Error);
174
+ }
175
+ });
176
+ };
177
+
178
+ const handleGenerateShare = async () => {
179
+ if (generating) return;
180
+ setGenerating(true);
181
+ setCopyStatus('idle');
182
+ try {
183
+ const { svg, w, h } = generateSvg();
184
+ const url = await svgToPngDataUrl(svg, w, h);
185
+ setShareImageUrl(url);
186
+ setSharePreviewOpen(true);
187
+ } finally {
188
+ setGenerating(false);
189
+ }
190
+ };
191
+
192
+ const handleDownload = () => {
193
+ if (!shareImageUrl) return;
194
+ const a = document.createElement('a');
195
+ a.href = shareImageUrl;
196
+ const filename = `年度报告_${displayYear.replace('年', '')}.png`;
197
+ a.download = filename;
198
+ document.body.appendChild(a);
199
+ a.click();
200
+ document.body.removeChild(a);
201
+ };
202
+
203
+ const handleCopy = async () => {
204
+ if (!shareImageUrl) return;
205
+ setCopyStatus('idle');
206
+ try {
207
+ const res = await fetch(shareImageUrl);
208
+ const blob = await res.blob();
209
+ const item = new ClipboardItem({ [blob.type]: blob });
210
+ await navigator.clipboard.write([item]);
211
+ setCopyStatus('success');
212
+ } catch {
213
+ setCopyStatus('error');
214
+ }
215
+ };
216
+
217
+ return (
218
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
219
+ <div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl max-w-md w-full overflow-hidden relative animate-in fade-in zoom-in duration-300">
220
+ {/* Header with gradient */}
221
+ <div className="bg-gradient-to-r from-primary-600 to-purple-600 p-6 text-white text-center relative overflow-hidden">
222
+ <div className="absolute top-0 left-0 w-full h-full opacity-10 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')]"></div>
223
+ <Sparkles className="w-8 h-8 mx-auto mb-2 text-yellow-300 animate-pulse" />
224
+ <h2 className="text-2xl font-bold tracking-tight">{displayYear} 年度报告</h2>
225
+ <p className="text-primary-100 text-sm mt-1">您的知识库回顾</p>
226
+
227
+ <button onClick={onClose} className="absolute top-4 right-4 text-white/70 hover:text-white transition-colors">
228
+ <X className="w-5 h-5" />
229
+ </button>
230
+ <div className="absolute top-4 right-14">
231
+ <button
232
+ onClick={handleGenerateShare}
233
+ className="flex items-center gap-1.5 px-2.5 py-1.5 bg-white/20 hover:bg-white/30 text-white rounded-md text-xs font-medium transition"
234
+ >
235
+ <Share2 className="w-4 h-4" />
236
+ {generating ? '生成中' : '分享图片'}
237
+ </button>
238
+ </div>
239
+ </div>
240
+
241
+ <div className="p-6 space-y-6">
242
+ {/* Active Days Highlight */}
243
+ <div className="text-center space-y-2">
244
+ <div className="text-sm text-gray-500 dark:text-gray-400 uppercase tracking-wider">年度活跃天数</div>
245
+ <div className="text-4xl font-black text-gray-900 dark:text-white flex items-center justify-center gap-2">
246
+ {activeDays}
247
+ <span className="text-lg font-medium text-gray-500">天</span>
248
+ </div>
249
+ {isFullAttendance && (
250
+ <div className="inline-block px-3 py-1 rounded-full bg-yellow-100 text-yellow-700 text-xs font-bold animate-bounce">
251
+ 🏆 全勤达成!太强了!
252
+ </div>
253
+ )}
254
+ {!isFullAttendance && year !== 'all' && (
255
+ <div className="text-xs text-gray-400">
256
+ 距离全勤还差 {daysInYear - activeDays} 天
257
+ </div>
258
+ )}
259
+ </div>
260
+
261
+ {/* Insights Grid */}
262
+ <div className="grid grid-cols-2 gap-4">
263
+
264
+ {/* Top Month */}
265
+ <div className="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-xl text-center">
266
+ <div className="text-2xl font-bold text-blue-600 dark:text-blue-400 mb-0.5">{totalDocs}</div>
267
+ <div className="text-xs text-gray-500 dark:text-gray-400">总文档数</div>
268
+ </div>
269
+
270
+
271
+
272
+ {/* Total Words */}
273
+ <div className="bg-green-50 dark:bg-green-900/20 p-4 rounded-xl text-center">
274
+ <div className="text-2xl font-bold text-green-600 dark:text-green-400 mb-0.5">{(totalWords / 10000).toFixed(1)}w</div>
275
+ <div className="text-xs text-gray-500 dark:text-gray-400">总字数</div>
276
+ </div>
277
+ </div>
278
+
279
+ {/* Detailed Stats */}
280
+ <div className="space-y-3">
281
+
282
+ <div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg border border-gray-100 dark:border-gray-700 overflow-hidden">
283
+ <div className="px-4 py-2 bg-gray-100/50 dark:bg-gray-800 border-b border-gray-100 dark:border-gray-700 flex justify-between text-xs font-medium text-gray-500">
284
+ <span>知识库</span>
285
+ <div className="flex gap-4">
286
+ <span className="w-12 text-right">文档</span>
287
+ <span className="w-16 text-right">字数</span>
288
+ </div>
289
+ </div>
290
+ <div className="divide-y divide-gray-100 dark:divide-gray-700 max-h-48 overflow-y-auto">
291
+ {data.kbStats.map((kb) => (
292
+ <div key={kb.namespace} className="px-4 py-2 flex items-center justify-between text-sm hover:bg-white dark:hover:bg-gray-700/50 transition-colors">
293
+ <span className="font-medium text-gray-700 dark:text-gray-300 truncate pr-2 flex-1" title={kb.name}>{kb.name}</span>
294
+ <div className="flex gap-4 text-gray-600 dark:text-gray-400 text-xs tabular-nums">
295
+ <span className="w-12 text-right">{kb.count}</span>
296
+ <span className="w-16 text-right">{(kb.words / 10000).toFixed(1)}w</span>
297
+ </div>
298
+ </div>
299
+ ))}
300
+ </div>
301
+ </div>
302
+ </div>
303
+
304
+
305
+ </div>
306
+ </div>
307
+ {sharePreviewOpen && shareImageUrl && (
308
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-2 sm:p-4 bg-black/60">
309
+ <div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl max-w-[92vw] w-auto overflow-hidden relative">
310
+ <div className="px-4 sm:px-6 py-4 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between">
311
+ <div className="flex items-center gap-2 text-gray-800 dark:text-gray-100 font-semibold">
312
+ <Share2 className="w-5 h-5" />
313
+ 分享图片预览
314
+ </div>
315
+ <button onClick={() => setSharePreviewOpen(false)} className="text-gray-500 hover:text-gray-800 dark:hover:text-gray-200">
316
+ <X className="w-5 h-5" />
317
+ </button>
318
+ </div>
319
+ <div className="p-3 sm:p-4 overflow-auto max-h-[82vh] flex justify-center">
320
+ <img src={shareImageUrl} alt="年度报告分享图片" className="rounded-xl shadow-md w-[320px] sm:w-[420px] h-auto object-contain" />
321
+ </div>
322
+ <div className="px-4 sm:px-6 py-3 sm:py-4 border-t border-gray-100 dark:border-gray-800 flex items-center justify-end gap-3">
323
+ <button
324
+ onClick={handleCopy}
325
+ className="flex items-center gap-2 px-4 py-2 rounded-md bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-800 dark:text-gray-100 text-sm"
326
+ >
327
+ <Copy className="w-4 h-4" />
328
+ 复制到剪贴板
329
+ </button>
330
+ <button
331
+ onClick={handleDownload}
332
+ className="flex items-center gap-2 px-4 py-2 rounded-md bg-primary-600 hover:bg-primary-700 text-white text-sm"
333
+ >
334
+ <Download className="w-4 h-4" />
335
+ 下载 PNG
336
+ </button>
337
+ {copyStatus === 'success' && <span className="text-green-600 text-sm">已复制</span>}
338
+ {copyStatus === 'error' && <span className="text-red-600 text-sm">复制失败,请下载保存</span>}
339
+ </div>
340
+ </div>
341
+ </div>
342
+ )}
343
+ </div>
344
+ );
345
+ };
346
+
347
+ // Custom Icon to avoid import error if lucide-react version mismatch
348
+ const DatabaseIcon = ({ className }: { className?: string }) => (
349
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s 9-1.34 9-3V5"/></svg>
350
+ );
351
+
352
+ const Heatmap = ({ data, year, range }: { data: HeatmapData[]; year?: number; range?: { start: Date; end: Date } }) => {
353
+ const containerRef = useRef<HTMLDivElement>(null);
354
+ const [hoverInfo, setHoverInfo] = useState<{ date: string; count: number; x: number; y: number; cw: number } | null>(null);
355
+ const tipRef = useRef<HTMLDivElement>(null);
356
+ const [tipW, setTipW] = useState(120);
357
+ useEffect(() => {
358
+ if (tipRef.current) {
359
+ const w = tipRef.current.offsetWidth;
360
+ if (w && Math.abs(w - tipW) > 2) setTipW(w);
361
+ }
362
+ }, [hoverInfo, tipW]);
363
+ // Determine start and end dates
364
+ let startDate: Date;
365
+ let endDate: Date;
366
+
367
+ if (range) {
368
+ startDate = new Date(range.start);
369
+ endDate = new Date(range.end);
370
+ } else {
371
+ const targetYear = year || new Date().getFullYear();
372
+ startDate = new Date(targetYear, 0, 1);
373
+ endDate = new Date(targetYear, 11, 31);
374
+ }
375
+
376
+ // Create map for fast lookup
377
+ const dataMap = new Map(data.map(d => [d.date, d.count]));
378
+
379
+ // Helper to get color
380
+ const getColor = (count: number) => {
381
+ if (count === 0) return 'bg-gray-100 dark:bg-gray-800';
382
+ if (count <= 2) return 'bg-green-100 dark:bg-green-900/40';
383
+ if (count <= 5) return 'bg-green-300 dark:bg-green-800/60';
384
+ if (count <= 10) return 'bg-green-500 dark:bg-green-700';
385
+ return 'bg-green-700 dark:bg-green-600';
386
+ };
387
+
388
+ // Build weeks array
389
+ const weeks = [];
390
+ const currentDate = new Date(startDate);
391
+
392
+ while (currentDate.getDay() !== 1) {
393
+ currentDate.setDate(currentDate.getDate() - 1);
394
+ }
395
+
396
+ // Helper to check if date is within target range
397
+ const isDateWithinRange = (d: Date) => {
398
+ // Reset time part for comparison
399
+ const checkDate = new Date(d);
400
+ checkDate.setHours(0, 0, 0, 0);
401
+ const s = new Date(startDate);
402
+ s.setHours(0, 0, 0, 0);
403
+ const e = new Date(endDate);
404
+ e.setHours(0, 0, 0, 0);
405
+ return checkDate >= s && checkDate <= e;
406
+ };
407
+
408
+ // Loop until we cover the end date
409
+ // We also add a safety break to avoid infinite loops
410
+ let safetyCounter = 0;
411
+ while (true) {
412
+ const week = [];
413
+ for (let i = 0; i < 7; i++) {
414
+ const dateStr = currentDate.toISOString().split('T')[0];
415
+ const isWithin = isDateWithinRange(currentDate);
416
+
417
+ week.push({
418
+ date: dateStr,
419
+ count: isWithin ? (dataMap.get(dateStr) || 0) : -1,
420
+ isWithinRange: isWithin
421
+ });
422
+ currentDate.setDate(currentDate.getDate() + 1);
423
+ }
424
+ weeks.push(week);
425
+
426
+ // Break if the start of the next week is beyond endDate
427
+ if (currentDate > endDate) break;
428
+
429
+ // Safety break (approx 2 years)
430
+ if (safetyCounter++ > 110) break;
431
+ }
432
+
433
+ // Limit to 53 weeks max to fit layout if not custom range
434
+ const displayWeeks = weeks.slice(0, 54);
435
+
436
+ // Generate month labels aligned with weeks
437
+ const monthLabels = displayWeeks.map((week) => {
438
+ const firstDayOfMonth = week.find(d => d.date.endsWith('-01'));
439
+ if (firstDayOfMonth && firstDayOfMonth.isWithinRange) {
440
+ const month = parseInt(firstDayOfMonth.date.slice(5, 7));
441
+ return `${month}月`;
442
+ }
443
+ return null;
444
+ });
445
+
446
+ return (
447
+ <div className="w-full overflow-x-auto">
448
+ <div ref={containerRef} className="min-w-[900px] w-full flex flex-col relative">
449
+ <div className="flex justify-between w-full">
450
+ {displayWeeks.map((week, wIdx) => (
451
+ <div key={wIdx} className="flex flex-col gap-[3px]">
452
+ {week.map((day, dIdx) => (
453
+ <div
454
+ key={dIdx}
455
+ className={`w-3.5 h-3.5 rounded-sm transition-colors ${day.isWithinRange ? getColor(day.count) : 'bg-gray-50 dark:bg-gray-800/30 border border-gray-200 dark:border-gray-700'}`}
456
+ onMouseEnter={(e) => {
457
+ const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
458
+ const contRect = containerRef.current?.getBoundingClientRect();
459
+ if (!contRect) return;
460
+ const x = rect.left - contRect.left + rect.width / 2;
461
+ const y = rect.top - contRect.top - 8;
462
+ setHoverInfo({
463
+ date: day.date,
464
+ count: Math.max(0, day.count),
465
+ x,
466
+ y,
467
+ cw: contRect.width
468
+ });
469
+ }}
470
+ onMouseLeave={() => setHoverInfo(null)}
471
+ />
472
+ ))}
473
+ </div>
474
+ ))}
475
+ </div>
476
+ <div className="flex justify-between mt-2 w-full">
477
+ {monthLabels.map((label, idx) => (
478
+ <div key={idx} className="w-3.5 relative h-4">
479
+ {label && (
480
+ <span className="absolute left-0 top-0 text-xs text-gray-400 whitespace-nowrap">
481
+ {label}
482
+ </span>
483
+ )}
484
+ </div>
485
+ ))}
486
+ </div>
487
+ {hoverInfo && (
488
+ <div
489
+ ref={tipRef}
490
+ className="absolute z-50 px-2 py-1 rounded-md bg-black/80 text-white text-[11px] shadow pointer-events-none"
491
+ style={{
492
+ left: Math.min(Math.max(8, hoverInfo.x - tipW / 2), hoverInfo.cw - tipW - 8),
493
+ top: Math.max(0, hoverInfo.y - 28)
494
+ }}
495
+ >
496
+ <div className="tabular-nums">{hoverInfo.date}</div>
497
+ <div className="opacity-80">创建/更新 {hoverInfo.count}</div>
498
+ </div>
499
+ )}
500
+ </div>
501
+ </div>
502
+ );
503
+ };
504
+
505
+ export default function StatsPage() {
506
+ const router = useRouter();
507
+ const [statsData, setStatsData] = useState<StatsData | null>(null);
508
+ const [loading, setLoading] = useState(true);
509
+ const [selectedYear, setSelectedYear] = useState<number | 'all'>('all');
510
+ const [displayYear, setDisplayYear] = useState<number | 'all'>('all');
511
+ const [debouncedYear, setDebouncedYear] = useState<number | 'all'>('all');
512
+ const [isRefreshing, setIsRefreshing] = useState(false);
513
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
514
+ const [forceRefresh, setForceRefresh] = useState(false);
515
+ const [showReport, setShowReport] = useState(false);
516
+ const [showAllYears, setShowAllYears] = useState(false);
517
+
518
+ const cacheRef = useRef(new Map<string, { data: StatsData; ts: number }>());
519
+ const inflightRef = useRef<Map<string, AbortController>>(new Map());
520
+ const scrollYRef = useRef<number | null>(null);
521
+
522
+ useEffect(() => {
523
+ const handle = setTimeout(() => setDebouncedYear(selectedYear), 160);
524
+ return () => clearTimeout(handle);
525
+ }, [selectedYear]);
526
+
527
+ const fetchStatsFromApi = useCallback(async (year: number | 'all', signal: AbortSignal) => {
528
+ const res = await fetch(`/api/stats?year=${year}&t=${Date.now()}`, {
529
+ cache: 'no-store',
530
+ headers: {
531
+ 'Pragma': 'no-cache',
532
+ 'Cache-Control': 'no-cache, no-store, must-revalidate'
533
+ },
534
+ signal
535
+ });
536
+ if (!res.ok) {
537
+ throw new Error(`HTTP ${res.status}`);
538
+ }
539
+ return (await res.json()) as StatsData;
540
+ }, []);
541
+
542
+ const fetchWithRetry = useCallback(async (year: number | 'all', signal: AbortSignal) => {
543
+ const maxRetries = 2;
544
+ const baseDelayMs = 300;
545
+
546
+ let lastError: unknown = null;
547
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
548
+ if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
549
+ try {
550
+ return await fetchStatsFromApi(year, signal);
551
+ } catch (err) {
552
+ lastError = err;
553
+ if (signal.aborted) throw err;
554
+ if (attempt === maxRetries) break;
555
+ const delayMs = baseDelayMs * Math.pow(2, attempt);
556
+ await new Promise<void>((resolve) => {
557
+ const t = setTimeout(resolve, delayMs);
558
+ signal.addEventListener('abort', () => {
559
+ clearTimeout(t);
560
+ resolve();
561
+ }, { once: true });
562
+ });
563
+ }
564
+ }
565
+ throw lastError;
566
+ }, [fetchStatsFromApi]);
567
+
568
+ const applyDataForYear = useCallback((year: number | 'all', data: StatsData) => {
569
+ setStatsData(data);
570
+ setDisplayYear(year);
571
+ }, []);
572
+
573
+ const loadYear = useCallback(async (year: number | 'all', options?: { forceRefresh?: boolean }) => {
574
+ const forceRefresh = options?.forceRefresh ?? false;
575
+ const key = String(year);
576
+ const now = Date.now();
577
+ const ttlMs = 5 * 60 * 1000;
578
+
579
+ const cached = cacheRef.current.get(key);
580
+ const isFresh = cached ? (now - cached.ts) < ttlMs : false;
581
+
582
+ if (cached && !forceRefresh) {
583
+ applyDataForYear(year, cached.data);
584
+ setLoading(false);
585
+ setErrorMessage(null);
586
+ if (isFresh) return;
587
+ }
588
+
589
+ inflightRef.current.forEach((controller) => controller.abort());
590
+ inflightRef.current.clear();
591
+
592
+ const controller = new AbortController();
593
+ inflightRef.current.set(key, controller);
594
+
595
+ if (typeof window !== 'undefined') {
596
+ scrollYRef.current = window.scrollY;
597
+ }
598
+
599
+ setIsRefreshing(true);
600
+ setErrorMessage(null);
601
+ if (!statsData && !cached) setLoading(true);
602
+
603
+ try {
604
+ const data = await fetchWithRetry(year, controller.signal);
605
+ if (controller.signal.aborted) return;
606
+ cacheRef.current.set(key, { data, ts: Date.now() });
607
+ applyDataForYear(year, data);
608
+ } catch (err) {
609
+ if ((err as { name?: string } | null)?.name === 'AbortError') return;
610
+ setErrorMessage('加载失败');
611
+ } finally {
612
+ inflightRef.current.delete(key);
613
+ setIsRefreshing(false);
614
+ setLoading(false);
615
+ if (typeof window !== 'undefined' && scrollYRef.current !== null) {
616
+ const y = scrollYRef.current;
617
+ scrollYRef.current = null;
618
+ requestAnimationFrame(() => window.scrollTo({ top: y }));
619
+ }
620
+ }
621
+ }, [applyDataForYear, fetchWithRetry, statsData]);
622
+
623
+ const prefetchYear = useCallback(async (year: number) => {
624
+ const key = String(year);
625
+ const now = Date.now();
626
+ const ttlMs = 5 * 60 * 1000;
627
+
628
+ const cached = cacheRef.current.get(key);
629
+ if (cached && (now - cached.ts) < ttlMs) return;
630
+ if (inflightRef.current.has(key)) return;
631
+
632
+ const controller = new AbortController();
633
+ inflightRef.current.set(key, controller);
634
+ try {
635
+ const data = await fetchWithRetry(year, controller.signal);
636
+ if (controller.signal.aborted) return;
637
+ cacheRef.current.set(key, { data, ts: Date.now() });
638
+ } catch (err) {
639
+ if ((err as { name?: string } | null)?.name === 'AbortError') return;
640
+ } finally {
641
+ inflightRef.current.delete(key);
642
+ }
643
+ }, [fetchWithRetry]);
644
+
645
+ useEffect(() => {
646
+ const inflightMap = inflightRef.current;
647
+ let disposed = false;
648
+
649
+ void (async () => {
650
+ await loadYear(debouncedYear, { forceRefresh });
651
+ if (!disposed && forceRefresh) setForceRefresh(false);
652
+ })();
653
+
654
+ return () => {
655
+ disposed = true;
656
+ inflightMap.forEach((controller) => controller.abort());
657
+ inflightMap.clear();
658
+ };
659
+ }, [debouncedYear, forceRefresh, loadYear]);
660
+
661
+ useEffect(() => {
662
+ if (!statsData?.years?.length) return;
663
+ if (displayYear === 'all') {
664
+ const nowYear = new Date().getFullYear();
665
+ const candidates = statsData.years.filter((y) => y !== nowYear).slice(0, 1);
666
+ void prefetchYear(nowYear);
667
+ if (candidates[0]) void prefetchYear(candidates[0]);
668
+ return;
669
+ }
670
+ if (typeof displayYear !== 'number') return;
671
+ const years = statsData.years;
672
+ const idx = years.indexOf(displayYear);
673
+ if (idx > 0) void prefetchYear(years[idx - 1]);
674
+ if (idx >= 0 && idx < years.length - 1) void prefetchYear(years[idx + 1]);
675
+ }, [displayYear, prefetchYear, statsData?.years, statsData?.years?.length]);
676
+
677
+ const heatmapProps = displayYear === 'all'
678
+ ? {
679
+ range: {
680
+ start: new Date(new Date().setFullYear(new Date().getFullYear() - 1)),
681
+ end: new Date()
682
+ }
683
+ }
684
+ : { year: typeof displayYear === 'number' ? displayYear : new Date().getFullYear() };
685
+
686
+ const heatmapTitle = displayYear === 'all'
687
+ ? "活跃度 (近一年)"
688
+ : `活跃度 (${displayYear}年)`;
689
+
690
+ const formatWords = (words: number | undefined) => {
691
+ if (typeof words !== 'number') return '-';
692
+ return (words / 10000).toFixed(1);
693
+ };
694
+
695
+ if (loading && !statsData) {
696
+ return (
697
+ <div className="min-h-screen bg-white dark:bg-gray-900 flex items-center justify-center">
698
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
699
+ </div>
700
+ );
701
+ }
702
+
703
+ if (errorMessage && !statsData) {
704
+ return (
705
+ <div className="min-h-screen bg-white dark:bg-gray-900 flex flex-col items-center justify-center gap-4">
706
+ <div className="text-red-500 font-medium">{errorMessage}</div>
707
+ <button
708
+ onClick={() => setForceRefresh(true)}
709
+ className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
710
+ >
711
+ 重试
712
+ </button>
713
+ </div>
714
+ );
715
+ }
716
+
717
+ if (!statsData) return null;
718
+
719
+ return (
720
+ <div className="min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
721
+ {/* Header */}
722
+ <div className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 sticky top-0 z-10">
723
+ <div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
724
+ <div className="flex items-center justify-between h-16">
725
+ <div className="flex items-center gap-4">
726
+ <button onClick={() => router.back()} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition text-gray-500">
727
+ <ArrowLeft className="w-5 h-5" />
728
+ </button>
729
+ <h1 className="text-xl font-bold flex items-center gap-2">
730
+ <BarChart2 className="w-6 h-6 text-primary-600" />
731
+ 知识库统计
732
+ </h1>
733
+ </div>
734
+
735
+ <div className="flex items-center gap-3">
736
+ <div className="flex items-center gap-2">
737
+ <select
738
+ value={selectedYear}
739
+ onChange={(e) => setSelectedYear(e.target.value === 'all' ? 'all' : parseInt(e.target.value))}
740
+ className="bg-gray-100 dark:bg-gray-800 border-none rounded-lg py-1.5 px-3 text-sm font-medium focus:ring-2 focus:ring-primary-500 cursor-pointer"
741
+ >
742
+ <option value="all">所有年份</option>
743
+ {statsData?.years.map(y => (
744
+ <option key={y} value={y}>{y}年</option>
745
+ ))}
746
+ </select>
747
+ {isRefreshing && (
748
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-600" />
749
+ )}
750
+ </div>
751
+ {errorMessage && (
752
+ <button
753
+ type="button"
754
+ onClick={() => setForceRefresh(true)}
755
+ className="text-xs font-medium text-red-600 dark:text-red-400 hover:underline"
756
+ >
757
+ {errorMessage},重试
758
+ </button>
759
+ )}
760
+
761
+ <button
762
+ onClick={() => setShowReport(true)}
763
+ className="flex items-center gap-2 px-3 py-1.5 bg-gradient-to-r from-primary-600 to-purple-600 text-white text-sm font-medium rounded-lg hover:shadow-lg transition-all active:scale-95"
764
+ >
765
+ <Sparkles className="w-4 h-4" />
766
+ 年度报告
767
+ </button>
768
+ </div>
769
+ </div>
770
+ </div>
771
+ </div>
772
+
773
+ <main className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
774
+ <div className={`transition-opacity duration-200 ${isRefreshing ? 'opacity-70' : 'opacity-100'}`}>
775
+ <>
776
+ {/* Summary Cards */}
777
+ <div key={displayYear} className="grid grid-cols-1 md:grid-cols-2 gap-6">
778
+ <div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-800 flex items-center gap-6">
779
+ <div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-xl text-blue-600 dark:text-blue-400">
780
+ <FileText className="w-8 h-8" />
781
+ </div>
782
+ <div>
783
+ <p className="text-sm text-gray-500 dark:text-gray-400 font-medium">新增文档数</p>
784
+ <h3 className="text-3xl font-bold mt-1">{statsData?.stats.docsCount ?? statsData?.stats.totalDocs}</h3>
785
+ </div>
786
+ </div>
787
+
788
+ <div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-800 flex items-center gap-6">
789
+ <div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-xl text-green-600 dark:text-green-400">
790
+ <Type className="w-8 h-8" />
791
+ </div>
792
+ <div>
793
+ <p className="text-sm text-gray-500 dark:text-gray-400 font-medium">{displayYear === 'all' ? '总字数' : '年度字数(创建或更新)'}</p>
794
+ <h3 className="text-3xl font-bold mt-1">{formatWords(statsData?.stats.totalWords)} <span className="text-sm text-gray-400 font-normal">万字</span></h3>
795
+ </div>
796
+ </div>
797
+
798
+ <div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-800 flex items-center gap-6">
799
+ <div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-xl text-purple-600 dark:text-purple-400">
800
+ <FileText className="w-8 h-8" />
801
+ </div>
802
+ <div>
803
+ <p className="text-sm text-gray-500 dark:text-gray-400 font-medium">新增小记数</p>
804
+ <h3 className="text-3xl font-bold mt-1">{statsData?.stats.notesCount ?? 0}</h3>
805
+ </div>
806
+ </div>
807
+
808
+ <div className="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-800 flex items-center gap-6">
809
+ <div className="p-4 bg-orange-50 dark:bg-orange-900/20 rounded-xl text-orange-600 dark:text-orange-400">
810
+ <DatabaseIcon className="w-8 h-8" />
811
+ </div>
812
+ <div>
813
+ <p className="text-sm text-gray-500 dark:text-gray-400 font-medium">{displayYear === 'all' ? '总文档数' : '年度总文档数'}</p>
814
+ <h3 className="text-3xl font-bold mt-1">{statsData?.stats.totalDocs ?? 0}</h3>
815
+ </div>
816
+ </div>
817
+ </div>
818
+
819
+ {/* Heatmap Card */}
820
+ <div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700 p-6">
821
+ <div className="mb-4 flex items-center justify-between">
822
+ <h2 className="text-lg font-bold flex items-center gap-2">
823
+ <Calendar className="w-5 h-5 text-primary-600" />
824
+ {heatmapTitle}
825
+ </h2>
826
+ <div className="flex items-center gap-2">
827
+ <span className="text-xs text-gray-400">少</span>
828
+ <div className="flex items-center gap-[3px]">
829
+ <div className="w-3.5 h-3.5 rounded-sm bg-gray-100 dark:bg-gray-800" />
830
+ <div className="w-3.5 h-3.5 rounded-sm bg-green-100 dark:bg-green-900/40" />
831
+ <div className="w-3.5 h-3.5 rounded-sm bg-green-300 dark:bg-green-800/60" />
832
+ <div className="w-3.5 h-3.5 rounded-sm bg-green-500 dark:bg-green-700" />
833
+ <div className="w-3.5 h-3.5 rounded-sm bg-green-700 dark:bg-green-600" />
834
+ </div>
835
+ <span className="text-xs text-gray-400">多</span>
836
+ </div>
837
+ </div>
838
+ {statsData?.stats.heatmap && (
839
+ <Heatmap data={statsData.stats.heatmap} {...heatmapProps} />
840
+ )}
841
+ </div>
842
+
843
+ {/* KB Breakdown */}
844
+ <div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-800 overflow-hidden">
845
+ <div className="px-6 py-4 border-b border-gray-100 dark:border-gray-800">
846
+ <h3 className="text-lg font-bold">知识库明细</h3>
847
+ </div>
848
+ <div className="overflow-x-auto">
849
+ {(() => {
850
+ const totalDocs = statsData?.stats.totalDocs || 1;
851
+ const totalWords = statsData?.stats.totalWords || 1;
852
+
853
+ return (
854
+ <table className="w-full text-left text-sm">
855
+ <thead className="bg-gray-50 dark:bg-gray-900/50">
856
+ <tr>
857
+ <th className="px-6 py-3 font-medium text-gray-500 dark:text-gray-400">知识库名称</th>
858
+ <th className="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 text-right">文档数</th>
859
+ <th className="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 text-right">字数</th>
860
+ <th className="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 text-right">占比 (文档)</th>
861
+ <th className="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 text-right">占比 (字数)</th>
862
+ </tr>
863
+ </thead>
864
+ <tbody className="divide-y divide-gray-100 dark:divide-gray-800">
865
+ {statsData?.stats.kbStats.map((kb) => {
866
+ const docPercent = Math.round((kb.count / totalDocs) * 100);
867
+ const wordPercent = Math.round((kb.words / totalWords) * 100);
868
+
869
+ return (
870
+ <tr key={kb.namespace} className="hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
871
+ <td className="px-6 py-4 font-medium">{kb.name}</td>
872
+ <td className="px-6 py-4 text-right text-gray-600 dark:text-gray-300">{kb.count}</td>
873
+ <td className="px-6 py-4 text-right text-gray-600 dark:text-gray-300">{(kb.words / 10000).toFixed(1)}万</td>
874
+ <td className="px-6 py-4 text-right">
875
+ <div className="flex items-center justify-end gap-2">
876
+ <span className="text-gray-500">{docPercent}%</span>
877
+ <div className="w-16 h-1.5 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
878
+ <div
879
+ className="h-full bg-blue-500 rounded-full"
880
+ style={{ width: `${docPercent}%` }}
881
+ />
882
+ </div>
883
+ </div>
884
+ </td>
885
+ <td className="px-6 py-4 text-right">
886
+ <div className="flex items-center justify-end gap-2">
887
+ <span className="text-gray-500">{wordPercent}%</span>
888
+ <div className="w-16 h-1.5 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
889
+ <div
890
+ className="h-full bg-green-500 rounded-full"
891
+ style={{ width: `${wordPercent}%` }}
892
+ />
893
+ </div>
894
+ </div>
895
+ </td>
896
+ </tr>
897
+ );
898
+ })}
899
+ </tbody>
900
+ </table>
901
+ );
902
+ })()}
903
+ </div>
904
+ </div>
905
+
906
+ {/* Annual Stats Table (History) */}
907
+ {displayYear === 'all' && statsData?.stats.annualStats && (
908
+ <div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700 p-6">
909
+ <div className="mb-4 flex items-center justify-between">
910
+ <h2 className="text-lg font-bold flex items-center gap-2">
911
+ <FileText className="w-5 h-5 text-primary-600" />
912
+ 历年新增统计
913
+ </h2>
914
+ <button
915
+ onClick={() => setShowAllYears(v => !v)}
916
+ className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300"
917
+ >
918
+ {showAllYears ? '收起旧年份' : '展开旧年份'}
919
+ </button>
920
+ </div>
921
+ <div className="overflow-x-auto">
922
+ <table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
923
+ <thead>
924
+ <tr>
925
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">年份</th>
926
+ <th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">文档数</th>
927
+ <th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
928
+ <div className="flex items-center justify-end gap-2">
929
+ <span className="w-12 text-right">占比</span>
930
+ <div className="w-16 h-1.5 rounded-full overflow-hidden opacity-0">
931
+ <div className="h-full bg-primary-600 rounded-full w-full" />
932
+ </div>
933
+ </div>
934
+ </th>
935
+ <th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">小记数</th>
936
+ <th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
937
+ <div className="flex items-center justify-end gap-2">
938
+ <span className="w-12 text-right">占比</span>
939
+ <div className="w-16 h-1.5 rounded-full overflow-hidden opacity-0">
940
+ <div className="h-full bg-purple-500 rounded-full w-full" />
941
+ </div>
942
+ </div>
943
+ </th>
944
+ <th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">总文档数</th>
945
+ <th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
946
+ <div className="flex items-center justify-end gap-2">
947
+ <span className="w-12 text-right">占比</span>
948
+ <div className="w-16 h-1.5 rounded-full overflow-hidden opacity-0">
949
+ <div className="h-full bg-blue-500 rounded-full w-full" />
950
+ </div>
951
+ </div>
952
+ </th>
953
+ <th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">总字数</th>
954
+ <th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
955
+ <div className="flex items-center justify-end gap-2">
956
+ <span className="w-12 text-right">占比</span>
957
+ <div className="w-16 h-1.5 rounded-full overflow-hidden opacity-0">
958
+ <div className="h-full bg-green-500 rounded-full w-full" />
959
+ </div>
960
+ </div>
961
+ </th>
962
+ </tr>
963
+ </thead>
964
+ <tbody className="divide-y divide-gray-200 dark:divide-gray-700">
965
+ {(() => {
966
+ const annualStats = statsData.stats.annualStats || [];
967
+ const totalCount = annualStats.reduce((sum, s) => sum + s.count, 0);
968
+ const totalNotes = annualStats.reduce((sum, s) => sum + (s.notes_count || 0), 0);
969
+ const totalPureDocs = totalCount - totalNotes;
970
+ const totalWords = annualStats.reduce((sum, s) => sum + s.words, 0);
971
+ const displayStats = showAllYears ? annualStats : annualStats.slice(0, 10);
972
+
973
+ return (
974
+ <>
975
+ {/* Total Row */}
976
+ <tr className="bg-gray-50 dark:bg-gray-700/30 font-bold">
977
+ <td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">总计</td>
978
+ <td className="px-4 py-3 whitespace-nowrap text-sm text-right text-gray-900 dark:text-gray-100">
979
+ {totalPureDocs}
980
+ </td>
981
+ <td className="px-4 py-3 whitespace-nowrap text-sm text-right text-gray-900 dark:text-gray-100">
982
+ <div className="flex items-center justify-end gap-2">
983
+ <span className="w-12 text-right">100%</span>
984
+ <div className="w-16 h-1.5 rounded-full overflow-hidden opacity-0">
985
+ <div className="h-full bg-primary-600 rounded-full w-full" />
986
+ </div>
987
+ </div>
988
+ </td>
989
+ <td className="px-4 py-3 whitespace-nowrap text-sm text-right text-gray-900 dark:text-gray-100">
990
+ {totalNotes}
991
+ </td>
992
+ <td className="px-4 py-3 whitespace-nowrap text-sm text-right text-gray-900 dark:text-gray-100">
993
+ <div className="flex items-center justify-end gap-2">
994
+ <span className="w-12 text-right">100%</span>
995
+ <div className="w-16 h-1.5 rounded-full overflow-hidden opacity-0">
996
+ <div className="h-full bg-purple-500 rounded-full w-full" />
997
+ </div>
998
+ </div>
999
+ </td>
1000
+ <td className="px-4 py-3 whitespace-nowrap text-sm text-right text-gray-900 dark:text-gray-100">
1001
+ {totalCount}
1002
+ </td>
1003
+ <td className="px-4 py-3 whitespace-nowrap text-sm text-right text-gray-900 dark:text-gray-100">
1004
+ <div className="flex items-center justify-end gap-2">
1005
+ <span className="w-12 text-right">100%</span>
1006
+ <div className="w-16 h-1.5 rounded-full overflow-hidden opacity-0">
1007
+ <div className="h-full bg-blue-500 rounded-full w-full" />
1008
+ </div>
1009
+ </div>
1010
+ </td>
1011
+ <td className="px-4 py-3 whitespace-nowrap text-sm text-right text-gray-900 dark:text-gray-100">
1012
+ {(totalWords / 10000).toFixed(1)}w
1013
+ </td>
1014
+ <td className="px-4 py-3 whitespace-nowrap text-sm text-right text-gray-900 dark:text-gray-100">
1015
+ <div className="flex items-center justify-end gap-2">
1016
+ <span className="w-12 text-right">100%</span>
1017
+ <div className="w-16 h-1.5 rounded-full overflow-hidden opacity-0">
1018
+ <div className="h-full bg-green-500 rounded-full w-full" />
1019
+ </div>
1020
+ </div>
1021
+ </td>
1022
+ </tr>
1023
+ {displayStats.map((stat) => {
1024
+ const pureDocsCount = stat.count - (stat.notes_count || 0);
1025
+ const countPercent = totalPureDocs > 0 ? (pureDocsCount / totalPureDocs) * 100 : 0;
1026
+ const notePercent = totalNotes > 0 ? ((stat.notes_count || 0) / totalNotes) * 100 : 0;
1027
+ const wordPercent = totalWords > 0 ? (stat.words / totalWords) * 100 : 0;
1028
+ const docTotalPercent = totalCount > 0 ? (stat.count / totalCount) * 100 : 0;
1029
+
1030
+ return (
1031
+ <tr key={stat.year} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
1032
+ <td className="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">{stat.year}</td>
1033
+ <td className="px-4 py-3 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">{pureDocsCount}</td>
1034
+ <td className="px-4 py-3 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">
1035
+ <div className="flex items-center justify-end gap-2">
1036
+ <span className="w-12 text-right">{countPercent.toFixed(1)}%</span>
1037
+ <div className="w-16 h-1.5 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
1038
+ <div
1039
+ className="h-full bg-primary-500/60 rounded-full"
1040
+ style={{ width: `${countPercent}%` }}
1041
+ />
1042
+ </div>
1043
+ </div>
1044
+ </td>
1045
+ <td className="px-4 py-3 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">{stat.notes_count || 0}</td>
1046
+ <td className="px-4 py-3 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">
1047
+ <div className="flex items-center justify-end gap-2">
1048
+ <span className="w-12 text-right">{notePercent.toFixed(1)}%</span>
1049
+ <div className="w-16 h-1.5 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
1050
+ <div
1051
+ className="h-full bg-purple-500/60 rounded-full"
1052
+ style={{ width: `${notePercent}%` }}
1053
+ />
1054
+ </div>
1055
+ </div>
1056
+ </td>
1057
+ <td className="px-4 py-3 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">{stat.count}</td>
1058
+ <td className="px-4 py-3 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">
1059
+ <div className="flex items-center justify-end gap-2">
1060
+ <span className="w-12 text-right">{docTotalPercent.toFixed(1)}%</span>
1061
+ <div className="w-16 h-1.5 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
1062
+ <div
1063
+ className="h-full bg-blue-500/60 rounded-full"
1064
+ style={{ width: `${docTotalPercent}%` }}
1065
+ />
1066
+ </div>
1067
+ </div>
1068
+ </td>
1069
+ <td className="px-4 py-3 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">{(stat.words / 10000).toFixed(1)}w</td>
1070
+ <td className="px-4 py-3 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">
1071
+ <div className="flex items-center justify-end gap-2">
1072
+ <span className="w-12 text-right">{wordPercent.toFixed(1)}%</span>
1073
+ <div className="w-16 h-1.5 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
1074
+ <div
1075
+ className="h-full bg-green-500/60 rounded-full"
1076
+ style={{ width: `${wordPercent}%` }}
1077
+ />
1078
+ </div>
1079
+ </div>
1080
+ </td>
1081
+ </tr>
1082
+ );
1083
+ })}
1084
+ </>
1085
+ );
1086
+ })()}
1087
+ </tbody>
1088
+ </table>
1089
+ </div>
1090
+ </div>
1091
+ )}
1092
+ </>
1093
+ </div>
1094
+ </main>
1095
+
1096
+ <AnnualReportModal
1097
+ isOpen={showReport}
1098
+ onClose={() => setShowReport(false)}
1099
+ year={displayYear}
1100
+ data={statsData?.stats || { totalDocs: 0, totalWords: 0, kbStats: [], heatmap: [] }}
1101
+ />
1102
+ </div>
1103
+ );
1104
+ }
src/app/login/page.tsx ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { useState, useRef, useEffect } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+ import { ChevronDown, Lock, User, X } from 'lucide-react'
6
+
7
+ export default function LoginPage() {
8
+ const router = useRouter()
9
+ const [username, setUsername] = useState('duqing')
10
+ const [password, setPassword] = useState('')
11
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false)
12
+ const [isInputFocused, setIsInputFocused] = useState(false)
13
+ const [error, setError] = useState('')
14
+ const dropdownRef = useRef<HTMLDivElement>(null)
15
+ const inputRef = useRef<HTMLInputElement>(null)
16
+
17
+ const users = ['duqing', 'admin', 'guest']
18
+
19
+ useEffect(() => {
20
+ function handleClickOutside(event: MouseEvent) {
21
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
22
+ setIsDropdownOpen(false)
23
+ }
24
+ }
25
+ document.addEventListener("mousedown", handleClickOutside)
26
+ return () => {
27
+ document.removeEventListener("mousedown", handleClickOutside)
28
+ }
29
+ }, [])
30
+
31
+ const handleLogin = async (e: React.FormEvent) => {
32
+ e.preventDefault()
33
+ setError('')
34
+
35
+ // Validation
36
+ // Check for length 8
37
+ if (password.length !== 8) {
38
+ setError('密码长度必须为8位')
39
+ return
40
+ }
41
+
42
+ // Check characters (Chinese, English, Numbers)
43
+ if (!/^[\u4e00-\u9fa5a-zA-Z0-9]+$/.test(password)) {
44
+ setError('密码只能包含中英文或数字')
45
+ return
46
+ }
47
+
48
+ // Check for "correct" credentials
49
+ // Correct password for "duqing" is "1234qwer"
50
+ if (username === 'duqing' && password === '1234qwer') {
51
+ // Set cookie
52
+ document.cookie = `auth_token=valid_token; path=/; max-age=${60 * 60 * 24}` // 1 day
53
+ // Clear last session ID to ensure we start with a new chat
54
+ localStorage.removeItem('rag_kb_current_session_id');
55
+ router.push('/')
56
+ } else {
57
+ setError('账号或密码错误')
58
+ }
59
+ }
60
+
61
+ const handleSelectUser = (user: string) => {
62
+ setUsername(user)
63
+ setIsDropdownOpen(false)
64
+ }
65
+
66
+ return (
67
+ <div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 transition-colors duration-200">
68
+ <div className="max-w-md w-full space-y-8 p-10 bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-100 dark:border-gray-700">
69
+ <div className="text-center">
70
+ <h1 className="text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-primary-400">
71
+ RAG 知识库系统
72
+ </h1>
73
+ <h2 className="mt-4 text-xl font-medium text-gray-900 dark:text-gray-100">
74
+ 欢迎回来
75
+ </h2>
76
+ <p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
77
+ 请登录您的账户以继续
78
+ </p>
79
+ </div>
80
+
81
+ <form className="mt-8 space-y-6" onSubmit={handleLogin}>
82
+ <div className="space-y-5">
83
+
84
+ {/* Username Field with Custom Select */}
85
+ <div className="relative" ref={dropdownRef}>
86
+ <label htmlFor="username" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
87
+ 账号
88
+ </label>
89
+ <div className="relative flex items-center group">
90
+ <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none z-10">
91
+ <User className="h-5 w-5 text-gray-400 group-focus-within:text-primary-500 transition-colors" />
92
+ </div>
93
+ <input
94
+ ref={inputRef}
95
+ id="username"
96
+ name="username"
97
+ type="text"
98
+ required
99
+ className="appearance-none rounded-xl relative block w-full pl-10 pr-10 py-3 border border-gray-300 dark:border-gray-600 placeholder-gray-400 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 sm:text-sm bg-white dark:bg-gray-700 transition-all duration-200"
100
+ placeholder="请输入账号"
101
+ value={username}
102
+ onChange={(e) => {
103
+ setUsername(e.target.value)
104
+ setIsDropdownOpen(true)
105
+ }}
106
+ onFocus={() => {
107
+ setIsDropdownOpen(true)
108
+ setIsInputFocused(true)
109
+ }}
110
+ onBlur={() => setIsInputFocused(false)}
111
+ autoComplete="off"
112
+ />
113
+ <div
114
+ className="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer hover:text-gray-600 dark:hover:text-gray-200 transition-colors z-10"
115
+ onMouseDown={(e) => e.preventDefault()}
116
+ onClick={(e) => {
117
+ e.stopPropagation()
118
+ if (isInputFocused && username) {
119
+ setUsername('')
120
+ setIsDropdownOpen(true)
121
+ inputRef.current?.focus()
122
+ } else {
123
+ setIsDropdownOpen(!isDropdownOpen)
124
+ if (!isDropdownOpen) {
125
+ inputRef.current?.focus()
126
+ }
127
+ }
128
+ }}
129
+ >
130
+ {isInputFocused && username ? (
131
+ <X className="h-5 w-5 text-gray-400" />
132
+ ) : (
133
+ <ChevronDown className={`h-5 w-5 text-gray-400 transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''}`} />
134
+ )}
135
+ </div>
136
+ </div>
137
+
138
+ {/* Dropdown Menu */}
139
+ {isDropdownOpen && (
140
+ <div className="absolute z-20 mt-2 w-full bg-white dark:bg-gray-700 shadow-lg max-h-60 rounded-xl py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm border border-gray-100 dark:border-gray-600 animate-in fade-in zoom-in-95 duration-100">
141
+ {users.filter(u => u.toLowerCase().includes(username.toLowerCase())).length > 0 ? (
142
+ users.filter(u => u.toLowerCase().includes(username.toLowerCase())).map((user) => (
143
+ <div
144
+ key={user}
145
+ className="cursor-pointer select-none relative py-3 pl-4 pr-9 hover:bg-gray-50 dark:hover:bg-gray-600/50 text-gray-900 dark:text-white transition-colors"
146
+ onMouseDown={(e) => e.preventDefault()}
147
+ onClick={() => handleSelectUser(user)}
148
+ >
149
+ <span className={`block truncate ${username === user ? 'font-semibold text-primary-600 dark:text-primary-400' : 'font-normal'}`}>
150
+ {user}
151
+ </span>
152
+ </div>
153
+ ))) : (
154
+ <div className="py-3 pl-4 pr-9 text-gray-500 dark:text-gray-400">
155
+ 无匹配用户
156
+ </div>
157
+ )}
158
+ </div>
159
+ )}
160
+ </div>
161
+
162
+ {/* Password Field */}
163
+ <div>
164
+ <label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
165
+ 密码
166
+ </label>
167
+ <div className="relative flex items-center group">
168
+ <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none z-10">
169
+ <Lock className="h-5 w-5 text-gray-400 group-focus-within:text-primary-500 transition-colors" />
170
+ </div>
171
+ <input
172
+ id="password"
173
+ name="password"
174
+ type="password"
175
+ required
176
+ className="appearance-none rounded-xl relative block w-full pl-10 px-3 py-3 border border-gray-300 dark:border-gray-600 placeholder-gray-400 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 sm:text-sm bg-white dark:bg-gray-700 transition-all duration-200"
177
+ placeholder="请输入8位密码"
178
+ value={password}
179
+ onChange={(e) => setPassword(e.target.value)}
180
+ />
181
+ </div>
182
+ <p className="mt-2 text-xs text-gray-500 dark:text-gray-400 ml-1">
183
+ 密码长度必须为8位(支持中英文)
184
+ </p>
185
+ </div>
186
+ </div>
187
+
188
+ <div className="flex items-center justify-between">
189
+ <div className="text-sm">
190
+ <a href="#" className="font-medium text-primary-600 hover:text-primary-500 transition-colors">
191
+ 忘记密码?
192
+ </a>
193
+ </div>
194
+ </div>
195
+
196
+ <div className="h-5 flex items-center justify-center">
197
+ <p className={`text-sm text-red-500 font-medium transition-opacity duration-200 ${error ? 'opacity-100' : 'opacity-0'}`}>
198
+ {error}
199
+ </p>
200
+ </div>
201
+
202
+ <div>
203
+ <button
204
+ type="submit"
205
+ className="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-medium rounded-xl text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:focus:ring-offset-gray-800 transition-all duration-200 shadow-md hover:shadow-lg active:scale-[0.98]"
206
+ >
207
+ 登录
208
+ </button>
209
+ </div>
210
+ </form>
211
+ </div>
212
+ </div>
213
+ )
214
+ }
src/app/page.tsx CHANGED
@@ -6,7 +6,7 @@ import { LanguageProvider, useLanguage } from "@/contexts/LanguageContext";
6
  import { ThemeProvider } from "@/contexts/ThemeContext";
7
  import { ThemeSwitcher } from "@/components/ThemeSwitcher";
8
  import { useChatHistory } from "@/hooks/useChatHistory";
9
- import { Globe, MessageSquare, Plus, Trash2, BookOpenCheck, PanelLeftClose, PanelLeftOpen, User, Palette, Database } from "lucide-react";
10
  import { useCallback, useState, useEffect } from "react";
11
  import { Message } from "ai";
12
  import Link from "next/link";
@@ -35,6 +35,7 @@ function HomeContent() {
35
  // It will expand on desktop after mount.
36
  const [isSidebarOpen, setIsSidebarOpen] = useState(false);
37
  const [isTransitionEnabled, setIsTransitionEnabled] = useState(false);
 
38
 
39
  // Helper to update state and localStorage
40
  const setSidebarState = useCallback((isOpen: boolean) => {
@@ -111,12 +112,19 @@ function HomeContent() {
111
  }
112
  }, [currentSessionId, updateSessionMessages]);
113
 
 
 
 
 
 
 
114
  // Better approach:
115
  // We use a separate state to track "pending auto prompt" for a specific session ID.
116
  const [pendingAutoPrompts, setPendingAutoPrompts] = useState<Record<string, string>>({});
117
 
118
  const triggerQuiz = useCallback(async () => {
119
- const newSessionId = await createNewSession();
 
120
  if (newSessionId) {
121
  setPendingAutoPrompts(prev => ({
122
  ...prev,
@@ -192,6 +200,7 @@ function HomeContent() {
192
  <div className="flex flex-col gap-1">
193
  <button
194
  onClick={() => {
 
195
  createNewSession();
196
  }}
197
  className="flex items-center px-2 py-2.5 rounded-lg transition-colors hover:bg-gray-100 text-gray-700 overflow-hidden w-full group"
@@ -217,8 +226,31 @@ function HomeContent() {
217
  {t('knowledgeManagement')}
218
  </span>
219
  </Link>
 
 
 
 
 
 
 
 
 
 
 
 
 
220
 
221
- <Upload collapsed={!isSidebarOpen} className="px-2 py-2.5" iconContainerClass="w-8 h-5 flex justify-center items-center shrink-0" />
 
 
 
 
 
 
 
 
 
 
222
 
223
  <button
224
  onClick={() => {
@@ -280,7 +312,7 @@ function HomeContent() {
280
  {/* User Profile / Bottom Section */}
281
  <div className={`p-2 bg-gray-50 mt-auto ${isSidebarOpen ? 'border-t border-gray-200' : ''}`}>
282
  <div className="group relative">
283
- <div className={`flex items-center px-2 py-2.5 rounded-lg transition-colors w-full relative ${!isSidebarOpen ? 'hover:bg-gray-100 cursor-pointer' : ''}`}>
284
  <div className="w-8 h-8 flex justify-center items-center shrink-0">
285
  <div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center text-gray-600">
286
  <User className="w-4 h-4" />
@@ -292,64 +324,60 @@ function HomeContent() {
292
  </div>
293
 
294
  {isSidebarOpen && (
295
- <div className="flex items-center gap-1 ml-auto shrink-0 z-10">
296
- <ThemeSwitcher />
297
- <button
298
- onClick={(e) => {
299
- e.stopPropagation();
300
- setLanguage(language === 'en' ? 'zh' : 'en');
301
- }}
302
- className="w-8 h-8 flex items-center justify-center text-primary-600 hover:text-primary-700 hover:bg-gray-200 rounded-lg transition-colors cursor-pointer group/lang"
303
- title={language === 'en' ? 'Switch to Chinese' : '切换到英文'}
304
- >
305
- <span className="text-xs font-medium">
306
- {language === 'en' ? '中' : 'En'}
307
- </span>
308
- </button>
309
  </div>
310
  )}
311
  </div>
312
 
313
- {/* Hover Popover - Only when collapsed */}
314
- {!isSidebarOpen && (
315
- <div className="absolute left-full bottom-0 ml-3 w-64 bg-white rounded-xl shadow-xl border border-gray-100 p-4 invisible opacity-0 group-hover:visible group-hover:opacity-100 transition-all duration-200 z-50">
316
- <div className="flex items-center gap-3 mb-4">
317
- <div className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 shrink-0">
318
- <User className="w-5 h-5" />
319
- </div>
320
- <div className="flex-1 min-w-0">
321
- <p className="text-sm font-semibold text-gray-900 truncate">{t('userAccount')}</p>
322
- <p className="text-xs text-gray-500 truncate">{t('freePlan')}</p>
323
- </div>
324
- </div>
325
-
326
- <div className="h-px bg-gray-100 -mx-4 mb-2"></div>
327
-
328
- <ThemeSwitcher
329
- customTrigger={
330
- <div className="flex items-center gap-3 w-full p-2 hover:bg-gray-100 rounded-lg text-sm text-primary-600 hover:text-primary-700 transition-colors cursor-pointer">
331
- <div className="w-6 h-6 flex items-center justify-center shrink-0">
332
- <Palette className="w-5 h-5" />
333
- </div>
334
- <span>主题风格</span>
335
- </div>
336
- }
337
- />
338
-
339
- <button
340
- onClick={(e) => {
341
- e.stopPropagation();
342
- setLanguage(language === 'en' ? 'zh' : 'en');
343
- }}
344
- className="flex items-center gap-3 w-full p-2 hover:bg-gray-100 rounded-lg text-sm text-primary-600 hover:text-primary-700 transition-colors cursor-pointer"
345
- >
346
- <span className="w-6 h-6 flex items-center justify-center bg-gray-100 rounded text-xs font-medium text-primary-600 shrink-0">
347
- {language === 'en' ? '中' : 'En'}
348
- </span>
349
- <span>{language === 'en' ? 'Switch to Chinese' : '切换到英文'}</span>
350
- </button>
351
  </div>
352
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  </div>
354
  </div>
355
 
@@ -384,6 +412,10 @@ function HomeContent() {
384
  initialMessages={currentSession?.messages || []}
385
  onMessagesUpdate={handleMessagesUpdate}
386
  autoSubmitPrompt={currentSessionId ? pendingAutoPrompts[currentSessionId] : undefined}
 
 
 
 
387
  />
388
  </div>
389
  </div>
 
6
  import { ThemeProvider } from "@/contexts/ThemeContext";
7
  import { ThemeSwitcher } from "@/components/ThemeSwitcher";
8
  import { useChatHistory } from "@/hooks/useChatHistory";
9
+ import { Globe, MessageSquare, Plus, Trash2, BookOpenCheck, PanelLeftClose, PanelLeftOpen, User, Palette, Database, BarChart2, LogOut, ChevronUp } from "lucide-react";
10
  import { useCallback, useState, useEffect } from "react";
11
  import { Message } from "ai";
12
  import Link from "next/link";
 
35
  // It will expand on desktop after mount.
36
  const [isSidebarOpen, setIsSidebarOpen] = useState(false);
37
  const [isTransitionEnabled, setIsTransitionEnabled] = useState(false);
38
+ const [useRAG, setUseRAG] = useState(true);
39
 
40
  // Helper to update state and localStorage
41
  const setSidebarState = useCallback((isOpen: boolean) => {
 
112
  }
113
  }, [currentSessionId, updateSessionMessages]);
114
 
115
+ const handleLogout = () => {
116
+ document.cookie = "auth_token=; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT";
117
+ localStorage.removeItem('rag_kb_current_session_id');
118
+ window.location.href = "/login";
119
+ };
120
+
121
  // Better approach:
122
  // We use a separate state to track "pending auto prompt" for a specific session ID.
123
  const [pendingAutoPrompts, setPendingAutoPrompts] = useState<Record<string, string>>({});
124
 
125
  const triggerQuiz = useCallback(async () => {
126
+ setUseRAG(false);
127
+ const newSessionId = await createNewSession('quiz');
128
  if (newSessionId) {
129
  setPendingAutoPrompts(prev => ({
130
  ...prev,
 
200
  <div className="flex flex-col gap-1">
201
  <button
202
  onClick={() => {
203
+ setUseRAG(true);
204
  createNewSession();
205
  }}
206
  className="flex items-center px-2 py-2.5 rounded-lg transition-colors hover:bg-gray-100 text-gray-700 overflow-hidden w-full group"
 
226
  {t('knowledgeManagement')}
227
  </span>
228
  </Link>
229
+
230
+ <Link
231
+ href="/knowledge/stats"
232
+ className="flex items-center px-2 py-2.5 rounded-lg transition-colors hover:bg-gray-100 text-gray-700 overflow-hidden w-full group"
233
+ title="数据统计"
234
+ >
235
+ <div className="w-8 h-5 flex justify-center items-center shrink-0">
236
+ <BarChart2 className="w-5 h-5 text-gray-500 group-hover:text-gray-900" />
237
+ </div>
238
+ <span className={`text-sm font-medium whitespace-nowrap overflow-hidden transition-all duration-200 ease-in-out ${isSidebarOpen ? 'max-w-[200px] opacity-100 ml-3' : 'max-w-0 opacity-0 ml-0'}`}>
239
+ 数据统计
240
+ </span>
241
+ </Link>
242
 
243
+ <Upload
244
+ collapsed={!isSidebarOpen}
245
+ className="px-2 py-2.5"
246
+ iconContainerClass="w-8 h-5 flex justify-center items-center shrink-0"
247
+ onUploadComplete={async (fileName) => {
248
+ setUseRAG(false); // Disable global RAG when chatting with a file
249
+ // If fileName is provided, we can pass it to the session.
250
+ // Note: backend needs to support filtering by file.
251
+ await createNewSession('file', undefined, fileName ? { name: fileName } : undefined);
252
+ }}
253
+ />
254
 
255
  <button
256
  onClick={() => {
 
312
  {/* User Profile / Bottom Section */}
313
  <div className={`p-2 bg-gray-50 mt-auto ${isSidebarOpen ? 'border-t border-gray-200' : ''}`}>
314
  <div className="group relative">
315
+ <div className="flex items-center px-2 py-2.5 rounded-lg transition-colors w-full relative hover:bg-gray-100 cursor-pointer">
316
  <div className="w-8 h-8 flex justify-center items-center shrink-0">
317
  <div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center text-gray-600">
318
  <User className="w-4 h-4" />
 
324
  </div>
325
 
326
  {isSidebarOpen && (
327
+ <div className="flex items-center gap-1 ml-auto shrink-0 z-10 text-gray-400">
328
+ <ChevronUp className="w-4 h-4" />
 
 
 
 
 
 
 
 
 
 
 
 
329
  </div>
330
  )}
331
  </div>
332
 
333
+ {/* Unified Hover Popover - For both Expanded and Collapsed states */}
334
+ <div className={`absolute bottom-0 mb-2 bg-white rounded-xl shadow-xl border border-gray-100 p-4 invisible opacity-0 group-hover:visible group-hover:opacity-100 transition-all duration-200 z-50 left-full ml-3 w-64`}>
335
+ <div className="flex items-center gap-3 mb-4">
336
+ <div className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 shrink-0">
337
+ <User className="w-5 h-5" />
338
+ </div>
339
+ <div className="flex-1 min-w-0">
340
+ <p className="text-sm font-medium text-gray-900 truncate">{t('userAccount')}</p>
341
+ <p className="text-xs text-gray-500 truncate">{t('freePlan')}</p>
342
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  </div>
344
+
345
+ <div className="h-px bg-gray-100 -mx-4 mb-2"></div>
346
+
347
+ <ThemeSwitcher
348
+ isPopover
349
+ label={t('theme')}
350
+ className="w-full flex items-center justify-between px-2 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded-lg cursor-pointer"
351
+ />
352
+
353
+ <button
354
+ onClick={(e) => {
355
+ e.stopPropagation();
356
+ setLanguage(language === 'en' ? 'zh' : 'en');
357
+ }}
358
+ className="flex items-center gap-3 w-full p-2 hover:bg-gray-100 rounded-lg text-sm text-primary-600 hover:text-primary-700 transition-colors cursor-pointer"
359
+ >
360
+ <span className="w-6 h-6 flex items-center justify-center bg-gray-100 rounded text-xs font-medium text-primary-600 shrink-0">
361
+ {language === 'en' ? '中' : 'En'}
362
+ </span>
363
+ <span>{language === 'en' ? 'Switch to Chinese' : '切换到英文'}</span>
364
+ </button>
365
+
366
+ <div className="h-px bg-gray-100 -mx-4 my-2"></div>
367
+
368
+ <button
369
+ onClick={(e) => {
370
+ e.stopPropagation();
371
+ handleLogout();
372
+ }}
373
+ className="flex items-center gap-3 w-full p-2 hover:bg-red-50 rounded-lg text-sm text-red-600 hover:text-red-700 transition-colors cursor-pointer"
374
+ >
375
+ <span className="w-6 h-6 flex items-center justify-center shrink-0">
376
+ <LogOut className="w-5 h-5" />
377
+ </span>
378
+ <span>退出登录</span>
379
+ </button>
380
+ </div>
381
  </div>
382
  </div>
383
 
 
412
  initialMessages={currentSession?.messages || []}
413
  onMessagesUpdate={handleMessagesUpdate}
414
  autoSubmitPrompt={currentSessionId ? pendingAutoPrompts[currentSessionId] : undefined}
415
+ useRAG={useRAG}
416
+ onUseRAGChange={setUseRAG}
417
+ showRAGToggle={!currentSession || (currentSession.type !== 'quiz' && currentSession.type !== 'file')}
418
+ fileInfo={currentSession?.fileInfo}
419
  />
420
  </div>
421
  </div>
src/components/Chat.tsx CHANGED
@@ -2,16 +2,20 @@
2
 
3
  import { useChat } from "@ai-sdk/react";
4
  import { Message } from "ai";
5
- import { Send, User, Bot, ChevronDown, ChevronUp, Database, MessageSquare } from "lucide-react";
6
  import ReactMarkdown from "react-markdown";
7
  import { useEffect, useRef, useState, useMemo } from "react";
8
  import { useLanguage } from "@/contexts/LanguageContext";
9
  import { InteractiveQuiz, QuizQuestion } from "./InteractiveQuiz";
10
 
11
- interface ChatProps {
12
  initialMessages?: Message[];
13
  onMessagesUpdate?: (messages: Message[]) => void;
14
  autoSubmitPrompt?: string;
 
 
 
 
15
  }
16
 
17
  // Helper to process think tags
@@ -23,15 +27,19 @@ const processThinkTags = (content: string) => {
23
  });
24
  };
25
 
26
- const QuizLoadingSkeleton = () => (
27
  <div className="bg-white rounded-xl shadow-sm border border-gray-200 my-4 w-full max-w-xl overflow-hidden animate-pulse">
28
  <div className="p-6 border-b border-gray-100 flex items-center justify-between bg-white">
29
  <div className="h-7 bg-gray-100 rounded-md w-40"></div>
30
  <div className="h-8 bg-gray-100 rounded-md w-20"></div>
31
  </div>
32
  <div className="p-6 space-y-8">
 
 
 
 
33
  {[1, 2].map((i) => (
34
- <div key={i} className="space-y-4">
35
  <div className="h-5 bg-gray-100 rounded w-3/4"></div>
36
  <div className="space-y-3">
37
  {[1, 2, 3, 4].map((j) => (
@@ -63,7 +71,7 @@ const ThinkBlock = ({ children, isThinkingFinished }: { children: React.ReactNod
63
  );
64
  };
65
 
66
- const getMarkdownComponents = (isStreaming: boolean, isThinkingFinished: boolean = true) => ({
67
  pre: ({ children }: React.ComponentPropsWithoutRef<'pre'>) => <>{children}</>,
68
  p: ({ children, node, ...props }: React.ComponentPropsWithoutRef<'p'> & { node?: unknown }) => (
69
  <div {...props} className="!mb-3 last:!mb-0 !leading-relaxed text-gray-800">
@@ -139,7 +147,7 @@ const getMarkdownComponents = (isStreaming: boolean, isThinkingFinished: boolean
139
  } catch {
140
  // If it's explicitly a quiz tag but parsing failed (likely streaming), show loading skeleton
141
  if (isQuizTag && isStreaming) {
142
- return <QuizLoadingSkeleton />;
143
  }
144
  // For json tag or non-streaming quiz tag, we fall back to code block because it might be regular JSON or broken
145
  }
@@ -156,7 +164,7 @@ const getMarkdownComponents = (isStreaming: boolean, isThinkingFinished: boolean
156
  }
157
 
158
  return (
159
- <pre className="!whitespace-pre-wrap !break-words !overflow-x-hidden !my-4 !rounded-xl !bg-gray-50 !border !border-gray-100">
160
  {codeElement}
161
  </pre>
162
  );
@@ -164,7 +172,7 @@ const getMarkdownComponents = (isStreaming: boolean, isThinkingFinished: boolean
164
  });
165
 
166
  const UserMarkdownComponents = {
167
- ...getMarkdownComponents(false),
168
  p: ({ children, node, ...props }: React.ComponentPropsWithoutRef<'p'> & { node?: unknown }) => (
169
  <div {...props} className="!mb-2 last:!mb-0 !leading-relaxed">
170
  {children}
@@ -217,7 +225,7 @@ const CollapsibleUserMessage = ({ content }: { content: string }) => {
217
 
218
 
219
 
220
- const SmoothMarkdown = ({ content, isStreaming, onContentUpdate }: { content: string, isStreaming: boolean, onContentUpdate?: () => void }) => {
221
  const [displayed, setDisplayed] = useState(isStreaming ? '' : content);
222
  const targetRef = useRef(content);
223
  const displayedRef = useRef(displayed);
@@ -286,7 +294,7 @@ const SmoothMarkdown = ({ content, isStreaming, onContentUpdate }: { content: st
286
  // The raw 'displayed' text contains <think>...</think> if the stream has delivered it.
287
  const isThinkingFinished = useMemo(() => displayed.includes('</think>'), [displayed]);
288
 
289
- const components = useMemo(() => getMarkdownComponents(isStreaming, isThinkingFinished), [isStreaming, isThinkingFinished]);
290
 
291
  // Pre-process content to handle <think> tags
292
  const processedContent = useMemo(() => processThinkTags(displayed), [displayed]);
@@ -300,18 +308,26 @@ const SmoothMarkdown = ({ content, isStreaming, onContentUpdate }: { content: st
300
  );
301
  };
302
 
303
- export function Chat({ initialMessages = [], onMessagesUpdate, autoSubmitPrompt }: ChatProps) {
304
- const [useRAG, setUseRAG] = useState(true);
 
 
 
 
 
 
 
305
  const { t, language } = useLanguage();
306
 
307
- const { messages, input, handleInputChange, handleSubmit, isLoading, error, append } = useChat({
308
  initialMessages,
309
- body: { useRAG },
310
  });
311
  const scrollContainerRef = useRef<HTMLDivElement>(null);
312
  const inputRef = useRef<HTMLInputElement>(null);
313
  const hasAutoSubmitted = useRef(false);
314
  const isAtBottomRef = useRef(true);
 
315
 
316
  useEffect(() => {
317
  // Auto-focus input on mount (new chat or switching history)
@@ -351,6 +367,14 @@ export function Chat({ initialMessages = [], onMessagesUpdate, autoSubmitPrompt
351
  // User is considered "at bottom" if they are within 50px of the bottom
352
  const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
353
  isAtBottomRef.current = isAtBottom;
 
 
 
 
 
 
 
 
354
  };
355
 
356
  const scrollToBottom = (smooth = false) => {
@@ -445,10 +469,11 @@ export function Chat({ initialMessages = [], onMessagesUpdate, autoSubmitPrompt
445
  <SmoothMarkdown
446
  content={m.content}
447
  isStreaming={true}
 
448
  // onContentUpdate={() => isAtBottomRef.current && scrollToBottom(false)}
449
  />
450
  ) : (
451
- <ReactMarkdown components={getMarkdownComponents(false)}>
452
  {m.content}
453
  </ReactMarkdown>
454
  )}
@@ -471,38 +496,67 @@ export function Chat({ initialMessages = [], onMessagesUpdate, autoSubmitPrompt
471
  </div>
472
  </div>
473
 
 
 
 
 
 
 
 
 
 
 
474
  <div className="p-4 bg-white/80 backdrop-blur-sm border-t border-gray-100">
475
  <div className="max-w-3xl mx-auto">
476
  <form onSubmit={handleSubmit} className="relative flex items-center gap-2">
477
- <button
478
- type="button"
479
- onClick={() => setUseRAG(!useRAG)}
480
- className={`p-2.5 rounded-xl transition-all flex items-center justify-center shrink-0 border ${
481
- useRAG
482
- ? "bg-primary-50 text-primary-600 border-primary-200 hover:bg-primary-100"
483
- : "bg-white text-gray-400 border-gray-200 hover:bg-gray-50 hover:text-gray-600"
484
- }`}
485
- title={useRAG ? (language === 'zh' ? "已开启知识库对话" : "RAG Enabled") : (language === 'zh' ? "已切换为普通对话" : "Normal Chat")}
486
- >
487
- {useRAG ? <Database className="w-5 h-5" /> : <MessageSquare className="w-5 h-5" />}
488
- </button>
489
-
490
  <div className="relative flex-1">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
491
  <input
492
  ref={inputRef}
493
- className="w-full pl-5 pr-12 py-3.5 bg-gray-50 rounded-full focus:outline-none transition-all text-gray-700 placeholder-gray-400 focus:bg-white focus:ring-2 focus:ring-primary-100 focus:border-primary-200 border border-transparent"
494
  value={input}
495
- placeholder={useRAG ? (language === 'zh' ? "对话知识库..." : "Chat with Knowledge Base...") : t('inputPlaceholder')}
496
  onChange={handleInputChange}
497
  disabled={isLoading}
498
  />
499
- <button
500
- type="submit"
501
- disabled={isLoading || !input.trim()}
502
- className="absolute right-2 top-1/2 -translate-y-1/2 p-2 bg-gray-900 text-white rounded-full hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-sm hover:shadow-md"
503
- >
504
- <Send className="w-4 h-4" />
505
- </button>
 
 
 
 
 
 
 
 
 
 
 
506
  </div>
507
  </form>
508
  <p className="text-center text-xs text-gray-400 mt-3">
 
2
 
3
  import { useChat } from "@ai-sdk/react";
4
  import { Message } from "ai";
5
+ import { Send, User, Bot, ChevronDown, ChevronUp, Database, MessageSquare, ArrowUp, Square, FileText } from "lucide-react";
6
  import ReactMarkdown from "react-markdown";
7
  import { useEffect, useRef, useState, useMemo } from "react";
8
  import { useLanguage } from "@/contexts/LanguageContext";
9
  import { InteractiveQuiz, QuizQuestion } from "./InteractiveQuiz";
10
 
11
+ export interface ChatProps {
12
  initialMessages?: Message[];
13
  onMessagesUpdate?: (messages: Message[]) => void;
14
  autoSubmitPrompt?: string;
15
+ useRAG: boolean;
16
+ onUseRAGChange: (useRAG: boolean) => void;
17
+ showRAGToggle?: boolean;
18
+ fileInfo?: { name: string };
19
  }
20
 
21
  // Helper to process think tags
 
27
  });
28
  };
29
 
30
+ const QuizLoadingSkeleton = ({ language }: { language: string }) => (
31
  <div className="bg-white rounded-xl shadow-sm border border-gray-200 my-4 w-full max-w-xl overflow-hidden animate-pulse">
32
  <div className="p-6 border-b border-gray-100 flex items-center justify-between bg-white">
33
  <div className="h-7 bg-gray-100 rounded-md w-40"></div>
34
  <div className="h-8 bg-gray-100 rounded-md w-20"></div>
35
  </div>
36
  <div className="p-6 space-y-8">
37
+ <div className="flex items-center justify-center py-8 text-gray-400 gap-2">
38
+ <span className="animate-spin text-xl">⏳</span>
39
+ <span className="font-medium">{language === 'zh' ? "正在生成试题..." : "Generating Quiz..."}</span>
40
+ </div>
41
  {[1, 2].map((i) => (
42
+ <div key={i} className="space-y-4 opacity-50">
43
  <div className="h-5 bg-gray-100 rounded w-3/4"></div>
44
  <div className="space-y-3">
45
  {[1, 2, 3, 4].map((j) => (
 
71
  );
72
  };
73
 
74
+ const getMarkdownComponents = (isStreaming: boolean, isThinkingFinished: boolean = true, language: string = 'en') => ({
75
  pre: ({ children }: React.ComponentPropsWithoutRef<'pre'>) => <>{children}</>,
76
  p: ({ children, node, ...props }: React.ComponentPropsWithoutRef<'p'> & { node?: unknown }) => (
77
  <div {...props} className="!mb-3 last:!mb-0 !leading-relaxed text-gray-800">
 
147
  } catch {
148
  // If it's explicitly a quiz tag but parsing failed (likely streaming), show loading skeleton
149
  if (isQuizTag && isStreaming) {
150
+ return <QuizLoadingSkeleton language={language} />;
151
  }
152
  // For json tag or non-streaming quiz tag, we fall back to code block because it might be regular JSON or broken
153
  }
 
164
  }
165
 
166
  return (
167
+ <pre className="!whitespace-pre-wrap !break-words !overflow-x-hidden !my-4 !rounded-xl">
168
  {codeElement}
169
  </pre>
170
  );
 
172
  });
173
 
174
  const UserMarkdownComponents = {
175
+ ...getMarkdownComponents(false, true, 'en'), // Default to en for user messages as language context isn't critical there
176
  p: ({ children, node, ...props }: React.ComponentPropsWithoutRef<'p'> & { node?: unknown }) => (
177
  <div {...props} className="!mb-2 last:!mb-0 !leading-relaxed">
178
  {children}
 
225
 
226
 
227
 
228
+ const SmoothMarkdown = ({ content, isStreaming, onContentUpdate, language }: { content: string, isStreaming: boolean, onContentUpdate?: () => void, language: string }) => {
229
  const [displayed, setDisplayed] = useState(isStreaming ? '' : content);
230
  const targetRef = useRef(content);
231
  const displayedRef = useRef(displayed);
 
294
  // The raw 'displayed' text contains <think>...</think> if the stream has delivered it.
295
  const isThinkingFinished = useMemo(() => displayed.includes('</think>'), [displayed]);
296
 
297
+ const components = useMemo(() => getMarkdownComponents(isStreaming, isThinkingFinished, language), [isStreaming, isThinkingFinished, language]);
298
 
299
  // Pre-process content to handle <think> tags
300
  const processedContent = useMemo(() => processThinkTags(displayed), [displayed]);
 
308
  );
309
  };
310
 
311
+ export function Chat({
312
+ initialMessages = [],
313
+ onMessagesUpdate,
314
+ autoSubmitPrompt,
315
+ useRAG,
316
+ onUseRAGChange,
317
+ showRAGToggle = true,
318
+ fileInfo
319
+ }: ChatProps) {
320
  const { t, language } = useLanguage();
321
 
322
+ const { messages, input, handleInputChange, handleSubmit, isLoading, error, append, stop } = useChat({
323
  initialMessages,
324
+ body: { useRAG: fileInfo ? true : useRAG, fileInfo },
325
  });
326
  const scrollContainerRef = useRef<HTMLDivElement>(null);
327
  const inputRef = useRef<HTMLInputElement>(null);
328
  const hasAutoSubmitted = useRef(false);
329
  const isAtBottomRef = useRef(true);
330
+ const [showScrollTop, setShowScrollTop] = useState(false);
331
 
332
  useEffect(() => {
333
  // Auto-focus input on mount (new chat or switching history)
 
367
  // User is considered "at bottom" if they are within 50px of the bottom
368
  const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
369
  isAtBottomRef.current = isAtBottom;
370
+ setShowScrollTop(scrollTop > 400);
371
+ };
372
+
373
+ const scrollToTop = () => {
374
+ scrollContainerRef.current?.scrollTo({
375
+ top: 0,
376
+ behavior: 'smooth'
377
+ });
378
  };
379
 
380
  const scrollToBottom = (smooth = false) => {
 
469
  <SmoothMarkdown
470
  content={m.content}
471
  isStreaming={true}
472
+ language={language}
473
  // onContentUpdate={() => isAtBottomRef.current && scrollToBottom(false)}
474
  />
475
  ) : (
476
+ <ReactMarkdown components={getMarkdownComponents(false, true, language)}>
477
  {m.content}
478
  </ReactMarkdown>
479
  )}
 
496
  </div>
497
  </div>
498
 
499
+ {showScrollTop && (
500
+ <button
501
+ onClick={scrollToTop}
502
+ className="absolute bottom-32 right-6 p-3 bg-white border border-gray-200 shadow-lg rounded-full text-gray-500 hover:bg-gray-50 hover:text-gray-900 transition-all z-10 opacity-90 hover:opacity-100"
503
+ title={language === 'zh' ? "回到顶部" : "Scroll to Top"}
504
+ >
505
+ <ArrowUp className="w-5 h-5" />
506
+ </button>
507
+ )}
508
+
509
  <div className="p-4 bg-white/80 backdrop-blur-sm border-t border-gray-100">
510
  <div className="max-w-3xl mx-auto">
511
  <form onSubmit={handleSubmit} className="relative flex items-center gap-2">
 
 
 
 
 
 
 
 
 
 
 
 
 
512
  <div className="relative flex-1">
513
+ {fileInfo ? (
514
+ <div
515
+ className="absolute left-2 top-1/2 -translate-y-1/2 p-2 text-gray-500 hover:text-primary-600 cursor-default z-10 transition-colors"
516
+ title={fileInfo.name}
517
+ >
518
+ <FileText className="w-5 h-5" />
519
+ </div>
520
+ ) : showRAGToggle && (
521
+ <button
522
+ type="button"
523
+ onClick={() => onUseRAGChange(!useRAG)}
524
+ className={`absolute left-2 top-1/2 -translate-y-1/2 p-2 rounded-full transition-all z-10 ${
525
+ useRAG
526
+ ? "text-primary-600 hover:bg-primary-50"
527
+ : "text-gray-400 hover:text-gray-600 hover:bg-gray-100"
528
+ }`}
529
+ title={useRAG ? (language === 'zh' ? "普通对话" : "Normal Chat") : (language === 'zh' ? "对话知识库" : "Chat with Knowledge Base")}
530
+ >
531
+ {useRAG ? <Database className="w-5 h-5" /> : <MessageSquare className="w-5 h-5" />}
532
+ </button>
533
+ )}
534
  <input
535
  ref={inputRef}
536
+ className={`w-full ${fileInfo || showRAGToggle ? 'pl-12' : 'pl-5'} pr-12 py-3.5 bg-gray-50 rounded-full focus:outline-none transition-all text-gray-700 placeholder-gray-400 focus:bg-white focus:ring-2 focus:ring-primary-100 focus:border-primary-200 border border-transparent`}
537
  value={input}
538
+ placeholder={fileInfo ? (language === 'zh' ? `与 ${fileInfo.name} 对话...` : `Chat with ${fileInfo.name}...`) : (useRAG ? (language === 'zh' ? "对话知识库..." : "Chat with Knowledge Base...") : t('inputPlaceholder'))}
539
  onChange={handleInputChange}
540
  disabled={isLoading}
541
  />
542
+ {isLoading ? (
543
+ <button
544
+ type="button"
545
+ onClick={() => stop()}
546
+ className="absolute right-2 top-1/2 -translate-y-1/2 p-2 bg-red-500 text-white rounded-full hover:bg-red-600 transition-all shadow-sm hover:shadow-md animate-in fade-in zoom-in duration-200"
547
+ title={language === 'zh' ? "停止生成" : "Stop generating"}
548
+ >
549
+ <Square className="w-4 h-4 fill-current" />
550
+ </button>
551
+ ) : (
552
+ <button
553
+ type="submit"
554
+ disabled={!input.trim()}
555
+ className="absolute right-2 top-1/2 -translate-y-1/2 p-2 bg-gray-900 text-white rounded-full hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-sm hover:shadow-md"
556
+ >
557
+ <Send className="w-4 h-4" />
558
+ </button>
559
+ )}
560
  </div>
561
  </form>
562
  <p className="text-center text-xs text-gray-400 mt-3">
src/components/ThemeSwitcher.tsx CHANGED
@@ -6,10 +6,13 @@ import { useState, useRef, useEffect, ReactNode } from "react";
6
 
7
  interface ThemeSwitcherProps {
8
  customTrigger?: ReactNode;
9
- position?: 'top' | 'bottom';
 
 
 
10
  }
11
 
12
- export function ThemeSwitcher({ customTrigger, position = 'top' }: ThemeSwitcherProps) {
13
  const { theme, setTheme } = useTheme();
14
  const [isOpen, setIsOpen] = useState(false);
15
  const containerRef = useRef<HTMLDivElement>(null);
@@ -41,6 +44,21 @@ export function ThemeSwitcher({ customTrigger, position = 'top' }: ThemeSwitcher
41
  }}>
42
  {customTrigger}
43
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  ) : (
45
  <button
46
  onClick={(e) => {
@@ -56,12 +74,14 @@ export function ThemeSwitcher({ customTrigger, position = 'top' }: ThemeSwitcher
56
 
57
  {isOpen && (
58
  <div
59
- className={`absolute left-1/2 -translate-x-1/2 p-3 bg-white rounded-xl shadow-xl border border-gray-100 flex flex-col gap-2 z-50 min-w-[160px]
60
- ${position === 'top' ? 'bottom-full mb-2' : 'top-full mt-2'}
 
 
61
  `}
62
  onClick={(e) => e.stopPropagation()}
63
  >
64
- <div className="text-xs font-medium text-gray-500 px-1">主题风格</div>
65
  <div className="flex justify-between items-center gap-1">
66
  {themes.map((t) => (
67
  <button
 
6
 
7
  interface ThemeSwitcherProps {
8
  customTrigger?: ReactNode;
9
+ position?: 'top' | 'bottom' | 'right';
10
+ isPopover?: boolean;
11
+ label?: string;
12
+ className?: string;
13
  }
14
 
15
+ export function ThemeSwitcher({ customTrigger, position = 'top', isPopover, label, className }: ThemeSwitcherProps) {
16
  const { theme, setTheme } = useTheme();
17
  const [isOpen, setIsOpen] = useState(false);
18
  const containerRef = useRef<HTMLDivElement>(null);
 
44
  }}>
45
  {customTrigger}
46
  </div>
47
+ ) : isPopover ? (
48
+ <div
49
+ onClick={(e) => {
50
+ e.stopPropagation();
51
+ setIsOpen(!isOpen);
52
+ }}
53
+ className={className}
54
+ >
55
+ <div className="flex items-center gap-3">
56
+ <span className="w-6 h-6 flex items-center justify-center shrink-0">
57
+ <Palette className="w-5 h-5" />
58
+ </span>
59
+ <span>{label || '主题风格'}</span>
60
+ </div>
61
+ </div>
62
  ) : (
63
  <button
64
  onClick={(e) => {
 
74
 
75
  {isOpen && (
76
  <div
77
+ className={`absolute bg-white rounded-xl shadow-xl border border-gray-100 flex flex-col gap-2 z-50 min-w-[160px] p-3
78
+ ${position === 'top' ? 'left-1/2 -translate-x-1/2 bottom-full mb-2' : ''}
79
+ ${position === 'bottom' ? 'left-1/2 -translate-x-1/2 top-full mt-2' : ''}
80
+ ${position === 'right' ? 'left-full top-0 ml-2' : ''}
81
  `}
82
  onClick={(e) => e.stopPropagation()}
83
  >
84
+ <div className="text-xs font-medium text-gray-500 px-1">{label || '主题风格'}</div>
85
  <div className="flex justify-between items-center gap-1">
86
  {themes.map((t) => (
87
  <button
src/components/Upload.tsx CHANGED
@@ -4,7 +4,7 @@ import { useState } from "react";
4
  import { UploadCloud, Loader2 } from "lucide-react";
5
  import { useLanguage } from "@/contexts/LanguageContext";
6
 
7
- export function Upload({ className, collapsed = false, iconContainerClass }: { className?: string; collapsed?: boolean; iconContainerClass?: string }) {
8
  const [uploading, setUploading] = useState(false);
9
  const [message, setMessage] = useState("");
10
  const { t } = useLanguage();
@@ -26,9 +26,8 @@ export function Upload({ className, collapsed = false, iconContainerClass }: { c
26
  });
27
  const data = await res.json();
28
  if (res.ok) {
29
- setMessage(`${t('uploadSuccess')}: ${data.message} (${data.chunks} ${t('chunks')})`);
30
- // Clear message after 3 seconds
31
- setTimeout(() => setMessage(""), 3000);
32
  } else {
33
  setMessage(`${t('uploadError')}: ${data.error}`);
34
  }
 
4
  import { UploadCloud, Loader2 } from "lucide-react";
5
  import { useLanguage } from "@/contexts/LanguageContext";
6
 
7
+ export function Upload({ className, collapsed = false, iconContainerClass, onUploadComplete }: { className?: string; collapsed?: boolean; iconContainerClass?: string; onUploadComplete?: (fileName?: string) => void }) {
8
  const [uploading, setUploading] = useState(false);
9
  const [message, setMessage] = useState("");
10
  const { t } = useLanguage();
 
26
  });
27
  const data = await res.json();
28
  if (res.ok) {
29
+ // Immediately trigger complete without showing success message in UI
30
+ if (onUploadComplete) onUploadComplete(file.name);
 
31
  } else {
32
  setMessage(`${t('uploadError')}: ${data.error}`);
33
  }
src/contexts/LanguageContext.tsx CHANGED
@@ -37,9 +37,9 @@ const translations: Record<Language, Translations> = {
37
  disclaimer: "AI generated content may be inaccurate. Please verify important information.",
38
  generateQuiz: "Generate Quiz",
39
  knowledgeManagement: "Knowledge Base Management",
40
- userAccount: "User Account",
41
- freePlan: "Free Plan",
42
- quizPrompt: "Please generate 5 multiple-choice questions based on the uploaded knowledge base content.\n\nRequirements:\n1. If no relevant documents are found in the knowledge base, please label the questions as '(Demo Questions)'.\n2. Strict Formatting Rules:\n - Use a number for the question (e.g., 1. Question...).\n - FORCE A NEW LINE for each option (A, B, C, D).\n - Format: \n 1. Question Text\n A. Option 1\n B. Option 2\n C. Option 3\n D. Option 4\n3. Answer Key:\n - Display answers at the bottom.\n - Format: 1. A 2. B 3. C...",
43
  sync: "Sync",
44
  syncing: "Syncing...",
45
  outline: "Outline",
@@ -51,7 +51,8 @@ const translations: Record<Language, Translations> = {
51
  noKb: "No Knowledge Base, please sync",
52
  searchResults: "Search Results",
53
  noDocs: "No matching documents",
54
- demoMode: "Demo Mode"
 
55
  },
56
  zh: {
57
  title: "RAG 知识库系统",
@@ -62,7 +63,7 @@ const translations: Record<Language, Translations> = {
62
  tech3: "流式响应",
63
  tech4: "本地向量库 (HNSWLib)",
64
  uploadTitle: "知识库上传",
65
- uploadButton: "选择文件 (MD/Txt)",
66
  uploading: "上传中...",
67
  uploadSupport: "支持 .md 和 .txt 文件。数据将被分块并建立本地索引。",
68
  uploadSuccess: "成功",
@@ -76,14 +77,14 @@ const translations: Record<Language, Translations> = {
76
  chunks: "个分块",
77
  send: "发送",
78
  history: "历史记录",
79
- newChat: "新对话",
80
  deleteChat: "删除",
81
  disclaimer: "AI 生成的内容可能不准确,请核实重要信息。",
82
  generateQuiz: "生成试题",
83
  knowledgeManagement: "知识库管理",
84
- userAccount: "用户账号",
85
- freePlan: "免费计划",
86
- quizPrompt: "请基于已上传的知识库内容生成 5 道单项选择题。\n\n**重要:请务必将生成结果封装在 JSON 代码块中,并使用 `quiz` 作为语言标签(即 ```quiz)。**\n\nJSON 数据结构要求:\n一个包含 5 个象的数组,每个对象包含以下字段:\n- id: 数字序号\n- question: 目文本\n- options: 包含 4 个选项内容的字符串数组(**注意:请绝对不要在选项内容前加 A. B. C. D. 等前缀,只保留选项内容本身**)\n- correctAnswer: 正确选项的索引(数字 0-3,0代表A,1代表B,以此类推)\n- explanation: 答案解析\n\n示例格式:\n```quiz\n[\n {\n \"id\": 1,\n \"question\": \"题目内容...\",\n \"options\": [\"内容1\", \"内容2\", \"内容3\", \"内容4\"],\n \"correctAnswer\": 0,\n \"explanation\": \"解析内容...\"\n }\n]\n```",
87
  sync: "同步",
88
  syncing: "同步中",
89
  outline: "大纲",
@@ -95,7 +96,8 @@ const translations: Record<Language, Translations> = {
95
  noKb: "暂无知识库,请点击同步",
96
  searchResults: "搜索结果",
97
  noDocs: "未找到匹配文档",
98
- demoMode: "演示模式"
 
99
  }
100
  };
101
 
 
37
  disclaimer: "AI generated content may be inaccurate. Please verify important information.",
38
  generateQuiz: "Generate Quiz",
39
  knowledgeManagement: "Knowledge Base Management",
40
+ userAccount: "duqing",
41
+ freePlan: "Super Admin",
42
+ quizPrompt: "Generate Quiz",
43
  sync: "Sync",
44
  syncing: "Syncing...",
45
  outline: "Outline",
 
51
  noKb: "No Knowledge Base, please sync",
52
  searchResults: "Search Results",
53
  noDocs: "No matching documents",
54
+ demoMode: "Demo Mode",
55
+ theme: "Theme"
56
  },
57
  zh: {
58
  title: "RAG 知识库系统",
 
63
  tech3: "流式响应",
64
  tech4: "本地向量库 (HNSWLib)",
65
  uploadTitle: "知识库上传",
66
+ uploadButton: "选择文件",
67
  uploading: "上传中...",
68
  uploadSupport: "支持 .md 和 .txt 文件。数据将被分块并建立本地索引。",
69
  uploadSuccess: "成功",
 
77
  chunks: "个分块",
78
  send: "发送",
79
  history: "历史记录",
80
+ newChat: "新对话",
81
  deleteChat: "删除",
82
  disclaimer: "AI 生成的内容可能不准确,请核实重要信息。",
83
  generateQuiz: "生成试题",
84
  knowledgeManagement: "知识库管理",
85
+ userAccount: "duqing",
86
+ freePlan: "超级管理员",
87
+ quizPrompt: "对话试题",
88
  sync: "同步",
89
  syncing: "同步中",
90
  outline: "大纲",
 
96
  noKb: "暂无知识库,请点击同步",
97
  searchResults: "搜索结果",
98
  noDocs: "未找到匹配文档",
99
+ demoMode: "演示模式",
100
+ theme: "主题风格"
101
  }
102
  };
103
 
src/hooks/useChatHistory.ts CHANGED
@@ -9,6 +9,10 @@ export interface ChatSession {
9
  messages: Message[];
10
  createdAt: number;
11
  isTemp?: boolean;
 
 
 
 
12
  }
13
 
14
  // Simple debounce implementation if lodash is not available or to avoid dependency
@@ -80,20 +84,23 @@ export function useChatHistory() {
80
  }
81
  }, [currentSessionId, sessions]);
82
 
83
- const createNewSession = useCallback(async () => {
84
- // Check if we already have an empty temp session to reuse
85
- const existingTemp = sessionsRef.current.find(s => s.isTemp && s.messages.length === 0);
86
- if (existingTemp) {
 
87
  setCurrentSessionId(existingTemp.id);
88
  return existingTemp.id;
89
  }
90
 
91
  const newSession: ChatSession = {
92
  id: crypto.randomUUID(),
93
- title: 'New Chat',
94
  messages: [],
95
  createdAt: Date.now(),
96
  isTemp: true, // Mark as temporary, don't persist yet
 
 
97
  };
98
 
99
  // Optimistic update
@@ -167,8 +174,8 @@ export function useChatHistory() {
167
  setSessions(prev => prev.map(session => {
168
  if (session.id === id) {
169
  let title = session.title;
170
- // Update title if it's the first message
171
- if ((session.isTemp || title === 'New Chat') && messages.length > 0) {
172
  const firstUserMsg = messages.find(m => m.role === 'user');
173
  if (firstUserMsg) {
174
  title = firstUserMsg.content.slice(0, 30) + (firstUserMsg.content.length > 30 ? '...' : '');
@@ -190,9 +197,12 @@ export function useChatHistory() {
190
  // We can recalculate it here to be safe or grab from state later?
191
  // Recalculating is safer for the async call.
192
  let title = session?.title || 'New Chat';
193
- const firstUserMsg = messages.find(m => m.role === 'user');
194
- if (firstUserMsg) {
195
- title = firstUserMsg.content.slice(0, 30) + (firstUserMsg.content.length > 30 ? '...' : '');
 
 
 
196
  }
197
 
198
  await fetch('/api/history/sessions', {
@@ -201,7 +211,8 @@ export function useChatHistory() {
201
  body: JSON.stringify({
202
  id: id,
203
  title: title,
204
- createdAt: session?.createdAt || Date.now()
 
205
  })
206
  });
207
  } catch (e) {
 
9
  messages: Message[];
10
  createdAt: number;
11
  isTemp?: boolean;
12
+ type?: 'chat' | 'quiz' | 'file';
13
+ fileInfo?: {
14
+ name: string;
15
+ };
16
  }
17
 
18
  // Simple debounce implementation if lodash is not available or to avoid dependency
 
84
  }
85
  }, [currentSessionId, sessions]);
86
 
87
+ const createNewSession = useCallback(async (type: 'chat' | 'quiz' | 'file' = 'chat', title?: string, fileInfo?: { name: string }) => {
88
+ // Check if we already have an empty temp session to reuse (only if type matches)
89
+ const existingTemp = sessionsRef.current.find(s => s.isTemp && s.messages.length === 0 && (s.type === type || (!s.type && type === 'chat')));
90
+ // For file type, we must also match the file name, or just create new one to be safe
91
+ if (existingTemp && type !== 'file') {
92
  setCurrentSessionId(existingTemp.id);
93
  return existingTemp.id;
94
  }
95
 
96
  const newSession: ChatSession = {
97
  id: crypto.randomUUID(),
98
+ title: title || (type === 'quiz' ? 'Quiz Generation' : (type === 'file' && fileInfo ? `Chat with ${fileInfo.name}` : 'New Chat')),
99
  messages: [],
100
  createdAt: Date.now(),
101
  isTemp: true, // Mark as temporary, don't persist yet
102
+ type,
103
+ fileInfo
104
  };
105
 
106
  // Optimistic update
 
174
  setSessions(prev => prev.map(session => {
175
  if (session.id === id) {
176
  let title = session.title;
177
+ // Update title if it's the first message AND it's a regular chat
178
+ if ((session.isTemp || title === 'New Chat') && messages.length > 0 && (!session.type || session.type === 'chat')) {
179
  const firstUserMsg = messages.find(m => m.role === 'user');
180
  if (firstUserMsg) {
181
  title = firstUserMsg.content.slice(0, 30) + (firstUserMsg.content.length > 30 ? '...' : '');
 
197
  // We can recalculate it here to be safe or grab from state later?
198
  // Recalculating is safer for the async call.
199
  let title = session?.title || 'New Chat';
200
+ // Only update title from message content if it is a regular chat
201
+ if (!session?.type || session.type === 'chat') {
202
+ const firstUserMsg = messages.find(m => m.role === 'user');
203
+ if (firstUserMsg) {
204
+ title = firstUserMsg.content.slice(0, 30) + (firstUserMsg.content.length > 30 ? '...' : '');
205
+ }
206
  }
207
 
208
  await fetch('/api/history/sessions', {
 
211
  body: JSON.stringify({
212
  id: id,
213
  title: title,
214
+ createdAt: session?.createdAt || Date.now(),
215
+ type: session?.type || 'chat'
216
  })
217
  });
218
  } catch (e) {
{data → src/lib}/db.sqlite RENAMED
File without changes
src/lib/db.ts CHANGED
@@ -7,7 +7,10 @@ function getDb() {
7
  if (!db) {
8
  // Try to open the database file
9
  try {
10
- const dbPath = path.join(process.cwd(), 'rag-kb.db');
 
 
 
11
  db = new Database(dbPath);
12
  } catch (error) {
13
  console.warn('Failed to open persistent database, falling back to in-memory database:', error);
@@ -19,7 +22,8 @@ function getDb() {
19
  CREATE TABLE IF NOT EXISTS sessions (
20
  id TEXT PRIMARY KEY,
21
  title TEXT NOT NULL,
22
- created_at INTEGER NOT NULL
 
23
  );
24
 
25
  CREATE TABLE IF NOT EXISTS messages (
@@ -48,9 +52,21 @@ function getDb() {
48
  synced_at INTEGER NOT NULL,
49
  parent_uuid TEXT,
50
  uuid TEXT,
51
- sort_order INTEGER DEFAULT 0
 
 
 
52
  );
 
 
 
 
 
 
 
 
53
 
 
54
  CREATE TABLE IF NOT EXISTS knowledge_bases (
55
  namespace TEXT PRIMARY KEY,
56
  name TEXT NOT NULL,
@@ -59,6 +75,8 @@ function getDb() {
59
  );
60
  `);
61
 
 
 
62
  // Migration: Add missing columns for documents table if they don't exist
63
  try {
64
  const columns = db.prepare("PRAGMA table_info(documents)").all() as { name: string }[];
@@ -79,9 +97,30 @@ function getDb() {
79
  if (!columnNames.includes('updated_at')) {
80
  db.exec('ALTER TABLE documents ADD COLUMN updated_at INTEGER');
81
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  } catch (error) {
83
  console.error('Database migration failed:', error);
84
  }
 
 
 
 
85
  }
86
  return db;
87
  }
 
7
  if (!db) {
8
  // Try to open the database file
9
  try {
10
+ const configuredPath = process.env.RAG_KB_DB_PATH?.trim();
11
+ const dbPath = configuredPath
12
+ ? (path.isAbsolute(configuredPath) ? configuredPath : path.join(process.cwd(), configuredPath))
13
+ : path.join(process.cwd(), 'rag-kb.db');
14
  db = new Database(dbPath);
15
  } catch (error) {
16
  console.warn('Failed to open persistent database, falling back to in-memory database:', error);
 
22
  CREATE TABLE IF NOT EXISTS sessions (
23
  id TEXT PRIMARY KEY,
24
  title TEXT NOT NULL,
25
+ created_at INTEGER NOT NULL,
26
+ type TEXT DEFAULT 'chat'
27
  );
28
 
29
  CREATE TABLE IF NOT EXISTS messages (
 
52
  synced_at INTEGER NOT NULL,
53
  parent_uuid TEXT,
54
  uuid TEXT,
55
+ sort_order INTEGER DEFAULT 0,
56
+ word_count INTEGER DEFAULT 0,
57
+ updated_at INTEGER,
58
+ created_at INTEGER
59
  );
60
+ `);
61
+
62
+ // Migration: Add created_at column if it doesn't exist
63
+ try {
64
+ db.prepare('ALTER TABLE documents ADD COLUMN created_at INTEGER').run();
65
+ } catch (error) {
66
+ // Column likely already exists
67
+ }
68
 
69
+ db.exec(`
70
  CREATE TABLE IF NOT EXISTS knowledge_bases (
71
  namespace TEXT PRIMARY KEY,
72
  name TEXT NOT NULL,
 
75
  );
76
  `);
77
 
78
+ // Create indexes
79
+
80
  // Migration: Add missing columns for documents table if they don't exist
81
  try {
82
  const columns = db.prepare("PRAGMA table_info(documents)").all() as { name: string }[];
 
97
  if (!columnNames.includes('updated_at')) {
98
  db.exec('ALTER TABLE documents ADD COLUMN updated_at INTEGER');
99
  }
100
+ if (!columnNames.includes('tags')) {
101
+ db.exec('ALTER TABLE documents ADD COLUMN tags TEXT');
102
+ }
103
+
104
+ // Migration: Add last_offset to knowledge_bases
105
+ const kbColumns = db.prepare("PRAGMA table_info(knowledge_bases)").all() as { name: string }[];
106
+ const kbColumnNames = kbColumns.map(c => c.name);
107
+ if (!kbColumnNames.includes('last_offset')) {
108
+ db.exec('ALTER TABLE knowledge_bases ADD COLUMN last_offset INTEGER DEFAULT 0');
109
+ }
110
+
111
+ // Migration: Add type to sessions
112
+ const sessionColumns = db.prepare("PRAGMA table_info(sessions)").all() as { name: string }[];
113
+ const sessionColumnNames = sessionColumns.map(c => c.name);
114
+ if (!sessionColumnNames.includes('type')) {
115
+ db.exec("ALTER TABLE sessions ADD COLUMN type TEXT DEFAULT 'chat'");
116
+ }
117
  } catch (error) {
118
  console.error('Database migration failed:', error);
119
  }
120
+
121
+ try {
122
+ db.prepare("UPDATE knowledge_bases SET name = ? WHERE namespace = ? AND name = ?").run('小记', 'NOTES', '我的小记');
123
+ } catch {}
124
  }
125
  return db;
126
  }
src/lib/local-embeddings.ts CHANGED
@@ -45,6 +45,9 @@ class LocalEmbeddings extends Embeddings {
45
  const text = doc.replace(/\n/g, " ");
46
  const output = await pipe(text, { pooling: "mean", normalize: true });
47
  embeddings.push(Array.from(output.data));
 
 
 
48
  }
49
 
50
  return embeddings;
 
45
  const text = doc.replace(/\n/g, " ");
46
  const output = await pipe(text, { pooling: "mean", normalize: true });
47
  embeddings.push(Array.from(output.data));
48
+
49
+ // Yield to event loop to prevent blocking server during large batch processing
50
+ await new Promise(resolve => setTimeout(resolve, 0));
51
  }
52
 
53
  return embeddings;
src/lib/yuque-service.ts CHANGED
@@ -1,5 +1,6 @@
1
 
2
  import { Document } from "@langchain/core/documents";
 
3
  import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
4
  import { getEmbeddings } from "./vector-store";
5
  import { HNSWLib } from "@langchain/community/vectorstores/hnswlib";
@@ -23,6 +24,7 @@ export interface YuqueDoc {
23
  parent_uuid?: string;
24
  uuid: string;
25
  type?: string;
 
26
  }
27
 
28
  export interface SyncStatus {
@@ -45,6 +47,53 @@ let currentSyncStatus: SyncStatus = {
45
  message: ''
46
  };
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  async function asyncPool<T, R>(poolLimit: number, array: T[], iteratorFn: (item: T, array: T[]) => Promise<R>): Promise<R[]> {
49
  const ret: Promise<R>[] = [];
50
  const executing: Promise<void>[] = [];
@@ -65,10 +114,10 @@ async function asyncPool<T, R>(poolLimit: number, array: T[], iteratorFn: (item:
65
  return Promise.all(ret);
66
  }
67
 
68
- class SimpleYuqueLoader {
69
  constructor(private token: string, private namespace: string) {}
70
 
71
- private async fetchAPI(endpoint: string) {
72
  const url = `${YUQUE_BASE_URL}${endpoint}`;
73
  const headers = {
74
  "X-Auth-Token": this.token,
@@ -76,18 +125,48 @@ class SimpleYuqueLoader {
76
  "Content-Type": "application/json",
77
  };
78
 
79
- const maxRetries = 5;
80
  let attempt = 0;
 
81
 
82
- while (attempt < maxRetries) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  let response;
84
  try {
 
 
 
85
  if (process.env.HTTPS_PROXY) {
86
  const agent = new HttpsProxyAgent(process.env.HTTPS_PROXY);
87
- response = await nodeFetch(url, { headers, agent });
88
  } else {
89
- response = await fetch(url, { headers });
90
  }
 
91
 
92
  if (response.ok) {
93
  return await response.json();
@@ -105,16 +184,22 @@ class SimpleYuqueLoader {
105
  if (response.status === 429) {
106
  const retryAfter = response.headers.get('retry-after');
107
  // Default to exponential backoff if no retry-after header
108
- // Aggressively increase base delay: 10s base
109
- let delay = retryAfter ? parseInt(retryAfter) * 1000 : 10000 * Math.pow(1.5, attempt);
110
 
111
  // Add larger jitter (random delay between 1000-3000ms)
112
  delay += 1000 + Math.random() * 2000;
113
 
114
- // Cap at 300s (5 minutes)
115
- if (delay > 300000) delay = 300000;
116
 
117
- const msg = `语雀 API 触发速率限制,${Math.round(delay/1000)}秒后重试... (Attempt ${attempt + 1}/${maxRetries})`;
 
 
 
 
 
 
118
  console.warn(msg);
119
  currentSyncStatus.message = msg;
120
 
@@ -127,6 +212,7 @@ class SimpleYuqueLoader {
127
  throw new Error(`Yuque API Error: ${response.status} ${response.statusText} - ${errorText.substring(0, 200)} - ${url}`);
128
  } catch (error: unknown) {
129
  const errorMessage = error instanceof Error ? error.message : String(error);
 
130
 
131
  // If it's an Auth error or 404, stop immediately (re-throw)
132
  if (errorMessage.includes("Yuque API Auth Error") || errorMessage.includes("Yuque API 404 Not Found")) {
@@ -137,19 +223,25 @@ class SimpleYuqueLoader {
137
  // If it's a "Yuque API Error" (thrown above), check status inside message if possible,
138
  // but we already handled 429.
139
 
140
- console.warn(`Yuque API Request Failed: ${errorMessage}. Retrying... (Attempt ${attempt + 1}/${maxRetries})`);
141
 
142
- // General error backoff: 3s * 1.5^attempt
143
- const backoff = 3000 * Math.pow(1.5, attempt) + Math.random() * 1000;
144
  await new Promise(resolve => setTimeout(resolve, backoff));
145
  attempt++;
146
  }
147
  }
148
 
149
- throw new Error(`Yuque API Failed after ${maxRetries} retries: ${url}`);
 
 
150
  }
151
 
152
- async loadDocList(): Promise<YuqueDoc[]> {
 
 
 
 
153
  try {
154
  currentSyncStatus.message = `正在获取目录结构:${this.namespace}...`;
155
 
@@ -164,7 +256,14 @@ class SimpleYuqueLoader {
164
  }
165
 
166
  // Fetch all docs metadata (including updated_at) to merge
167
- const docsMap = await this.fetchAllDocsMetadata();
 
 
 
 
 
 
 
168
 
169
  // Merge Strategy:
170
  // 1. Start with TOC items (they contain structure info like parent_uuid)
@@ -210,19 +309,74 @@ class SimpleYuqueLoader {
210
 
211
  } catch (e: unknown) {
212
  console.error(`Failed to fetch doc list for ${this.namespace}:`, e);
213
- const message = e instanceof Error ? e.message : String(e);
214
- if (message.includes("Yuque API Auth Error")) throw e;
215
- return [];
216
  }
217
  }
218
 
219
- private async fetchAllDocsMetadata(): Promise<Map<number, YuqueDoc>> {
220
  const map = new Map<number, YuqueDoc>();
221
- let offset = 0;
222
- const limit = 100;
223
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  while (true) {
225
  try {
 
 
226
  currentSyncStatus.message = `正在获取元数据:${this.namespace} (已获取 ${map.size})...`;
227
  const res = await this.fetchAPI(`/repos/${this.namespace}/docs?offset=${offset}&limit=${limit}`);
228
  const list = res.data as YuqueDoc[];
@@ -233,24 +387,40 @@ class SimpleYuqueLoader {
233
 
234
  if (list.length < limit) break;
235
  // Increased delay to prevent rate limiting
236
- await new Promise(resolve => setTimeout(resolve, 1000));
237
  } catch (e: unknown) {
238
- console.error(`Error fetching docs list page for ${this.namespace}:`, e);
239
  const message = e instanceof Error ? e.message : String(e);
240
- if (message.includes("Yuque API Auth Error")) throw e;
241
- break;
 
 
 
 
 
 
 
242
  }
243
  }
244
  return map;
245
  }
246
 
247
- async fetchRepoDetail(): Promise<{ name: string; description: string; updated_at: string } | null> {
 
 
 
 
 
 
 
 
248
  try {
249
  const data = await this.fetchAPI(`/repos/${this.namespace}`);
250
  return {
251
  name: data.data.name,
252
  description: data.data.description || '',
253
- updated_at: data.data.updated_at
 
254
  };
255
  } catch (e: unknown) {
256
  console.error(`Failed to fetch repo detail for ${this.namespace}:`, e);
@@ -261,6 +431,9 @@ class SimpleYuqueLoader {
261
  }
262
 
263
  async fetchDocDetail(docInfo: YuqueDoc): Promise<Document | null> {
 
 
 
264
  try {
265
  const detailData = await this.fetchAPI(`/repos/${this.namespace}/docs/${docInfo.id}`);
266
  const docDetail = detailData.data;
@@ -281,11 +454,58 @@ class SimpleYuqueLoader {
281
  }
282
  } catch (e: unknown) {
283
  const message = e instanceof Error ? e.message : String(e);
284
- if (message.includes("Yuque API Auth Error")) throw e;
285
  console.error(`Failed to fetch doc ${docInfo.title}:`, e);
286
  }
287
  return null;
288
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  }
290
 
291
  export const getSyncStatus = () => currentSyncStatus;
@@ -297,7 +517,14 @@ export const startYuqueSync = async () => {
297
 
298
  const token = process.env.YUQUE_TOKEN;
299
  const namespacesEnv = process.env.YUQUE_NAMESPACE;
300
- const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
 
 
 
 
 
 
 
301
 
302
  if (!token || !namespacesEnv) {
303
  currentSyncStatus = { ...currentSyncStatus, status: 'error', error: '缺少环境变量配置' };
@@ -312,22 +539,26 @@ export const startYuqueSync = async () => {
312
  totalBatches: 0,
313
  status: 'running'
314
  };
 
315
 
316
  // Run in background (don't await this promise in the API handler)
317
  (async () => {
318
  try {
319
  const namespaces = namespacesEnv.split(",").map(s => s.trim()).filter(Boolean);
320
  const embeddings = getEmbeddings();
 
321
 
322
  const splitter = new RecursiveCharacterTextSplitter({
323
  chunkSize: 1000,
324
  chunkOverlap: 200,
325
  });
326
 
327
- const BATCH_SIZE = 50;
328
- const CONCURRENCY = 5; // Increased concurrency for faster sync
329
 
330
  for (const ns of namespaces) {
 
 
331
  // Reset progress for the new namespace to avoid UI confusion
332
  currentSyncStatus.total = 0;
333
  currentSyncStatus.processed = 0;
@@ -338,28 +569,47 @@ export const startYuqueSync = async () => {
338
  // Fetch and save KB info
339
  currentSyncStatus.message = `正在获取知识库信息:${ns}...`;
340
  const repoInfo = await loader.fetchRepoDetail();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  if (repoInfo) {
342
- // SMART SYNC CHECK
343
  const forceFullSync = process.env.FORCE_FULL_SYNC === 'true';
344
 
345
  // Check local DB for last sync time
346
  const localKb = db.prepare('SELECT synced_at FROM knowledge_bases WHERE namespace = ?').get(ns) as { synced_at: number } | undefined;
 
347
 
348
  const repoUpdatedAt = new Date(repoInfo.updated_at).getTime();
349
 
350
- // If local sync time >= repo update time, we are up to date.
351
- if (!forceFullSync && localKb && localKb.synced_at >= repoUpdatedAt) {
352
- console.log(`[Smart Sync] Skipping ${ns} (Up to date). Repo Updated: ${repoInfo.updated_at}, Last Sync: ${new Date(localKb.synced_at).toISOString()}`);
353
- currentSyncStatus.message = `知识库【${repoInfo.name}】已是最新,跳过同步。`;
 
 
 
 
354
  await new Promise(resolve => setTimeout(resolve, 1500));
355
  continue;
 
 
 
 
356
  }
357
-
358
- const stmt = db.prepare(`
359
- INSERT OR REPLACE INTO knowledge_bases (namespace, name, description, synced_at)
360
- VALUES (?, ?, ?, ?)
361
- `);
362
- stmt.run(ns, repoInfo.name, repoInfo.description, Date.now());
363
  }
364
 
365
  // Minimal delay between requests
@@ -367,7 +617,24 @@ export const startYuqueSync = async () => {
367
 
368
  // Fetch List
369
  currentSyncStatus.message = `正在获取文档列表:${ns}...`;
370
- const docs = await loader.loadDocList();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
 
372
  type DocItem = { namespace: string; doc: YuqueDoc; index: number };
373
  const docsWithIndex: DocItem[] = docs.map((d, i) => ({ namespace: ns, doc: d, index: i }));
@@ -375,10 +642,6 @@ export const startYuqueSync = async () => {
375
  currentSyncStatus.total += docsWithIndex.length;
376
 
377
  // --- INCREMENTAL SYNC OPTIMIZATION ---
378
- // Separate documents into two queues:
379
- // 1. downloadQueue: Needs full content fetch (New or Updated)
380
- // 2. metadataQueue: Only needs metadata update (Skipped content fetch)
381
-
382
  const downloadQueue: DocItem[] = [];
383
  const metadataQueue: DocItem[] = [];
384
  const titleNodeQueue: DocItem[] = [];
@@ -427,44 +690,21 @@ export const startYuqueSync = async () => {
427
  currentSyncStatus.message = `正在快速更新 ${metadataQueue.length} 篇无变更文档的元数据:${ns}...`;
428
  const updateStmt = db.prepare(`
429
  UPDATE documents
430
- SET word_count = ?, updated_at = ?, sort_order = ?, title = ?, parent_uuid = ?
431
  WHERE id = ?
432
  `);
433
 
434
- const insertTitleStmt = db.prepare(`
435
- INSERT OR REPLACE INTO documents (id, yuque_id, title, slug, url, namespace, content_preview, synced_at, parent_uuid, uuid, sort_order, word_count, updated_at)
436
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
437
- `);
438
-
439
  const transaction = db.transaction(() => {
440
- // Process Metadata Queue
441
  for (const item of metadataQueue) {
442
  const docId = `${item.namespace}/${item.doc.slug}`;
443
  const updatedAt = item.doc.updated_at ? new Date(item.doc.updated_at).getTime() : Date.now();
 
444
  const wordCount = item.doc.word_count || 0;
445
- updateStmt.run(wordCount, updatedAt, item.index, item.doc.title, item.doc.parent_uuid || null, docId);
446
  }
447
  // Process Title Nodes (Always fast)
448
  for (const item of titleNodeQueue) {
449
- const uniqueId = `${item.namespace}/dir-${item.doc.uuid}`;
450
- const uniqueSlug = `dir-${item.doc.uuid}`;
451
- const updatedAt = item.doc.updated_at ? new Date(item.doc.updated_at).getTime() : Date.now();
452
-
453
- insertTitleStmt.run(
454
- uniqueId,
455
- item.doc.id || 0,
456
- item.doc.title,
457
- uniqueSlug,
458
- `https://www.yuque.com/${item.namespace}/#${uniqueSlug}`,
459
- item.namespace,
460
- '',
461
- Date.now(),
462
- item.doc.parent_uuid || null,
463
- item.doc.uuid,
464
- item.index,
465
- 0,
466
- updatedAt
467
- );
468
  }
469
  });
470
 
@@ -474,107 +714,390 @@ export const startYuqueSync = async () => {
474
 
475
  // 2. Slow Process: Download Content (Only for new/updated docs)
476
  if (downloadQueue.length > 0) {
477
- const nsTotalBatches = Math.ceil(downloadQueue.length / BATCH_SIZE);
478
- currentSyncStatus.totalBatches += nsTotalBatches;
479
-
480
- const nsDocsForVectorStore: Document[] = [];
481
-
482
- for (let i = 0; i < downloadQueue.length; i += BATCH_SIZE) {
483
- currentSyncStatus.currentBatch++;
484
- const batchInfos = downloadQueue.slice(i, i + BATCH_SIZE);
485
- currentSyncStatus.message = `正在下载文档内容:${ns} (批次 ${Math.floor(i / BATCH_SIZE) + 1}/${nsTotalBatches})...`;
486
-
487
- await asyncPool(CONCURRENCY, batchInfos, async (item) => {
488
- try {
489
- // Removed artificial delay to improve speed.
490
- // We rely on fetchAPI's 429 handling for rate limits.
491
-
492
- const docId = `${item.namespace}/${item.doc.slug}`;
493
- const updatedAt = item.doc.updated_at ? new Date(item.doc.updated_at).getTime() : Date.now();
494
-
495
- const loader = new SimpleYuqueLoader(token, item.namespace);
496
- const doc = await loader.fetchDocDetail(item.doc);
497
- if (doc) {
498
- nsDocsForVectorStore.push(doc);
499
-
500
- const wordCount = item.doc.word_count || (doc.pageContent ? doc.pageContent.length : 0);
501
-
502
- try {
503
- const stmt = db.prepare(`
504
- INSERT OR REPLACE INTO documents (id, yuque_id, title, slug, url, namespace, content_preview, synced_at, parent_uuid, uuid, sort_order, word_count, updated_at)
505
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
506
- `);
507
-
508
- stmt.run(
509
- docId,
510
- item.doc.id,
511
- item.doc.title,
512
- item.doc.slug,
513
- doc.metadata.url,
514
- item.namespace,
515
- doc.pageContent, // Save full content
516
- Date.now(),
517
- item.doc.parent_uuid || null,
518
- item.doc.uuid,
519
- item.index, // Use index as sort_order
520
- wordCount,
521
- updatedAt
522
- );
523
- } catch (e) {
524
- console.error(`Failed to save document ${docId} to DB:`, e);
525
- }
526
- }
527
- currentSyncStatus.processed++;
528
- } catch (docError) {
529
- console.error(`Failed to process document ${item.doc.title} (${item.doc.slug}):`, docError);
530
- // Don't throw here, so other docs can continue
531
- }
532
- });
533
- }
534
-
535
- // Update Vector Store per namespace to save progress
536
- if (nsDocsForVectorStore.length > 0) {
537
- currentSyncStatus.message = `正在更新 ${ns} 的向量索引 (${nsDocsForVectorStore.length} 篇文档)...`;
538
- console.log(`Updating vector store with ${nsDocsForVectorStore.length} new/updated documents for ${ns}...`);
539
- const chunks = await splitter.splitDocuments(nsDocsForVectorStore);
540
-
541
- let vectorStore: HNSWLib | null = null;
542
- const indexFile = path.join(VECTOR_STORE_PATH, "hnswlib.index");
543
-
544
- if (fs.existsSync(indexFile)) {
545
- try {
546
- const loadedStore = await HNSWLib.load(VECTOR_STORE_PATH, embeddings);
547
- await loadedStore.addDocuments(chunks);
548
- vectorStore = loadedStore;
549
- } catch (err) {
550
- console.error("Failed to load vector store (corruption detected), resetting:", err);
551
- }
552
- }
553
-
554
- if (!vectorStore) {
555
- vectorStore = await HNSWLib.fromDocuments(chunks, embeddings);
556
- }
557
- await vectorStore.save(VECTOR_STORE_PATH);
558
-
559
- // Clear memory
560
- nsDocsForVectorStore.length = 0;
561
- }
562
  }
563
 
564
  } catch (e: unknown) {
565
  console.error(`Failed to sync namespace ${ns}:`, e);
 
566
  const message = e instanceof Error ? e.message : String(e);
567
- if (message.includes("Yuque API Auth Error")) throw e;
568
  }
569
  }
570
 
571
- currentSyncStatus.status = 'completed';
572
- currentSyncStatus.message = '同步已完成';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
573
 
574
  } catch (error) {
575
- console.error("Sync failed:", error);
576
- currentSyncStatus.status = 'error';
577
- currentSyncStatus.error = error instanceof Error ? error.message : String(error);
578
- }
 
 
 
 
 
 
 
579
  })();
580
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
 
2
  import { Document } from "@langchain/core/documents";
3
+ import { Embeddings } from "@langchain/core/embeddings";
4
  import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
5
  import { getEmbeddings } from "./vector-store";
6
  import { HNSWLib } from "@langchain/community/vectorstores/hnswlib";
 
24
  parent_uuid?: string;
25
  uuid: string;
26
  type?: string;
27
+ tags?: string[];
28
  }
29
 
30
  export interface SyncStatus {
 
47
  message: ''
48
  };
49
 
50
+ // === RATE LIMITER GLOBALS ===
51
+ const MIN_REQUEST_DELAY = Number(process.env.SYNC_MIN_DELAY ?? 100);
52
+ // Timestamp of the last request dispatched
53
+ let lastRequestTime = 0;
54
+ // If we hit a 429, we set this timestamp. All requests must wait until this time.
55
+ let globalRateLimitResetTime = 0;
56
+ const SYNC_RPS = parseInt(process.env.SYNC_RPS ?? '3');
57
+ const SYNC_BURST = parseInt(process.env.SYNC_BURST ?? '6');
58
+ let bucketTokens = SYNC_BURST;
59
+ let lastRefillTime = Date.now();
60
+ let rateLimitHits = 0;
61
+ function refillTokens() {
62
+ const now = Date.now();
63
+ const elapsed = now - lastRefillTime;
64
+ if (elapsed > 0) {
65
+ const add = (elapsed * SYNC_RPS) / 1000;
66
+ bucketTokens = Math.min(bucketTokens + add, SYNC_BURST);
67
+ lastRefillTime = now;
68
+ }
69
+ }
70
+ async function waitForToken() {
71
+ while (true) {
72
+ refillTokens();
73
+ if (bucketTokens >= 1) {
74
+ bucketTokens -= 1;
75
+ return;
76
+ }
77
+ const need = 1 - bucketTokens;
78
+ const waitMs = Math.ceil((need * 1000) / SYNC_RPS);
79
+ await new Promise((r) => setTimeout(r, Math.max(waitMs, 1)));
80
+ }
81
+ }
82
+ function consumeRateLimitHits() {
83
+ const c = rateLimitHits;
84
+ rateLimitHits = 0;
85
+ return c;
86
+ }
87
+
88
+ let isStopRequested = false;
89
+
90
+ export const stopYuqueSync = () => {
91
+ if (currentSyncStatus.status === 'running') {
92
+ isStopRequested = true;
93
+ currentSyncStatus.message = '正在停止同步...';
94
+ }
95
+ };
96
+
97
  async function asyncPool<T, R>(poolLimit: number, array: T[], iteratorFn: (item: T, array: T[]) => Promise<R>): Promise<R[]> {
98
  const ret: Promise<R>[] = [];
99
  const executing: Promise<void>[] = [];
 
114
  return Promise.all(ret);
115
  }
116
 
117
+ export class SimpleYuqueLoader {
118
  constructor(private token: string, private namespace: string) {}
119
 
120
+ public async fetchAPI(endpoint: string) {
121
  const url = `${YUQUE_BASE_URL}${endpoint}`;
122
  const headers = {
123
  "X-Auth-Token": this.token,
 
125
  "Content-Type": "application/json",
126
  };
127
 
128
+ const MAX_RETRIES = 3; // Reduced from 5 to fail faster
129
  let attempt = 0;
130
+ let lastError: Error | null = null;
131
 
132
+ while (attempt < MAX_RETRIES) {
133
+ if (isStopRequested) {
134
+ throw new Error('Sync stopped by user');
135
+ }
136
+
137
+ // === GLOBAL RATE LIMITER CHECK ===
138
+ const now = Date.now();
139
+
140
+ // 1. Check if we are in a "Cool Down" period from a previous 429
141
+ if (globalRateLimitResetTime > now) {
142
+ const waitTime = globalRateLimitResetTime - now;
143
+ if (waitTime > 1000) {
144
+ console.log(`[Rate Limit] Global Pause active. Waiting ${Math.round(waitTime/1000)}s...`);
145
+ currentSyncStatus.message = `触发限流,全局暂停 ${Math.round(waitTime/1000)}秒...`;
146
+ }
147
+ await new Promise(resolve => setTimeout(resolve, waitTime));
148
+ }
149
+
150
+ await waitForToken();
151
+ // 2. Enforce minimum interval between requests (Throttle)
152
+ const timeSinceLastRequest = Date.now() - lastRequestTime;
153
+ if (timeSinceLastRequest < MIN_REQUEST_DELAY) {
154
+ await new Promise(resolve => setTimeout(resolve, MIN_REQUEST_DELAY - timeSinceLastRequest));
155
+ }
156
+ lastRequestTime = Date.now();
157
+
158
  let response;
159
  try {
160
+ const controller = new AbortController();
161
+ const timeoutId = setTimeout(() => controller.abort(), 60000); // Increased to 60s timeout for large docs
162
+
163
  if (process.env.HTTPS_PROXY) {
164
  const agent = new HttpsProxyAgent(process.env.HTTPS_PROXY);
165
+ response = await nodeFetch(url, { headers, agent, signal: controller.signal });
166
  } else {
167
+ response = await fetch(url, { headers, signal: controller.signal });
168
  }
169
+ clearTimeout(timeoutId);
170
 
171
  if (response.ok) {
172
  return await response.json();
 
184
  if (response.status === 429) {
185
  const retryAfter = response.headers.get('retry-after');
186
  // Default to exponential backoff if no retry-after header
187
+ // Aggressively increase base delay: 10s base for 429
188
+ let delay = retryAfter ? parseInt(retryAfter) * 1000 : 10000 * Math.pow(2, attempt);
189
 
190
  // Add larger jitter (random delay between 1000-3000ms)
191
  delay += 1000 + Math.random() * 2000;
192
 
193
+ // Cap at 60s (1 minute) to avoid "hanging" appearance
194
+ if (delay > 60000) delay = 60000;
195
 
196
+ // === SET GLOBAL PAUSE ===
197
+ // If one request hits 429, ALL requests should pause.
198
+ // We set the global reset time to now + delay.
199
+ globalRateLimitResetTime = Date.now() + delay;
200
+ rateLimitHits++;
201
+
202
+ const msg = `语雀 API 触发速率限制,${Math.round(delay/1000)}秒后重试... (Attempt ${attempt + 1}/${MAX_RETRIES})`;
203
  console.warn(msg);
204
  currentSyncStatus.message = msg;
205
 
 
212
  throw new Error(`Yuque API Error: ${response.status} ${response.statusText} - ${errorText.substring(0, 200)} - ${url}`);
213
  } catch (error: unknown) {
214
  const errorMessage = error instanceof Error ? error.message : String(error);
215
+ lastError = error instanceof Error ? error : new Error(errorMessage);
216
 
217
  // If it's an Auth error or 404, stop immediately (re-throw)
218
  if (errorMessage.includes("Yuque API Auth Error") || errorMessage.includes("Yuque API 404 Not Found")) {
 
223
  // If it's a "Yuque API Error" (thrown above), check status inside message if possible,
224
  // but we already handled 429.
225
 
226
+ console.warn(`Yuque API Request Failed: ${errorMessage}. Retrying... (Attempt ${attempt + 1}/${MAX_RETRIES})`);
227
 
228
+ // General error backoff: 5s * 1.5^attempt
229
+ const backoff = 5000 * Math.pow(1.5, attempt) + Math.random() * 1000;
230
  await new Promise(resolve => setTimeout(resolve, backoff));
231
  attempt++;
232
  }
233
  }
234
 
235
+ // Ensure lastError is captured even if it wasn't set (e.g. timeout)
236
+ const errorMsg = lastError ? lastError.message : 'Unknown Error';
237
+ throw new Error(`Yuque API Failed after ${MAX_RETRIES} retries: ${url}. Last Error: ${errorMsg}`);
238
  }
239
 
240
+ async loadDocList(totalCount?: number): Promise<YuqueDoc[]> {
241
+ if (this.namespace === 'NOTES') {
242
+ // NOTES are handled separately via streaming/paging now
243
+ return [];
244
+ }
245
  try {
246
  currentSyncStatus.message = `正在获取目录结构:${this.namespace}...`;
247
 
 
256
  }
257
 
258
  // Fetch all docs metadata (including updated_at) to merge
259
+ // We pass the TOC count as a loose reference, but we really need the Repo Detail count (which we don't have here easily unless passed in)
260
+ // But fetchAllDocsMetadata will try to fetch EVERYTHING via paging.
261
+ const docsMap = await this.fetchAllDocsMetadata(totalCount);
262
+
263
+ // --- INTEGRITY CHECK ---
264
+ // If we have a TOC, the docsMap size should be roughly similar or larger (TOC includes empty nodes, but docsMap includes orphans)
265
+ // A better check is usually against the repo info.
266
+ // We will do a stricter check in the main sync loop.
267
 
268
  // Merge Strategy:
269
  // 1. Start with TOC items (they contain structure info like parent_uuid)
 
309
 
310
  } catch (e: unknown) {
311
  console.error(`Failed to fetch doc list for ${this.namespace}:`, e);
312
+ throw e;
 
 
313
  }
314
  }
315
 
316
+ private async fetchAllDocsMetadata(totalCount?: number): Promise<Map<number, YuqueDoc>> {
317
  const map = new Map<number, YuqueDoc>();
318
+ const limit = 100; // Increased to 100 (max) to reduce requests
 
319
 
320
+ // === OPTIMIZATION: Parallel Fetching if Total Count is Known ===
321
+ if (totalCount && totalCount > 0) {
322
+ const totalPages = Math.ceil(totalCount / limit);
323
+ // Add a safety buffer of 1 extra page just in case count is slightly off
324
+ const offsets: number[] = [];
325
+ for (let i = 0; i < totalPages + 1; i++) {
326
+ offsets.push(i * limit);
327
+ }
328
+
329
+ console.log(`[Metadata Sync] Parallel fetching ${offsets.length} pages for ${this.namespace} (Total: ${totalCount})...`);
330
+
331
+ // Use asyncPool to limit concurrency to avoid aggressive rate limiting
332
+ // Reduced from 5 to 3 to be safer
333
+ const metaConcurrency = parseInt(process.env.SYNC_METADATA_CONCURRENCY ?? '5');
334
+ await asyncPool(metaConcurrency, offsets, async (offset) => {
335
+ try {
336
+ if (isStopRequested) return;
337
+
338
+ const res = await this.fetchAPI(`/repos/${this.namespace}/docs?offset=${offset}&limit=${limit}`);
339
+ const list = res.data as YuqueDoc[];
340
+
341
+ if (list && list.length > 0) {
342
+ list.forEach(d => map.set(d.id, d));
343
+ // Update progress message periodically
344
+ if (map.size % 500 === 0) {
345
+ currentSyncStatus.message = `正在并行获取元数据:${this.namespace} (已获取 ${map.size}/${totalCount})...`;
346
+ }
347
+ }
348
+ } catch (e: unknown) {
349
+ const message = e instanceof Error ? e.message : String(e);
350
+ // If we hit 404/Auth, we stop.
351
+ // If we hit Rate Limit, fetchAPI already retries.
352
+ // If we still fail, we log but continue other pages?
353
+ // No, for metadata, if we miss a page, we miss docs. We should throw.
354
+ if (message.includes("Yuque API Auth Error") || message.includes("Sync stopped by user")) {
355
+ throw e;
356
+ }
357
+ console.warn(`[Metadata Sync] Warning: Failed to fetch page at offset ${offset}: ${message}`);
358
+ // We rely on Integrity Check later to catch missing docs.
359
+ }
360
+ });
361
+
362
+ if (map.size > 0) {
363
+ return map;
364
+ }
365
+ // If map is empty or something failed silently, fallback to sequential?
366
+ // No, if totalCount was > 0 and we got 0, something is wrong.
367
+ if (totalCount > 0 && map.size === 0) {
368
+ console.warn(`[Metadata Sync] Parallel fetch returned 0 items but expected ${totalCount}. Falling back to sequential.`);
369
+ } else {
370
+ return map;
371
+ }
372
+ }
373
+
374
+ // === FALLBACK: Sequential Fetching (Old Logic) ===
375
+ let offset = 0;
376
  while (true) {
377
  try {
378
+ if (isStopRequested) throw new Error('Sync stopped by user');
379
+
380
  currentSyncStatus.message = `正在获取元数据:${this.namespace} (已获取 ${map.size})...`;
381
  const res = await this.fetchAPI(`/repos/${this.namespace}/docs?offset=${offset}&limit=${limit}`);
382
  const list = res.data as YuqueDoc[];
 
387
 
388
  if (list.length < limit) break;
389
  // Increased delay to prevent rate limiting
390
+ await new Promise(resolve => setTimeout(resolve, 500));
391
  } catch (e: unknown) {
392
+ console.error(`Error fetching docs list page for ${this.namespace} at offset ${offset}:`, e);
393
  const message = e instanceof Error ? e.message : String(e);
394
+
395
+ // Critical errors that should stop the process
396
+ if (message.includes("Yuque API Auth Error") || message.includes("Sync stopped by user")) {
397
+ throw e;
398
+ }
399
+
400
+ // For other errors (like network timeout after retries), we should ALSO throw to avoid partial sync.
401
+ // Previously we used 'break', which caused the "incomplete sync marked as success" issue.
402
+ throw new Error(`Failed to fetch complete doc list at offset ${offset}. Sync aborted to ensure data integrity. Error: ${message}`);
403
  }
404
  }
405
  return map;
406
  }
407
 
408
+ async fetchRepoDetail(): Promise<{ name: string; description: string; updated_at: string; items_count: number } | null> {
409
+ if (this.namespace === 'NOTES') {
410
+ return {
411
+ name: '小记',
412
+ description: '来自语雀小记的内容',
413
+ updated_at: new Date().toISOString(),
414
+ items_count: 0 // Notes count is dynamic
415
+ };
416
+ }
417
  try {
418
  const data = await this.fetchAPI(`/repos/${this.namespace}`);
419
  return {
420
  name: data.data.name,
421
  description: data.data.description || '',
422
+ updated_at: data.data.updated_at,
423
+ items_count: data.data.items_count || 0
424
  };
425
  } catch (e: unknown) {
426
  console.error(`Failed to fetch repo detail for ${this.namespace}:`, e);
 
431
  }
432
 
433
  async fetchDocDetail(docInfo: YuqueDoc): Promise<Document | null> {
434
+ if (this.namespace === 'NOTES') {
435
+ return this.fetchNoteDetail(docInfo);
436
+ }
437
  try {
438
  const detailData = await this.fetchAPI(`/repos/${this.namespace}/docs/${docInfo.id}`);
439
  const docDetail = detailData.data;
 
454
  }
455
  } catch (e: unknown) {
456
  const message = e instanceof Error ? e.message : String(e);
457
+ if (message.includes("Yuque API Auth Error") || message.includes("Sync stopped by user")) throw e;
458
  console.error(`Failed to fetch doc ${docInfo.title}:`, e);
459
  }
460
  return null;
461
  }
462
+
463
+ public extractNoteTitle(abstract?: string): string {
464
+ if (!abstract) return '无标题小记';
465
+ // Remove HTML tags and take first 200 chars (increased from 100)
466
+ const text = abstract.replace(/<[^>]+>/g, '').trim();
467
+ return text.substring(0, 200) + (text.length > 200 ? '...' : '');
468
+ }
469
+
470
+ async fetchNoteDetail(docInfo: YuqueDoc): Promise<Document | null> {
471
+ try {
472
+ const res = await this.fetchAPI(`/notes/${docInfo.id}`);
473
+ const noteData = res.data;
474
+ // Prefer HTML content, fallback to abstract
475
+ const htmlContent = noteData.content?.html || noteData.content?.abstract || "";
476
+
477
+ // Simple HTML to Text conversion (very basic)
478
+ // For RAG, we prefer text.
479
+ // We can use a regex to strip tags, but keep line breaks.
480
+ let textContent = htmlContent
481
+ .replace(/<br\s*\/?>/gi, '\n')
482
+ .replace(/<\/p>/gi, '\n')
483
+ .replace(/<\/div>/gi, '\n')
484
+ .replace(/<[^>]+>/g, '');
485
+
486
+ textContent = textContent.replace(/&nbsp;/g, ' ').trim();
487
+
488
+ if (textContent) {
489
+ return new Document({
490
+ pageContent: textContent,
491
+ metadata: {
492
+ source: `yuque://NOTES/${docInfo.slug}`,
493
+ title: docInfo.title,
494
+ id: docInfo.id,
495
+ yuque_slug: docInfo.slug,
496
+ namespace: 'NOTES',
497
+ type: 'NOTE',
498
+ url: `https://www.yuque.com/dashboard/notes` // Notes don't have public URLs usually
499
+ }
500
+ });
501
+ }
502
+ } catch (e: unknown) {
503
+ const message = e instanceof Error ? e.message : String(e);
504
+ if (message.includes("Sync stopped by user")) throw e;
505
+ console.error("Failed to fetch note detail", e);
506
+ }
507
+ return null;
508
+ }
509
  }
510
 
511
  export const getSyncStatus = () => currentSyncStatus;
 
517
 
518
  const token = process.env.YUQUE_TOKEN;
519
  const namespacesEnv = process.env.YUQUE_NAMESPACE;
520
+
521
+ // Check initial DB state
522
+ try {
523
+ const count = db.prepare('SELECT COUNT(*) as c FROM documents').get() as { c: number };
524
+ console.log(`[Start Sync] Current DB document count: ${count.c}`);
525
+ } catch (e) {
526
+ console.log(`[Start Sync] Could not check DB count (DB might be new): ${e}`);
527
+ }
528
 
529
  if (!token || !namespacesEnv) {
530
  currentSyncStatus = { ...currentSyncStatus, status: 'error', error: '缺少环境变量配置' };
 
539
  totalBatches: 0,
540
  status: 'running'
541
  };
542
+ isStopRequested = false;
543
 
544
  // Run in background (don't await this promise in the API handler)
545
  (async () => {
546
  try {
547
  const namespaces = namespacesEnv.split(",").map(s => s.trim()).filter(Boolean);
548
  const embeddings = getEmbeddings();
549
+ let hasError = false;
550
 
551
  const splitter = new RecursiveCharacterTextSplitter({
552
  chunkSize: 1000,
553
  chunkOverlap: 200,
554
  });
555
 
556
+ const BATCH_SIZE = parseInt(process.env.SYNC_BATCH_SIZE ?? '100');
557
+ const CONCURRENCY = parseInt(process.env.SYNC_CONCURRENCY ?? '3');
558
 
559
  for (const ns of namespaces) {
560
+ if (isStopRequested) break;
561
+
562
  // Reset progress for the new namespace to avoid UI confusion
563
  currentSyncStatus.total = 0;
564
  currentSyncStatus.processed = 0;
 
569
  // Fetch and save KB info
570
  currentSyncStatus.message = `正在获取知识库信息:${ns}...`;
571
  const repoInfo = await loader.fetchRepoDetail();
572
+
573
+ if (ns === 'NOTES') {
574
+ // === SPECIAL HANDLING FOR NOTES (PAGED/STREAMED SYNC) ===
575
+ console.log(`[Sync] Starting Paged Sync for NOTES...`);
576
+ const notesConcurrency = parseInt(process.env.NOTES_CONCURRENCY ?? '2');
577
+ await syncNotesWithPaging(loader, splitter, embeddings, BATCH_SIZE, notesConcurrency);
578
+ continue; // Skip the standard flow
579
+ }
580
+
581
+ if (!repoInfo) {
582
+ console.error(`[Sync] Skipping ${ns}: Could not fetch repository details (404 or access denied). Check your namespace configuration.`);
583
+ currentSyncStatus.message = `无法访问知识库:${ns} (请检查配置)`;
584
+ hasError = true;
585
+ continue;
586
+ }
587
+
588
  if (repoInfo) {
589
+ // SMART SYNC CHECK (Standard KB)
590
  const forceFullSync = process.env.FORCE_FULL_SYNC === 'true';
591
 
592
  // Check local DB for last sync time
593
  const localKb = db.prepare('SELECT synced_at FROM knowledge_bases WHERE namespace = ?').get(ns) as { synced_at: number } | undefined;
594
+ const docCount = db.prepare('SELECT COUNT(*) as c FROM documents WHERE namespace = ?').get(ns) as { c: number };
595
 
596
  const repoUpdatedAt = new Date(repoInfo.updated_at).getTime();
597
 
598
+ // If local sync time >= repo update time AND we have documents AND document count matches roughly, we are up to date.
599
+ // We use a threshold of 5% difference or 10 docs to allow for small discrepancies (drafts, etc)
600
+ // But if user reports large diff (4000 vs 8000), this check will fail and force sync.
601
+ const isCountMatch = Math.abs(docCount.c - repoInfo.items_count) < 5 || (repoInfo.items_count > 0 && Math.abs(docCount.c - repoInfo.items_count) / repoInfo.items_count < 0.05);
602
+
603
+ if (!forceFullSync && localKb && localKb.synced_at >= repoUpdatedAt && docCount.c > 0 && isCountMatch) {
604
+ console.log(`[Smart Sync] Skipping ${ns} (Up to date). Repo Updated: ${repoInfo.updated_at}, Last Sync: ${new Date(localKb.synced_at).toISOString()}, Docs: ${docCount.c} (Remote: ${repoInfo.items_count})`);
605
+ currentSyncStatus.message = `知识库【${repoInfo.name}】已是最新 (本地:${docCount.c}/云端:${repoInfo.items_count}),跳过同步。`;
606
  await new Promise(resolve => setTimeout(resolve, 1500));
607
  continue;
608
+ } else if (!forceFullSync && localKb && localKb.synced_at >= repoUpdatedAt && !isCountMatch) {
609
+ console.log(`[Smart Sync] Force Syncing ${ns} due to document count mismatch. Local: ${docCount.c}, Remote: ${repoInfo.items_count}`);
610
+ currentSyncStatus.message = `发现数量不一致 (本地:${docCount.c}/云端:${repoInfo.items_count}),强制同步【${repoInfo.name}】...`;
611
+ await new Promise(resolve => setTimeout(resolve, 1500));
612
  }
 
 
 
 
 
 
613
  }
614
 
615
  // Minimal delay between requests
 
617
 
618
  // Fetch List
619
  currentSyncStatus.message = `正在获取文档列表:${ns}...`;
620
+ const docs = await loader.loadDocList(repoInfo ? repoInfo.items_count : undefined);
621
+
622
+ // --- INTEGRITY CHECK ---
623
+ // Verify if we fetched a reasonable amount of docs compared to repo info
624
+ if (repoInfo && repoInfo.items_count > 0) {
625
+ const fetchedCount = docs.filter(d => d.type === 'DOC' || !d.type).length; // Filter out titles/dirs
626
+ // Allow 10% deviation or 20 docs diff (whichever is larger)
627
+ const diff = Math.abs(fetchedCount - repoInfo.items_count);
628
+ const allowedDiff = Math.max(20, repoInfo.items_count * 0.1);
629
+
630
+ if (diff > allowedDiff) {
631
+ console.warn(`[Integrity Check Failed] ${ns}: Fetched ${fetchedCount} docs, but Repo says ${repoInfo.items_count}. Deviation: ${diff}`);
632
+ // We should probably NOT stop, but we MUST mark this as a "Partial Sync" so we don't update the 'synced_at' timestamp
633
+ hasError = true;
634
+ currentSyncStatus.message = `警告:文档数量差异大 (获取 ${fetchedCount} / 预期 ${repoInfo.items_count}),本次同步将不标记为完成。`;
635
+ // Allow to proceed to try and sync what we have, but ensure we don't mark KB as fully synced.
636
+ }
637
+ }
638
 
639
  type DocItem = { namespace: string; doc: YuqueDoc; index: number };
640
  const docsWithIndex: DocItem[] = docs.map((d, i) => ({ namespace: ns, doc: d, index: i }));
 
642
  currentSyncStatus.total += docsWithIndex.length;
643
 
644
  // --- INCREMENTAL SYNC OPTIMIZATION ---
 
 
 
 
645
  const downloadQueue: DocItem[] = [];
646
  const metadataQueue: DocItem[] = [];
647
  const titleNodeQueue: DocItem[] = [];
 
690
  currentSyncStatus.message = `正在快速更新 ${metadataQueue.length} 篇无变更文档的元数据:${ns}...`;
691
  const updateStmt = db.prepare(`
692
  UPDATE documents
693
+ SET word_count = ?, updated_at = ?, sort_order = ?, title = ?, parent_uuid = ?, created_at = ?
694
  WHERE id = ?
695
  `);
696
 
 
 
 
 
 
697
  const transaction = db.transaction(() => {
 
698
  for (const item of metadataQueue) {
699
  const docId = `${item.namespace}/${item.doc.slug}`;
700
  const updatedAt = item.doc.updated_at ? new Date(item.doc.updated_at).getTime() : Date.now();
701
+ const createdAt = item.doc.created_at ? new Date(item.doc.created_at).getTime() : updatedAt;
702
  const wordCount = item.doc.word_count || 0;
703
+ updateStmt.run(wordCount, updatedAt, item.index, item.doc.title, item.doc.parent_uuid || null, createdAt, docId);
704
  }
705
  // Process Title Nodes (Always fast)
706
  for (const item of titleNodeQueue) {
707
+ processTitleNode(item);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
708
  }
709
  });
710
 
 
714
 
715
  // 2. Slow Process: Download Content (Only for new/updated docs)
716
  if (downloadQueue.length > 0) {
717
+ await processDownloadQueue(downloadQueue, loader, splitter, embeddings, BATCH_SIZE, CONCURRENCY);
718
+ }
719
+
720
+ // Update KB info (Success) - Only update if not stopped AND no error occurred
721
+ // Note: hasError might be set by the integrity check above
722
+ if (repoInfo && !isStopRequested && !hasError) {
723
+ const stmt = db.prepare(`
724
+ INSERT OR REPLACE INTO knowledge_bases (namespace, name, description, synced_at)
725
+ VALUES (?, ?, ?, ?)
726
+ `);
727
+ stmt.run(ns, repoInfo.name, repoInfo.description, Date.now());
728
+ } else if (hasError) {
729
+ console.log(`[Sync] Skipping KB timestamp update for ${ns} due to errors/integrity check failure.`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
730
  }
731
 
732
  } catch (e: unknown) {
733
  console.error(`Failed to sync namespace ${ns}:`, e);
734
+ hasError = true;
735
  const message = e instanceof Error ? e.message : String(e);
736
+ if (message.includes("Yuque API Auth Error") || message.includes("Sync stopped by user")) throw e;
737
  }
738
  }
739
 
740
+ if (isStopRequested) {
741
+ currentSyncStatus.status = 'idle';
742
+ currentSyncStatus.message = '同步已停止';
743
+ } else if (hasError) {
744
+ currentSyncStatus.status = 'error';
745
+ currentSyncStatus.message = '同步完成,但部分知识库失败,请查看后台日志';
746
+ } else {
747
+ currentSyncStatus.status = 'completed';
748
+ currentSyncStatus.message = '同步已完成';
749
+ }
750
+
751
+ // Log final count
752
+ try {
753
+ const count = db.prepare('SELECT COUNT(*) as c FROM documents').get() as { c: number };
754
+ console.log(`[End Sync] Final DB document count: ${count.c}`);
755
+ } catch (e) {}
756
 
757
  } catch (error) {
758
+ const msg = error instanceof Error ? error.message : String(error);
759
+ if (msg.includes("Sync stopped by user")) {
760
+ console.log("Sync process stopped by user.");
761
+ currentSyncStatus.status = 'idle';
762
+ currentSyncStatus.message = '同步已停止';
763
+ } else {
764
+ console.error("Sync failed:", error);
765
+ currentSyncStatus.status = 'error';
766
+ currentSyncStatus.error = msg;
767
+ }
768
+ }
769
  })();
770
  };
771
+
772
+ let lastTagsBackfillAt = 0;
773
+ export const backfillNoteTags = async (pageLimit = 200, pages = 3) => {
774
+ try {
775
+ const token = process.env.YUQUE_TOKEN;
776
+ if (!token) return;
777
+ const now = Date.now();
778
+ if (now - lastTagsBackfillAt < 30000) return;
779
+ lastTagsBackfillAt = now;
780
+ const loader = new SimpleYuqueLoader(token, 'NOTES');
781
+ let offset = 0;
782
+ for (let i = 0; i < pages; i++) {
783
+ const res = await loader.fetchAPI(`/notes?offset=${offset}&limit=${pageLimit}`);
784
+ const rawNotes = (res && res.data && Array.isArray(res.data.notes)) ? res.data.notes : [];
785
+ if (!rawNotes || rawNotes.length === 0) break;
786
+ const items = rawNotes.map((n: { slug: string; tags?: unknown }) => ({
787
+ slug: n.slug,
788
+ tags: Array.isArray(n.tags) ? (n.tags as unknown[]).map((t: unknown) => {
789
+ if (typeof t === 'string') return t;
790
+ if (t && typeof t === 'object') {
791
+ const record = t as Record<string, unknown>;
792
+ // Check for 'title' (standard docs) or 'name' (notes)
793
+ const v = record['title'] || record['name'];
794
+ return typeof v === 'string' ? v : '';
795
+ }
796
+ return '';
797
+ }).filter((s: string) => s.length > 0) : []
798
+ }));
799
+ const tx = db.transaction(() => {
800
+ const stmt = db.prepare('UPDATE documents SET tags = ? WHERE id = ?');
801
+ for (const it of items) {
802
+ const id = `NOTES/${it.slug}`;
803
+ stmt.run(JSON.stringify(it.tags || []), id);
804
+ }
805
+ });
806
+ tx();
807
+ offset += pageLimit;
808
+ if (rawNotes.length < pageLimit) break;
809
+ }
810
+ } catch {}
811
+ };
812
+
813
+
814
+ // === HELPER FUNCTIONS ===
815
+
816
+ function processTitleNode(item: { namespace: string; doc: YuqueDoc; index: number }) {
817
+ const insertTitleStmt = db.prepare(`
818
+ INSERT OR REPLACE INTO documents (id, yuque_id, title, slug, url, namespace, content_preview, synced_at, parent_uuid, uuid, sort_order, word_count, updated_at, created_at)
819
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
820
+ `);
821
+
822
+ const uniqueId = `${item.namespace}/dir-${item.doc.uuid}`;
823
+ const uniqueSlug = `dir-${item.doc.uuid}`;
824
+ const updatedAt = item.doc.updated_at ? new Date(item.doc.updated_at).getTime() : Date.now();
825
+ const createdAt = item.doc.created_at ? new Date(item.doc.created_at).getTime() : updatedAt;
826
+
827
+ insertTitleStmt.run(
828
+ uniqueId,
829
+ item.doc.id || 0,
830
+ item.doc.title,
831
+ uniqueSlug,
832
+ `https://www.yuque.com/${item.namespace}/#${uniqueSlug}`,
833
+ item.namespace,
834
+ '',
835
+ Date.now(),
836
+ item.doc.parent_uuid || null,
837
+ item.doc.uuid,
838
+ item.index,
839
+ 0,
840
+ updatedAt,
841
+ createdAt
842
+ );
843
+ }
844
+
845
+ async function syncNotesWithPaging(loader: SimpleYuqueLoader, splitter: RecursiveCharacterTextSplitter, embeddings: Embeddings, batchSize: number, concurrency: number) {
846
+ let offset = 0;
847
+ const rawLimit = parseInt(process.env.YUQUE_NOTES_LIMIT ?? '50');
848
+ const maxLimitEnv = parseInt(process.env.YUQUE_NOTES_MAX ?? '50');
849
+ const safeMax = Number.isFinite(maxLimitEnv) && maxLimitEnv > 0 ? maxLimitEnv : 50;
850
+ const limit = Math.min(Number.isFinite(rawLimit) ? rawLimit : safeMax, safeMax);
851
+ const ns = 'NOTES';
852
+ let hasMore = true;
853
+ const forceFullSync = process.env.FORCE_FULL_SYNC === 'true';
854
+
855
+ // Get existing sync state
856
+ const kbInfo = db.prepare('SELECT last_offset FROM knowledge_bases WHERE namespace = ?').get(ns) as { last_offset: number } | undefined;
857
+
858
+ if (forceFullSync) {
859
+ console.log(`[NOTES Sync] Force Full Sync enabled. Resetting offset to 0.`);
860
+ offset = 0;
861
+ // Reset offset in DB
862
+ db.prepare('UPDATE knowledge_bases SET last_offset = 0 WHERE namespace = ?').run(ns);
863
+ } else if (kbInfo && kbInfo.last_offset) {
864
+ offset = kbInfo.last_offset;
865
+ console.log(`[NOTES Sync] Resuming from offset ${offset}...`);
866
+ }
867
+
868
+ // Save initial KB info (preserve existing offset)
869
+ const insertKbStmt = db.prepare(`
870
+ INSERT OR REPLACE INTO knowledge_bases (namespace, name, description, synced_at, last_offset)
871
+ VALUES (?, ?, ?, ?, ?)
872
+ `);
873
+ insertKbStmt.run('NOTES', '小记', '来自语雀小记的内容', Date.now(), offset);
874
+
875
+ while (hasMore) {
876
+ currentSyncStatus.message = `正在同步小记 (Offset: ${offset})...`;
877
+ console.log(`[NOTES Sync] Fetching batch offset ${offset}...`);
878
+
879
+ let notesBatch: YuqueDoc[] = [];
880
+ try {
881
+ const res = await loader.fetchAPI(`/notes?offset=${offset}&limit=${limit}`);
882
+ const rawNotes = res.data.notes || [];
883
+ console.log(`[NOTES Sync] Requested limit=${limit}, received=${rawNotes.length} items at offset=${offset}`);
884
+
885
+ if (rawNotes.length === 0) {
886
+ hasMore = false;
887
+ // Sync complete, reset offset for next time?
888
+ // Or keep it to indicate we reached the end?
889
+ // Usually for paging APIs, if we finish, next time we might want to start from 0 to check for new items
890
+ // OR we rely on the "incremental" check within the first few pages.
891
+ // But since new notes appear at the TOP (offset 0), resuming from 28000 doesn't help find NEW notes.
892
+ // "Resumable" is useful for the INITIAL sync or if it crashes in the middle.
893
+ // Once completed, we should probably reset offset to 0 so next run checks from start.
894
+ console.log(`[NOTES Sync] No more notes found. Sync complete.`);
895
+
896
+ // Reset offset to 0 for next run (so we check from beginning next time)
897
+ db.prepare('UPDATE knowledge_bases SET last_offset = 0 WHERE namespace = ?').run(ns);
898
+ break;
899
+ }
900
+
901
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
902
+ notesBatch = rawNotes.map((n: any) => ({
903
+ id: n.id,
904
+ slug: n.slug,
905
+ title: loader.extractNoteTitle(n.content?.abstract) || `小记-${n.id}`,
906
+ created_at: n.created_at,
907
+ updated_at: n.updated_at,
908
+ uuid: n.slug,
909
+ type: 'NOTE',
910
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
911
+ tags: Array.isArray(n.tags) ? n.tags.map((t: any) => t.title || t.name || t) : []
912
+ }));
913
+
914
+ if (rawNotes.length < limit) {
915
+ hasMore = false;
916
+ // We reached the end
917
+ console.log(`[NOTES Sync] Reached last page.`);
918
+ db.prepare('UPDATE knowledge_bases SET last_offset = 0 WHERE namespace = ?').run(ns);
919
+ }
920
+ } catch (e) {
921
+ console.error(`[NOTES Sync] Failed to fetch batch at offset ${offset}:`, e);
922
+ throw e; // Stop sync on API error
923
+ }
924
+
925
+ // Identify which notes need download (Incremental check)
926
+ // Since we are paging, we process this batch immediately
927
+ const downloadQueue: { namespace: string; doc: YuqueDoc; index: number }[] = [];
928
+ const metadataQueue: { namespace: string; doc: YuqueDoc; index: number }[] = [];
929
+
930
+ const existingDocs = db.prepare(`SELECT id, synced_at FROM documents WHERE namespace = 'NOTES' AND id IN (${notesBatch.map(n => `'NOTES/${n.slug}'`).join(',')})`).all() as { id: string, synced_at: number }[];
931
+ const existingDocsMap = new Map(existingDocs.map(d => [d.id, d]));
932
+
933
+ // Check forced sync again (already checked at start, but good to keep variable scope)
934
+ // const forceFullSync = process.env.FORCE_FULL_SYNC === 'true';
935
+
936
+ notesBatch.forEach((note, idx) => {
937
+ const docId = `NOTES/${note.slug}`;
938
+ const existing = existingDocsMap.get(docId);
939
+ const yuqueTime = note.updated_at ? new Date(note.updated_at).getTime() : Date.now();
940
+ const index = offset + idx;
941
+
942
+ if (forceFullSync) {
943
+ downloadQueue.push({ namespace: 'NOTES', doc: note, index });
944
+ } else if (existing && existing.synced_at >= yuqueTime) {
945
+ metadataQueue.push({ namespace: 'NOTES', doc: note, index });
946
+ } else {
947
+ downloadQueue.push({ namespace: 'NOTES', doc: note, index });
948
+ }
949
+ });
950
+
951
+ // Update Metadata
952
+ if (metadataQueue.length > 0) {
953
+ const updateStmt = db.prepare(`
954
+ UPDATE documents
955
+ SET updated_at = ?, sort_order = ?, title = ?, tags = ?
956
+ WHERE id = ?
957
+ `);
958
+ const transaction = db.transaction(() => {
959
+ for (const item of metadataQueue) {
960
+ const docId = `NOTES/${item.doc.slug}`;
961
+ const updatedAt = item.doc.updated_at ? new Date(item.doc.updated_at).getTime() : Date.now();
962
+ updateStmt.run(updatedAt, item.index, item.doc.title, JSON.stringify(item.doc.tags || []), docId);
963
+ }
964
+ });
965
+ transaction();
966
+ currentSyncStatus.processed += metadataQueue.length;
967
+ }
968
+
969
+ // Download & Save Content
970
+ if (downloadQueue.length > 0) {
971
+ await processDownloadQueue(downloadQueue, loader, splitter, embeddings, batchSize, concurrency);
972
+ }
973
+
974
+ // Advance offset and Save Progress
975
+ offset += limit;
976
+ if (hasMore) {
977
+ // Only save offset if we are NOT done yet.
978
+ // If we are done, we reset to 0 (handled above).
979
+ db.prepare('UPDATE knowledge_bases SET last_offset = ? WHERE namespace = ?').run(offset, ns);
980
+ }
981
+
982
+ currentSyncStatus.total = offset; // Approx total for progress bar
983
+
984
+ // Anti-rate-limit delay
985
+ const pageDelay = parseInt(process.env.NOTES_PAGE_DELAY_MS ?? '500');
986
+ await new Promise(resolve => setTimeout(resolve, Math.max(0, pageDelay)));
987
+ }
988
+ }
989
+
990
+ async function processDownloadQueue(
991
+ downloadQueue: { namespace: string; doc: YuqueDoc; index: number }[],
992
+ loader: SimpleYuqueLoader,
993
+ splitter: RecursiveCharacterTextSplitter,
994
+ embeddings: Embeddings,
995
+ batchSize: number,
996
+ concurrency: number
997
+ ) {
998
+ const nsTotalBatches = Math.ceil(downloadQueue.length / batchSize);
999
+ currentSyncStatus.totalBatches += nsTotalBatches;
1000
+
1001
+ const nsDocsForVectorStore: Document[] = [];
1002
+ let currentConcurrency = Math.max(1, Math.floor(concurrency / 2));
1003
+ const maxConcurrency = Math.max(1, concurrency);
1004
+
1005
+ for (let i = 0; i < downloadQueue.length; i += batchSize) {
1006
+ if (isStopRequested) break;
1007
+ currentSyncStatus.currentBatch++;
1008
+ const batchInfos = downloadQueue.slice(i, i + batchSize);
1009
+ const ns = batchInfos[0].namespace;
1010
+ currentSyncStatus.message = `正在下载文档内容:${ns} (批次 ${Math.floor(i / batchSize) + 1}/${nsTotalBatches})...`;
1011
+
1012
+ // Clear previous batch docs
1013
+ nsDocsForVectorStore.length = 0;
1014
+
1015
+ await asyncPool(currentConcurrency, batchInfos, async (item) => {
1016
+ try {
1017
+ const docId = `${item.namespace}/${item.doc.slug}`;
1018
+ const updatedAt = item.doc.updated_at ? new Date(item.doc.updated_at).getTime() : Date.now();
1019
+ const createdAt = item.doc.created_at ? new Date(item.doc.created_at).getTime() : updatedAt;
1020
+
1021
+ const doc = await loader.fetchDocDetail(item.doc);
1022
+ if (doc) {
1023
+ nsDocsForVectorStore.push(doc);
1024
+
1025
+ const wordCount = item.doc.word_count || (doc.pageContent ? doc.pageContent.length : 0);
1026
+
1027
+ try {
1028
+ const stmt = db.prepare(`
1029
+ INSERT OR REPLACE INTO documents (id, yuque_id, title, slug, url, namespace, content_preview, synced_at, parent_uuid, uuid, sort_order, word_count, updated_at, created_at, tags)
1030
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1031
+ `);
1032
+
1033
+ stmt.run(
1034
+ docId,
1035
+ item.doc.id,
1036
+ item.doc.title,
1037
+ item.doc.slug,
1038
+ doc.metadata.url,
1039
+ item.namespace,
1040
+ doc.pageContent, // Save full content
1041
+ Date.now(),
1042
+ item.doc.parent_uuid || null,
1043
+ item.doc.uuid,
1044
+ item.index, // Use index as sort_order
1045
+ wordCount,
1046
+ updatedAt,
1047
+ createdAt,
1048
+ JSON.stringify(item.doc.tags || [])
1049
+ );
1050
+ } catch (e) {
1051
+ console.error(`Failed to save document ${docId} to DB:`, e);
1052
+ }
1053
+ }
1054
+ currentSyncStatus.processed++;
1055
+ } catch (docError: unknown) {
1056
+ const message = docError instanceof Error ? docError.message : String(docError);
1057
+ if (message.includes("Sync stopped by user")) throw docError;
1058
+ console.error(`Failed to process document ${item.doc.title} (${item.doc.slug}):`, docError);
1059
+ }
1060
+ });
1061
+
1062
+ // Update Vector Store per batch immediately
1063
+ if (nsDocsForVectorStore.length > 0) {
1064
+ currentSyncStatus.message = `正在更新向量索引 (批次 ${Math.floor(i / batchSize) + 1}/${nsTotalBatches})...`;
1065
+ console.log(`Updating vector store with ${nsDocsForVectorStore.length} docs (Batch ${Math.floor(i / batchSize) + 1})...`);
1066
+
1067
+ try {
1068
+ const chunks = await splitter.splitDocuments(nsDocsForVectorStore);
1069
+
1070
+ let vectorStore: HNSWLib | null = null;
1071
+ if (fs.existsSync(path.join(VECTOR_STORE_PATH, "hnswlib.index"))) {
1072
+ try {
1073
+ const loadedStore = await HNSWLib.load(VECTOR_STORE_PATH, embeddings);
1074
+ await loadedStore.addDocuments(chunks);
1075
+ vectorStore = loadedStore;
1076
+ } catch (err) {
1077
+ console.error("Failed to load vector store, creating new one:", err);
1078
+ }
1079
+ }
1080
+
1081
+ if (!vectorStore) {
1082
+ vectorStore = await HNSWLib.fromDocuments(chunks, embeddings);
1083
+ }
1084
+ await vectorStore.save(VECTOR_STORE_PATH);
1085
+ } catch (e) {
1086
+ console.error("Failed to update vector store for batch:", e);
1087
+ // Continue despite vector store error to ensure at least DB is updated?
1088
+ // Or stop? Probably continue, but log error.
1089
+ }
1090
+ }
1091
+
1092
+ {
1093
+ const hits = consumeRateLimitHits();
1094
+ if (hits > 0) {
1095
+ currentConcurrency = Math.max(1, Math.ceil(currentConcurrency / 2));
1096
+ } else if (currentConcurrency < maxConcurrency) {
1097
+ currentConcurrency = currentConcurrency + 1;
1098
+ }
1099
+ }
1100
+ }
1101
+
1102
+ // Final cleanup not needed as we processed in loop
1103
+ }
src/middleware.ts ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server'
2
+ import type { NextRequest } from 'next/server'
3
+
4
+ export function middleware(request: NextRequest) {
5
+ const token = request.cookies.get('auth_token')?.value
6
+ const { pathname } = request.nextUrl
7
+
8
+ // Allow access to login page
9
+ if (pathname === '/login') {
10
+ // If already authenticated, redirect to home
11
+ if (token) {
12
+ return NextResponse.redirect(new URL('/', request.url))
13
+ }
14
+ return NextResponse.next()
15
+ }
16
+
17
+ // Allow access to public assets and API for now (unless strictly required to protect)
18
+ // We strictly protect pages.
19
+ if (
20
+ pathname.startsWith('/_next') ||
21
+ pathname.startsWith('/static') ||
22
+ pathname.includes('.') // file extensions like .svg, .ico
23
+ ) {
24
+ return NextResponse.next()
25
+ }
26
+
27
+ // If not authenticated, redirect to login
28
+ if (!token) {
29
+ const url = new URL('/login', request.url)
30
+ return NextResponse.redirect(url)
31
+ }
32
+
33
+ return NextResponse.next()
34
+ }
35
+
36
+ export const config = {
37
+ matcher: [
38
+ /*
39
+ * Match all request paths except for the ones starting with:
40
+ * - api (API routes)
41
+ * - _next/static (static files)
42
+ * - _next/image (image optimization files)
43
+ * - favicon.ico (favicon file)
44
+ */
45
+ '/((?!api|_next/static|_next/image|favicon.ico).*)',
46
+ ],
47
+ }
test-db-logic.js ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ const { spawn } = require('child_process');
3
+ const http = require('http');
4
+
5
+ // Helper to fetch URL
6
+ function fetchUrl(url) {
7
+ return new Promise((resolve, reject) => {
8
+ http.get(url, (res) => {
9
+ let data = '';
10
+ res.on('data', chunk => data += chunk);
11
+ res.on('end', () => resolve(JSON.parse(data)));
12
+ res.on('error', reject);
13
+ });
14
+ });
15
+ }
16
+
17
+ // We cannot easily start the Next.js server from here as it takes time and resources.
18
+ // Instead, I will assume the server is running or I can run a standalone node script that imports the logic?
19
+ // No, importing Next.js app logic in a standalone script is hard.
20
+
21
+ // Since I cannot run the server, I will simulate the DB logic directly using better-sqlite3.
22
+ // I need to know where the DB is.
23
+ // Usually in `prisma` or `data.db`.
24
+ // Let's check `src/lib/db.ts`.
25
+
26
+ console.log("Checking DB logic...");
test_spam_filter.js ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ const db = require('better-sqlite3')('rag-kb.db');
3
+
4
+ const startTime = new Date('2025-01-01').getTime();
5
+ const endTime = new Date('2026-01-01').getTime();
6
+
7
+ // 1. Get raw count
8
+ const rawStats = db.prepare(`
9
+ SELECT COUNT(*) as count, SUM(word_count) as words
10
+ FROM documents
11
+ WHERE
12
+ yuque_id != 0
13
+ AND namespace != 'NOTES'
14
+ AND (slug IS NULL OR slug NOT LIKE 'dir-%')
15
+ AND created_at >= ?
16
+ AND created_at < ?
17
+ `).get(startTime, endTime);
18
+
19
+ console.log("Raw Stats:");
20
+ console.log(` Count: ${rawStats.count}`);
21
+ console.log(` Words: ${(rawStats.words / 10000).toFixed(1)}w`);
22
+
23
+ // 2. Get filtered count (Anti-Migration Logic)
24
+ // Exclude docs created on days where that Namespace had > 15 creations
25
+ const threshold = 5;
26
+
27
+ const filteredQuery = `
28
+ SELECT COUNT(*) as count, SUM(word_count) as words
29
+ FROM documents d
30
+ WHERE
31
+ d.yuque_id != 0
32
+ AND d.namespace != 'NOTES'
33
+ AND (d.slug IS NULL OR d.slug NOT LIKE 'dir-%')
34
+ AND d.created_at >= ?
35
+ AND d.created_at < ?
36
+ AND NOT EXISTS (
37
+ SELECT 1
38
+ FROM documents d2
39
+ WHERE
40
+ d2.namespace = d.namespace
41
+ AND date(d2.created_at / 1000, 'unixepoch', 'localtime') = date(d.created_at / 1000, 'unixepoch', 'localtime')
42
+ GROUP BY date(d2.created_at / 1000, 'unixepoch', 'localtime')
43
+ HAVING COUNT(*) > ?
44
+ )
45
+ `;
46
+
47
+ const filteredStats = db.prepare(filteredQuery).get(startTime, endTime, threshold);
48
+
49
+ console.log(`\nFiltered Stats (Threshold ${threshold}):`);
50
+ console.log(` Count: ${filteredStats.count}`);
51
+ console.log(` Words: ${(filteredStats.words / 10000).toFixed(1)}w`);
upload_status.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "status": "pending_upload",
3
+ "last_attempt": "2026-01-09T04:46:36Z",
4
+ "retry_count": 3,
5
+ "error_log": "deploy.log",
6
+ "reason": "Network connection refused after multiple retries"
7
+ }
verify_backup_logic.js ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ const cwd = process.cwd();
5
+ const dbFiles = ['rag-kb.db', 'yuque-rag.db', 'documents.db', 'yuque_rag.db', 'yuque_db.sqlite'];
6
+
7
+ console.log('Checking for database files in:', cwd);
8
+
9
+ let found = false;
10
+ for (const dbFile of dbFiles) {
11
+ const sourcePath = path.join(cwd, dbFile);
12
+ if (fs.existsSync(sourcePath)) {
13
+ console.log(`Found: ${dbFile}`);
14
+ found = true;
15
+
16
+ // Simulate backup
17
+ const backupName = `test_backup_${path.parse(dbFile).name}.db`;
18
+ const destPath = path.join(cwd, backupName);
19
+ console.log(`Copying to ${backupName}...`);
20
+ try {
21
+ fs.copyFileSync(sourcePath, destPath);
22
+ console.log('Success!');
23
+ // Clean up
24
+ fs.unlinkSync(destPath);
25
+ console.log('Cleaned up test backup.');
26
+ } catch (e) {
27
+ console.error('Copy failed:', e);
28
+ }
29
+ } else {
30
+ console.log(`Not found: ${dbFile}`);
31
+ }
32
+ }
33
+
34
+ if (!found) {
35
+ console.log('No database files found!');
36
+ }
verify_stats_api.mjs ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import http from 'http';
3
+
4
+ function fetchStats(year) {
5
+ return new Promise((resolve, reject) => {
6
+ const options = {
7
+ hostname: 'localhost',
8
+ port: 3000,
9
+ path: `/api/stats?year=${year}&t=${Date.now()}`,
10
+ method: 'GET',
11
+ headers: {
12
+ 'Pragma': 'no-cache',
13
+ 'Cache-Control': 'no-cache'
14
+ }
15
+ };
16
+
17
+ const req = http.request(options, (res) => {
18
+ let data = '';
19
+ res.on('data', (chunk) => {
20
+ data += chunk;
21
+ });
22
+ res.on('end', () => {
23
+ try {
24
+ if (res.statusCode !== 200) {
25
+ reject(new Error(`Status Code: ${res.statusCode}`));
26
+ return;
27
+ }
28
+ const json = JSON.parse(data);
29
+ resolve(json);
30
+ } catch (e) {
31
+ reject(e);
32
+ }
33
+ });
34
+ });
35
+
36
+ req.on('error', (e) => {
37
+ reject(e);
38
+ });
39
+
40
+ req.end();
41
+ });
42
+ }
43
+
44
+ async function run() {
45
+ try {
46
+ console.log('Fetching 2025...');
47
+ const data2025 = await fetchStats('2025');
48
+ console.log('2025 Total Words:', data2025.stats.totalWords);
49
+ console.log('2025 Docs Count:', data2025.stats.totalDocs);
50
+
51
+ console.log('\nFetching 2024...');
52
+ const data2024 = await fetchStats('2024');
53
+ console.log('2024 Total Words:', data2024.stats.totalWords);
54
+ console.log('2024 Docs Count:', data2024.stats.totalDocs);
55
+
56
+ console.log('\nFetching All...');
57
+ const dataAll = await fetchStats('all');
58
+ console.log('All Total Words:', dataAll.stats.totalWords);
59
+
60
+ if (data2025.stats.totalWords === data2024.stats.totalWords) {
61
+ console.error('\nERROR: Total Words are identical for 2025 and 2024!');
62
+ } else {
63
+ console.log('\nSUCCESS: Total Words are different.');
64
+ }
65
+
66
+ } catch (err) {
67
+ console.error('Test failed:', err);
68
+ }
69
+ }
70
+
71
+ run();
yuque-rag.db DELETED
File without changes
yuque_db.sqlite DELETED
File without changes
yuque_rag.db DELETED
File without changes
备注.md CHANGED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ 大文件上传,超 10m 单独上传:
2
+ huggingface-cli upload duqing2026/rag-kb-demo ./你的文件名.db ./你的文件名.db --repo-type
3
+ export HF_ENDPOINT="https://hf-mirror.com"
4
+ huggingface-cli upload duqing2026/rag-kb-demo /Users/by/code/rag-kb-system/rag-kb.db