mirror of
				https://github.com/open-webui/open-webui
				synced 2025-06-26 18:26:48 +00:00 
			
		
		
		
	
						commit
						1af45435eb
					
				@ -64,6 +64,7 @@ from open_webui.routers import (
 | 
			
		||||
    auths,
 | 
			
		||||
    channels,
 | 
			
		||||
    chats,
 | 
			
		||||
    notes,
 | 
			
		||||
    folders,
 | 
			
		||||
    configs,
 | 
			
		||||
    groups,
 | 
			
		||||
@ -1000,6 +1001,8 @@ app.include_router(users.router, prefix="/api/v1/users", tags=["users"])
 | 
			
		||||
 | 
			
		||||
app.include_router(channels.router, prefix="/api/v1/channels", tags=["channels"])
 | 
			
		||||
app.include_router(chats.router, prefix="/api/v1/chats", tags=["chats"])
 | 
			
		||||
app.include_router(notes.router, prefix="/api/v1/notes", tags=["notes"])
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
app.include_router(models.router, prefix="/api/v1/models", tags=["models"])
 | 
			
		||||
app.include_router(knowledge.router, prefix="/api/v1/knowledge", tags=["knowledge"])
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,33 @@
 | 
			
		||||
"""Add note table
 | 
			
		||||
 | 
			
		||||
Revision ID: 9f0c9cd09105
 | 
			
		||||
Revises: 3781e22d8b01
 | 
			
		||||
Create Date: 2025-05-03 03:00:00.000000
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
from alembic import op
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
 | 
			
		||||
revision = "9f0c9cd09105"
 | 
			
		||||
down_revision = "3781e22d8b01"
 | 
			
		||||
branch_labels = None
 | 
			
		||||
depends_on = None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def upgrade():
 | 
			
		||||
    op.create_table(
 | 
			
		||||
        "note",
 | 
			
		||||
        sa.Column("id", sa.Text(), nullable=False, primary_key=True, unique=True),
 | 
			
		||||
        sa.Column("user_id", sa.Text(), nullable=True),
 | 
			
		||||
        sa.Column("title", sa.Text(), nullable=True),
 | 
			
		||||
        sa.Column("data", sa.JSON(), nullable=True),
 | 
			
		||||
        sa.Column("meta", sa.JSON(), nullable=True),
 | 
			
		||||
        sa.Column("access_control", sa.JSON(), nullable=True),
 | 
			
		||||
        sa.Column("created_at", sa.BigInteger(), nullable=True),
 | 
			
		||||
        sa.Column("updated_at", sa.BigInteger(), nullable=True),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def downgrade():
 | 
			
		||||
    op.drop_table("note")
 | 
			
		||||
							
								
								
									
										135
									
								
								backend/open_webui/models/notes.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								backend/open_webui/models/notes.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,135 @@
 | 
			
		||||
import json
 | 
			
		||||
import time
 | 
			
		||||
import uuid
 | 
			
		||||
from typing import Optional
 | 
			
		||||
 | 
			
		||||
from open_webui.internal.db import Base, get_db
 | 
			
		||||
from open_webui.utils.access_control import has_access
 | 
			
		||||
from open_webui.models.users import Users, UserResponse
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
from pydantic import BaseModel, ConfigDict
 | 
			
		||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
 | 
			
		||||
from sqlalchemy import or_, func, select, and_, text
 | 
			
		||||
from sqlalchemy.sql import exists
 | 
			
		||||
 | 
			
		||||
####################
 | 
			
		||||
# Note DB Schema
 | 
			
		||||
####################
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Note(Base):
 | 
			
		||||
    __tablename__ = "note"
 | 
			
		||||
 | 
			
		||||
    id = Column(Text, primary_key=True)
 | 
			
		||||
    user_id = Column(Text)
 | 
			
		||||
 | 
			
		||||
    title = Column(Text)
 | 
			
		||||
    data = Column(JSON, nullable=True)
 | 
			
		||||
    meta = Column(JSON, nullable=True)
 | 
			
		||||
 | 
			
		||||
    access_control = Column(JSON, nullable=True)
 | 
			
		||||
 | 
			
		||||
    created_at = Column(BigInteger)
 | 
			
		||||
    updated_at = Column(BigInteger)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NoteModel(BaseModel):
 | 
			
		||||
    model_config = ConfigDict(from_attributes=True)
 | 
			
		||||
 | 
			
		||||
    id: str
 | 
			
		||||
    user_id: str
 | 
			
		||||
 | 
			
		||||
    title: str
 | 
			
		||||
    data: Optional[dict] = None
 | 
			
		||||
    meta: Optional[dict] = None
 | 
			
		||||
 | 
			
		||||
    access_control: Optional[dict] = None
 | 
			
		||||
 | 
			
		||||
    created_at: int  # timestamp in epoch
 | 
			
		||||
    updated_at: int  # timestamp in epoch
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
####################
 | 
			
		||||
# Forms
 | 
			
		||||
####################
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NoteForm(BaseModel):
 | 
			
		||||
    title: str
 | 
			
		||||
    data: Optional[dict] = None
 | 
			
		||||
    meta: Optional[dict] = None
 | 
			
		||||
    access_control: Optional[dict] = None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NoteUserResponse(NoteModel):
 | 
			
		||||
    user: Optional[UserResponse] = None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NoteTable:
 | 
			
		||||
    def insert_new_note(
 | 
			
		||||
        self,
 | 
			
		||||
        form_data: NoteForm,
 | 
			
		||||
        user_id: str,
 | 
			
		||||
    ) -> Optional[NoteModel]:
 | 
			
		||||
        with get_db() as db:
 | 
			
		||||
            note = NoteModel(
 | 
			
		||||
                **{
 | 
			
		||||
                    "id": str(uuid.uuid4()),
 | 
			
		||||
                    "user_id": user_id,
 | 
			
		||||
                    **form_data.model_dump(),
 | 
			
		||||
                    "created_at": int(time.time_ns()),
 | 
			
		||||
                    "updated_at": int(time.time_ns()),
 | 
			
		||||
                }
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
            new_note = Note(**note.model_dump())
 | 
			
		||||
 | 
			
		||||
            db.add(new_note)
 | 
			
		||||
            db.commit()
 | 
			
		||||
            return note
 | 
			
		||||
 | 
			
		||||
    def get_notes(self) -> list[NoteModel]:
 | 
			
		||||
        with get_db() as db:
 | 
			
		||||
            notes = db.query(Note).order_by(Note.updated_at.desc()).all()
 | 
			
		||||
            return [NoteModel.model_validate(note) for note in notes]
 | 
			
		||||
 | 
			
		||||
    def get_notes_by_user_id(
 | 
			
		||||
        self, user_id: str, permission: str = "write"
 | 
			
		||||
    ) -> list[NoteModel]:
 | 
			
		||||
        notes = self.get_notes()
 | 
			
		||||
        return [
 | 
			
		||||
            note
 | 
			
		||||
            for note in notes
 | 
			
		||||
            if note.user_id == user_id
 | 
			
		||||
            or has_access(user_id, permission, note.access_control)
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
    def get_note_by_id(self, id: str) -> Optional[NoteModel]:
 | 
			
		||||
        with get_db() as db:
 | 
			
		||||
            note = db.query(Note).filter(Note.id == id).first()
 | 
			
		||||
            return NoteModel.model_validate(note) if note else None
 | 
			
		||||
 | 
			
		||||
    def update_note_by_id(self, id: str, form_data: NoteForm) -> Optional[NoteModel]:
 | 
			
		||||
        with get_db() as db:
 | 
			
		||||
            note = db.query(Note).filter(Note.id == id).first()
 | 
			
		||||
            if not note:
 | 
			
		||||
                return None
 | 
			
		||||
 | 
			
		||||
            note.title = form_data.title
 | 
			
		||||
            note.data = form_data.data
 | 
			
		||||
            note.meta = form_data.meta
 | 
			
		||||
            note.access_control = form_data.access_control
 | 
			
		||||
            note.updated_at = int(time.time_ns())
 | 
			
		||||
 | 
			
		||||
            db.commit()
 | 
			
		||||
            return NoteModel.model_validate(note) if note else None
 | 
			
		||||
 | 
			
		||||
    def delete_note_by_id(self, id: str):
 | 
			
		||||
        with get_db() as db:
 | 
			
		||||
            db.query(Note).filter(Note.id == id).delete()
 | 
			
		||||
            db.commit()
 | 
			
		||||
            return True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Notes = NoteTable()
 | 
			
		||||
							
								
								
									
										159
									
								
								backend/open_webui/routers/notes.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										159
									
								
								backend/open_webui/routers/notes.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,159 @@
 | 
			
		||||
import json
 | 
			
		||||
import logging
 | 
			
		||||
from typing import Optional
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
from fastapi import APIRouter, Depends, HTTPException, Request, status, BackgroundTasks
 | 
			
		||||
from pydantic import BaseModel
 | 
			
		||||
 | 
			
		||||
from open_webui.models.users import Users, UserResponse
 | 
			
		||||
from open_webui.models.notes import Notes, NoteModel, NoteForm, NoteUserResponse
 | 
			
		||||
 | 
			
		||||
from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT
 | 
			
		||||
from open_webui.constants import ERROR_MESSAGES
 | 
			
		||||
from open_webui.env import SRC_LOG_LEVELS
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
from open_webui.utils.auth import get_admin_user, get_verified_user
 | 
			
		||||
from open_webui.utils.access_control import has_access
 | 
			
		||||
 | 
			
		||||
log = logging.getLogger(__name__)
 | 
			
		||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
 | 
			
		||||
 | 
			
		||||
router = APIRouter()
 | 
			
		||||
 | 
			
		||||
############################
 | 
			
		||||
# GetNotes
 | 
			
		||||
############################
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@router.get("/", response_model=list[NoteUserResponse])
 | 
			
		||||
async def get_notes(user=Depends(get_verified_user)):
 | 
			
		||||
    notes = [
 | 
			
		||||
        NoteUserResponse(
 | 
			
		||||
            **{
 | 
			
		||||
                **note.model_dump(),
 | 
			
		||||
                "user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()),
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
        for note in Notes.get_notes_by_user_id(user.id, "write")
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    return notes
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@router.get("/list", response_model=list[NoteUserResponse])
 | 
			
		||||
async def get_note_list(user=Depends(get_verified_user)):
 | 
			
		||||
    notes = [
 | 
			
		||||
        NoteUserResponse(
 | 
			
		||||
            **{
 | 
			
		||||
                **note.model_dump(),
 | 
			
		||||
                "user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()),
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
        for note in Notes.get_notes_by_user_id(user.id, "read")
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    return notes
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
############################
 | 
			
		||||
# CreateNewNote
 | 
			
		||||
############################
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@router.post("/create", response_model=Optional[NoteModel])
 | 
			
		||||
async def create_new_note(form_data: NoteForm, user=Depends(get_admin_user)):
 | 
			
		||||
    try:
 | 
			
		||||
        note = Notes.insert_new_note(form_data, user.id)
 | 
			
		||||
        return note
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        log.exception(e)
 | 
			
		||||
        raise HTTPException(
 | 
			
		||||
            status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
############################
 | 
			
		||||
# GetNoteById
 | 
			
		||||
############################
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@router.get("/{id}", response_model=Optional[NoteModel])
 | 
			
		||||
async def get_note_by_id(id: str, user=Depends(get_verified_user)):
 | 
			
		||||
    note = Notes.get_note_by_id(id)
 | 
			
		||||
    if not note:
 | 
			
		||||
        raise HTTPException(
 | 
			
		||||
            status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    if user.role != "admin" and not has_access(
 | 
			
		||||
        user.id, type="read", access_control=note.access_control
 | 
			
		||||
    ):
 | 
			
		||||
        raise HTTPException(
 | 
			
		||||
            status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    return note
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
############################
 | 
			
		||||
# UpdateNoteById
 | 
			
		||||
############################
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@router.post("/{id}/update", response_model=Optional[NoteModel])
 | 
			
		||||
async def update_note_by_id(
 | 
			
		||||
    id: str, form_data: NoteForm, user=Depends(get_verified_user)
 | 
			
		||||
):
 | 
			
		||||
    note = Notes.get_note_by_id(id)
 | 
			
		||||
    if not note:
 | 
			
		||||
        raise HTTPException(
 | 
			
		||||
            status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    if user.role != "admin" and not has_access(
 | 
			
		||||
        user.id, type="write", access_control=note.access_control
 | 
			
		||||
    ):
 | 
			
		||||
        raise HTTPException(
 | 
			
		||||
            status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        note = Notes.update_note_by_id(id, form_data)
 | 
			
		||||
        return note
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        log.exception(e)
 | 
			
		||||
        raise HTTPException(
 | 
			
		||||
            status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
############################
 | 
			
		||||
# DeleteNoteById
 | 
			
		||||
############################
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@router.delete("/{id}/delete", response_model=bool)
 | 
			
		||||
async def delete_note_by_id(id: str, user=Depends(get_verified_user)):
 | 
			
		||||
    note = Notes.get_note_by_id(id)
 | 
			
		||||
    if not note:
 | 
			
		||||
        raise HTTPException(
 | 
			
		||||
            status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    if user.role != "admin" and not has_access(
 | 
			
		||||
        user.id, type="write", access_control=note.access_control
 | 
			
		||||
    ):
 | 
			
		||||
        raise HTTPException(
 | 
			
		||||
            status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        note = Notes.delete_note_by_id(id)
 | 
			
		||||
        return True
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        log.exception(e)
 | 
			
		||||
        raise HTTPException(
 | 
			
		||||
            status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
 | 
			
		||||
        )
 | 
			
		||||
							
								
								
									
										187
									
								
								src/lib/apis/notes/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										187
									
								
								src/lib/apis/notes/index.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,187 @@
 | 
			
		||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
 | 
			
		||||
import { getTimeRange } from '$lib/utils';
 | 
			
		||||
 | 
			
		||||
type NoteItem = {
 | 
			
		||||
	title: string;
 | 
			
		||||
	data: object;
 | 
			
		||||
	meta?: null | object;
 | 
			
		||||
	access_control?: null | object;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const createNewNote = async (token: string, note: NoteItem) => {
 | 
			
		||||
	let error = null;
 | 
			
		||||
 | 
			
		||||
	const res = await fetch(`${WEBUI_API_BASE_URL}/notes/create`, {
 | 
			
		||||
		method: 'POST',
 | 
			
		||||
		headers: {
 | 
			
		||||
			Accept: 'application/json',
 | 
			
		||||
			'Content-Type': 'application/json',
 | 
			
		||||
			authorization: `Bearer ${token}`
 | 
			
		||||
		},
 | 
			
		||||
		body: JSON.stringify({
 | 
			
		||||
			...note
 | 
			
		||||
		})
 | 
			
		||||
	})
 | 
			
		||||
		.then(async (res) => {
 | 
			
		||||
			if (!res.ok) throw await res.json();
 | 
			
		||||
			return res.json();
 | 
			
		||||
		})
 | 
			
		||||
		.catch((err) => {
 | 
			
		||||
			error = err.detail;
 | 
			
		||||
			console.log(err);
 | 
			
		||||
			return null;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
	if (error) {
 | 
			
		||||
		throw error;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return res;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getNotes = async (token: string = '') => {
 | 
			
		||||
	let error = null;
 | 
			
		||||
 | 
			
		||||
	const res = await fetch(`${WEBUI_API_BASE_URL}/notes/`, {
 | 
			
		||||
		method: 'GET',
 | 
			
		||||
		headers: {
 | 
			
		||||
			Accept: 'application/json',
 | 
			
		||||
			'Content-Type': 'application/json',
 | 
			
		||||
			authorization: `Bearer ${token}`
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
		.then(async (res) => {
 | 
			
		||||
			if (!res.ok) throw await res.json();
 | 
			
		||||
			return res.json();
 | 
			
		||||
		})
 | 
			
		||||
		.then((json) => {
 | 
			
		||||
			return json;
 | 
			
		||||
		})
 | 
			
		||||
		.catch((err) => {
 | 
			
		||||
			error = err.detail;
 | 
			
		||||
			console.log(err);
 | 
			
		||||
			return null;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
	if (error) {
 | 
			
		||||
		throw error;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (!Array.isArray(res)) {
 | 
			
		||||
		return {}; // or throw new Error("Notes response is not an array")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Build the grouped object
 | 
			
		||||
	const grouped: Record<string, any[]> = {};
 | 
			
		||||
	for (const note of res) {
 | 
			
		||||
		const timeRange = getTimeRange(note.updated_at / 1000000000);
 | 
			
		||||
		if (!grouped[timeRange]) {
 | 
			
		||||
			grouped[timeRange] = [];
 | 
			
		||||
		}
 | 
			
		||||
		grouped[timeRange].push({
 | 
			
		||||
			...note,
 | 
			
		||||
			timeRange
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return grouped;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getNoteById = async (token: string, id: string) => {
 | 
			
		||||
	let error = null;
 | 
			
		||||
 | 
			
		||||
	const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}`, {
 | 
			
		||||
		method: 'GET',
 | 
			
		||||
		headers: {
 | 
			
		||||
			Accept: 'application/json',
 | 
			
		||||
			'Content-Type': 'application/json',
 | 
			
		||||
			authorization: `Bearer ${token}`
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
		.then(async (res) => {
 | 
			
		||||
			if (!res.ok) throw await res.json();
 | 
			
		||||
			return res.json();
 | 
			
		||||
		})
 | 
			
		||||
		.then((json) => {
 | 
			
		||||
			return json;
 | 
			
		||||
		})
 | 
			
		||||
		.catch((err) => {
 | 
			
		||||
			error = err.detail;
 | 
			
		||||
 | 
			
		||||
			console.log(err);
 | 
			
		||||
			return null;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
	if (error) {
 | 
			
		||||
		throw error;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return res;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const updateNoteById = async (token: string, id: string, note: NoteItem) => {
 | 
			
		||||
	let error = null;
 | 
			
		||||
 | 
			
		||||
	const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}/update`, {
 | 
			
		||||
		method: 'POST',
 | 
			
		||||
		headers: {
 | 
			
		||||
			Accept: 'application/json',
 | 
			
		||||
			'Content-Type': 'application/json',
 | 
			
		||||
			authorization: `Bearer ${token}`
 | 
			
		||||
		},
 | 
			
		||||
		body: JSON.stringify({
 | 
			
		||||
			...note
 | 
			
		||||
		})
 | 
			
		||||
	})
 | 
			
		||||
		.then(async (res) => {
 | 
			
		||||
			if (!res.ok) throw await res.json();
 | 
			
		||||
			return res.json();
 | 
			
		||||
		})
 | 
			
		||||
		.then((json) => {
 | 
			
		||||
			return json;
 | 
			
		||||
		})
 | 
			
		||||
		.catch((err) => {
 | 
			
		||||
			error = err.detail;
 | 
			
		||||
 | 
			
		||||
			console.log(err);
 | 
			
		||||
			return null;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
	if (error) {
 | 
			
		||||
		throw error;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return res;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const deleteNoteById = async (token: string, id: string) => {
 | 
			
		||||
	let error = null;
 | 
			
		||||
 | 
			
		||||
	const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}/delete`, {
 | 
			
		||||
		method: 'DELETE',
 | 
			
		||||
		headers: {
 | 
			
		||||
			Accept: 'application/json',
 | 
			
		||||
			'Content-Type': 'application/json',
 | 
			
		||||
			authorization: `Bearer ${token}`
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
		.then(async (res) => {
 | 
			
		||||
			if (!res.ok) throw await res.json();
 | 
			
		||||
			return res.json();
 | 
			
		||||
		})
 | 
			
		||||
		.then((json) => {
 | 
			
		||||
			return json;
 | 
			
		||||
		})
 | 
			
		||||
		.catch((err) => {
 | 
			
		||||
			error = err.detail;
 | 
			
		||||
 | 
			
		||||
			console.log(err);
 | 
			
		||||
			return null;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
	if (error) {
 | 
			
		||||
		throw error;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return res;
 | 
			
		||||
};
 | 
			
		||||
@ -192,7 +192,7 @@
 | 
			
		||||
 | 
			
		||||
<svelte:head>
 | 
			
		||||
	<title>
 | 
			
		||||
		{$i18n.t('Functions')} | {$WEBUI_NAME}
 | 
			
		||||
		{$i18n.t('Functions')} • {$WEBUI_NAME}
 | 
			
		||||
	</title>
 | 
			
		||||
</svelte:head>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -195,7 +195,7 @@
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<svelte:head>
 | 
			
		||||
	<title>#{channel?.name ?? 'Channel'} | Open WebUI</title>
 | 
			
		||||
	<title>#{channel?.name ?? 'Channel'} • Open WebUI</title>
 | 
			
		||||
</svelte:head>
 | 
			
		||||
 | 
			
		||||
<div
 | 
			
		||||
 | 
			
		||||
@ -357,14 +357,14 @@
 | 
			
		||||
			{#if recording}
 | 
			
		||||
				<VoiceRecording
 | 
			
		||||
					bind:recording
 | 
			
		||||
					on:cancel={async () => {
 | 
			
		||||
					onCancel={async () => {
 | 
			
		||||
						recording = false;
 | 
			
		||||
 | 
			
		||||
						await tick();
 | 
			
		||||
						document.getElementById(`chat-input-${id}`)?.focus();
 | 
			
		||||
					}}
 | 
			
		||||
					on:confirm={async (e) => {
 | 
			
		||||
						const { text, filename } = e.detail;
 | 
			
		||||
					onConfirm={async (data) => {
 | 
			
		||||
						const { text, filename } = data;
 | 
			
		||||
						content = `${content}${text} `;
 | 
			
		||||
						recording = false;
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1921,7 +1921,7 @@
 | 
			
		||||
<svelte:head>
 | 
			
		||||
	<title>
 | 
			
		||||
		{$chatTitle
 | 
			
		||||
			? `${$chatTitle.length > 30 ? `${$chatTitle.slice(0, 30)}...` : $chatTitle} | ${$WEBUI_NAME}`
 | 
			
		||||
			? `${$chatTitle.length > 30 ? `${$chatTitle.slice(0, 30)}...` : $chatTitle} • ${$WEBUI_NAME}`
 | 
			
		||||
			: `${$WEBUI_NAME}`}
 | 
			
		||||
	</title>
 | 
			
		||||
</svelte:head>
 | 
			
		||||
 | 
			
		||||
@ -479,14 +479,14 @@
 | 
			
		||||
					{#if recording}
 | 
			
		||||
						<VoiceRecording
 | 
			
		||||
							bind:recording
 | 
			
		||||
							on:cancel={async () => {
 | 
			
		||||
							onCancel={async () => {
 | 
			
		||||
								recording = false;
 | 
			
		||||
 | 
			
		||||
								await tick();
 | 
			
		||||
								document.getElementById('chat-input')?.focus();
 | 
			
		||||
							}}
 | 
			
		||||
							on:confirm={async (e) => {
 | 
			
		||||
								const { text, filename } = e.detail;
 | 
			
		||||
							onConfirm={async (data) => {
 | 
			
		||||
								const { text, filename } = data;
 | 
			
		||||
								prompt = `${prompt}${text} `;
 | 
			
		||||
 | 
			
		||||
								recording = false;
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
	import { toast } from 'svelte-sonner';
 | 
			
		||||
	import { createEventDispatcher, tick, getContext, onMount, onDestroy } from 'svelte';
 | 
			
		||||
	import { tick, getContext, onMount, onDestroy } from 'svelte';
 | 
			
		||||
	import { config, settings } from '$lib/stores';
 | 
			
		||||
	import { blobToFile, calculateSHA256, extractCurlyBraceWords } from '$lib/utils';
 | 
			
		||||
 | 
			
		||||
@ -8,11 +8,13 @@
 | 
			
		||||
 | 
			
		||||
	const i18n = getContext('i18n');
 | 
			
		||||
 | 
			
		||||
	const dispatch = createEventDispatcher();
 | 
			
		||||
 | 
			
		||||
	export let recording = false;
 | 
			
		||||
	export let transcribe = true;
 | 
			
		||||
	export let className = ' p-2.5 w-full max-w-full';
 | 
			
		||||
 | 
			
		||||
	export let onCancel = () => {};
 | 
			
		||||
	export let onConfirm = (data) => {};
 | 
			
		||||
 | 
			
		||||
	let loading = false;
 | 
			
		||||
	let confirmed = false;
 | 
			
		||||
 | 
			
		||||
@ -130,20 +132,32 @@
 | 
			
		||||
		detectSound();
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const transcribeHandler = async (audioBlob) => {
 | 
			
		||||
	const onStopHandler = async (audioBlob) => {
 | 
			
		||||
		// Create a blob from the audio chunks
 | 
			
		||||
 | 
			
		||||
		await tick();
 | 
			
		||||
		const file = blobToFile(audioBlob, 'recording.wav');
 | 
			
		||||
 | 
			
		||||
		const res = await transcribeAudio(localStorage.token, file).catch((error) => {
 | 
			
		||||
			toast.error(`${error}`);
 | 
			
		||||
			return null;
 | 
			
		||||
		});
 | 
			
		||||
		if (transcribe) {
 | 
			
		||||
			if ($config.audio.stt.engine === 'web' || ($settings?.audio?.stt?.engine ?? '') === 'web') {
 | 
			
		||||
				// with web stt, we don't need to send the file to the server
 | 
			
		||||
				return;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
		if (res) {
 | 
			
		||||
			console.log(res);
 | 
			
		||||
			dispatch('confirm', res);
 | 
			
		||||
			const res = await transcribeAudio(localStorage.token, file).catch((error) => {
 | 
			
		||||
				toast.error(`${error}`);
 | 
			
		||||
				return null;
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			if (res) {
 | 
			
		||||
				console.log(res);
 | 
			
		||||
				onConfirm(res);
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			onConfirm({
 | 
			
		||||
				file: file,
 | 
			
		||||
				blob: audioBlob
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
@ -168,6 +182,7 @@
 | 
			
		||||
				autoGainControl: true
 | 
			
		||||
			}
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		mediaRecorder = new MediaRecorder(stream);
 | 
			
		||||
		mediaRecorder.onstart = () => {
 | 
			
		||||
			console.log('Recording started');
 | 
			
		||||
@ -180,77 +195,79 @@
 | 
			
		||||
		mediaRecorder.ondataavailable = (event) => audioChunks.push(event.data);
 | 
			
		||||
		mediaRecorder.onstop = async () => {
 | 
			
		||||
			console.log('Recording stopped');
 | 
			
		||||
			if ($config.audio.stt.engine === 'web' || ($settings?.audio?.stt?.engine ?? '') === 'web') {
 | 
			
		||||
				audioChunks = [];
 | 
			
		||||
			} else {
 | 
			
		||||
				if (confirmed) {
 | 
			
		||||
					const audioBlob = new Blob(audioChunks, { type: 'audio/wav' });
 | 
			
		||||
 | 
			
		||||
					await transcribeHandler(audioBlob);
 | 
			
		||||
			if (confirmed) {
 | 
			
		||||
				const audioBlob = new Blob(audioChunks, { type: 'audio/wav' });
 | 
			
		||||
				await onStopHandler(audioBlob);
 | 
			
		||||
 | 
			
		||||
					confirmed = false;
 | 
			
		||||
					loading = false;
 | 
			
		||||
				}
 | 
			
		||||
				audioChunks = [];
 | 
			
		||||
				recording = false;
 | 
			
		||||
				confirmed = false;
 | 
			
		||||
				loading = false;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			audioChunks = [];
 | 
			
		||||
			recording = false;
 | 
			
		||||
		};
 | 
			
		||||
		mediaRecorder.start();
 | 
			
		||||
		if ($config.audio.stt.engine === 'web' || ($settings?.audio?.stt?.engine ?? '') === 'web') {
 | 
			
		||||
			if ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) {
 | 
			
		||||
				// Create a SpeechRecognition object
 | 
			
		||||
				speechRecognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
 | 
			
		||||
 | 
			
		||||
				// Set continuous to true for continuous recognition
 | 
			
		||||
				speechRecognition.continuous = true;
 | 
			
		||||
		if (transcribe) {
 | 
			
		||||
			if ($config.audio.stt.engine === 'web' || ($settings?.audio?.stt?.engine ?? '') === 'web') {
 | 
			
		||||
				if ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) {
 | 
			
		||||
					// Create a SpeechRecognition object
 | 
			
		||||
					speechRecognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
 | 
			
		||||
 | 
			
		||||
				// Set the timeout for turning off the recognition after inactivity (in milliseconds)
 | 
			
		||||
				const inactivityTimeout = 2000; // 3 seconds
 | 
			
		||||
					// Set continuous to true for continuous recognition
 | 
			
		||||
					speechRecognition.continuous = true;
 | 
			
		||||
 | 
			
		||||
				let timeoutId;
 | 
			
		||||
				// Start recognition
 | 
			
		||||
				speechRecognition.start();
 | 
			
		||||
					// Set the timeout for turning off the recognition after inactivity (in milliseconds)
 | 
			
		||||
					const inactivityTimeout = 2000; // 3 seconds
 | 
			
		||||
 | 
			
		||||
				// Event triggered when speech is recognized
 | 
			
		||||
				speechRecognition.onresult = async (event) => {
 | 
			
		||||
					// Clear the inactivity timeout
 | 
			
		||||
					clearTimeout(timeoutId);
 | 
			
		||||
					let timeoutId;
 | 
			
		||||
					// Start recognition
 | 
			
		||||
					speechRecognition.start();
 | 
			
		||||
 | 
			
		||||
					// Handle recognized speech
 | 
			
		||||
					console.log(event);
 | 
			
		||||
					const transcript = event.results[Object.keys(event.results).length - 1][0].transcript;
 | 
			
		||||
					// Event triggered when speech is recognized
 | 
			
		||||
					speechRecognition.onresult = async (event) => {
 | 
			
		||||
						// Clear the inactivity timeout
 | 
			
		||||
						clearTimeout(timeoutId);
 | 
			
		||||
 | 
			
		||||
					transcription = `${transcription}${transcript}`;
 | 
			
		||||
						// Handle recognized speech
 | 
			
		||||
						console.log(event);
 | 
			
		||||
						const transcript = event.results[Object.keys(event.results).length - 1][0].transcript;
 | 
			
		||||
 | 
			
		||||
					await tick();
 | 
			
		||||
					document.getElementById('chat-input')?.focus();
 | 
			
		||||
						transcription = `${transcription}${transcript}`;
 | 
			
		||||
 | 
			
		||||
					// Restart the inactivity timeout
 | 
			
		||||
					timeoutId = setTimeout(() => {
 | 
			
		||||
						console.log('Speech recognition turned off due to inactivity.');
 | 
			
		||||
						speechRecognition.stop();
 | 
			
		||||
					}, inactivityTimeout);
 | 
			
		||||
				};
 | 
			
		||||
						await tick();
 | 
			
		||||
						document.getElementById('chat-input')?.focus();
 | 
			
		||||
 | 
			
		||||
				// Event triggered when recognition is ended
 | 
			
		||||
				speechRecognition.onend = function () {
 | 
			
		||||
					// Restart recognition after it ends
 | 
			
		||||
					console.log('recognition ended');
 | 
			
		||||
						// Restart the inactivity timeout
 | 
			
		||||
						timeoutId = setTimeout(() => {
 | 
			
		||||
							console.log('Speech recognition turned off due to inactivity.');
 | 
			
		||||
							speechRecognition.stop();
 | 
			
		||||
						}, inactivityTimeout);
 | 
			
		||||
					};
 | 
			
		||||
 | 
			
		||||
					confirmRecording();
 | 
			
		||||
					dispatch('confirm', { text: transcription });
 | 
			
		||||
					confirmed = false;
 | 
			
		||||
					loading = false;
 | 
			
		||||
				};
 | 
			
		||||
					// Event triggered when recognition is ended
 | 
			
		||||
					speechRecognition.onend = function () {
 | 
			
		||||
						// Restart recognition after it ends
 | 
			
		||||
						console.log('recognition ended');
 | 
			
		||||
 | 
			
		||||
				// Event triggered when an error occurs
 | 
			
		||||
				speechRecognition.onerror = function (event) {
 | 
			
		||||
					console.log(event);
 | 
			
		||||
					toast.error($i18n.t(`Speech recognition error: {{error}}`, { error: event.error }));
 | 
			
		||||
					dispatch('cancel');
 | 
			
		||||
						confirmRecording();
 | 
			
		||||
						onConfirm({
 | 
			
		||||
							text: transcription
 | 
			
		||||
						});
 | 
			
		||||
						confirmed = false;
 | 
			
		||||
						loading = false;
 | 
			
		||||
					};
 | 
			
		||||
 | 
			
		||||
					stopRecording();
 | 
			
		||||
				};
 | 
			
		||||
					// Event triggered when an error occurs
 | 
			
		||||
					speechRecognition.onerror = function (event) {
 | 
			
		||||
						console.log(event);
 | 
			
		||||
						toast.error($i18n.t(`Speech recognition error: {{error}}`, { error: event.error }));
 | 
			
		||||
						onCancel();
 | 
			
		||||
 | 
			
		||||
						stopRecording();
 | 
			
		||||
					};
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
@ -339,7 +356,7 @@
 | 
			
		||||
             rounded-full"
 | 
			
		||||
			on:click={async () => {
 | 
			
		||||
				stopRecording();
 | 
			
		||||
				dispatch('cancel');
 | 
			
		||||
				onCancel();
 | 
			
		||||
			}}
 | 
			
		||||
		>
 | 
			
		||||
			<svg
 | 
			
		||||
 | 
			
		||||
@ -47,8 +47,8 @@
 | 
			
		||||
 | 
			
		||||
<ShareChatModal bind:show={showShareChatModal} chatId={$chatId} />
 | 
			
		||||
 | 
			
		||||
<nav class="sticky top-0 z-30 w-full py-1.5 -mb-8 flex flex-col items-center drag-region">
 | 
			
		||||
	<div class="flex items-center w-full px-1.5">
 | 
			
		||||
<nav class="sticky top-0 z-30 w-full py-1 -mb-8 flex flex-col items-center drag-region">
 | 
			
		||||
	<div class="flex items-center w-full pl-1.5 pr-1">
 | 
			
		||||
		<div
 | 
			
		||||
			class=" bg-linear-to-b via-50% from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pointer-events-none absolute inset-0 -bottom-7 z-[-1]"
 | 
			
		||||
		></div>
 | 
			
		||||
 | 
			
		||||
@ -29,15 +29,19 @@
 | 
			
		||||
 | 
			
		||||
	export let oncompositionstart = (e) => {};
 | 
			
		||||
	export let oncompositionend = (e) => {};
 | 
			
		||||
	export let onChange = (e) => {};
 | 
			
		||||
 | 
			
		||||
	// create a lowlight instance with all languages loaded
 | 
			
		||||
	const lowlight = createLowlight(all);
 | 
			
		||||
 | 
			
		||||
	export let className = 'input-prose';
 | 
			
		||||
	export let placeholder = 'Type here...';
 | 
			
		||||
	export let value = '';
 | 
			
		||||
	export let id = '';
 | 
			
		||||
 | 
			
		||||
	export let id = '';
 | 
			
		||||
	export let value = '';
 | 
			
		||||
	export let html = '';
 | 
			
		||||
 | 
			
		||||
	export let json = false;
 | 
			
		||||
	export let raw = false;
 | 
			
		||||
 | 
			
		||||
	export let preserveBreaks = false;
 | 
			
		||||
@ -128,41 +132,43 @@
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	onMount(async () => {
 | 
			
		||||
		console.log(value);
 | 
			
		||||
 | 
			
		||||
		if (preserveBreaks) {
 | 
			
		||||
			turndownService.addRule('preserveBreaks', {
 | 
			
		||||
				filter: 'br', // Target <br> elements
 | 
			
		||||
				replacement: function (content) {
 | 
			
		||||
					return '<br/>';
 | 
			
		||||
				}
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		let content = value;
 | 
			
		||||
 | 
			
		||||
		if (!raw) {
 | 
			
		||||
			async function tryParse(value, attempts = 3, interval = 100) {
 | 
			
		||||
				try {
 | 
			
		||||
					// Try parsing the value
 | 
			
		||||
					return marked.parse(value.replaceAll(`\n<br/>`, `<br/>`), {
 | 
			
		||||
						breaks: false
 | 
			
		||||
					});
 | 
			
		||||
				} catch (error) {
 | 
			
		||||
					// If no attempts remain, fallback to plain text
 | 
			
		||||
					if (attempts <= 1) {
 | 
			
		||||
						return value;
 | 
			
		||||
		if (!json) {
 | 
			
		||||
			if (preserveBreaks) {
 | 
			
		||||
				turndownService.addRule('preserveBreaks', {
 | 
			
		||||
					filter: 'br', // Target <br> elements
 | 
			
		||||
					replacement: function (content) {
 | 
			
		||||
						return '<br/>';
 | 
			
		||||
					}
 | 
			
		||||
					// Wait for the interval, then retry
 | 
			
		||||
					await new Promise((resolve) => setTimeout(resolve, interval));
 | 
			
		||||
					return tryParse(value, attempts - 1, interval); // Recursive call
 | 
			
		||||
				}
 | 
			
		||||
				});
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Usage example
 | 
			
		||||
			content = await tryParse(value);
 | 
			
		||||
			if (!raw) {
 | 
			
		||||
				async function tryParse(value, attempts = 3, interval = 100) {
 | 
			
		||||
					try {
 | 
			
		||||
						// Try parsing the value
 | 
			
		||||
						return marked.parse(value.replaceAll(`\n<br/>`, `<br/>`), {
 | 
			
		||||
							breaks: false
 | 
			
		||||
						});
 | 
			
		||||
					} catch (error) {
 | 
			
		||||
						// If no attempts remain, fallback to plain text
 | 
			
		||||
						if (attempts <= 1) {
 | 
			
		||||
							return value;
 | 
			
		||||
						}
 | 
			
		||||
						// Wait for the interval, then retry
 | 
			
		||||
						await new Promise((resolve) => setTimeout(resolve, interval));
 | 
			
		||||
						return tryParse(value, attempts - 1, interval); // Recursive call
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				// Usage example
 | 
			
		||||
				content = await tryParse(value);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		console.log('content', content);
 | 
			
		||||
 | 
			
		||||
		editor = new Editor({
 | 
			
		||||
			element: element,
 | 
			
		||||
			extensions: [
 | 
			
		||||
@ -198,32 +204,44 @@
 | 
			
		||||
				// force re-render so `editor.isActive` works as expected
 | 
			
		||||
				editor = editor;
 | 
			
		||||
 | 
			
		||||
				if (!raw) {
 | 
			
		||||
					let newValue = turndownService
 | 
			
		||||
						.turndown(
 | 
			
		||||
							editor
 | 
			
		||||
								.getHTML()
 | 
			
		||||
								.replace(/<p><\/p>/g, '<br/>')
 | 
			
		||||
								.replace(/ {2,}/g, (m) => m.replace(/ /g, '\u00a0'))
 | 
			
		||||
						)
 | 
			
		||||
						.replace(/\u00a0/g, ' ');
 | 
			
		||||
				html = editor.getHTML();
 | 
			
		||||
 | 
			
		||||
					if (!preserveBreaks) {
 | 
			
		||||
						newValue = newValue.replace(/<br\/>/g, '');
 | 
			
		||||
					}
 | 
			
		||||
				onChange({
 | 
			
		||||
					html: editor.getHTML(),
 | 
			
		||||
					json: editor.getJSON(),
 | 
			
		||||
					md: turndownService.turndown(editor.getHTML())
 | 
			
		||||
				});
 | 
			
		||||
 | 
			
		||||
					if (value !== newValue) {
 | 
			
		||||
						value = newValue;
 | 
			
		||||
				if (json) {
 | 
			
		||||
					value = editor.getJSON();
 | 
			
		||||
				} else {
 | 
			
		||||
					if (!raw) {
 | 
			
		||||
						let newValue = turndownService
 | 
			
		||||
							.turndown(
 | 
			
		||||
								editor
 | 
			
		||||
									.getHTML()
 | 
			
		||||
									.replace(/<p><\/p>/g, '<br/>')
 | 
			
		||||
									.replace(/ {2,}/g, (m) => m.replace(/ /g, '\u00a0'))
 | 
			
		||||
							)
 | 
			
		||||
							.replace(/\u00a0/g, ' ');
 | 
			
		||||
 | 
			
		||||
						// check if the node is paragraph as well
 | 
			
		||||
						if (editor.isActive('paragraph')) {
 | 
			
		||||
							if (value === '') {
 | 
			
		||||
								editor.commands.clearContent();
 | 
			
		||||
						if (!preserveBreaks) {
 | 
			
		||||
							newValue = newValue.replace(/<br\/>/g, '');
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						if (value !== newValue) {
 | 
			
		||||
							value = newValue;
 | 
			
		||||
 | 
			
		||||
							// check if the node is paragraph as well
 | 
			
		||||
							if (editor.isActive('paragraph')) {
 | 
			
		||||
								if (value === '') {
 | 
			
		||||
									editor.commands.clearContent();
 | 
			
		||||
								}
 | 
			
		||||
							}
 | 
			
		||||
						}
 | 
			
		||||
					} else {
 | 
			
		||||
						value = editor.getHTML();
 | 
			
		||||
					}
 | 
			
		||||
				} else {
 | 
			
		||||
					value = editor.getHTML();
 | 
			
		||||
				}
 | 
			
		||||
			},
 | 
			
		||||
			editorProps: {
 | 
			
		||||
@ -356,35 +374,25 @@
 | 
			
		||||
		}
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Update the editor content if the external `value` changes
 | 
			
		||||
	$: if (
 | 
			
		||||
		editor &&
 | 
			
		||||
		(raw
 | 
			
		||||
			? value !== editor.getHTML()
 | 
			
		||||
			: value !==
 | 
			
		||||
				turndownService
 | 
			
		||||
					.turndown(
 | 
			
		||||
						(preserveBreaks
 | 
			
		||||
							? editor.getHTML().replace(/<p><\/p>/g, '<br/>')
 | 
			
		||||
							: editor.getHTML()
 | 
			
		||||
						).replace(/ {2,}/g, (m) => m.replace(/ /g, '\u00a0'))
 | 
			
		||||
					)
 | 
			
		||||
					.replace(/\u00a0/g, ' '))
 | 
			
		||||
	) {
 | 
			
		||||
		if (raw) {
 | 
			
		||||
			editor.commands.setContent(value);
 | 
			
		||||
		} else {
 | 
			
		||||
			preserveBreaks
 | 
			
		||||
				? editor.commands.setContent(value)
 | 
			
		||||
				: editor.commands.setContent(
 | 
			
		||||
						marked.parse(value.replaceAll(`\n<br/>`, `<br/>`), {
 | 
			
		||||
							breaks: false
 | 
			
		||||
						})
 | 
			
		||||
					); // Update editor content
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		selectTemplate();
 | 
			
		||||
	$: if (value && editor) {
 | 
			
		||||
		onValueChange();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const onValueChange = () => {
 | 
			
		||||
		if (!editor) return;
 | 
			
		||||
 | 
			
		||||
		if (json) {
 | 
			
		||||
			if (JSON.stringify(value) !== JSON.stringify(editor.getJSON())) {
 | 
			
		||||
				editor.commands.setContent(value);
 | 
			
		||||
				selectTemplate();
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			if (value !== editor.getHTML()) {
 | 
			
		||||
				editor.commands.setContent(value);
 | 
			
		||||
				selectTemplate();
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<div bind:this={element} class="relative w-full min-w-full h-full min-h-fit {className}" />
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										19
									
								
								src/lib/components/icons/Calendar.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/lib/components/icons/Calendar.svelte
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,19 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
	export let className = 'size-3';
 | 
			
		||||
	export let strokeWidth = '1.5';
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<svg
 | 
			
		||||
	xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
	fill="none"
 | 
			
		||||
	viewBox="0 0 24 24"
 | 
			
		||||
	stroke-width={strokeWidth}
 | 
			
		||||
	stroke="currentColor"
 | 
			
		||||
	class={className}
 | 
			
		||||
>
 | 
			
		||||
	<path
 | 
			
		||||
		stroke-linecap="round"
 | 
			
		||||
		stroke-linejoin="round"
 | 
			
		||||
		d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5"
 | 
			
		||||
	/>
 | 
			
		||||
</svg>
 | 
			
		||||
							
								
								
									
										11
									
								
								src/lib/components/icons/CalendarSolid.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/lib/components/icons/CalendarSolid.svelte
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,11 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
	export let className = 'size-4';
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class={className}>
 | 
			
		||||
	<path
 | 
			
		||||
		fill-rule="evenodd"
 | 
			
		||||
		d="M6.75 2.25A.75.75 0 0 1 7.5 3v1.5h9V3A.75.75 0 0 1 18 3v1.5h.75a3 3 0 0 1 3 3v11.25a3 3 0 0 1-3 3H5.25a3 3 0 0 1-3-3V7.5a3 3 0 0 1 3-3H6V3a.75.75 0 0 1 .75-.75Zm13.5 9a1.5 1.5 0 0 0-1.5-1.5H5.25a1.5 1.5 0 0 0-1.5 1.5v7.5a1.5 1.5 0 0 0 1.5 1.5h13.5a1.5 1.5 0 0 0 1.5-1.5v-7.5Z"
 | 
			
		||||
		clip-rule="evenodd"
 | 
			
		||||
	/>
 | 
			
		||||
</svg>
 | 
			
		||||
							
								
								
									
										19
									
								
								src/lib/components/icons/Users.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/lib/components/icons/Users.svelte
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,19 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
	export let className = 'size-3';
 | 
			
		||||
	export let strokeWidth = '1.5';
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<svg
 | 
			
		||||
	xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
	fill="none"
 | 
			
		||||
	viewBox="0 0 24 24"
 | 
			
		||||
	stroke-width={strokeWidth}
 | 
			
		||||
	stroke="currentColor"
 | 
			
		||||
	class={className}
 | 
			
		||||
>
 | 
			
		||||
	<path
 | 
			
		||||
		stroke-linecap="round"
 | 
			
		||||
		stroke-linejoin="round"
 | 
			
		||||
		d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"
 | 
			
		||||
	/>
 | 
			
		||||
</svg>
 | 
			
		||||
@ -38,6 +38,7 @@
 | 
			
		||||
	import Check from '$lib/components/icons/Check.svelte';
 | 
			
		||||
	import XMark from '$lib/components/icons/XMark.svelte';
 | 
			
		||||
	import Document from '$lib/components/icons/Document.svelte';
 | 
			
		||||
	import Sparkles from '$lib/components/icons/Sparkles.svelte';
 | 
			
		||||
 | 
			
		||||
	export let className = '';
 | 
			
		||||
 | 
			
		||||
@ -65,6 +66,7 @@
 | 
			
		||||
 | 
			
		||||
	let showShareChatModal = false;
 | 
			
		||||
	let confirmEdit = false;
 | 
			
		||||
	let editInputFocused = false;
 | 
			
		||||
 | 
			
		||||
	let chatTitle = title;
 | 
			
		||||
 | 
			
		||||
@ -133,10 +135,6 @@
 | 
			
		||||
		dispatch('change');
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const focusEdit = async (node: HTMLInputElement) => {
 | 
			
		||||
		node.focus();
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	let itemElement;
 | 
			
		||||
 | 
			
		||||
	let dragged = false;
 | 
			
		||||
@ -213,6 +211,20 @@
 | 
			
		||||
			chatTitle = '';
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const focusEditInput = async () => {
 | 
			
		||||
		console.log('focusEditInput');
 | 
			
		||||
		await tick();
 | 
			
		||||
 | 
			
		||||
		const input = document.getElementById(`chat-title-input-${id}`);
 | 
			
		||||
		if (input) {
 | 
			
		||||
			input.focus();
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	$: if (confirmEdit) {
 | 
			
		||||
		focusEditInput();
 | 
			
		||||
	}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<ShareChatModal bind:show={showShareChatModal} chatId={id} />
 | 
			
		||||
@ -257,11 +269,23 @@
 | 
			
		||||
					: 'group-hover:bg-gray-100 dark:group-hover:bg-gray-950'}  whitespace-nowrap text-ellipsis"
 | 
			
		||||
		>
 | 
			
		||||
			<input
 | 
			
		||||
				use:focusEdit
 | 
			
		||||
				bind:value={chatTitle}
 | 
			
		||||
				id="chat-title-input-{id}"
 | 
			
		||||
				bind:value={chatTitle}
 | 
			
		||||
				class=" bg-transparent w-full outline-hidden mr-10"
 | 
			
		||||
				on:keydown={chatTitleInputKeydownHandler}
 | 
			
		||||
				on:focus={() => {
 | 
			
		||||
					editInputFocused = true;
 | 
			
		||||
				}}
 | 
			
		||||
				on:blur={() => {
 | 
			
		||||
					if (editInputFocused) {
 | 
			
		||||
						if (chatTitle !== title) {
 | 
			
		||||
							editChatTitle(id, chatTitle);
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						confirmEdit = false;
 | 
			
		||||
						chatTitle = '';
 | 
			
		||||
					}
 | 
			
		||||
				}}
 | 
			
		||||
			/>
 | 
			
		||||
		</div>
 | 
			
		||||
	{:else}
 | 
			
		||||
@ -311,7 +335,7 @@
 | 
			
		||||
				: 'invisible group-hover:visible from-gray-100 dark:from-gray-950'}
 | 
			
		||||
            absolute {className === 'pr-2'
 | 
			
		||||
			? 'right-[8px]'
 | 
			
		||||
			: 'right-0'}  top-[4px] py-1 pr-0.5 mr-1.5 pl-5 bg-linear-to-l from-80%
 | 
			
		||||
			: 'right-1'} top-[4px] py-1 pr-0.5 mr-1.5 pl-5 bg-linear-to-l from-80%
 | 
			
		||||
 | 
			
		||||
              to-transparent"
 | 
			
		||||
		on:mouseenter={(e) => {
 | 
			
		||||
@ -325,28 +349,9 @@
 | 
			
		||||
			<div
 | 
			
		||||
				class="flex self-center items-center space-x-1.5 z-10 translate-y-[0.5px] -translate-x-[0.5px]"
 | 
			
		||||
			>
 | 
			
		||||
				<Tooltip content={$i18n.t('Confirm')}>
 | 
			
		||||
					<button
 | 
			
		||||
						class=" self-center dark:hover:text-white transition"
 | 
			
		||||
						on:click={() => {
 | 
			
		||||
							editChatTitle(id, chatTitle);
 | 
			
		||||
							confirmEdit = false;
 | 
			
		||||
							chatTitle = '';
 | 
			
		||||
						}}
 | 
			
		||||
					>
 | 
			
		||||
						<Check className=" size-3.5" strokeWidth="2.5" />
 | 
			
		||||
					</button>
 | 
			
		||||
				</Tooltip>
 | 
			
		||||
 | 
			
		||||
				<Tooltip content={$i18n.t('Cancel')}>
 | 
			
		||||
					<button
 | 
			
		||||
						class=" self-center dark:hover:text-white transition"
 | 
			
		||||
						on:click={() => {
 | 
			
		||||
							confirmEdit = false;
 | 
			
		||||
							chatTitle = '';
 | 
			
		||||
						}}
 | 
			
		||||
					>
 | 
			
		||||
						<XMark strokeWidth="2.5" />
 | 
			
		||||
				<Tooltip content={$i18n.t('Generate')}>
 | 
			
		||||
					<button class=" self-center dark:hover:text-white transition" on:click={() => {}}>
 | 
			
		||||
						<Sparkles strokeWidth="2" />
 | 
			
		||||
					</button>
 | 
			
		||||
				</Tooltip>
 | 
			
		||||
			</div>
 | 
			
		||||
@ -377,7 +382,7 @@
 | 
			
		||||
				</Tooltip>
 | 
			
		||||
			</div>
 | 
			
		||||
		{:else}
 | 
			
		||||
			<div class="flex self-center space-x-1 z-10">
 | 
			
		||||
			<div class="flex self-center z-10 items-end">
 | 
			
		||||
				<ChatMenu
 | 
			
		||||
					chatId={id}
 | 
			
		||||
					cloneChatHandler={() => {
 | 
			
		||||
@ -414,7 +419,7 @@
 | 
			
		||||
				>
 | 
			
		||||
					<button
 | 
			
		||||
						aria-label="Chat Menu"
 | 
			
		||||
						class=" self-center dark:hover:text-white transition"
 | 
			
		||||
						class=" self-center dark:hover:text-white transition m-0"
 | 
			
		||||
						on:click={() => {
 | 
			
		||||
							dispatch('select');
 | 
			
		||||
						}}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										220
									
								
								src/lib/components/notes/NoteEditor.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										220
									
								
								src/lib/components/notes/NoteEditor.svelte
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,220 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
	import { getContext, onMount } from 'svelte';
 | 
			
		||||
	const i18n = getContext('i18n');
 | 
			
		||||
 | 
			
		||||
	import { toast } from 'svelte-sonner';
 | 
			
		||||
 | 
			
		||||
	import { showSidebar } from '$lib/stores';
 | 
			
		||||
	import { goto } from '$app/navigation';
 | 
			
		||||
 | 
			
		||||
	import dayjs from '$lib/dayjs';
 | 
			
		||||
	import calendar from 'dayjs/plugin/calendar';
 | 
			
		||||
	import duration from 'dayjs/plugin/duration';
 | 
			
		||||
	import relativeTime from 'dayjs/plugin/relativeTime';
 | 
			
		||||
 | 
			
		||||
	dayjs.extend(calendar);
 | 
			
		||||
	dayjs.extend(duration);
 | 
			
		||||
	dayjs.extend(relativeTime);
 | 
			
		||||
 | 
			
		||||
	async function loadLocale(locales) {
 | 
			
		||||
		for (const locale of locales) {
 | 
			
		||||
			try {
 | 
			
		||||
				dayjs.locale(locale);
 | 
			
		||||
				break; // Stop after successfully loading the first available locale
 | 
			
		||||
			} catch (error) {
 | 
			
		||||
				console.error(`Could not load locale '${locale}':`, error);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Assuming $i18n.languages is an array of language codes
 | 
			
		||||
	$: loadLocale($i18n.languages);
 | 
			
		||||
 | 
			
		||||
	import { getNoteById, updateNoteById } from '$lib/apis/notes';
 | 
			
		||||
 | 
			
		||||
	import RichTextInput from '../common/RichTextInput.svelte';
 | 
			
		||||
	import Spinner from '../common/Spinner.svelte';
 | 
			
		||||
	import Mic from '../icons/Mic.svelte';
 | 
			
		||||
	import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte';
 | 
			
		||||
	import Tooltip from '../common/Tooltip.svelte';
 | 
			
		||||
 | 
			
		||||
	import Calendar from '../icons/Calendar.svelte';
 | 
			
		||||
	import Users from '../icons/Users.svelte';
 | 
			
		||||
 | 
			
		||||
	export let id: null | string = null;
 | 
			
		||||
 | 
			
		||||
	let note = {
 | 
			
		||||
		title: '',
 | 
			
		||||
		data: {
 | 
			
		||||
			content: {
 | 
			
		||||
				json: null,
 | 
			
		||||
				html: '',
 | 
			
		||||
				md: ''
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
		meta: null,
 | 
			
		||||
		access_control: null
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	let voiceInput = false;
 | 
			
		||||
	let loading = false;
 | 
			
		||||
 | 
			
		||||
	const init = async () => {
 | 
			
		||||
		loading = true;
 | 
			
		||||
		const res = await getNoteById(localStorage.token, id).catch((error) => {
 | 
			
		||||
			toast.error(`${error}`);
 | 
			
		||||
			return null;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		if (res) {
 | 
			
		||||
			note = res;
 | 
			
		||||
		} else {
 | 
			
		||||
			toast.error($i18n.t('Note not found'));
 | 
			
		||||
			goto('/notes');
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		loading = false;
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	let debounceTimeout: NodeJS.Timeout | null = null;
 | 
			
		||||
 | 
			
		||||
	const changeDebounceHandler = () => {
 | 
			
		||||
		console.log('debounce');
 | 
			
		||||
		if (debounceTimeout) {
 | 
			
		||||
			clearTimeout(debounceTimeout);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		debounceTimeout = setTimeout(async () => {
 | 
			
		||||
			const res = await updateNoteById(localStorage.token, id, {
 | 
			
		||||
				...note,
 | 
			
		||||
				title: note.title === '' ? $i18n.t('Untitled') : note.title
 | 
			
		||||
			}).catch((e) => {
 | 
			
		||||
				toast.error(`${e}`);
 | 
			
		||||
			});
 | 
			
		||||
		}, 200);
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	$: if (note) {
 | 
			
		||||
		changeDebounceHandler();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	$: if (id) {
 | 
			
		||||
		init();
 | 
			
		||||
	}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<div class="relative flex-1 w-full h-full flex justify-center">
 | 
			
		||||
	{#if loading}
 | 
			
		||||
		<div class=" absolute top-0 bottom-0 left-0 right-0 flex">
 | 
			
		||||
			<div class="m-auto">
 | 
			
		||||
				<Spinner />
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	{:else}
 | 
			
		||||
		<div class=" w-full flex flex-col {loading ? 'opacity-20' : ''}">
 | 
			
		||||
			<div class="shrink-0 w-full flex justify-between items-center px-4.5 mb-1">
 | 
			
		||||
				<div class="w-full">
 | 
			
		||||
					<input
 | 
			
		||||
						class="w-full text-2xl font-medium bg-transparent outline-hidden"
 | 
			
		||||
						type="text"
 | 
			
		||||
						bind:value={note.title}
 | 
			
		||||
						placeholder={$i18n.t('Title')}
 | 
			
		||||
						required
 | 
			
		||||
					/>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			<div
 | 
			
		||||
				class="flex gap-1 px-3.5 items-center text-xs font-medium text-gray-500 dark:text-gray-500 mb-4"
 | 
			
		||||
			>
 | 
			
		||||
				<button class=" flex items-center gap-1 w-fit py-1 px-1.5 rounded-lg">
 | 
			
		||||
					<Calendar className="size-3.5" strokeWidth="2" />
 | 
			
		||||
 | 
			
		||||
					<span>{dayjs(note.created_at / 1000000).calendar()}</span>
 | 
			
		||||
				</button>
 | 
			
		||||
 | 
			
		||||
				<button class=" flex items-center gap-1 w-fit py-1 px-1.5 rounded-lg">
 | 
			
		||||
					<Users className="size-3.5" strokeWidth="2" />
 | 
			
		||||
 | 
			
		||||
					<span> You </span>
 | 
			
		||||
				</button>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			<div class=" flex-1 w-full h-full overflow-auto px-4.5 pb-5">
 | 
			
		||||
				<RichTextInput
 | 
			
		||||
					className="input-prose-sm"
 | 
			
		||||
					bind:value={note.data.content.json}
 | 
			
		||||
					placeholder={$i18n.t('Write something...')}
 | 
			
		||||
					json={true}
 | 
			
		||||
					onChange={(content) => {
 | 
			
		||||
						note.data.html = content.html;
 | 
			
		||||
						note.data.md = content.md;
 | 
			
		||||
					}}
 | 
			
		||||
				/>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	{/if}
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div class="absolute bottom-0 right-0 p-5 max-w-full flex justify-end">
 | 
			
		||||
	<div
 | 
			
		||||
		class="flex gap-0.5 justify-end w-full {$showSidebar && voiceInput
 | 
			
		||||
			? 'md:max-w-[calc(100%-260px)]'
 | 
			
		||||
			: ''} max-w-full"
 | 
			
		||||
	>
 | 
			
		||||
		{#if voiceInput}
 | 
			
		||||
			<div class="flex-1 w-full">
 | 
			
		||||
				<VoiceRecording
 | 
			
		||||
					bind:recording={voiceInput}
 | 
			
		||||
					className="p-1 w-full max-w-full"
 | 
			
		||||
					transcribe={false}
 | 
			
		||||
					onCancel={() => {
 | 
			
		||||
						voiceInput = false;
 | 
			
		||||
					}}
 | 
			
		||||
					onConfirm={(data) => {
 | 
			
		||||
						console.log(data);
 | 
			
		||||
					}}
 | 
			
		||||
				/>
 | 
			
		||||
			</div>
 | 
			
		||||
		{:else}
 | 
			
		||||
			<Tooltip content={$i18n.t('Record')}>
 | 
			
		||||
				<button
 | 
			
		||||
					class="cursor-pointer p-2.5 flex rounded-full border border-gray-50 dark:border-none dark:bg-gray-850 hover:bg-gray-50 dark:hover:bg-gray-800 transition shadow-xl"
 | 
			
		||||
					type="button"
 | 
			
		||||
					on:click={async () => {
 | 
			
		||||
						try {
 | 
			
		||||
							let stream = await navigator.mediaDevices
 | 
			
		||||
								.getUserMedia({ audio: true })
 | 
			
		||||
								.catch(function (err) {
 | 
			
		||||
									toast.error(
 | 
			
		||||
										$i18n.t(`Permission denied when accessing microphone: {{error}}`, {
 | 
			
		||||
											error: err
 | 
			
		||||
										})
 | 
			
		||||
									);
 | 
			
		||||
									return null;
 | 
			
		||||
								});
 | 
			
		||||
 | 
			
		||||
							if (stream) {
 | 
			
		||||
								voiceInput = true;
 | 
			
		||||
								const tracks = stream.getTracks();
 | 
			
		||||
								tracks.forEach((track) => track.stop());
 | 
			
		||||
							}
 | 
			
		||||
							stream = null;
 | 
			
		||||
						} catch {
 | 
			
		||||
							toast.error($i18n.t('Permission denied when accessing microphone'));
 | 
			
		||||
						}
 | 
			
		||||
					}}
 | 
			
		||||
				>
 | 
			
		||||
					<Mic className="size-4.5" />
 | 
			
		||||
				</button>
 | 
			
		||||
			</Tooltip>
 | 
			
		||||
		{/if}
 | 
			
		||||
 | 
			
		||||
		<!-- <button
 | 
			
		||||
            class="cursor-pointer p-2.5 flex rounded-full hover:bg-gray-100 dark:hover:bg-gray-850 transition shadow-xl"
 | 
			
		||||
        >
 | 
			
		||||
            <SparklesSolid className="size-4" />
 | 
			
		||||
        </button> -->
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										250
									
								
								src/lib/components/notes/Notes.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										250
									
								
								src/lib/components/notes/Notes.svelte
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,250 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
	import { toast } from 'svelte-sonner';
 | 
			
		||||
	import fileSaver from 'file-saver';
 | 
			
		||||
	const { saveAs } = fileSaver;
 | 
			
		||||
 | 
			
		||||
	import dayjs from '$lib/dayjs';
 | 
			
		||||
	import duration from 'dayjs/plugin/duration';
 | 
			
		||||
	import relativeTime from 'dayjs/plugin/relativeTime';
 | 
			
		||||
 | 
			
		||||
	dayjs.extend(duration);
 | 
			
		||||
	dayjs.extend(relativeTime);
 | 
			
		||||
 | 
			
		||||
	async function loadLocale(locales) {
 | 
			
		||||
		for (const locale of locales) {
 | 
			
		||||
			try {
 | 
			
		||||
				dayjs.locale(locale);
 | 
			
		||||
				break; // Stop after successfully loading the first available locale
 | 
			
		||||
			} catch (error) {
 | 
			
		||||
				console.error(`Could not load locale '${locale}':`, error);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Assuming $i18n.languages is an array of language codes
 | 
			
		||||
	$: loadLocale($i18n.languages);
 | 
			
		||||
 | 
			
		||||
	import { goto } from '$app/navigation';
 | 
			
		||||
	import { onMount, getContext } from 'svelte';
 | 
			
		||||
	import { WEBUI_NAME, config, prompts as _prompts, user } from '$lib/stores';
 | 
			
		||||
 | 
			
		||||
	import { createNewNote, getNotes } from '$lib/apis/notes';
 | 
			
		||||
 | 
			
		||||
	import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
 | 
			
		||||
	import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
 | 
			
		||||
	import Search from '../icons/Search.svelte';
 | 
			
		||||
	import Plus from '../icons/Plus.svelte';
 | 
			
		||||
	import ChevronRight from '../icons/ChevronRight.svelte';
 | 
			
		||||
	import Spinner from '../common/Spinner.svelte';
 | 
			
		||||
	import Tooltip from '../common/Tooltip.svelte';
 | 
			
		||||
	import { capitalizeFirstLetter } from '$lib/utils';
 | 
			
		||||
 | 
			
		||||
	const i18n = getContext('i18n');
 | 
			
		||||
	let loaded = false;
 | 
			
		||||
 | 
			
		||||
	let importFiles = '';
 | 
			
		||||
	let query = '';
 | 
			
		||||
 | 
			
		||||
	let notes = [];
 | 
			
		||||
	let selectedNote = null;
 | 
			
		||||
 | 
			
		||||
	let showDeleteConfirm = false;
 | 
			
		||||
 | 
			
		||||
	const init = async () => {
 | 
			
		||||
		notes = await getNotes(localStorage.token);
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const createNoteHandler = async () => {
 | 
			
		||||
		const res = await createNewNote(localStorage.token, {
 | 
			
		||||
			title: $i18n.t('New Note'),
 | 
			
		||||
			data: {
 | 
			
		||||
				content: {
 | 
			
		||||
					json: null,
 | 
			
		||||
					html: '',
 | 
			
		||||
					md: ''
 | 
			
		||||
				}
 | 
			
		||||
			},
 | 
			
		||||
			meta: null,
 | 
			
		||||
			access_control: null
 | 
			
		||||
		}).catch((error) => {
 | 
			
		||||
			toast.error(`${error}`);
 | 
			
		||||
			return null;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		if (res) {
 | 
			
		||||
			goto(`/notes/${res.id}`);
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	onMount(async () => {
 | 
			
		||||
		await init();
 | 
			
		||||
		loaded = true;
 | 
			
		||||
	});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<svelte:head>
 | 
			
		||||
	<title>
 | 
			
		||||
		{$i18n.t('Notes')} • {$WEBUI_NAME}
 | 
			
		||||
	</title>
 | 
			
		||||
</svelte:head>
 | 
			
		||||
 | 
			
		||||
{#if loaded}
 | 
			
		||||
	<DeleteConfirmDialog
 | 
			
		||||
		bind:show={showDeleteConfirm}
 | 
			
		||||
		title={$i18n.t('Delete note?')}
 | 
			
		||||
		on:confirm={() => {}}
 | 
			
		||||
	>
 | 
			
		||||
		<div class=" text-sm text-gray-500">
 | 
			
		||||
			{$i18n.t('This will delete')} <span class="  font-semibold">{selectedNote.title}</span>.
 | 
			
		||||
		</div>
 | 
			
		||||
	</DeleteConfirmDialog>
 | 
			
		||||
 | 
			
		||||
	<div class="px-4.5 @container h-full">
 | 
			
		||||
		{#if Object.keys(notes).length > 0}
 | 
			
		||||
			{#each Object.keys(notes) as timeRange}
 | 
			
		||||
				<div class="w-full text-xs text-gray-500 dark:text-gray-500 font-medium pb-2">
 | 
			
		||||
					{$i18n.t(timeRange)}
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				<div class="mb-5 gap-2 grid grid-cols-1 lg:grid-cols-3 xl:grid-cols-4">
 | 
			
		||||
					{#each notes[timeRange] as note, idx (note.id)}
 | 
			
		||||
						<div
 | 
			
		||||
							class=" flex space-x-4 cursor-pointer w-full px-4.5 py-4 bg-gray-50 dark:bg-gray-850 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl transition"
 | 
			
		||||
						>
 | 
			
		||||
							<div class=" flex flex-1 space-x-4 cursor-pointer w-full">
 | 
			
		||||
								<a
 | 
			
		||||
									href={`/notes/${note.id}`}
 | 
			
		||||
									class="w-full -translate-y-0.5 flex flex-col justify-between"
 | 
			
		||||
								>
 | 
			
		||||
									<div class="flex-1">
 | 
			
		||||
										<div class="  flex items-center gap-2 self-center mb-1">
 | 
			
		||||
											<div class=" font-semibold line-clamp-1 capitalize">{note.title}</div>
 | 
			
		||||
										</div>
 | 
			
		||||
 | 
			
		||||
										<div
 | 
			
		||||
											class=" text-xs text-gray-500 dark:text-gray-500 mb-3 line-clamp-5 min-h-18"
 | 
			
		||||
										>
 | 
			
		||||
											{#if note.data?.md}
 | 
			
		||||
												{note.data?.md}
 | 
			
		||||
											{:else}
 | 
			
		||||
												{$i18n.t('No content')}
 | 
			
		||||
											{/if}
 | 
			
		||||
										</div>
 | 
			
		||||
									</div>
 | 
			
		||||
 | 
			
		||||
									<div class=" text-xs px-0.5 w-full flex justify-between items-center">
 | 
			
		||||
										<div>
 | 
			
		||||
											{dayjs(note.updated_at / 1000000).fromNow()}
 | 
			
		||||
										</div>
 | 
			
		||||
										<Tooltip
 | 
			
		||||
											content={note?.user?.email ?? $i18n.t('Deleted User')}
 | 
			
		||||
											className="flex shrink-0"
 | 
			
		||||
											placement="top-start"
 | 
			
		||||
										>
 | 
			
		||||
											<div class="shrink-0 text-gray-500">
 | 
			
		||||
												{$i18n.t('By {{name}}', {
 | 
			
		||||
													name: capitalizeFirstLetter(
 | 
			
		||||
														note?.user?.name ?? note?.user?.email ?? $i18n.t('Deleted User')
 | 
			
		||||
													)
 | 
			
		||||
												})}
 | 
			
		||||
											</div>
 | 
			
		||||
										</Tooltip>
 | 
			
		||||
									</div>
 | 
			
		||||
								</a>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					{/each}
 | 
			
		||||
				</div>
 | 
			
		||||
			{/each}
 | 
			
		||||
		{:else}
 | 
			
		||||
			<div class="w-full h-full flex flex-col items-center justify-center">
 | 
			
		||||
				<div class="pb-20 text-center">
 | 
			
		||||
					<div class=" text-xl font-medium text-gray-400 dark:text-gray-600">
 | 
			
		||||
						{$i18n.t('No Notes')}
 | 
			
		||||
					</div>
 | 
			
		||||
 | 
			
		||||
					<div class="mt-1 text-sm text-gray-300 dark:text-gray-700">
 | 
			
		||||
						{$i18n.t('Create your first note by clicking on the plus button below.')}
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		{/if}
 | 
			
		||||
	</div>
 | 
			
		||||
 | 
			
		||||
	<div class="absolute bottom-0 left-0 right-0 p-5 max-w-full flex justify-end">
 | 
			
		||||
		<div class="flex gap-0.5 justify-end w-full">
 | 
			
		||||
			<Tooltip content={$i18n.t('Create Note')}>
 | 
			
		||||
				<button
 | 
			
		||||
					class="cursor-pointer p-2.5 flex rounded-full border border-gray-50 dark:border-none dark:bg-gray-850 hover:bg-gray-50 dark:hover:bg-gray-800 transition shadow-xl"
 | 
			
		||||
					type="button"
 | 
			
		||||
					on:click={async () => {
 | 
			
		||||
						createNoteHandler();
 | 
			
		||||
					}}
 | 
			
		||||
				>
 | 
			
		||||
					<Plus className="size-4.5" strokeWidth="2.5" />
 | 
			
		||||
				</button>
 | 
			
		||||
			</Tooltip>
 | 
			
		||||
 | 
			
		||||
			<!-- <button
 | 
			
		||||
				class="cursor-pointer p-2.5 flex rounded-full hover:bg-gray-100 dark:hover:bg-gray-850 transition shadow-xl"
 | 
			
		||||
			>
 | 
			
		||||
				<SparklesSolid className="size-4" />
 | 
			
		||||
			</button> -->
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
 | 
			
		||||
	<!-- {#if $user?.role === 'admin'}
 | 
			
		||||
		<div class=" flex justify-end w-full mb-3">
 | 
			
		||||
			<div class="flex space-x-2">
 | 
			
		||||
				<input
 | 
			
		||||
					id="notes-import-input"
 | 
			
		||||
					bind:files={importFiles}
 | 
			
		||||
					type="file"
 | 
			
		||||
					accept=".md"
 | 
			
		||||
					hidden
 | 
			
		||||
					on:change={() => {
 | 
			
		||||
						console.log(importFiles);
 | 
			
		||||
 | 
			
		||||
						const reader = new FileReader();
 | 
			
		||||
						reader.onload = async (event) => {
 | 
			
		||||
							console.log(event.target.result);
 | 
			
		||||
						};
 | 
			
		||||
 | 
			
		||||
						reader.readAsText(importFiles[0]);
 | 
			
		||||
					}}
 | 
			
		||||
				/>
 | 
			
		||||
 | 
			
		||||
				<button
 | 
			
		||||
					class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
 | 
			
		||||
					on:click={() => {
 | 
			
		||||
						const notesImportInputElement = document.getElementById('notes-import-input');
 | 
			
		||||
						if (notesImportInputElement) {
 | 
			
		||||
							notesImportInputElement.click();
 | 
			
		||||
						}
 | 
			
		||||
					}}
 | 
			
		||||
				>
 | 
			
		||||
					<div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Import Notes')}</div>
 | 
			
		||||
 | 
			
		||||
					<div class=" self-center">
 | 
			
		||||
						<svg
 | 
			
		||||
							xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
							viewBox="0 0 16 16"
 | 
			
		||||
							fill="currentColor"
 | 
			
		||||
							class="w-4 h-4"
 | 
			
		||||
						>
 | 
			
		||||
							<path
 | 
			
		||||
								fill-rule="evenodd"
 | 
			
		||||
								d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 9.5a.75.75 0 0 1-.75-.75V8.06l-.72.72a.75.75 0 0 1-1.06-1.06l2-2a.75.75 0 0 1 1.06 0l2 2a.75.75 0 1 1-1.06 1.06l-.72-.72v2.69a.75.75 0 0 1-.75.75Z"
 | 
			
		||||
								clip-rule="evenodd"
 | 
			
		||||
							/>
 | 
			
		||||
						</svg>
 | 
			
		||||
					</div>
 | 
			
		||||
				</button>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	{/if} -->
 | 
			
		||||
{:else}
 | 
			
		||||
	<div class="w-full h-full flex justify-center items-center">
 | 
			
		||||
		<Spinner />
 | 
			
		||||
	</div>
 | 
			
		||||
{/if}
 | 
			
		||||
@ -57,11 +57,11 @@
 | 
			
		||||
					<VoiceRecording
 | 
			
		||||
						bind:recording={voiceInput}
 | 
			
		||||
						className="p-1 w-full max-w-full"
 | 
			
		||||
						on:cancel={() => {
 | 
			
		||||
						onCancel={() => {
 | 
			
		||||
							voiceInput = false;
 | 
			
		||||
						}}
 | 
			
		||||
						on:confirm={(e) => {
 | 
			
		||||
							const { text, filename } = e.detail;
 | 
			
		||||
						onConfirm={(data) => {
 | 
			
		||||
							const { text, filename } = data;
 | 
			
		||||
 | 
			
		||||
							// url is hostname + /cache/audio/transcription/ + filename
 | 
			
		||||
							const url = `${window.location.origin}/cache/audio/transcription/${filename}`;
 | 
			
		||||
 | 
			
		||||
@ -72,7 +72,7 @@
 | 
			
		||||
 | 
			
		||||
<svelte:head>
 | 
			
		||||
	<title>
 | 
			
		||||
		{$i18n.t('Knowledge')} | {$WEBUI_NAME}
 | 
			
		||||
		{$i18n.t('Knowledge')} • {$WEBUI_NAME}
 | 
			
		||||
	</title>
 | 
			
		||||
</svelte:head>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -85,11 +85,11 @@
 | 
			
		||||
							<VoiceRecording
 | 
			
		||||
								bind:recording={voiceInput}
 | 
			
		||||
								className="p-1"
 | 
			
		||||
								on:cancel={() => {
 | 
			
		||||
								onCancel={() => {
 | 
			
		||||
									voiceInput = false;
 | 
			
		||||
								}}
 | 
			
		||||
								on:confirm={(e) => {
 | 
			
		||||
									const { text, filename } = e.detail;
 | 
			
		||||
								onConfirm={(data) => {
 | 
			
		||||
									const { text, filename } = data;
 | 
			
		||||
									content = `${content}${text} `;
 | 
			
		||||
 | 
			
		||||
									voiceInput = false;
 | 
			
		||||
 | 
			
		||||
@ -196,7 +196,7 @@
 | 
			
		||||
 | 
			
		||||
<svelte:head>
 | 
			
		||||
	<title>
 | 
			
		||||
		{$i18n.t('Models')} | {$WEBUI_NAME}
 | 
			
		||||
		{$i18n.t('Models')} • {$WEBUI_NAME}
 | 
			
		||||
	</title>
 | 
			
		||||
</svelte:head>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -88,7 +88,7 @@
 | 
			
		||||
 | 
			
		||||
<svelte:head>
 | 
			
		||||
	<title>
 | 
			
		||||
		{$i18n.t('Prompts')} | {$WEBUI_NAME}
 | 
			
		||||
		{$i18n.t('Prompts')} • {$WEBUI_NAME}
 | 
			
		||||
	</title>
 | 
			
		||||
</svelte:head>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -164,7 +164,7 @@
 | 
			
		||||
 | 
			
		||||
<svelte:head>
 | 
			
		||||
	<title>
 | 
			
		||||
		{$i18n.t('Tools')} | {$WEBUI_NAME}
 | 
			
		||||
		{$i18n.t('Tools')} • {$WEBUI_NAME}
 | 
			
		||||
	</title>
 | 
			
		||||
</svelte:head>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,8 +1,9 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
	import { onMount, getContext } from 'svelte';
 | 
			
		||||
	import { WEBUI_NAME, showSidebar, functions, config } from '$lib/stores';
 | 
			
		||||
	import { WEBUI_NAME, showSidebar, functions, config, user, showArchivedChats } from '$lib/stores';
 | 
			
		||||
	import MenuLines from '$lib/components/icons/MenuLines.svelte';
 | 
			
		||||
	import { page } from '$app/stores';
 | 
			
		||||
	import UserMenu from '$lib/components/layout/Sidebar/UserMenu.svelte';
 | 
			
		||||
 | 
			
		||||
	const i18n = getContext('i18n');
 | 
			
		||||
 | 
			
		||||
@ -25,9 +26,9 @@
 | 
			
		||||
		? 'md:max-w-[calc(100%-260px)]'
 | 
			
		||||
		: ''} max-w-full"
 | 
			
		||||
>
 | 
			
		||||
	<nav class="   px-2.5 pt-1 backdrop-blur-xl w-full drag-region">
 | 
			
		||||
	<nav class="   px-2 pt-1 backdrop-blur-xl w-full drag-region">
 | 
			
		||||
		<div class=" flex items-center">
 | 
			
		||||
			<div class="{$showSidebar ? 'md:hidden' : ''} flex flex-none items-center self-end">
 | 
			
		||||
			<div class="{$showSidebar ? 'md:hidden' : ''} flex flex-none items-center">
 | 
			
		||||
				<button
 | 
			
		||||
					id="sidebar-toggle-button"
 | 
			
		||||
					class="cursor-pointer p-1.5 flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition"
 | 
			
		||||
@ -41,10 +42,50 @@
 | 
			
		||||
					</div>
 | 
			
		||||
				</button>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			<div class="ml-2 py-0.5 self-center flex items-center justify-between w-full">
 | 
			
		||||
				<div class="">
 | 
			
		||||
					<div
 | 
			
		||||
						class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium bg-transparent py-1 touch-auto pointer-events-auto"
 | 
			
		||||
					>
 | 
			
		||||
						<a class="min-w-fit transition" href="/notes">
 | 
			
		||||
							{$i18n.t('Notes')}
 | 
			
		||||
						</a>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				<div class=" self-center flex items-center gap-1">
 | 
			
		||||
					{#if $user !== undefined && $user !== null}
 | 
			
		||||
						<UserMenu
 | 
			
		||||
							className="max-w-[200px]"
 | 
			
		||||
							role={$user?.role}
 | 
			
		||||
							on:show={(e) => {
 | 
			
		||||
								if (e.detail === 'archived-chat') {
 | 
			
		||||
									showArchivedChats.set(true);
 | 
			
		||||
								}
 | 
			
		||||
							}}
 | 
			
		||||
						>
 | 
			
		||||
							<button
 | 
			
		||||
								class="select-none flex rounded-xl p-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-850 transition"
 | 
			
		||||
								aria-label="User Menu"
 | 
			
		||||
							>
 | 
			
		||||
								<div class=" self-center">
 | 
			
		||||
									<img
 | 
			
		||||
										src={$user?.profile_image_url}
 | 
			
		||||
										class="size-6 object-cover rounded-full"
 | 
			
		||||
										alt="User profile"
 | 
			
		||||
										draggable="false"
 | 
			
		||||
									/>
 | 
			
		||||
								</div>
 | 
			
		||||
							</button>
 | 
			
		||||
						</UserMenu>
 | 
			
		||||
					{/if}
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	</nav>
 | 
			
		||||
 | 
			
		||||
	<div class=" flex-1 max-h-full overflow-y-auto">
 | 
			
		||||
	<div class=" pb-1 flex-1 max-h-full overflow-y-auto @container">
 | 
			
		||||
		<slot />
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,5 @@
 | 
			
		||||
<script>
 | 
			
		||||
	import Notes from '$lib/components/notes/Notes.svelte';
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<Notes />
 | 
			
		||||
							
								
								
									
										6
									
								
								src/routes/(app)/notes/[id]/+page.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/routes/(app)/notes/[id]/+page.svelte
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,6 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
	import { page } from '$app/stores';
 | 
			
		||||
	import NoteEditor from '$lib/components/notes/NoteEditor.svelte';
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<NoteEditor id={$page.params.id} />
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user