mirror of
https://github.com/open-webui/open-webui
synced 2025-06-23 02:16:52 +00:00
feat: notes
This commit is contained in:
parent
7d184c3a14
commit
7fee84c06e
@ -64,6 +64,7 @@ from open_webui.routers import (
|
|||||||
auths,
|
auths,
|
||||||
channels,
|
channels,
|
||||||
chats,
|
chats,
|
||||||
|
notes,
|
||||||
folders,
|
folders,
|
||||||
configs,
|
configs,
|
||||||
groups,
|
groups,
|
||||||
@ -996,6 +997,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(channels.router, prefix="/api/v1/channels", tags=["channels"])
|
||||||
app.include_router(chats.router, prefix="/api/v1/chats", tags=["chats"])
|
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(models.router, prefix="/api/v1/models", tags=["models"])
|
||||||
app.include_router(knowledge.router, prefix="/api/v1/knowledge", tags=["knowledge"])
|
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()
|
163
backend/open_webui/routers/notes.py
Normal file
163
backend/open_webui/routers/notes.py
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
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, UserNameResponse
|
||||||
|
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": UserNameResponse(
|
||||||
|
**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": UserNameResponse(
|
||||||
|
**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()
|
||||||
|
)
|
168
src/lib/apis/notes/index.ts
Normal file
168
src/lib/apis/notes/index.ts
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||||
|
|
||||||
|
type NoteItem = {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
<svelte:head>
|
||||||
<title>
|
<title>
|
||||||
{$i18n.t('Functions')} | {$WEBUI_NAME}
|
{$i18n.t('Functions')} • {$WEBUI_NAME}
|
||||||
</title>
|
</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
@ -195,7 +195,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>#{channel?.name ?? 'Channel'} | Open WebUI</title>
|
<title>#{channel?.name ?? 'Channel'} • Open WebUI</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
@ -1921,7 +1921,7 @@
|
|||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>
|
<title>
|
||||||
{$chatTitle
|
{$chatTitle
|
||||||
? `${$chatTitle.length > 30 ? `${$chatTitle.slice(0, 30)}...` : $chatTitle} | ${$WEBUI_NAME}`
|
? `${$chatTitle.length > 30 ? `${$chatTitle.slice(0, 30)}...` : $chatTitle} • ${$WEBUI_NAME}`
|
||||||
: `${$WEBUI_NAME}`}
|
: `${$WEBUI_NAME}`}
|
||||||
</title>
|
</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
@ -47,8 +47,8 @@
|
|||||||
|
|
||||||
<ShareChatModal bind:show={showShareChatModal} chatId={$chatId} />
|
<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">
|
<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 px-1.5">
|
<div class="flex items-center w-full pl-1.5 pr-1">
|
||||||
<div
|
<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]"
|
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>
|
></div>
|
||||||
|
@ -38,6 +38,7 @@
|
|||||||
import Check from '$lib/components/icons/Check.svelte';
|
import Check from '$lib/components/icons/Check.svelte';
|
||||||
import XMark from '$lib/components/icons/XMark.svelte';
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
import Document from '$lib/components/icons/Document.svelte';
|
import Document from '$lib/components/icons/Document.svelte';
|
||||||
|
import Sparkles from '$lib/components/icons/Sparkles.svelte';
|
||||||
|
|
||||||
export let className = '';
|
export let className = '';
|
||||||
|
|
||||||
@ -65,6 +66,7 @@
|
|||||||
|
|
||||||
let showShareChatModal = false;
|
let showShareChatModal = false;
|
||||||
let confirmEdit = false;
|
let confirmEdit = false;
|
||||||
|
let editInputFocused = false;
|
||||||
|
|
||||||
let chatTitle = title;
|
let chatTitle = title;
|
||||||
|
|
||||||
@ -133,10 +135,6 @@
|
|||||||
dispatch('change');
|
dispatch('change');
|
||||||
};
|
};
|
||||||
|
|
||||||
const focusEdit = async (node: HTMLInputElement) => {
|
|
||||||
node.focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
let itemElement;
|
let itemElement;
|
||||||
|
|
||||||
let dragged = false;
|
let dragged = false;
|
||||||
@ -213,6 +211,20 @@
|
|||||||
chatTitle = '';
|
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>
|
</script>
|
||||||
|
|
||||||
<ShareChatModal bind:show={showShareChatModal} chatId={id} />
|
<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"
|
: 'group-hover:bg-gray-100 dark:group-hover:bg-gray-950'} whitespace-nowrap text-ellipsis"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
use:focusEdit
|
|
||||||
bind:value={chatTitle}
|
|
||||||
id="chat-title-input-{id}"
|
id="chat-title-input-{id}"
|
||||||
|
bind:value={chatTitle}
|
||||||
class=" bg-transparent w-full outline-hidden mr-10"
|
class=" bg-transparent w-full outline-hidden mr-10"
|
||||||
on:keydown={chatTitleInputKeydownHandler}
|
on:keydown={chatTitleInputKeydownHandler}
|
||||||
|
on:focus={() => {
|
||||||
|
editInputFocused = true;
|
||||||
|
}}
|
||||||
|
on:blur={() => {
|
||||||
|
if (editInputFocused) {
|
||||||
|
if (chatTitle !== title) {
|
||||||
|
editChatTitle(id, chatTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmEdit = false;
|
||||||
|
chatTitle = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@ -311,7 +335,7 @@
|
|||||||
: 'invisible group-hover:visible from-gray-100 dark:from-gray-950'}
|
: 'invisible group-hover:visible from-gray-100 dark:from-gray-950'}
|
||||||
absolute {className === 'pr-2'
|
absolute {className === 'pr-2'
|
||||||
? 'right-[8px]'
|
? '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"
|
to-transparent"
|
||||||
on:mouseenter={(e) => {
|
on:mouseenter={(e) => {
|
||||||
@ -325,28 +349,9 @@
|
|||||||
<div
|
<div
|
||||||
class="flex self-center items-center space-x-1.5 z-10 translate-y-[0.5px] -translate-x-[0.5px]"
|
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')}>
|
<Tooltip content={$i18n.t('Generate')}>
|
||||||
<button
|
<button class=" self-center dark:hover:text-white transition" on:click={() => {}}>
|
||||||
class=" self-center dark:hover:text-white transition"
|
<Sparkles strokeWidth="2" />
|
||||||
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" />
|
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@ -377,7 +382,7 @@
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex self-center space-x-1 z-10">
|
<div class="flex self-center z-10 items-end">
|
||||||
<ChatMenu
|
<ChatMenu
|
||||||
chatId={id}
|
chatId={id}
|
||||||
cloneChatHandler={() => {
|
cloneChatHandler={() => {
|
||||||
@ -414,7 +419,7 @@
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
aria-label="Chat Menu"
|
aria-label="Chat Menu"
|
||||||
class=" self-center dark:hover:text-white transition"
|
class=" self-center dark:hover:text-white transition m-0"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
dispatch('select');
|
dispatch('select');
|
||||||
}}
|
}}
|
||||||
|
204
src/lib/components/notes/Notes.svelte
Normal file
204
src/lib/components/notes/Notes.svelte
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import fileSaver from 'file-saver';
|
||||||
|
const { saveAs } = fileSaver;
|
||||||
|
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { onMount, getContext } from 'svelte';
|
||||||
|
import { WEBUI_NAME, config, prompts as _prompts, user } from '$lib/stores';
|
||||||
|
|
||||||
|
import { 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);
|
||||||
|
|
||||||
|
console.log(notes);
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{#if notes.length > 0}
|
||||||
|
<div class="flex flex-col gap-1 my-1.5">
|
||||||
|
<!-- <div class="flex justify-between items-center">
|
||||||
|
<div class="flex md:self-center text-xl font-medium px-0.5 items-center">
|
||||||
|
{$i18n.t('Notes')}
|
||||||
|
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
|
||||||
|
<span class="text-lg font-medium text-gray-500 dark:text-gray-300">{notes.length}</span>
|
||||||
|
</div>
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<div class=" flex w-full space-x-2">
|
||||||
|
<div class="flex flex-1">
|
||||||
|
<div class=" self-center ml-1 mr-3">
|
||||||
|
<Search className="size-3.5" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
||||||
|
bind:value={query}
|
||||||
|
placeholder={$i18n.t('Search Notes')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-5 gap-2 grid lg:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{#each notes as note}
|
||||||
|
<div
|
||||||
|
class=" flex space-x-4 cursor-pointer w-full px-3 py-2 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}`}>
|
||||||
|
<div class=" flex-1 flex items-center gap-2 self-center">
|
||||||
|
<div class=" font-semibold line-clamp-1 capitalize">{note.title}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class=" text-xs px-0.5">
|
||||||
|
<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>
|
||||||
|
{: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 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 bg-gray-50 dark:bg-gray-850 hover:bg-gray-100 dark:hover:bg-gray-800 transition shadow-xl"
|
||||||
|
type="button"
|
||||||
|
on:click={async () => {}}
|
||||||
|
>
|
||||||
|
<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}
|
@ -72,7 +72,7 @@
|
|||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>
|
<title>
|
||||||
{$i18n.t('Knowledge')} | {$WEBUI_NAME}
|
{$i18n.t('Knowledge')} • {$WEBUI_NAME}
|
||||||
</title>
|
</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
@ -196,7 +196,7 @@
|
|||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>
|
<title>
|
||||||
{$i18n.t('Models')} | {$WEBUI_NAME}
|
{$i18n.t('Models')} • {$WEBUI_NAME}
|
||||||
</title>
|
</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
@ -88,7 +88,7 @@
|
|||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>
|
<title>
|
||||||
{$i18n.t('Prompts')} | {$WEBUI_NAME}
|
{$i18n.t('Prompts')} • {$WEBUI_NAME}
|
||||||
</title>
|
</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
@ -164,7 +164,7 @@
|
|||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>
|
<title>
|
||||||
{$i18n.t('Tools')} | {$WEBUI_NAME}
|
{$i18n.t('Tools')} • {$WEBUI_NAME}
|
||||||
</title>
|
</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, getContext } from 'svelte';
|
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 MenuLines from '$lib/components/icons/MenuLines.svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import UserMenu from '$lib/components/layout/Sidebar/UserMenu.svelte';
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
@ -25,9 +26,9 @@
|
|||||||
? 'md:max-w-[calc(100%-260px)]'
|
? 'md:max-w-[calc(100%-260px)]'
|
||||||
: ''} max-w-full"
|
: ''} 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=" 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
|
<button
|
||||||
id="sidebar-toggle-button"
|
id="sidebar-toggle-button"
|
||||||
class="cursor-pointer p-1.5 flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition"
|
class="cursor-pointer p-1.5 flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition"
|
||||||
@ -41,10 +42,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 rounded-full bg-transparent py-1 touch-auto pointer-events-auto"
|
||||||
|
>
|
||||||
|
<a class="min-w-fit rounded-full 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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class=" flex-1 max-h-full overflow-y-auto">
|
<div class=" pb-1 px-[18px] flex-1 max-h-full overflow-y-auto">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -0,0 +1,5 @@
|
|||||||
|
<script>
|
||||||
|
import Notes from '$lib/components/notes/Notes.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Notes />
|
Loading…
Reference in New Issue
Block a user