ソースを参照

笔记功能3.0对应

anhuiqiang 8 時間 前
コミット
035ca04bdd

+ 5 - 1
server/src/assessment/graph/nodes/generator.node.ts

@@ -2,6 +2,7 @@ import { ChatOpenAI } from '@langchain/openai';
 import { SystemMessage, HumanMessage } from '@langchain/core/messages';
 import { RunnableConfig } from '@langchain/core/runnables';
 import { EvaluationState } from '../state';
+import { safeParseJson } from '../../../common/json-utils';
 
 /**
  * Node responsible for generating assessment questions based on the knowledge base content.
@@ -136,7 +137,10 @@ Return 1 question as a JSON array.`;
     ]);
 
     try {
-      const newQuestions = JSON.parse(response.content as string);
+      const newQuestions = safeParseJson(response.content as string);
+      if (!newQuestions || !Array.isArray(newQuestions)) {
+        throw new Error('Invalid JSON format from AI');
+      }
       const mappedNewQuestions = newQuestions.map((q: any) => ({
         id: (existingQuestions.length + 1).toString(),
         questionText: q.question_text,

+ 5 - 1
server/src/assessment/graph/nodes/grader.node.ts

@@ -6,6 +6,7 @@ import {
 } from '@langchain/core/messages';
 import { RunnableConfig } from '@langchain/core/runnables';
 import { EvaluationState } from '../state';
+import { safeParseJson } from '../../../common/json-utils';
 
 /**
  * Node responsible for grading the user's answer and deciding if a follow-up is needed.
@@ -164,7 +165,10 @@ Format your response as JSON:
   ]);
 
   try {
-    const result = JSON.parse(response.content as string);
+    const result = safeParseJson(response.content as string);
+    if (!result) {
+      throw new Error('Invalid JSON format from AI');
+    }
     console.log('[GraderNode] AI Grade Result:', result);
 
     const scoreLabel = isZh ? '得分' : isJa ? 'スコア' : 'Score';

+ 26 - 0
server/src/common/json-utils.ts

@@ -0,0 +1,26 @@
+/**
+ * Safely parses JSON from a string, handling markdown code blocks if present.
+ */
+export function safeParseJson<T = any>(text: string): T | null {
+  if (!text) return null as any;
+
+  let jsonStr = text.trim();
+
+  // Remove markdown code blocks if they exist
+  // Matches ```json ... ``` or just ``` ... ```
+  const codeBlockRegex = /```(?:json)?\s*([\s\S]*?)\s*```/i;
+  const match = jsonStr.match(codeBlockRegex);
+
+  if (match && match[1]) {
+    jsonStr = match[1].trim();
+  }
+
+  try {
+    return JSON.parse(jsonStr) as T;
+  } catch (error) {
+    console.error('Failed to parse JSON:', error);
+    console.error('Original text:', text);
+    console.error('Extracted string:', jsonStr);
+    throw error;
+  }
+}

+ 2 - 4
server/src/note/note-category.controller.ts

