Spaces:
Build error
Build error
Commit ·
9ed89c8
1
Parent(s): 522a44a
同步 hf
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +2 -0
- .gitattributes +3 -0
- .gitignore +5 -0
- Dockerfile +9 -8
- README.md +36 -1
- analyze_bulk_creation.js +47 -0
- check_db.py +25 -0
- debug-db-tags.ts +26 -0
- debug-verify-tags.ts +51 -0
- debug-yuque-notes.ts +64 -0
- debug_2025_stats.js +57 -0
- deploy.log +28 -0
- documents.db +0 -0
- eslint.config.mjs +12 -0
- package.json +2 -1
- scripts/benchmark-notes-speed.ts +61 -0
- scripts/deploy_to_hf.sh +67 -0
- scripts/export-hf-dataset.ts +119 -0
- scripts/import_hf_metadata.ts +147 -0
- scripts/push_hf_dataset.py +105 -0
- scripts/test-notes-pagination.ts +33 -0
- src/app/api/backup/route.ts +139 -0
- src/app/api/chat/route.ts +96 -19
- src/app/api/documents/route.ts +89 -13
- src/app/api/health/route.ts +43 -0
- src/app/api/history/sessions/route.ts +3 -3
- src/app/api/stats/route.ts +248 -0
- src/app/knowledge/page.tsx +413 -44
- src/app/knowledge/stats/page.tsx +1104 -0
- src/app/login/page.tsx +214 -0
- src/app/page.tsx +89 -57
- src/components/Chat.tsx +91 -37
- src/components/ThemeSwitcher.tsx +25 -5
- src/components/Upload.tsx +3 -4
- src/contexts/LanguageContext.tsx +12 -10
- src/hooks/useChatHistory.ts +22 -11
- {data → src/lib}/db.sqlite +0 -0
- src/lib/db.ts +42 -3
- src/lib/local-embeddings.ts +3 -0
- src/lib/yuque-service.ts +691 -168
- src/middleware.ts +47 -0
- test-db-logic.js +26 -0
- test_spam_filter.js +51 -0
- upload_status.json +7 -0
- verify_backup_logic.js +36 -0
- verify_stats_api.mjs +71 -0
- yuque-rag.db +0 -0
- yuque_db.sqlite +0 -0
- yuque_rag.db +0 -0
- 备注.md +4 -0
.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 |
-
|
| 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
|
| 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
|
| 57 |
# We copy existing stores so the demo works out of the box
|
| 58 |
-
|
| 59 |
-
# Copy database if it exists
|
| 60 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 205 |
-
|
| 206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 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
|
| 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:
|
| 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
|
| 176 |
try {
|
| 177 |
const status = getSyncStatus();
|
| 178 |
|
| 179 |
-
if (status.status ==
|
| 180 |
-
return NextResponse.json({ message: 'Sync
|
| 181 |
}
|
| 182 |
|
| 183 |
-
//
|
| 184 |
-
|
| 185 |
|
| 186 |
return NextResponse.json({
|
| 187 |
-
message: 'Sync
|
| 188 |
status: getSyncStatus()
|
| 189 |
});
|
| 190 |
|
| 191 |
} catch (error) {
|
| 192 |
-
console.error('Failed to
|
| 193 |
-
return NextResponse.json({ error: 'Failed to
|
| 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,
|
| 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=
|
| 256 |
-
<span className=
|
| 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 |
-
//
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
if (!currentKbNamespace)
|
| 352 |
-
|
| 353 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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={
|
| 521 |
-
disabled={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 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 |
-
|
| 529 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 538 |
-
<div className=
|
| 539 |
<span className="relative flex h-2 w-2">
|
| 540 |
-
<span className=
|
| 541 |
-
<span className=
|
| 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=
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 678 |
{searchTerm ? (
|
| 679 |
<div className="max-w-4xl mx-auto">
|
| 680 |
<h2 className="text-xl font-bold mb-6">搜索结果: "{searchTerm}"</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 |
-
|
| 719 |
-
<div>
|
| 720 |
-
<
|
| 721 |
-
|
| 722 |
-
|
|
|
|
|
|
|
| 723 |
</div>
|
| 724 |
-
|
| 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-
|
| 800 |
<div className="mb-8">
|
| 801 |
-
<
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
| 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">搜索结果: "{searchTerm}"</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, '&').replace(/</g, '<');
|
| 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 |
-
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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 |
-
<
|
| 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 -
|
| 314 |
-
{
|
| 315 |
-
<div className="
|
| 316 |
-
|
| 317 |
-
<
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
<
|
| 321 |
-
|
| 322 |
-
|
| 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
|
| 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({
|
| 304 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 494 |
value={input}
|
| 495 |
-
placeholder={useRAG ? (language === 'zh' ? "对话知识库..." : "Chat with Knowledge Base...") : t('inputPlaceholder')}
|
| 496 |
onChange={handleInputChange}
|
| 497 |
disabled={isLoading}
|
| 498 |
/>
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 60 |
-
${position === 'top' ? 'bottom-full mb-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 |
-
|
| 30 |
-
|
| 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: "
|
| 41 |
-
freePlan: "
|
| 42 |
-
quizPrompt: "
|
| 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: "选择文件
|
| 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: "
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 194 |
-
if (
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 80 |
let attempt = 0;
|
|
|
|
| 81 |
|
| 82 |
-
while (attempt <
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 110 |
|
| 111 |
// Add larger jitter (random delay between 1000-3000ms)
|
| 112 |
delay += 1000 + Math.random() * 2000;
|
| 113 |
|
| 114 |
-
// Cap at
|
| 115 |
-
if (delay >
|
| 116 |
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}/${
|
| 141 |
|
| 142 |
-
// General error backoff:
|
| 143 |
-
const backoff =
|
| 144 |
await new Promise(resolve => setTimeout(resolve, backoff));
|
| 145 |
attempt++;
|
| 146 |
}
|
| 147 |
}
|
| 148 |
|
| 149 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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,
|
| 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 |
-
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 328 |
-
const CONCURRENCY =
|
| 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 |
-
|
| 352 |
-
|
| 353 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 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 |
-
|
| 572 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 573 |
|
| 574 |
} catch (error) {
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 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(/ /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
|