@@ -19,7 +19,7 @@ export class NoteCategoryController {
 
   @Get()
   async findAll(@Request() req: any) {
-    return this.categoryService.findAll(req.user.id, req.user.tenantId);
+    return this.categoryService.findAll(req.user.id);
   }
 
   @Post()
@@ -30,7 +30,6 @@ export class NoteCategoryController {
   ) {
     return this.categoryService.create(
       req.user.id,
-      req.user.tenantId,
       name,
       parentId,
     );
@@ -45,7 +44,6 @@ export class NoteCategoryController {
   ) {
     return this.categoryService.update(
       req.user.id,
-      req.user.tenantId,
       id,
       name,
       parentId,
@@ -54,6 +52,6 @@ export class NoteCategoryController {
 
   @Delete(':id')
   async remove(@Request() req: any, @Param('id') id: string) {
-    return this.categoryService.remove(req.user.id, req.user.tenantId, id);
+    return this.categoryService.remove(req.user.id, id);
   }
 }

+ 0 - 8
server/src/note/note-category.entity.ts

@@ -9,7 +9,6 @@ import {
   OneToMany,
 } from 'typeorm';
 import { User } from '../user/user.entity';
-import { Tenant } from '../tenant/tenant.entity';
 import { Note } from './note.entity';
 
 @Entity('note_categories')
@@ -23,9 +22,6 @@ export class NoteCategory {
   @Column({ name: 'user_id' })
   userId: string;
 
-  @Column({ name: 'tenant_id', nullable: true, type: 'text' })
-  tenantId: string;
-
   @Column({ name: 'parent_id', nullable: true, type: 'text' })
   parentId: string;
 
@@ -41,10 +37,6 @@ export class NoteCategory {
   @OneToMany(() => NoteCategory, (category) => category.parent)
   children: NoteCategory[];
 
-  @ManyToOne(() => Tenant, { nullable: true, onDelete: 'CASCADE' })
-  @JoinColumn({ name: 'tenant_id' })
-  tenant: Tenant;
-
   @ManyToOne(() => User)
   @JoinColumn({ name: 'user_id' })
   user: User;

+ 6 - 10
server/src/note/note-category.service.ts

@@ -12,23 +12,22 @@ export class NoteCategoryService {
     private readonly i18nService: I18nService,
   ) {}
 
-  async findAll(userId: string, tenantId: string): Promise<NoteCategory[]> {
+  async findAll(userId: string): Promise<NoteCategory[]> {
     return this.categoryRepository.find({
-      where: { userId, tenantId },
+      where: { userId },
       order: { level: 'ASC', name: 'ASC' },
     });
   }
 
   async create(
     userId: string,
-    tenantId: string,
     name: string,
     parentId?: string,
   ): Promise<NoteCategory> {
     let level = 1;
     if (parentId) {
       const parent = await this.categoryRepository.findOne({
-        where: { id: parentId, userId, tenantId },
+        where: { id: parentId, userId },
       });
       if (!parent) {
         throw new NotFoundException(
@@ -46,7 +45,6 @@ export class NoteCategoryService {
     const category = this.categoryRepository.create({
       name,
       userId,
-      tenantId,
       parentId,
       level,
     });
@@ -55,13 +53,12 @@ export class NoteCategoryService {
 
   async update(
     userId: string,
-    tenantId: string,
     id: string,
     name?: string,
     parentId?: string,
   ): Promise<NoteCategory> {
     const category = await this.categoryRepository.findOne({
-      where: { id, userId, tenantId },
+      where: { id, userId },
     });
     if (!category) {
       throw new NotFoundException(
@@ -79,7 +76,7 @@ export class NoteCategoryService {
         category.level = 1;
       } else {
         const parent = await this.categoryRepository.findOne({
-          where: { id: parentId, userId, tenantId },
+          where: { id: parentId, userId },
         });
         if (!parent)
           throw new NotFoundException(
@@ -98,11 +95,10 @@ export class NoteCategoryService {
     return this.categoryRepository.save(category);
   }
 
-  async remove(userId: string, tenantId: string, id: string): Promise<void> {
+  async remove(userId: string, id: string): Promise<void> {
     const result = await this.categoryRepository.delete({
       id,
       userId,
-      tenantId,
     });
     if (result.affected === 0) {
       throw new NotFoundException(

+ 2 - 6
server/src/note/note.controller.ts

@@ -26,7 +26,6 @@ export class NoteController {
   create(@Req() req, @Body() createNoteDto: Partial<Note>) {
     return this.noteService.create(
       req.user.id,
-      req.user.tenantId,
       createNoteDto,
     );
   }
@@ -39,7 +38,6 @@ export class NoteController {
   ) {
     return this.noteService.findAll(
       req.user.id,
-      req.user.tenantId,
       req.user.isAdmin,
       groupId,
       categoryId,
@@ -50,7 +48,6 @@ export class NoteController {
   findOne(@Req() req, @Param('id') id: string) {
     return this.noteService.findOne(
       req.user.id,
-      req.user.tenantId,
       id,
       req.user.isAdmin,
     );
@@ -64,7 +61,6 @@ export class NoteController {
   ) {
     return this.noteService.update(
       req.user.id,
-      req.user.tenantId,
       id,
       updateNoteDto,
       req.user.isAdmin,
@@ -75,7 +71,6 @@ export class NoteController {
   remove(@Req() req, @Param('id') id: string) {
     return this.noteService.remove(
       req.user.id,
-      req.user.tenantId,
       id,
       req.user.isAdmin,
     );
@@ -88,14 +83,15 @@ export class NoteController {
     @UploadedFile() screenshot: Express.Multer.File,
     @Body('fileId') fileId: string,
     @Body('groupId') groupId?: string,
+    @Body('categoryId') categoryId?: string,
     @Body('pageNumber') pageNumber?: string,
   ) {
     return this.noteService.createFromPDFSelection(
       req.user.id,
-      req.user.tenantId,
       fileId,
       screenshot,
       groupId,
+      categoryId,
       pageNumber ? parseInt(pageNumber, 10) : undefined,
     );
   }

+ 0 - 8
server/src/note/note.entity.ts

@@ -9,7 +9,6 @@ import {
 } from 'typeorm';
 import { User } from '../user/user.entity';
 import { KnowledgeGroup } from '../knowledge-group/knowledge-group.entity';
-import { Tenant } from '../tenant/tenant.entity';
 
 @Entity('notes')
 export class Note {
@@ -25,13 +24,6 @@ export class Note {
   @Column({ name: 'user_id' })
   userId: string;
 
-  @Column({ name: 'tenant_id', nullable: true, type: 'text' })
-  tenantId: string;
-
-  @ManyToOne(() => Tenant, { nullable: true, onDelete: 'CASCADE' })
-  @JoinColumn({ name: 'tenant_id' })
-  tenant: Tenant;
-
   @Column({ name: 'group_id', nullable: true })
   groupId: string; // Corresponds to Notebook/KnowledgeGroup ID
 

+ 22 - 27
server/src/note/note.service.ts

@@ -11,9 +11,9 @@ import { I18nService } from '../i18n/i18n.service';
 
 @Injectable()
 export class NoteService {
-  // Directory will be created dynamically per tenant
-  private getScreenshotsDir(tenantId: string) {
-    return path.join(process.cwd(), 'uploads', tenantId, 'notes-screenshots');
+  // Directory will be created dynamically per user
+  private getScreenshotsDir(userId: string) {
+    return path.join(process.cwd(), 'uploads', 'notes-screenshots', userId);
   }
 
   constructor(
@@ -23,8 +23,8 @@ export class NoteService {
     private readonly i18nService: I18nService,
   ) {}
 
-  private async ensureScreenshotsDir(tenantId: string) {
-    const dir = this.getScreenshotsDir(tenantId);
+  private async ensureScreenshotsDir(userId: string) {
+    const dir = this.getScreenshotsDir(userId);
     try {
       await fs.access(dir);
     } catch {
@@ -34,7 +34,6 @@ export class NoteService {
 
   async create(
     userId: string,
-    tenantId: string,
     data: Partial<Note>,
   ): Promise<Note> {
     // Handle empty strings for foreign keys
@@ -48,27 +47,22 @@ export class NoteService {
     const note = this.noteRepository.create({
       ...data,
       userId,
-      tenantId,
     });
     return this.noteRepository.save(note);
   }
 
   async findAll(
     userId: string,
-    tenantId: string,
     isAdmin: boolean,
     groupId?: string,
     categoryId?: string,
   ): Promise<Note[]> {
     const query = this.noteRepository
       .createQueryBuilder('note')
-      .leftJoinAndSelect('note.user', 'user')
-      .where('note.tenantId = :tenantId', { tenantId })
-      .select(['note', 'user.id', 'user.username'])
-      .orderBy('note.updatedAt', 'DESC');
+      .leftJoinAndSelect('note.user', 'user');
 
     if (!isAdmin) {
-      query.andWhere('note.userId = :userId', { userId });
+      query.where('note.userId = :userId', { userId });
     }
 
     if (groupId) {
@@ -84,19 +78,18 @@ export class NoteService {
 
   async findOne(
     userId: string,
-    tenantId: string,
     id: string,
     isAdmin: boolean,
   ): Promise<Note> {
     let note;
     if (isAdmin) {
       note = await this.noteRepository.findOne({
-        where: { id, tenantId },
+        where: { id },
         relations: ['user'],
       });
     } else {
       note = await this.noteRepository.findOne({
-        where: { id, userId, tenantId },
+        where: { id, userId },
         relations: ['user'],
       });
     }
@@ -111,12 +104,11 @@ export class NoteService {
 
   async update(
     userId: string,
-    tenantId: string,
     id: string,
     data: Partial<Note>,
     isAdmin: boolean,
   ): Promise<Note> {
-    const note = await this.findOne(userId, tenantId, id, isAdmin);
+    const note = await this.findOne(userId, id, isAdmin);
     // Remove protected fields
     delete (data as any).id;
     delete (data as any).userId;
@@ -136,10 +128,10 @@ export class NoteService {
 
   async createFromPDFSelection(
     userId: string,
-    tenantId: string,
     fileId: string,
     screenshot: Express.Multer.File,
     groupId?: string,
+    categoryId?: string,
     pageNumber?: number,
   ): Promise<Note> {
     // If groupId is provided, verify that the group exists
@@ -149,7 +141,7 @@ export class NoteService {
       const groupRepo =
         this.noteRepository.manager.getRepository(KnowledgeGroup);
       const group = await groupRepo.findOne({
-        where: { id: groupId, tenantId },
+        where: { id: groupId },
       });
 
       if (!group) {
@@ -164,11 +156,15 @@ export class NoteService {
       console.log(`User ${userId} attempting to add note to group ${groupId}`);
     }
 
+    if (categoryId === '') {
+      categoryId = null as any;
+    }
+
     // Save screenshot to disk
-    await this.ensureScreenshotsDir(tenantId);
+    await this.ensureScreenshotsDir(userId);
     const filename = `${uuidv4()}-${Date.now()}.png`;
     const screenshotPath = path.join(
-      this.getScreenshotsDir(tenantId),
+      this.getScreenshotsDir(userId),
       filename,
     );
     await fs.writeFile(screenshotPath, screenshot.buffer);
@@ -188,14 +184,14 @@ export class NoteService {
     const note = this.noteRepository.create({
       userId,
       groupId: groupId || (null as any),
+      categoryId: categoryId || (null as any),
       title: this.i18nService.formatMessage('pdfNoteTitle', {
         date: new Date().toLocaleString(),
       }),
       content: extractedText || this.i18nService.getMessage('noTextExtracted'),
-      screenshotPath: `${tenantId}/notes-screenshots/${filename}`,
+      screenshotPath: `notes-screenshots/${userId}/${filename}`,
       sourceFileId: fileId,
       sourcePageNumber: pageNumber,
-      tenantId,
     });
 
     return this.noteRepository.save(note);
@@ -203,15 +199,14 @@ export class NoteService {
 
   async remove(
     userId: string,
-    tenantId: string,
     id: string,
     isAdmin: boolean,
   ): Promise<void> {
     let result;
     if (isAdmin) {
-      result = await this.noteRepository.delete({ id, tenantId });
+      result = await this.noteRepository.delete({ id });
     } else {
-      result = await this.noteRepository.delete({ id, userId, tenantId });
+      result = await this.noteRepository.delete({ id, userId });
     }
 
     if (result.affected === 0) {

+ 24 - 25
web/components/CreateNoteFromPDFDialog.tsx

@@ -1,18 +1,18 @@
 import React, { useState, useEffect } from 'react';
 import { X, Loader, Image as ImageIcon, Box } from 'lucide-react';
 import { ocrService } from '../services/ocrService';
-import { knowledgeGroupService } from '../services/knowledgeGroupService';
-import { KnowledgeGroup } from '../types';
+import { noteCategoryService } from '../services/noteCategoryService';
+import { NoteCategory } from '../types';
 import { useLanguage } from '../contexts/LanguageContext';
 import { useToast } from '../contexts/ToastContext';
 
 interface CreateNoteFromPDFDialogProps {
     screenshot: Blob;
     extractedText: string;
-    onSave: (title: string, content: string, groupId?: string) => Promise<void>;
+    onSave: (title: string, content: string, categoryId?: string) => Promise<void>;
     onCancel: () => void;
     authToken: string;
-    initialGroupId?: string;
+    initialCategoryId?: string;
     initialPageNumber?: number;
 }
 
@@ -22,7 +22,7 @@ export const CreateNoteFromPDFDialog: React.FC<CreateNoteFromPDFDialogProps> = (
     onSave,
     onCancel,
     authToken,
-    initialGroupId,
+    initialCategoryId,
     initialPageNumber,
 }) => {
     const { t } = useLanguage();
@@ -32,15 +32,15 @@ export const CreateNoteFromPDFDialog: React.FC<CreateNoteFromPDFDialogProps> = (
         : `${t('createPDFNote')} - ${new Date().toLocaleString()}`;
     const [title, setTitle] = useState(defaultTitle);
     const [content, setContent] = useState(extractedText);
-    const [selectedGroupId, setSelectedGroupId] = useState<string | undefined>(initialGroupId);
-    const [groups, setGroups] = useState<KnowledgeGroup[]>([]);
+    const [selectedCategoryId, setSelectedCategoryId] = useState<string | undefined>(initialCategoryId);
+    const [categories, setCategories] = useState<NoteCategory[]>([]);
     const [saving, setSaving] = useState(false);
     const [ocrLoading, setOcrLoading] = useState(false);
     const [screenshotUrl, setScreenshotUrl] = useState<string>('');
 
     useEffect(() => {
         if (authToken) {
-            knowledgeGroupService.getGroups().then(setGroups).catch(console.error);
+            noteCategoryService.getAll(authToken).then(setCategories).catch(console.error);
         }
     }, [authToken]);
 
@@ -61,15 +61,9 @@ export const CreateNoteFromPDFDialog: React.FC<CreateNoteFromPDFDialogProps> = (
     }, [screenshot, extractedText, authToken]);
 
     const handleSave = async () => {
-        // Check if knowledge group is selected
-        if (!selectedGroupId) {
-            showToast('warning', t('pleaseSelectKnowledgeGroupFirst')); // Use toast to prompt user to select a knowledge group first
-            return;
-        }
-
         setSaving(true);
         try {
-            await onSave(title, content, selectedGroupId);
+            await onSave(title, content, selectedCategoryId);
         } catch (error) {
             console.error('Failed to save note:', error);
         } finally {
@@ -111,24 +105,29 @@ export const CreateNoteFromPDFDialog: React.FC<CreateNoteFromPDFDialogProps> = (
                         </div>
                     </div>
 
-                    {/* Knowledge Group selector */}
+                    {/* Note Category selector */}
                     <div className="space-y-2">
                         <label className="block text-sm font-medium text-gray-700">
                             <Box size={16} className="inline mr-1" />
-                            {t('associateKnowledgeGroup')}
+                            {t('personalNotebook') || t('directoryLabel')}
                         </label>
                         <select
-                            value={selectedGroupId || ''}
-                            onChange={(e) => setSelectedGroupId(e.target.value || undefined)}
+                            value={selectedCategoryId || ''}
+                            onChange={(e) => setSelectedCategoryId(e.target.value || undefined)}
                             className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none"
                             disabled={saving}
                         >
-                            <option value="">{t('globalNoSpecificGroup')}</option>
-                            {groups.map(group => (
-                                <option key={group.id} value={group.id}>
-                                    {group.name}
-                                </option>
-                            ))}
+                            <option value="">{t('uncategorized')}</option>
+                            {categories.map(c => {
+                                const parent = categories.find(p => p.id === c.parentId)
+                                const grandparent = parent ? categories.find(gp => gp.id === parent.parentId) : null
+                                const path = [grandparent, parent, c].filter(Boolean).map(cat => cat?.name).join(' > ')
+                                return (
+                                    <option key={c.id} value={c.id}>
+                                        {'\u00A0'.repeat((c.level - 1) * 2)}{c.name}
+                                    </option>
+                                )
+                            })}
                         </select>
                     </div>
 

+ 4 - 3
web/components/PDFPreview.tsx

@@ -352,7 +352,7 @@ export const PDFPreview: React.FC<PDFPreviewProps> = ({ fileId, fileName, authTo
     setIsSelectionMode(false);
   };
 
-  const handleSaveNote = async (title: string, content: string, selectedGroupId?: string) => {
+  const handleSaveNote = async (title: string, content: string, selectedCategoryId?: string) => {
     if (!authToken || !selectionData) return;
 
     try {
@@ -360,7 +360,8 @@ export const PDFPreview: React.FC<PDFPreviewProps> = ({ fileId, fileName, authTo
         authToken,
         fileId,
         selectionData.screenshot,
-        selectedGroupId || groupId,
+        undefined, // groupId is no longer used for notes from PDF
+        selectedCategoryId,
         currentPage
       );
       showToast('success', t('noteCreatedSuccess'));
@@ -534,7 +535,7 @@ export const PDFPreview: React.FC<PDFPreviewProps> = ({ fileId, fileName, authTo
                 screenshot={selectionData.screenshot}
                 extractedText={selectionData.text}
                 authToken={authToken}
-                initialGroupId={groupId}
+                initialCategoryId={undefined} // Notes don't inherit KB group
                 initialPageNumber={currentPage}
                 onSave={handleSaveNote}
                 onCancel={() => setSelectionData(null)}

+ 6 - 0
web/services/apiClient.ts

@@ -140,6 +140,12 @@ class ApiClient {
     
     // Merge auth headers into request headers
     Object.entries(authHeaders).forEach(([key, value]) => {
+      // Don't override if already set
+      if (headers.has(key)) return;
+      
+      // Don't set Content-Type if body is FormData (let browser set it with boundary)
+      if (key === 'Content-Type' && options.body instanceof FormData) return;
+      
       headers.set(key, value);
     });
 

+ 4 - 0
web/services/noteService.ts

@@ -72,6 +72,7 @@ export const noteService = {
         fileId: string,
         screenshot: Blob,
         groupId?: string,
+        categoryId?: string,
         pageNumber?: number,
     ): Promise<Note> => {
         const formData = new FormData()
@@ -80,6 +81,9 @@ export const noteService = {
         if (groupId) {
             formData.append('groupId', groupId)
         }
+        if (categoryId) {
+            formData.append('categoryId', categoryId)
+        }
         if (pageNumber !== undefined) {
             formData.append('pageNumber', pageNumber.toString())
         }