diff --git a/.gitignore b/.gitignore index 505a3b1..45f99a6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,15 @@ wheels/ # Virtual environments .venv + +# VitePress +docs/.vitepress/dist +docs/.vitepress/cache + +node_modules/ + +alembic/versions/ + +.nyahome + +.codemoss diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index e69de29..830edc7 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,3 @@ +# NyaHome - 在线 AI 聊天室 基础设施 + +NyaHome 是由 FastAPI 后端、Vue WebUI 实现的在线 AI 文学创作平台的基础设施。 \ No newline at end of file diff --git a/logging.yaml b/logging.yaml new file mode 100644 index 0000000..08f9d26 --- /dev/null +++ b/logging.yaml @@ -0,0 +1,53 @@ +version: 1 +disable_existing_loggers: false + +formatters: + default: + "()": uvicorn.logging.DefaultFormatter + fmt: "%(asctime)s | %(levelprefix)s %(name)s | %(message)s" + use_colors: true + + access: + "()": uvicorn.logging.AccessFormatter + fmt: '%(asctime)s | %(client_addr)s - "%(request_line)s" %(status_code)s' + use_colors: true + +handlers: + default: + formatter: default + class: logging.StreamHandler + stream: ext://sys.stderr + + access: + formatter: access + class: logging.StreamHandler + stream: ext://sys.stdout + + file: + formatter: default + class: logging.handlers.RotatingFileHandler + filename: .nyahome/app.log + maxBytes: 10485760 + backupCount: 5 + encoding: utf8 + +loggers: + uvicorn: + handlers: [ default, file ] + level: INFO + propagate: false + + uvicorn.error: + handlers: [ default, file ] + level: INFO + propagate: false + + uvicorn.access: + handlers: [ access, file ] + level: INFO + propagate: false + + nyahome: + handlers: [ default, file ] + level: DEBUG + propagate: false \ No newline at end of file diff --git a/main.py b/main.py deleted file mode 100644 index c92f442..0000000 --- a/main.py +++ /dev/null @@ -1,6 +0,0 @@ -def main(): - print("Hello from nyahome!") - - -if __name__ == "__main__": - main() diff --git a/public/normal-avatar.png b/public/normal-avatar.png new file mode 100644 index 0000000..ff93166 Binary files /dev/null and b/public/normal-avatar.png differ diff --git a/public/normal-background.png b/public/normal-background.png new file mode 100644 index 0000000..49a49e6 Binary files /dev/null and b/public/normal-background.png differ diff --git a/public/normal-thumbnail.png b/public/normal-thumbnail.png new file mode 100644 index 0000000..603565d Binary files /dev/null and b/public/normal-thumbnail.png differ diff --git a/public/templates/2fa-otp.j2 b/public/templates/2fa-otp.j2 new file mode 100644 index 0000000..794c5ad --- /dev/null +++ b/public/templates/2fa-otp.j2 @@ -0,0 +1,212 @@ + + + + + {{ site_name }} 验证邮件 + + + + + + + + + + + + + + +
包含 5 分钟有效的验证码,由 NyaHome 系统自动发送。
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + +
+
{{ site_name }} 自动发送的验证邮件。
+
+
请不要将您的验证码发给他人。验证码的有效期为 5 分钟。
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
{{ email_reason }}
+
+
如果这不是您本人的请求,请忽略此邮件。您的账户没有风险。
+
+

+

+ +
+
您的验证码为
+
+
{{ otp_number }}
+
+
请仅在站点 {{ site_name }} 上使用此验证码。
+
+
站点地址为 {{ site_url }} 。
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + +
+
{{ site_name }} - 由 NyaHome 驱动
+
+
本自动邮件发送于服务器时间 {{ sent_time }}
+
+
由于这并非定时邮件,因此无法退订喵~
+
+
+ +
+
+ +
+ + + \ No newline at end of file diff --git a/public/templates/test.j2 b/public/templates/test.j2 new file mode 100644 index 0000000..b00dde0 --- /dev/null +++ b/public/templates/test.j2 @@ -0,0 +1,160 @@ + + + + + {{ site_name }} 测试邮件 + + + + + + + + + + + + + + +
由管理员手动发送的测试邮件。
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + +
+
这是一封测试邮件。
+
+
{{ site_name }} 的管理员选择向此邮箱发送了一封测试邮件。此邮件不含有任何有效内容。
+
+
但是邮件内容写太少了有一种负罪感,所以还是多叽里咕噜地写几句话吧。
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + +
+
{{ site_name }} - 由 NyaHome 驱动
+
+
本自动邮件发送于服务器时间 {{ sent_time }}
+
+
由于这并非定时邮件,因此无法退订喵~
+
+
+ +
+
+ +
+ + + \ No newline at end of file diff --git a/src/nyahome/__init__.py b/src/nyahome/__init__.py index 9226fe7..7eb56b4 100644 --- a/src/nyahome/__init__.py +++ b/src/nyahome/__init__.py @@ -1 +1 @@ -from .__version__ import __version__ +from .__version__ import __version__ as __version__ diff --git a/src/nyahome/config/__init__.py b/src/nyahome/config/__init__.py new file mode 100644 index 0000000..f7dd69e --- /dev/null +++ b/src/nyahome/config/__init__.py @@ -0,0 +1,5 @@ +from .manager import config_manager + +__all__ = [ + config_manager, +] diff --git a/src/nyahome/config/config.py b/src/nyahome/config/config.py new file mode 100644 index 0000000..9788bb2 --- /dev/null +++ b/src/nyahome/config/config.py @@ -0,0 +1,15 @@ +class Config: + def __init__(self) -> None: + self.site_name = "Nya Home" + self.site_url = "http://localhost:5173" + self.backend_url = "http://localhost:9000" + + self.jwt_secret_key = "see you tomorrow" + + self.smtp_enable = False + self.smtp_sender = "" + self.smtp_hostname = "smtp.gmail.com" + self.smtp_port = 587 + self.smtp_username = "" + self.smtp_password = "" + self.smtp_use_tls = True diff --git a/src/nyahome/config/manager.py b/src/nyahome/config/manager.py new file mode 100644 index 0000000..a354c45 --- /dev/null +++ b/src/nyahome/config/manager.py @@ -0,0 +1,94 @@ +import json +import logging +from pathlib import Path +from typing import Any, TypeVar + +import aiofiles + +from .config import Config + +logger = logging.getLogger(__name__) + +CONFIG_PATH = Path.cwd() / ".nyahome" / "config.json" + +T = TypeVar("T") + + +class ConfigManager: + def __init__(self) -> None: + CONFIG_PATH.parent.mkdir(exist_ok=True) + self._config = Config() + + def _parse(self, config: dict) -> None: + """ + 解析给定的字典作为配置。 + + Args: + config: 配置字典 + """ + for key, value in config.items(): + setattr(self._config, key, value) + + def _dumps(self) -> str: + """ + 将配置项序列化为 json 字符串,包含格式化缩进。 + + Returns: + json 字符串。 + """ + config = {} + for attr in dir(self._config): + if not attr.startswith("_"): + value = getattr(self._config, attr) + config[attr] = value + return json.dumps(config, ensure_ascii=False, indent=2) + + async def async_load_config(self) -> None: + async with aiofiles.open(CONFIG_PATH, "r", encoding="utf-8") as f: + self._parse(json.loads(await f.read())) + logger.info("异步从 config.json 读取设置完成。") + + def sync_load_config(self) -> None: + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + self._parse(json.load(f)) + logger.info("同步从 config.json 读取设置完成。") + + async def async_save_config(self) -> None: + async with aiofiles.open(CONFIG_PATH, "w", encoding="utf-8") as f: + await f.write(self._dumps()) + logger.info("异步保存设置到 config.json 完成。") + + def sync_save_config(self) -> None: + with open(CONFIG_PATH, "w", encoding="utf-8") as f: + f.write(self._dumps()) + logger.info("同步保存设置到 config.json 完成。") + + def get(self, key: str, default: T | None = None) -> T: + """ + 获取配置项 + + Args: + key: 配置键 + default: 默认值,如果不提供则会在获取配置项失败时报错 + + Returns: + 返回配置值,返回类型根据提供的默认值进行推断。 + """ + return getattr(self._config, key, default) # type: ignore[return-value] + + def get_config(self) -> dict[str, Any]: + config = {} + for attr in dir(self._config): + if not attr.startswith("_"): + value = getattr(self._config, attr) + config[attr] = value + return config + + def set_config(self, config: dict[str, Any]) -> dict[str, Any]: + for attr in dir(self._config): + if not attr.startswith("_"): + setattr(self._config, attr, config[attr]) + return self.get_config() + + +config_manager = ConfigManager() diff --git a/src/nyahome/core/__init__.py b/src/nyahome/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/nyahome/core/core_abc/__init__.py b/src/nyahome/core/core_abc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/nyahome/core/core_abc/otp.py b/src/nyahome/core/core_abc/otp.py new file mode 100644 index 0000000..b2b4f52 --- /dev/null +++ b/src/nyahome/core/core_abc/otp.py @@ -0,0 +1,75 @@ +import asyncio +import logging +import time +from abc import ABC, abstractmethod + +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + + +class OtpItem(BaseModel): + user_id: int + verify_code: str + expire_time: int + + +class OtpMemoryStore(ABC): + def __init__(self, type_name: str) -> None: + self._store: dict[str, OtpItem] = {} + + # 定时清理过期验证码的异步任务 + self._clean_task: asyncio.Task[None] | None = None + + self.type_name = type_name + + def start(self) -> None: + self._clean_task = asyncio.create_task(self._cleanup()) + + def _check(self, user_id: int, address: str) -> bool: + if address in self._store: + return False + + return all(item.user_id != user_id for item in self._store.values()) + + def _put(self, user_id: int, address: str, verify_code: str) -> None: + self._store[address] = OtpItem( + user_id=user_id, + verify_code=verify_code, + expire_time=int(time.time()) + 300, + ) + + async def _cleanup(self) -> None: + while True: + await asyncio.sleep(60) + logger.info(f"[{self.type_name}] 开始定时清理过期验证码。") + expires = [] + for address, item in self._store.items(): + if item.expire_time < time.time(): + logger.debug(f"[{self.type_name}] 移除过期的 {address}") + expires.append(address) + for address in expires: + self._store.pop(address) + logger.info(f"[{self.type_name}] 清理完成。") + + def verify(self, address: str, user_id: int, verify_code: str) -> bool: + item = self._store.get(address) + if item is None: + return False + if item.expire_time < time.time(): + self._store.pop(address) # 如果超时,顺手删掉 + return False + if item.user_id != user_id: + return False + if item.verify_code != verify_code: + return False + # 验证通过,也要删除 + self._store.pop(address) + return True + + @abstractmethod + async def generate_and_send(self, user_id: int, address: str, email_reason: str) -> bool: + """ + 在此实现验证码发送,以及调用 self._check(user_id, address) 检查、 self._put(user_id, address, verify_code) 存储验证码。 + """ + ... diff --git a/src/nyahome/core/core_abc/task_queue.py b/src/nyahome/core/core_abc/task_queue.py new file mode 100644 index 0000000..62b2074 --- /dev/null +++ b/src/nyahome/core/core_abc/task_queue.py @@ -0,0 +1,86 @@ +import asyncio +import logging +from abc import ABC, abstractmethod +from typing import Generic, TypeVar + +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +_V = TypeVar("_V", bound=BaseModel) + + +class TaskQueue(Generic[_V], ABC): + """ + 一个基于 asyncio.Queue 实现的内存任务队列。 + """ + + def __init__(self, max_workers: int) -> None: + self.max_workers = max_workers + self.queue: asyncio.Queue[_V] = asyncio.Queue() + self.workers: list[asyncio.Task] = [] + self._shutdown = False + + def start(self) -> None: + """ + 启动 worker 协程 + """ + for i in range(0, self.max_workers): + task = asyncio.create_task(self._worker(i), name=f"worker {i}") + self.workers.append(task) + + async def put(self, item: _V) -> None: + """ + 向队列提交任务。 + + Args: + item: 任务 + + Raises: + RuntimeError: 在队列关闭的过程中提交新任务。 + """ + if self._shutdown: + raise RuntimeError("队列正在关闭中,无法提交新任务。") + await self.queue.put(item) + + async def _worker(self, worker_id: int) -> None: + """ + 消费逻辑。 + + Args: + worker_id: 消费者 ID + """ + while True: + try: + # 使用 timeout 以便优雅地检查 shutdown + item = await asyncio.wait_for(self.queue.get(), timeout=1.0) + except asyncio.TimeoutError: + if self._shutdown: + break + continue + + try: + logger.info(f"[Worker {worker_id}] Processing: {item}") + await self._process(item) + except Exception as e: + logger.error(f"[Worker {worker_id}] Error processing {item}: {e}") + finally: + self.queue.task_done() + + async def join(self) -> None: + """等待队列中所有任务完成""" + await self.queue.join() + + async def shutdown(self) -> None: + """优雅关闭""" + self._shutdown = True + await self.join() + for w in self.workers: + w.cancel() + await asyncio.gather(*self.workers, return_exceptions=True) + logger.info("队列成功关闭。") + + @abstractmethod + async def _process(self, item: _V) -> None: + """实际执行的工作。接收 item,返回 None。请 overload 此方法。""" + ... diff --git a/src/nyahome/core/otp_store.py b/src/nyahome/core/otp_store.py new file mode 100644 index 0000000..5dfa3be --- /dev/null +++ b/src/nyahome/core/otp_store.py @@ -0,0 +1,44 @@ +import logging +import random + +from nyahome.config import config_manager +from nyahome.core.core_abc.otp import OtpMemoryStore +from nyahome.core.send_email import SendEmailItem, email_sender_queue +from nyahome.core.template_render import template_render + +logger = logging.getLogger(__name__) + + +def generate_random_code() -> str: + return f"{random.randint(0, 999999):06d}" + + +class EmailOtpMemoryStore(OtpMemoryStore): + def __init__(self) -> None: + super().__init__("EmailOtpMemoryStore") + + async def generate_and_send(self, user_id: int, address: str, email_reason: str) -> bool: + if not self._check(user_id, address): + logger.error(f"该邮件地址 {address} 或用户 {user_id} 已有待处理的邮件验证码。") + return False + code = generate_random_code() + site_name = config_manager.get("site_name", "Nya Home") + html = template_render.render_2fa_otp( + site_name=site_name, + site_url=config_manager.get("site_url"), + email_reason=email_reason, + otp_number=code, + ) + await email_sender_queue.put( + SendEmailItem( + to=address, + subject=f"{site_name} - 一次性邮件验证码", + body=html, + ) + ) + self._put(user_id, address, code) + logger.info(f"已经向邮件地址 {address} 发送用户 {user_id} 的一次性邮件验证码 {code}") + return True + + +email_otp_memory_store = EmailOtpMemoryStore() diff --git a/src/nyahome/core/password.py b/src/nyahome/core/password.py new file mode 100644 index 0000000..aa849ff --- /dev/null +++ b/src/nyahome/core/password.py @@ -0,0 +1,15 @@ +from passlib.context import CryptContext + +pwd_context = CryptContext( + schemes=["argon2"], + deprecated="auto", + # Argon2id:抵抗侧信道攻击和 GPU 破解的最佳平衡 + argon2__type="ID", + # 内存 64MB,迭代 3 轮,4 线程 + # 在普通 VPS 上大约耗时 0.3~0.6 秒 + argon2__memory_cost=65536, # 64 MB + argon2__time_cost=3, + argon2__parallelism=4, + # 哈希输出长度(默认 32 字节,一般不用改) + argon2__hash_len=32, +) diff --git a/src/nyahome/core/send_email.py b/src/nyahome/core/send_email.py new file mode 100644 index 0000000..3c7945e --- /dev/null +++ b/src/nyahome/core/send_email.py @@ -0,0 +1,105 @@ +import logging +from email.message import EmailMessage + +import aiosmtplib +from pydantic import BaseModel, ValidationError + +from nyahome.config import config_manager +from nyahome.core.core_abc.task_queue import TaskQueue + +logger = logging.getLogger(__name__) + + +class SendEmailItem(BaseModel): + to: str + subject: str + body: str + + def __str__(self) -> str: + return f"SendEmailItem(to={self.to}, subject={self.subject})" + + def __repr__(self) -> str: + return self.__str__() + + +async def send_email( + to: str, + sender: str, + subject: str, + body: str, + hostname: str, + port: int, + username: str, + password: str, + use_tls: bool, +) -> None: + """ + 底层的邮件发送方法,异步执行,调用 aiosmtplib.send()。不进行任何检查。 + + Args: + to: 收件人邮件地址 + sender: 发件人邮件地址 + subject: 邮件主题 + body: 邮件内容,可以是纯文本或者 HTML + hostname: SMTP 服务器主机名 + port: SMTP 服务器端口 + username: SMTP 用户名,一般与发件人邮件地址相同 + password: SMTP 密码 + use_tls: 使用 TLS + + Raises: + ValueError: 遭遇未知问题导致发件失败。 + aiosmtplib 的子异常类是可以排查的发件失败。 + """ + msg = EmailMessage() + msg["From"] = sender + msg["To"] = to + msg["Subject"] = subject + msg.set_content(body, subtype="html") + + try: + res = await aiosmtplib.send( + msg, + hostname=hostname, + port=port, + username=username, + password=password, + use_tls=use_tls, + ) + + if len(res[0]) == 0: + logger.debug(f"邮件发送成功 | {to=}, {subject=}") + else: + raise ValueError("邮件发送出现意外情况,我也不知道是什么情况……") + except Exception as e: + logger.error(f"邮件发送失败 | {e}") + + +class EmailSenderQueue(TaskQueue): + """ + 邮件发送任务队列。使用 put 方法提交的 item 需要为 :py:class:`SendEmailItem` 结构。 + """ + + def __init__(self) -> None: + super().__init__(2) + + async def _process(self, item: SendEmailItem) -> None: + try: + SendEmailItem.model_validate(item) + except ValidationError as e: + logger.error(f"向邮件发送队列提交了格式错误的 item - {e}") + raise e + await send_email( + to=item.to, + subject=item.subject, + body=item.body, + sender=config_manager.get("smtp_sender"), + hostname=config_manager.get("smtp_hostname"), + port=config_manager.get("smtp_port"), + username=config_manager.get("smtp_username"), + password=config_manager.get("smtp_password"), + use_tls=config_manager.get("smtp_use_tls"), + ) + + +email_sender_queue = EmailSenderQueue() diff --git a/src/nyahome/core/task.py b/src/nyahome/core/task.py new file mode 100644 index 0000000..6f6597f --- /dev/null +++ b/src/nyahome/core/task.py @@ -0,0 +1,36 @@ +import logging + +from sqlalchemy.exc import NoResultFound +from sqlmodel import select + +from nyahome.core.password import pwd_context +from nyahome.database import ModelUser, async_get_session + +logger = logging.getLogger(__name__) + + +async def init_admin_user() -> None: + """ + 异步初始化管理员用户。向数据库中添加一个 id=1,用户名和密码均为 admin 的用户。 + + 如果 id=1 的用户已经存在,视为初始化完成,执行结束。 + + 作为异步任务,应该使用 asyncio.create_task() 执行本方法。本方法无返回值。 + """ + async with async_get_session() as session: + logger.info("尝试初始化管理员用户...") + # 尝试获取 id=1 的用户,如果不存在则创建,存在则忽略。 + try: + admin: ModelUser = session.exec(select(ModelUser).where(ModelUser.id == 1)).one() + logger.info(f"管理员用户已存在:{admin.name}") + except NoResultFound: + admin = ModelUser( + id=1, + name="admin", + password=pwd_context.hash("admin"), # 使用 admin 作为密码 + is_admin=True, + secure_changes="[]", + ) + session.add(admin) + session.commit() + logger.info("管理员用户已创建,用户名和密码均为 admin。") diff --git a/src/nyahome/core/template_render.py b/src/nyahome/core/template_render.py new file mode 100644 index 0000000..8ed0590 --- /dev/null +++ b/src/nyahome/core/template_render.py @@ -0,0 +1,32 @@ +from datetime import datetime +from pathlib import Path + +import jinja2 + + +class TemplateRender: + def __init__(self) -> None: + self.env = jinja2.Environment(loader=jinja2.FileSystemLoader(Path.cwd() / "public" / "templates")) + self.t_test = self.env.get_template("test.j2") + self.t_2fa_otp = self.env.get_template("2fa-otp.j2") + + def render_test(self, site_name: str) -> str: + return self.t_test.render(site_name=site_name, sent_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S")) + + def render_2fa_otp( + self, + site_name: str, + site_url: str, + email_reason: str, + otp_number: str, + ) -> str: + return self.t_2fa_otp.render( + site_name=site_name, + site_url=site_url, + email_reason=email_reason, + otp_number=otp_number, + sent_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + ) + + +template_render = TemplateRender() diff --git a/src/nyahome/database/__init__.py b/src/nyahome/database/__init__.py new file mode 100644 index 0000000..0a8c234 --- /dev/null +++ b/src/nyahome/database/__init__.py @@ -0,0 +1,44 @@ +from sqlmodel import SQLModel + +from .engine import engine +from .model_aii import AiiModel, AiiModelPublic, AiiProvider, AiiProviderPublic, z_aii_model, z_aii_provider +from .model_story import ( + Chatroom, + ChatroomChat, + ChatroomChatAccept, + ChatroomChatDelete, + ChatroomChatEdit, + ChatroomPublic, + ChatScript, + ScriptTemplate, +) +from .model_user import ModelUploadFile, ModelUser +from .session import async_get_session, get_session + + +# 创建数据库连接和数据库文件 +def create_db() -> None: # noqa: RUF067 + SQLModel.metadata.create_all(engine) + + +__all__ = [ + AiiModel, + AiiModelPublic, + AiiProvider, + AiiProviderPublic, + ChatScript, + Chatroom, + ChatroomChat, + ChatroomChatAccept, + ChatroomChatDelete, + ChatroomChatEdit, + ChatroomPublic, + ModelUploadFile, + ModelUser, + ScriptTemplate, + async_get_session, + create_db, + get_session, + z_aii_model, + z_aii_provider, +] diff --git a/src/nyahome/database/engine.py b/src/nyahome/database/engine.py new file mode 100644 index 0000000..56d789e --- /dev/null +++ b/src/nyahome/database/engine.py @@ -0,0 +1,7 @@ +from pathlib import Path + +from sqlmodel import create_engine + +sqlite_file_path = Path.cwd() / ".nyahome" / "nyahome.db" + +engine = create_engine(f"sqlite:///{sqlite_file_path!s}", connect_args={"check_same_thread": False}) diff --git a/src/nyahome/database/model_aii.py b/src/nyahome/database/model_aii.py new file mode 100644 index 0000000..5a486ea --- /dev/null +++ b/src/nyahome/database/model_aii.py @@ -0,0 +1,60 @@ +from pydantic import BaseModel +from sqlmodel import Field, Relationship, SQLModel + + +class AiiProvider(SQLModel, table=True): + """ + 模型提供商。 + """ + + id: int | None = Field(default=None, primary_key=True) + name: str + base_url: str + api_key: str + + aii_models: list["AiiModel"] = Relationship(back_populates="aii_provider") + + +class AiiProviderPublic(BaseModel): + id: int | None = None + name: str + base_url: str + api_key: str + + +class AiiModel(SQLModel, table=True): + """ + 模型。 + """ + + id: int | None = Field(default=None, primary_key=True) + model_name: str + max_context_length: int + + aii_provider_id: int = Field(default=None, foreign_key="aiiprovider.id") + aii_provider: AiiProvider = Relationship(back_populates="aii_models") + + +class AiiModelPublic(BaseModel): + id: int | None = None + model_name: str + max_context_length: int + + aii_provider_id: int + + +def z_aii_model(am: AiiModel) -> dict: + return { + "id": am.id, + "model_name": am.model_name, + "max_context_length": am.max_context_length, + "aii_provider_id": am.aii_provider_id, + } + + +def z_aii_provider(ap: AiiProvider) -> dict: + return { + "id": ap.id, + "name": ap.name, + "base_url": ap.base_url, + } diff --git a/src/nyahome/database/model_story.py b/src/nyahome/database/model_story.py new file mode 100644 index 0000000..3f17b13 --- /dev/null +++ b/src/nyahome/database/model_story.py @@ -0,0 +1,107 @@ +from typing import Literal, Optional + +from pydantic import BaseModel +from sqlalchemy import Column, ForeignKey +from sqlmodel import Field, Relationship, SQLModel + +from ..config import config_manager + + +class Chatroom(SQLModel, table=True): + """ + 聊天室 表结构。 + 聊天室是供剧本演出的场所。在聊天室中,由用户选定剧本模板、决定剧本走向,AI 按照剧本进行演出。 + 我们规定 script 是故事脚本设定,content 是故事正片,script template 是脚本模板。 + + 规定 creator_id 为 0 的聊天室为公共聊天室,其权限由配置文件决定。 + """ + + id: int | None = Field(default=None, primary_key=True) + name: str + description: str + feature_image: str = Field( + default=f"{config_manager.get('site_url', 'http://localhost:9000')}/nyahome/normal-thumbnail.png" + ) + content: str + script: str + + script_template_id: int | None = Field( + default=None, sa_column=Column(ForeignKey("scripttemplate.id", name="fk_chatroom_script_template")) + ) + script_template_version: str | None + script_template: "ScriptTemplate" = Relationship() + + creator_id: int = Field(sa_column=Column(ForeignKey("modeluser.id", name="fk_chatroom_creator"))) + creator: Optional["ModelUser"] = Relationship(back_populates="chatrooms") + + +class ChatroomPublic(BaseModel): + id: int | None = None + name: str + description: str + feature_image: str + + script_template_id: int | None = None + script_template_version: str | None + + +class ScriptTemplate(SQLModel, table=True): + """ + 剧本模板 表结构。 + 聊天室通过加载剧本模板来开始演绎一个剧本。 + 【开发中】 + """ + + id: int | None = Field(default=None, primary_key=True) + name: str + description: str + version: str + origin_url: str + script: str + + +class ScriptWordBook(BaseModel): + key_word: str + message: str + + +class ChatScript(BaseModel): + """ + 剧本(提示词与世界书)。 + """ + + main_prompt: str + user_prefix: str + user_suffix: str + world_books: list[ScriptWordBook] + + +class ChatroomChat(BaseModel): + """ + 聊天室的 chat 端点接收的数据结构,作为用户输入。 + """ + + message: str + prefix: str + mode: Literal["continue", "expand"] + model_id: int + + +class ChatroomChatAccept(BaseModel): + user_message: str + aii_message: str + mode: Literal["continue", "expand"] + + +class ChatroomChatEdit(BaseModel): + old_message: str + new_message: str + change: Literal["user", "aii"] + + +class ChatroomChatDelete(BaseModel): + message: str + change: Literal["user", "aii"] + + +from .model_user import ModelUser # noqa: E402 diff --git a/src/nyahome/database/model_user.py b/src/nyahome/database/model_user.py new file mode 100644 index 0000000..de82da2 --- /dev/null +++ b/src/nyahome/database/model_user.py @@ -0,0 +1,49 @@ +from typing import Any + +from pydantic import model_serializer +from sqlalchemy import Column, ForeignKey +from sqlmodel import Field, Relationship, SQLModel + +from ..config import config_manager +from .model_story import Chatroom + + +class ModelUser(SQLModel, table=True): + id: int = Field(default=None, primary_key=True) + name: str + display_name: str | None + email: str | None + phone: str | None + avatar_url: str = Field( + default=f"{config_manager.get('site_url', 'http://localhost:9000')}/nyahome/normal-avatar.png" + ) + background_url: str = Field( + default=f"{config_manager.get('site_url', 'http://localhost:9000')}/nyahome/normal-background.png" + ) + description: str | None + + password: str + is_admin: bool = Field(default=False) + + upload_files: list["ModelUploadFile"] = Relationship(back_populates="uploader") + + chatrooms: list[Chatroom] = Relationship(back_populates="creator") + + secure_changes: str = Field(default="[]") + + @model_serializer(mode="wrap") + def serialize_user(self, handler) -> dict[str, Any]: # type: ignore[no-untyped-def] # noqa ANN001 + data = handler(self) + data.pop("password", None) + data.pop("secure_changes", None) + return data # type: ignore[no-any-return] + + +class ModelUploadFile(SQLModel, table=True): + id: int = Field(default=None, primary_key=True) + original_name: str + safe_name: str + download_url: str + + uploader_id: int = Field(sa_column=Column(ForeignKey("modeluser.id", name="fk_chatroom_creator"))) + uploader: ModelUser = Relationship(back_populates="upload_files") diff --git a/src/nyahome/database/session.py b/src/nyahome/database/session.py new file mode 100644 index 0000000..4fae80c --- /dev/null +++ b/src/nyahome/database/session.py @@ -0,0 +1,24 @@ +from contextlib import asynccontextmanager +from typing import AsyncGenerator, Generator + +from sqlmodel import Session + +from .engine import engine + + +def get_session() -> Generator[Session, None, None]: + """ + 用于以依赖注入的方式在 路由端点函数 中获取数据库会话。 + `session: Annotated[Session, Depends(get_session)],` + + Yields: + 数据库会话对象 Session。 + """ + with Session(engine) as session: + yield session + + +@asynccontextmanager +async def async_get_session() -> AsyncGenerator[Session, None]: + with Session(engine) as session: + yield session diff --git a/src/nyahome/manage.py b/src/nyahome/manage.py index 56ac1dd..470fc25 100644 --- a/src/nyahome/manage.py +++ b/src/nyahome/manage.py @@ -1,6 +1,6 @@ """ 此文件为命令行入口。 -避免在此文件中引用 router 模块内的代码。 +避免在此文件中引用 router 和 service 模块内的代码。 """ import typer @@ -45,10 +45,12 @@ def run() -> None: uvicorn.run( "nyahome.server:app", - reload=True, + reload=False, host="0.0.0.0", port=9000, timeout_graceful_shutdown=2, + log_config="logging.yaml", + log_level="debug", ) diff --git a/src/nyahome/router/__init__.py b/src/nyahome/router/__init__.py index 0495c2e..84cffb6 100644 --- a/src/nyahome/router/__init__.py +++ b/src/nyahome/router/__init__.py @@ -1,2 +1,13 @@ from .admin_router import admin_router +from .aii_router import aii_router +from .chatroom_router import chatroom_router +from .file_router import file_router from .webui_router import webui_router + +__all__ = [ + "admin_router", + "aii_router", + "chatroom_router", + "file_router", + "webui_router", +] diff --git a/src/nyahome/router/admin_router.py b/src/nyahome/router/admin_router.py index 903136a..e870a63 100644 --- a/src/nyahome/router/admin_router.py +++ b/src/nyahome/router/admin_router.py @@ -1,3 +1,213 @@ -from fastapi import APIRouter +import json +import logging +from datetime import datetime +from typing import Annotated, Any + +from fastapi import APIRouter, HTTPException +from fastapi.params import Depends +from pydantic import BaseModel +from sqlalchemy.exc import NoResultFound +from sqlmodel import Session, select + +from nyahome.config import config_manager +from nyahome.database import ModelUser, get_session +from nyahome.service.secure_service import SecureChange, s_append_secure_changes +from nyahome.service.verify_service import s_send_test_email, s_send_verify_email, s_verify_email + +from .auth import create_access_token, save_password, verify_password, verify_token +from .response_model import ReturnDto + +logger = logging.getLogger(__name__) admin_router = APIRouter(tags=["admin"], prefix="/admin") + + +class UserLogin(BaseModel): + username: str + password: str + + +class UserInfo(BaseModel): + name: str + display_name: str + avatar_url: str + background_url: str + description: str + + +class ChangePassword(BaseModel): + old_password: str + new_password: str + + +class SendEmail(BaseModel): + to: str + + +class VerifyEmail(BaseModel): + to: str + verify_code: str + + +@admin_router.post("/login/name/") +async def nyahome_login_name(user: UserLogin, session: Annotated[Session, Depends(get_session)]) -> ReturnDto: + try: + u: ModelUser = session.exec(select(ModelUser).where(ModelUser.name == user.username)).one() + except NoResultFound: + raise HTTPException(status_code=404, detail="用户不存在") from None + + if verify_password(user.password, u.password): + change = SecureChange( + created_at=datetime.now(), + type="login", + old=None, + new=None, + ) + u.secure_changes = s_append_secure_changes(u.secure_changes, change) + session.add(u) + session.commit() + return ReturnDto( + result={ + "user_id": u.id, + "access_token": create_access_token(u.id, u.password, 30), + } + ) + raise HTTPException(status_code=401, detail="验证失败,请检查用户名和密码是否正确") + + +@admin_router.get("/me/") +async def nyahome_get_me(user: Annotated[ModelUser, Depends(verify_token)]) -> ModelUser: + return user + + +@admin_router.post("/me/") +async def nyahome_post_me( + info: UserInfo, user: Annotated[ModelUser, Depends(verify_token)], session: Annotated[Session, Depends(get_session)] +) -> ModelUser: + user.name = info.name + user.display_name = info.display_name + user.avatar_url = info.avatar_url + user.background_url = info.background_url + user.description = info.description + session.add(user) + session.commit() + session.refresh(user) + return user + + +@admin_router.post("/me/password/") +async def nyahome_change_password( + change: ChangePassword, + user: Annotated[ModelUser, Depends(verify_token)], + session: Annotated[Session, Depends(get_session)], +) -> ReturnDto: + if verify_password(change.old_password, user.password): + user.password = save_password(change.new_password) + change_ = SecureChange( + created_at=datetime.now(), + type="change_password", + old=None, + new=None, + ) + user.secure_changes = s_append_secure_changes(user.secure_changes, change_) + session.add(user) + session.commit() + return ReturnDto(success=True) + raise HTTPException(status_code=400, detail="修改密码需要提供旧的密码,但提供的旧密码错误。") from None + + +@admin_router.post("/me/email-verify/") +async def nyahome_verify_email( + to: VerifyEmail, + user: Annotated[ModelUser, Depends(verify_token)], + session: Annotated[Session, Depends(get_session)], +) -> ReturnDto: + success = await s_verify_email(user_id=user.id, address=to.to, verify_code=to.verify_code) + if success: + old_email = user.email + user.email = to.to + user.secure_changes = s_append_secure_changes( + user.secure_changes, + SecureChange( + created_at=datetime.now(), + type="change_email", + old=old_email, + new=to.to, + ), + ) + session.add(user) + session.commit() + logger.info(f"已更新用户 {user.id} 的邮件地址至 {user.email}") + return ReturnDto(success=success) + + +@admin_router.post("/me/email-verify/send/") +async def nyahome_verify_email_send(to: SendEmail, user: Annotated[ModelUser, Depends(verify_token)]) -> ReturnDto: + success = await s_send_verify_email(user.id, to.to) + return ReturnDto(success=success) + + +@admin_router.get("/me/secure_changes/") +async def nyahome_get_secure_changes( + user: Annotated[ModelUser, Depends(verify_token)], +) -> list[SecureChange]: + return json.loads(user.secure_changes) # type: ignore[no-any-return] + + +@admin_router.get("/site_config/") +async def get_site_config(user: Annotated[ModelUser, Depends(verify_token)]) -> dict[str, Any]: + """ + 获取 NyaHome 的设置。 + + Raises: + HTTPException: 403 表示请求用户非管理员。 + + Returns: + dict[str, Any] NyaHome 设置 + """ + if not user.is_admin: + raise HTTPException(status_code=403, detail="非管理员禁止访问") from None + return config_manager.get_config() + + +@admin_router.post("/site_config/") +async def set_site_config( + user: Annotated[ModelUser, Depends(verify_token)], + config_: dict[str, Any], +) -> dict[str, Any]: + """ + 设置 NyaHome 的设置。 + + Raises: + HTTPException: 403 表示请求用户非管理员。 + + Returns: + dict[str, Any] 更新过的 NyaHome 设置 + """ + if not user.is_admin: + raise HTTPException(status_code=403, detail="非管理员禁止访问") from None + final_config = config_manager.set_config(config_) + await config_manager.async_save_config() + return final_config + + +@admin_router.post("/email-test/") +async def nyahome_test_email(to: SendEmail, user: Annotated[ModelUser, Depends(verify_token)]) -> ReturnDto: + """ + NyaHome 管理员面板中的测试邮件端点。 + + Args: + to: 测试邮件发送目标 + user: 当前用户,需要为管理员 + + Raises: + HTTPException: 403 表示请求用户非管理员。 + + Returns: + ReturnDto + """ + if not user.is_admin: + raise HTTPException(status_code=403, detail="非管理员禁止访问") from None + success = await s_send_test_email(to.to) + logger.info(f"发送测试邮件到 {to} - {success=}") + return ReturnDto(success=success) diff --git a/src/nyahome/router/aii_router.py b/src/nyahome/router/aii_router.py new file mode 100644 index 0000000..74b18dc --- /dev/null +++ b/src/nyahome/router/aii_router.py @@ -0,0 +1,122 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.exc import NoResultFound +from sqlmodel import Session, select + +from nyahome.database import ( + AiiModel, + AiiModelPublic, + AiiProvider, + AiiProviderPublic, + ModelUser, + get_session, + z_aii_model, + z_aii_provider, +) +from nyahome.service.aii_service import apply_get_models, s_check_remote_model, s_list_remote_provider_models + +from .auth import verify_token +from .response_model import ReturnDto + +aii_router = APIRouter(tags=["Aii"], prefix="/aii") + + +@aii_router.get("/model/") +async def get_all_model(session: Annotated[Session, Depends(get_session)]) -> ReturnDto: + final_model_list = apply_get_models(session) + return ReturnDto(result=final_model_list) + + +@aii_router.post("/model/") +async def add_model( + model: AiiModelPublic, + user: Annotated[ModelUser, Depends(verify_token)], + session: Annotated[Session, Depends(get_session)], +) -> ReturnDto: + if not user.is_admin: + raise HTTPException(status_code=401, detail="用户无权限管理模型。") from None + + try: + ap: AiiProvider = session.exec(select(AiiProvider).where(AiiProvider.id == model.aii_provider_id)).one() + except NoResultFound: + raise HTTPException(status_code=404, detail="Provider 不存在。") from None + am = AiiModel( + model_name=model.model_name, + max_context_length=model.max_context_length, + aii_provider_id=model.aii_provider_id, + aii_provider=ap, + ) + session.add(am) + session.commit() + session.refresh(am) + return ReturnDto(result=z_aii_model(am)) + + +@aii_router.get("/provider/") +async def get_all_provider(session: Annotated[Session, Depends(get_session)]) -> ReturnDto: + aii_providers = session.exec(select(AiiProvider)).all() + return ReturnDto(result=[z_aii_provider(ap) for ap in aii_providers]) + + +@aii_router.post("/provider/") +async def add_provider( + provider: AiiProviderPublic, + user: Annotated[ModelUser, Depends(verify_token)], + session: Annotated[Session, Depends(get_session)], +) -> ReturnDto: + if not user.is_admin: + raise HTTPException(status_code=401, detail="用户无权限管理模型。") from None + ap = AiiProvider(name=provider.name, base_url=provider.base_url, api_key=provider.api_key) + session.add(ap) + session.commit() + session.refresh(ap) + return ReturnDto(result=z_aii_provider(ap)) + + +@aii_router.get("/provider/{id_}/remote/models/") +async def get_provider_remote_models( + id_: int, user: Annotated[ModelUser, Depends(verify_token)], session: Annotated[Session, Depends(get_session)] +) -> ReturnDto: + if not user.is_admin: + raise HTTPException(status_code=401, detail="用户无权限管理模型。") from None + try: + ap: AiiProvider = session.exec(select(AiiProvider).where(AiiProvider.id == id_)).one() + except NoResultFound: + raise HTTPException(status_code=404, detail="Provider 不存在。") from None + models = await s_list_remote_provider_models(ap.base_url, ap.api_key) + # 只返回模型名称列表,方便前端填入表单 + return ReturnDto(result=[m["id"] for m in models]) + + +@aii_router.get("/provider/{id_}/remote/model/{model_name}/") +async def check_remote_provider_model( + id_: int, model_name: str, session: Annotated[Session, Depends(get_session)] +) -> ReturnDto: + """ + 检测指定提供商的指定名称模型是否可用。 + Args: + id_: 模型提供商 ID。 + model_name: 模型名称。 + session: 数据库连接对象。 + + Raises: + HTTPException: 404 表明提供商 ID 未找到。 + + Returns: + ReturnDto,其中 result 字段为布尔值,表明指定名称模型的可用状态。 + """ + try: + ap: AiiProvider = session.exec(select(AiiProvider).where(AiiProvider.id == id_)).one() + except NoResultFound: + raise HTTPException(status_code=404, detail="Provider 不存在。") from None + return ReturnDto(result=await s_check_remote_model(model_name, ap.base_url, ap.api_key)) + + +@aii_router.post("/remote/provider/check/") +async def check_remote_provider(provider: AiiProviderPublic) -> ReturnDto: + try: + count = len(await s_list_remote_provider_models(provider.base_url, provider.api_key)) + return ReturnDto(result=count) + except TypeError: + return ReturnDto(success=False) diff --git a/src/nyahome/router/auth.py b/src/nyahome/router/auth.py new file mode 100644 index 0000000..49f6910 --- /dev/null +++ b/src/nyahome/router/auth.py @@ -0,0 +1,109 @@ +import datetime +import hashlib + +# import logging +from typing import Annotated, Any + +from fastapi import Depends, HTTPException +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jose import jwt +from sqlalchemy.exc import NoResultFound +from sqlmodel import Session, select + +from nyahome.config import config_manager +from nyahome.core.password import pwd_context +from nyahome.database import ModelUser, get_session + +# logger = logging.getLogger(__name__) + +security = HTTPBearer() + + +def create_access_token(user_id: int, user_password: str, expire: int) -> str: + """ + 签发一个 access Token 给指定用户。 + + Args: + user_id: 用户 ID + user_password: 用户经过加密的密码密文 + expire: 逾期时间,单位为天 + + Returns: + 签发得到的 JWT Token + """ + return jwt.encode( + { + "user_id": user_id, + "pw_hash": hashlib.sha256(user_password.encode("utf-8")).hexdigest(), + "exp": datetime.datetime.now() + datetime.timedelta(days=expire), + }, + config_manager.get("site_jwt_secret", "see you tomorrow"), + algorithm="HS256", + ) + + +def verify_access_token(token: str, user_id: int | None = None) -> dict[str, Any]: + try: + claims = jwt.decode(token, config_manager.get("site_jwt_secret", "see you tomorrow")) + except Exception as e: + # logger.info(f"验证一个 Access Token 失败:{user_id=} | {e}") + raise ValueError("验证 Access Token 失败") from e + # 如果提供了 user_id 则顺手进行检查 + if user_id and claims.get("user_id") != user_id: + # logger.info(f"验证一个 Access Token 失败:{user_id=} | Token 有效,但用户错误。") + raise NameError("正在检查的 Access Token 不是签发给提供用户的……") + # logger.info(f"验证一个 Access Token 成功:{user_id=}") + return claims + + +def verify_password(input_password: str, saved_password: str) -> bool: + """ + 验证用户登录请求的密码是否正确。 + + Args: + input_password: 前端直接提供的密码原文 + saved_password: 保存在数据库中的、经过加密的密码 + + Returns: + 布尔值表明正确与否 + """ + return pwd_context.verify(input_password, saved_password) + + +def save_password(password: str) -> str: + return pwd_context.hash(password) + + +async def verify_token( + credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)], + session: Annotated[Session, Depends(get_session)], +) -> ModelUser: + """ + 验证 Bearer Token。 + + 验证内容包括 Access Token 本身合法性以及签发的目标用户合法性。 + + 另外,修改密码会导致所有签发的 Access Token 失效。 + + Raises: + HTTPException: 所有验证失败均返回 401。 + + Returns: + ModelUser + """ + token = credentials.credentials + + try: + claims = verify_access_token(token) + except Exception as e: + raise HTTPException(status_code=401, detail="Access Token 验证失败1") from e + + user_id = claims.get("user_id") + try: + user: ModelUser = session.exec(select(ModelUser).where(ModelUser.id == user_id)).one() + except NoResultFound: + raise HTTPException(status_code=401, detail="Access Token 验证失败2") from None + if hashlib.sha256(user.password.encode("utf-8")).hexdigest() != claims.get("pw_hash"): + raise HTTPException(status_code=401, detail="Access Token 验证失败3") from None + + return user diff --git a/src/nyahome/router/chatroom_router.py b/src/nyahome/router/chatroom_router.py new file mode 100644 index 0000000..2aa16c0 --- /dev/null +++ b/src/nyahome/router/chatroom_router.py @@ -0,0 +1,296 @@ +import json +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import StreamingResponse +from sqlalchemy.exc import NoResultFound +from sqlmodel import Session, select + +from nyahome.database import ( + Chatroom, + ChatroomChat, + ChatroomChatAccept, + ChatroomChatDelete, + ChatroomChatEdit, + ChatroomPublic, + ChatScript, + ModelUser, + get_session, +) +from nyahome.service.chat_service import ( + apply_chat, + s_append_chatroom_content, + s_delete_chatroom_content, + s_edit_chatroom_content, + s_start_async_streaming_chat, +) + +from .auth import verify_token +from .response_model import ReturnDto + +chatroom_router = APIRouter(tags=["Chatroom"], prefix="/chatroom") + + +@chatroom_router.get("/{id_}/") +async def get_chatroom( + id_: int, user: Annotated[ModelUser, Depends(verify_token)], session: Annotated[Session, Depends(get_session)] +) -> ReturnDto: + """ + 根据 ID 获取聊天室。这里获取到的是完整的聊天室信息。 + + Returns: + 聊天室对象。 + + Raises: + HTTPException: 404 未找到指定 ID 的聊天室。 + """ + try: + cr: Chatroom = session.exec( + select(Chatroom).where(Chatroom.id == id_).where(Chatroom.creator_id == user.id) + ).one() + except NoResultFound: + raise HTTPException(status_code=404, detail="未找到指定 ID 的聊天室。") from None + else: + return ReturnDto(result=cr.model_dump()) + + +@chatroom_router.get("/") +async def get_all_chatroom( + user: Annotated[ModelUser, Depends(verify_token)], session: Annotated[Session, Depends(get_session)] +) -> ReturnDto: + """ + 获取全部聊天室。这里获取到的是 public 简略聊天室信息,不包含 content 和 script 字段。 + + Returns: + 包含全部聊天室的列表。 + """ + crs = session.exec(select(Chatroom).where(Chatroom.creator_id == user.id)).all() + return ReturnDto(result=[cr.model_dump(exclude={"content", "script"}) for cr in crs]) + + +@chatroom_router.post("/") +async def create_chatroom( + chatroom: ChatroomPublic, + user: Annotated[ModelUser, Depends(verify_token)], + session: Annotated[Session, Depends(get_session)], +) -> ReturnDto: + """ + 创建聊天室。 + 在请求体中提供聊天室信息,详请参阅 ChatroomPublic。注意,提供的 id 会被忽略。 + + Returns: + 创建的聊天室对象,包含由数据库分配的 id。 + """ + cr = Chatroom( + name=chatroom.name, + description=chatroom.description, + content="[]", + script="{}", + feature_image=chatroom.feature_image if chatroom.feature_image != "" else None, + script_template_id=chatroom.script_template_id, + creator_id=user.id, + ) + session.add(cr) + session.commit() + session.refresh(cr) + return ReturnDto(result=cr.model_dump()) + + +@chatroom_router.post("/{id_}/") +async def edit_chatroom( + id_: int, + chatroom: ChatroomPublic, + user: Annotated[ModelUser, Depends(verify_token)], + session: Annotated[Session, Depends(get_session)], +) -> ReturnDto: + """ + 修改聊天室的基本信息。 + content 和 script 需要从各自的独立端点请求修改,不包含在本端点的负责范围内。 + + Args: + id_: 聊天室 ID + chatroom: 聊天室基本信息,类型为 ChatroomPublic。注意 id 不可更改,如提供则会被忽略 + user: 用户 + session: 数据库连接对象 + + Raises: + HTTPException: 404 表示未找到聊天室 + + Returns: + 修改过的聊天室对象,供前端更新。 + """ + try: + cr: Chatroom = session.exec( + select(Chatroom).where(Chatroom.id == id_).where(Chatroom.creator_id == user.id) + ).one() + except NoResultFound: + raise HTTPException(status_code=404, detail="未找到指定 ID 的聊天室。") from None + cr.name = chatroom.name + cr.description = chatroom.description + if chatroom.feature_image != "": + cr.feature_image = chatroom.feature_image + cr.script_template_id = chatroom.script_template_id + cr.script_template_version = chatroom.script_template_version + session.add(cr) + session.commit() + session.refresh(cr) + return ReturnDto(result=cr.model_dump()) + + +@chatroom_router.post("/{id_}/script/") +async def update_chatroom_script( + id_: int, + script: ChatScript, + user: Annotated[ModelUser, Depends(verify_token)], + session: Annotated[Session, Depends(get_session)], +) -> ReturnDto: + """ + 更新聊天室的剧本(提示词与世界书)。 + + Args: + id_: 聊天室 ID + script: 剧本 + user: 用户 + session: 数据库连接对象 + + Raises: + HTTPException: 404 表示未找到聊天室 + + Returns: + result 字段包含最新的剧本。 + """ + try: + cr: Chatroom = session.exec( + select(Chatroom).where(Chatroom.id == id_).where(Chatroom.creator_id == user.id) + ).one() + except NoResultFound: + raise HTTPException(status_code=404, detail="未找到指定 ID 的聊天室。") from None + cr.script = json.dumps(script.model_dump(), ensure_ascii=False) + session.add(cr) + session.commit() + session.refresh(cr) + return ReturnDto(result=script.model_dump()) + + +@chatroom_router.post("/{id_}/chat/") +async def post_chatroom_chat( + id_: int, + chat: ChatroomChat, + user: Annotated[ModelUser, Depends(verify_token)], + session: Annotated[Session, Depends(get_session)], +) -> StreamingResponse: + """ + 在聊天室中发送新的用户消息,流式返回 AI 调用结果。 + + Args: + id_: (路径参数)聊天室 ID + chat: 用户消息 + user: 用户 + session: 数据库连接对象 + + Raises: + HTTPException: 404 表示聊天室未找到,444 表示模型未找到。 + + Returns: + SSE 流式输出,实质上相当于转发 AI 的流式输出结果。 + """ + try: + return StreamingResponse( + s_start_async_streaming_chat(**apply_chat(id_, user.id, chat, session)), + media_type="text/event-stream", + ) + except HTTPException as e: + raise e + + +@chatroom_router.post("/{id_}/chat/accept/") +async def accept_chatroom_chat( + id_: int, + accept: ChatroomChatAccept, + user: Annotated[ModelUser, Depends(verify_token)], + session: Annotated[Session, Depends(get_session)], +) -> ReturnDto: + """ + 此端点不负责调用 AI 生成输出,而是用于保存一对用户消息和 AI 输出到聊天室 content 的最后。 + + Raises: + HTTPException: 404 表明未找到聊天室。 + + Returns: + ReturnDto,其中 result 字段是该聊天室的最新 content,以供前端刷新。 + """ + try: + cr: Chatroom = session.exec( + select(Chatroom).where(Chatroom.id == id_).where(Chatroom.creator_id == user.id) + ).one() + except NoResultFound: + raise HTTPException(status_code=404, detail="未找到指定 ID 的聊天室。") from None + cr.content = s_append_chatroom_content(cr.content, accept) + session.add(cr) + session.commit() + session.refresh(cr) + return ReturnDto(result=cr.model_dump()) + + +@chatroom_router.post("/{id_}/chat/edit/") +async def edit_chatroom_chat( + id_: int, + edit: ChatroomChatEdit, + user: Annotated[ModelUser, Depends(verify_token)], + session: Annotated[Session, Depends(get_session)], +) -> ReturnDto: + """ + 此端点不负责调用 AI 生成输出,而是用于修改一条已经保存在聊天记录中的消息。 + + Raises: + HTTPException: 404 表明未找到聊天室,400 表明聊天记录匹配失败,未更新。 + + Returns: + ReturnDto,其中 result 字段是该聊天室的最新 content,以供前端刷新。 + """ + try: + cr: Chatroom = session.exec( + select(Chatroom).where(Chatroom.id == id_).where(Chatroom.creator_id == user.id) + ).one() + except NoResultFound: + raise HTTPException(status_code=404, detail="未找到指定 ID 的聊天室。") from None + try: + cr.content = s_edit_chatroom_content(cr.content, edit) + session.add(cr) + session.commit() + session.refresh(cr) + return ReturnDto(result=cr.model_dump()) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + +@chatroom_router.post("/{id_}/chat/delete/") +async def delete_chatroom_chat( + id_: int, + delete: ChatroomChatDelete, + user: Annotated[ModelUser, Depends(verify_token)], + session: Annotated[Session, Depends(get_session)], +) -> ReturnDto: + """ + 此端点不负责调用 AI 生成输出,而是用于删除一条已经保存在聊天记录中的消息。关联的 user 或 aii 消息会一并删除。 + + Raises: + HTTPException: 404 表明未找到聊天室,400 表明聊天记录匹配失败,未更新。 + + Returns: + ReturnDto,其中 result 字段是该聊天室的最新 content,以供前端刷新。 + """ + try: + cr: Chatroom = session.exec( + select(Chatroom).where(Chatroom.id == id_).where(Chatroom.creator_id == user.id) + ).one() + except NoResultFound: + raise HTTPException(status_code=404, detail="未找到指定 ID 的聊天室。") from None + try: + cr.content = s_delete_chatroom_content(cr.content, delete) + session.add(cr) + session.commit() + session.refresh(cr) + return ReturnDto(result=cr.model_dump()) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e diff --git a/src/nyahome/router/file_router.py b/src/nyahome/router/file_router.py new file mode 100644 index 0000000..f6e1937 --- /dev/null +++ b/src/nyahome/router/file_router.py @@ -0,0 +1,55 @@ +from typing import Annotated, Sequence + +from fastapi import APIRouter, File, HTTPException, UploadFile +from fastapi.params import Depends +from sqlmodel import Session, select + +from nyahome.config import config_manager +from nyahome.database import ModelUploadFile, ModelUser, get_session +from nyahome.service.file_service import UPLOAD_DIR, s_get_safe_filename, s_save_upload_file + +from .auth import verify_token + +file_router = APIRouter(tags=["File"], prefix="/file") + + +@file_router.get("/") +async def get_files( + user: Annotated[ModelUser, Depends(verify_token)], + session: Annotated[Session, Depends(get_session)], +) -> Sequence[ModelUploadFile]: + files: Sequence[ModelUploadFile] = session.exec( + select(ModelUploadFile).where(ModelUploadFile.uploader_id == user.id) + ).all() + + return files + + +@file_router.post("/upload/") +async def file_upload( + file: Annotated[UploadFile, File()], + user: Annotated[ModelUser, Depends(verify_token)], + session: Annotated[Session, Depends(get_session)], +) -> ModelUploadFile: + try: + safe_name = s_get_safe_filename(file.filename) # type: ignore[arg-type] + dest_path = UPLOAD_DIR / safe_name + except TypeError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + try: + await s_save_upload_file(dest_path, file) + except TypeError as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + download_url = f"{config_manager.get('site_url', 'http://localhost:9000')}/download/{safe_name}" + upload_file = ModelUploadFile( + original_name=file.filename, + safe_name=safe_name, + download_url=download_url, + uploader_id=user.id, + ) + session.add(upload_file) + session.commit() + session.refresh(upload_file) + return upload_file diff --git a/src/nyahome/router/response_model.py b/src/nyahome/router/response_model.py new file mode 100644 index 0000000..f911594 --- /dev/null +++ b/src/nyahome/router/response_model.py @@ -0,0 +1,9 @@ +from typing import Any + +from pydantic import BaseModel + + +class ReturnDto(BaseModel): + success: bool = True + message: str | None = None + result: Any = None diff --git a/src/nyahome/server.py b/src/nyahome/server.py index ea677e1..5bf6b93 100644 --- a/src/nyahome/server.py +++ b/src/nyahome/server.py @@ -1,8 +1,55 @@ +import asyncio +import logging +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any, AsyncGenerator + from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles -from nyahome.router import admin_router, webui_router +from nyahome.config import config_manager +from nyahome.core.otp_store import email_otp_memory_store +from nyahome.core.send_email import email_sender_queue +from nyahome.core.task import init_admin_user +from nyahome.database import create_db +from nyahome.router import admin_router, aii_router, chatroom_router, file_router, webui_router -app = FastAPI(title="🌸 NyaHome ~") +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app_: FastAPI) -> AsyncGenerator[None, Any]: + logger.info("🚀 服务启动中...") + create_db() + await asyncio.gather(init_admin_user(), config_manager.async_load_config()) + email_sender_queue.start() + email_otp_memory_store.start() + logger.info("🌸 server 启动完成。") + + try: + yield + except Exception as e: + logger.error(f"捕获到无法处理的异常,NyaHome 即将结束 - {e}") + finally: + logger.info("🌕 服务关闭中...") + + +app = FastAPI(title="🌸 NyaHome ~", lifespan=lifespan) -app.include_router(admin_router) app.include_router(webui_router) +app.include_router(chatroom_router, prefix="/api") +app.include_router(admin_router, prefix="/api") +app.include_router(file_router, prefix="/api") +app.include_router(aii_router, prefix="/api") + +app.mount("/nyahome", StaticFiles(directory=Path.cwd() / "public"), name="public") +app.mount("/download", StaticFiles(directory=Path.cwd() / ".nyahome/contents"), name="upload") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) diff --git a/src/nyahome/service/__init__.py b/src/nyahome/service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/nyahome/service/aii_service.py b/src/nyahome/service/aii_service.py new file mode 100644 index 0000000..7277ed8 --- /dev/null +++ b/src/nyahome/service/aii_service.py @@ -0,0 +1,58 @@ +import openai +from openai import AsyncOpenAI +from sqlalchemy.orm import joinedload +from sqlmodel import Session, select + +from nyahome.database import AiiModel + + +def apply_get_models(session: Session) -> list[dict]: + """ + 从数据库中获取可用的 AI 模型列表。 + + Args: + session: 数据库连接对象。 + + Returns: + + """ + aii_models = session.exec(select(AiiModel).options(joinedload(AiiModel.aii_provider))).all() # type: ignore[arg-type] + + final_model_list = [] + for aii_model in aii_models: + final_model_list.append({ + "id": aii_model.id, + "model_name": aii_model.model_name, + "max_content_length": aii_model.max_context_length, + "provider_id": aii_model.id, + "provider_name": aii_model.aii_provider.name, + "base_url": aii_model.aii_provider.base_url, + }) + + return final_model_list + + +async def s_list_remote_provider_models(base_url: str, api_key: str) -> list[dict]: + client = AsyncOpenAI(base_url=base_url, api_key=api_key) + try: + models = await client.models.list() + final_model_list = [] + async for model in models: + # model 实际上是 pydantic 模型,因此拥有 BaseModel 的所有方法。 + # model.model_dump() 的示例结果: + # {'id': 'xxx', 'created': None, 'object': 'model', 'owned_by': 'xxx'} + final_model_list.append(model.model_dump()) + return final_model_list + except Exception as e: + raise TypeError(f"获取模型提供商 {base_url} 的可用模型列表失败。") from e + + +async def s_check_remote_model(model_name: str, base_url: str, api_key: str) -> bool: + client = AsyncOpenAI(base_url=base_url, api_key=api_key) + try: + await client.models.retrieve(model_name) + return True + except openai.NotFoundError: + return False + except Exception as e: + raise TypeError(f"从模型提供商 {base_url} 检测模型 {model_name} 可用性时遇到未知错误") from e diff --git a/src/nyahome/service/chat_service.py b/src/nyahome/service/chat_service.py new file mode 100644 index 0000000..d8aad1b --- /dev/null +++ b/src/nyahome/service/chat_service.py @@ -0,0 +1,208 @@ +import json +import logging +from collections.abc import AsyncGenerator +from typing import Literal + +from fastapi import HTTPException +from openai import AsyncOpenAI +from sqlalchemy.exc import NoResultFound +from sqlalchemy.orm import joinedload +from sqlmodel import Session, select + +from nyahome.database import ( + AiiModel, + Chatroom, + ChatroomChat, + ChatroomChatAccept, + ChatroomChatDelete, + ChatroomChatEdit, + ChatScript, +) + +logger = logging.getLogger(__name__) + +ContentList = list[ + dict[ + Literal[ + "role", + "message", + "mode", + ], + str, + ] +] + +CONTINUE_MESSAGE = ( + "推进模式:用户输入的情节已经发生,请续写接下来的故事,注意并非扩写用户的输入;" + "注意细节描写,情节合理,符合故事设定。" +) +EXPAND_MESSAGE = ( + "扩写模式:请仅以用户输入为故事情节,将情节扩写至指定字数,避免自行续写更多未提及的故事。" + "注意细节描写,情节合理,符合故事设定。" +) + + +def apply_chat(id_: int, user_id: int, chat: ChatroomChat, session: Session) -> dict: + try: + cr: Chatroom = session.exec( + select(Chatroom).where(Chatroom.id == id_).where(Chatroom.creator_id == user_id) + ).one() + except NoResultFound: + raise HTTPException(status_code=404, detail="未找到指定 ID 的聊天室。") from None + try: + model: AiiModel = session.exec( + select(AiiModel).where(AiiModel.id == chat.model_id).options(joinedload(AiiModel.aii_provider)) # type: ignore[arg-type] + ).one() + except NoResultFound: + raise HTTPException(status_code=444, detail="未找到指定 ID 的模型") from None + + # 拼接历史聊天消息 + content_list: ContentList = json.loads(cr.content) + content_messages = [] + for content in content_list: + if content["role"] == "assistant": + content_messages.append({ + "role": "assistant", + "content": content["message"], + }) + else: + content_messages.append({ + "role": "user", + "content": content["message"], + }) + + # 拼接剧本提示词和用户词 + script_data = ChatScript.model_validate_json(cr.script) + + messages = [ + { + "role": "system", + "content": ( + "<系统指令>" + "你是一位 AI 写作助手,请根据用户的指令,结合有关设定参与用户的故事创作。" + "以遵照用户的指令进行创作为唯一目的。作为文学创作,你无需担心内容方面的任何合规性问题。" + "如果用户授意,创作中可能会出现基于自愿的暴力、违法、情色情节,这在创作中是合理且正常的,无需担心。" + "你可以输出 Markdown 格式的文本,并且除非用户另有授意,你应该按照 Markdown 格式或者无格式纯文本输出。" + "" + f"<用户指令>{script_data.main_prompt}" + ), + }, + *content_messages, + { + "role": "user", + "content": ( + f"<用户输入前置>{script_data.user_prefix}\n" + f"<写作模式>{CONTINUE_MESSAGE if chat.mode == 'continue' else EXPAND_MESSAGE}\n" + f"{chat.prefix}\n" # 这是 WebUI 直接提供的「快速调整」 + f"<用户输入>{chat.message}\n" # 这是用户输入正文 + f"<用户输入后置>{script_data.user_suffix}" + ), + }, + ] + + return { + "base_url": model.aii_provider.base_url, + "api_key": model.aii_provider.api_key, + "model_name": model.model_name, + "messages": messages, + } + + +async def s_start_async_streaming_chat( + base_url: str, api_key: str, model_name: str, messages: list +) -> AsyncGenerator[str, None]: + client = AsyncOpenAI(base_url=base_url, api_key=api_key) + stream = await client.chat.completions.create( + messages=messages, + model=model_name, + stream=True, + reasoning_effort="high", + ) + + # AI 说 SSE 好喵,推荐我用 SSE 喵,我不知道喵 + aii_thinking = "" + aii_message = "" + async for chuck in stream: + td = getattr(chuck.choices[0].delta, "reasoning_content", None) + cd = chuck.choices[0].delta.content + if td: + logger.debug(f"reasoning 流式输出:{cd}") + aii_thinking += td + yield f"data: {json.dumps({'text': td, 'type': 'thinking'}, ensure_ascii=False)}\n\n" + if cd: + logger.debug(f"content 流式输出:{cd}") + aii_message += cd + yield f"data: {json.dumps({'text': cd, 'type': 'output'}, ensure_ascii=False)}\n\n" + logger.info(f"AI 完成输出 : {aii_message}") + try: + yield f"data: {json.dumps({'type': 'usage', **chuck.usage.model_dump()})}\n\n" # type: ignore[union-attr] + finally: + yield "data: [DONE]\n\n" + + +def s_append_chatroom_content(content: str, accept: ChatroomChatAccept) -> str: + content_list: ContentList = json.loads(content) + content_list.append({ + "role": "user", + "message": accept.user_message, + "mode": accept.mode, + }) + content_list.append({ + "role": "assistant", + "message": accept.aii_message, + }) + return json.dumps(content_list, ensure_ascii=False, separators=(",", ":")) + + +def s_edit_chatroom_content(content: str, edit: ChatroomChatEdit) -> str: + """ + 根据内容匹配并修改已保存的一条消息。 + + Args: + content: 保存在数据库中的序列化 json 数据。 + edit: ChatroomChatEdit + + Raises: + ValueError: 未找到匹配的原消息。 + + Returns: + 经过修改的序列化 json 数据 + """ + content_list: ContentList = json.loads(content) + target_search_message = edit.old_message + target_edit_message = edit.new_message + target_change_type = "assistant" if edit.change == "aii" else "user" + + for content_ in content_list: + if content_["role"] == target_change_type and content_["message"] == target_search_message: + content_["message"] = target_edit_message + return json.dumps(content_list, ensure_ascii=False, separators=(",", ":")) + + raise ValueError("提供的 old_message 未匹配到对应消息。", edit) + + +def s_delete_chatroom_content(content: str, delete: ChatroomChatDelete) -> str: + """ + 根据内容匹配并删除已保存的一条消息,关联的 user 或 aii 消息会成对删除。 + + Args: + content: 保存在数据库中的序列化 json 数据。 + delete: ChatroomChatDelete + + Raises: + ValueError: 未找到匹配的原消息。 + + Returns: + 经过删除的序列化 json 数据 + """ + content_list: ContentList = json.loads(content) + target_delete_type = "assistant" if delete.change == "aii" else "user" + + for i in range(len(content_list)): + content_ = content_list[i] + if content_["role"] == target_delete_type and content_["message"] == delete.message: + content_list.pop(i) + content_list.pop(i if content_["role"] == "user" else (i - 1)) + return json.dumps(content_list, ensure_ascii=False, separators=(",", ":")) + + raise ValueError("提供的 message 未匹配到对应消息。", delete) diff --git a/src/nyahome/service/file_service.py b/src/nyahome/service/file_service.py new file mode 100644 index 0000000..02cc690 --- /dev/null +++ b/src/nyahome/service/file_service.py @@ -0,0 +1,43 @@ +import logging +import uuid +from pathlib import Path + +import aiofiles +from fastapi import UploadFile + +logger = logging.getLogger(__name__) + +UPLOAD_DIR = Path.cwd() / ".nyahome" / "contents" + +ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif"} + + +def s_get_safe_filename(original_name: str) -> str: + """ + 使用 uuid4 生成一个安全的文件名。 + + Args: + original_name: 完整的原始文件名。 + + Raises: + TypeError: 拓展名不在允许列表内。 + + Returns: + uuid4 生成的安全的文件名,后缀不变 + """ + suffix = original_name.rsplit(".", maxsplit=1)[-1] + if suffix not in ALLOWED_EXTENSIONS: + raise TypeError(f"给定文件的拓展名 {suffix} 不被允许。允许的文件拓展名:{ALLOWED_EXTENSIONS}") + return f"{uuid.uuid4().hex}.{suffix}" + + +async def s_save_upload_file(filename: Path, file: UploadFile) -> None: + try: + async with aiofiles.open(filename, mode="wb") as f: + await f.write(await file.read()) + logger.info(f"保存文件:{filename.name}") + except Exception as e: + logger.error(f"保存文件失败:{filename.name}") + raise TypeError("保存文件时遇到未知错误,请检查。") from e + finally: + await file.close() diff --git a/src/nyahome/service/secure_service.py b/src/nyahome/service/secure_service.py new file mode 100644 index 0000000..956db20 --- /dev/null +++ b/src/nyahome/service/secure_service.py @@ -0,0 +1,31 @@ +import json +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, field_serializer, field_validator + + +class SecureChange(BaseModel): + created_at: datetime + type: Literal["login", "change_password", "change_email", "change_phone"] + old: str | None + new: str | None + + # 输入时:int -> datetime + @field_validator("created_at", mode="before") + @classmethod + def from_timestamp(cls, v): # type: ignore[no-untyped-def] # noqa: ANN001, ANN206 + if isinstance(v, int): + return datetime.fromtimestamp(v) + return v + + # 输出时:datetime -> int + @field_serializer("created_at") + def to_timestamp(self, v: datetime) -> int: + return int(v.timestamp()) + + +def s_append_secure_changes(original_changes: str, new_change: SecureChange) -> str: + changes: list[dict] = json.loads(original_changes) + changes.append(new_change.model_dump()) + return json.dumps(changes) diff --git a/src/nyahome/service/verify_service.py b/src/nyahome/service/verify_service.py new file mode 100644 index 0000000..6514d10 --- /dev/null +++ b/src/nyahome/service/verify_service.py @@ -0,0 +1,58 @@ +import logging + +from nyahome.config import config_manager +from nyahome.core.otp_store import email_otp_memory_store +from nyahome.core.send_email import SendEmailItem, email_sender_queue +from nyahome.core.template_render import template_render +from nyahome.database import ModelUser + +logger = logging.getLogger(__name__) + + +async def send_2fa_email(user: ModelUser) -> bool: + """ + 向指定用户的邮箱发送验证邮件,用于验证登录请求。 + + Returns: + 布尔值,表明邮件是否提交到发送队列。 + 提交到发送队列并不代表邮件发送成功。 + """ + if not user.email: + logger.warning(f"用户 {user.name} [{user.id}] 未提供邮箱,无法发送 2fa 邮件。") + return False + return await email_otp_memory_store.generate_and_send(user.id, user.email, "有人正在请求使用您的账户登录。") + + +async def s_send_verify_email(user_id: int, address: str) -> bool: + """ + 验证用户的更改邮箱请求的邮件地址 + + Returns: + 布尔值,表明邮件是否提交到发送队列。 + 提交到发送队列并不代表邮件发送成功。 + """ + return await email_otp_memory_store.generate_and_send(user_id, address, "您正在请求修改您的账户的邮件地址。") + + +async def s_verify_email(user_id: int, address: str, verify_code: str) -> bool: + return email_otp_memory_store.verify(address=address, user_id=user_id, verify_code=verify_code) + + +async def s_send_test_email(to: str) -> bool: + """ + 向指定邮箱发送测试邮件。 + + Returns: + 布尔值,表明邮件是否提交到发送队列。 + 提交到发送队列并不代表邮件发送成功。 + """ + site_name = config_manager.get("site_name", "Nya Home") + html = template_render.render_test(site_name=site_name) + await email_sender_queue.put( + SendEmailItem( + to=to, + subject=f"{site_name} - 邮件系统测试", + body=html, + ) + ) + return True diff --git a/webui/auto-imports.d.ts b/webui/auto-imports.d.ts index 341fc51..e340f20 100644 --- a/webui/auto-imports.d.ts +++ b/webui/auto-imports.d.ts @@ -18,6 +18,7 @@ declare global { const getCurrentWatcher: typeof import('vue').getCurrentWatcher const h: typeof import('vue').h const inject: typeof import('vue').inject + const injectHead: typeof import('@unhead/vue').injectHead const isProxy: typeof import('vue').isProxy const isReactive: typeof import('vue').isReactive const isReadonly: typeof import('vue').isReadonly @@ -57,11 +58,14 @@ declare global { const useCssModule: typeof import('vue').useCssModule const useCssVars: typeof import('vue').useCssVars const useDialog: typeof import('naive-ui').useDialog + const useHead: typeof import('@unhead/vue').useHead + const useHeadSafe: typeof import('@unhead/vue').useHeadSafe const useId: typeof import('vue').useId const useLoadingBar: typeof import('naive-ui').useLoadingBar const useMessage: typeof import('naive-ui').useMessage const useModel: typeof import('vue').useModel const useNotification: typeof import('naive-ui').useNotification + const useSeoMeta: typeof import('@unhead/vue').useSeoMeta const useSlots: typeof import('vue').useSlots const useTemplateRef: typeof import('vue').useTemplateRef const watch: typeof import('vue').watch diff --git a/webui/components.d.ts b/webui/components.d.ts index da33e91..28257c7 100644 --- a/webui/components.d.ts +++ b/webui/components.d.ts @@ -12,19 +12,137 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { + AiiModelAddModal: typeof import('./src/components/chatroom/AiiModelAddModal.vue')['default'] + AiiProviderAddModal: typeof import('./src/components/chatroom/AiiProviderAddModal.vue')['default'] + ChangeEmailModal: typeof import('./src/components/admin/ChangeEmailModal.vue')['default'] + ChatControlPanel: typeof import('./src/components/chatroom/ChatControlPanel.vue')['default'] + ChatMessage: typeof import('./src/components/chatroom/ChatMessage.vue')['default'] + ChatPromptQuicker: typeof import('./src/components/chatroom/ChatPromptQuicker.vue')['default'] + ChatroomCard: typeof import('./src/components/chatroom/ChatroomCard.vue')['default'] + ChatroomCreatorModal: typeof import('./src/components/chatroom/ChatroomCreatorModal.vue')['default'] + ChatTable: typeof import('./src/components/chatroom/ChatTable.vue')['default'] + ConfigCard: typeof import('./src/components/admin/ConfigCard.vue')['default'] + FileModal: typeof import('./src/components/file/FileModal.vue')['default'] + FileThumbnail: typeof import('./src/components/file/FileThumbnail.vue')['default'] + InDev: typeof import('./src/components/InDev.vue')['default'] + NAlert: typeof import('naive-ui')['NAlert'] + NAvatar: typeof import('naive-ui')['NAvatar'] + NButton: typeof import('naive-ui')['NButton'] + NButtonGroup: typeof import('naive-ui')['NButtonGroup'] NCard: typeof import('naive-ui')['NCard'] + NCode: typeof import('naive-ui')['NCode'] NConfigProvider: typeof import('naive-ui')['NConfigProvider'] + NDataTable: typeof import('naive-ui')['NDataTable'] + NDrawer: typeof import('naive-ui')['NDrawer'] + NDrawerContent: typeof import('naive-ui')['NDrawerContent'] + NEllipsis: typeof import('naive-ui')['NEllipsis'] + NEmpty: typeof import('naive-ui')['NEmpty'] + NFlex: typeof import('naive-ui')['NFlex'] + NForm: typeof import('naive-ui')['NForm'] + NFormItem: typeof import('naive-ui')['NFormItem'] NGlobalStyle: typeof import('naive-ui')['NGlobalStyle'] + NGrid: typeof import('naive-ui')['NGrid'] + NGridItem: typeof import('naive-ui')['NGridItem'] + NH2: typeof import('naive-ui')['NH2'] + NH3: typeof import('naive-ui')['NH3'] + NImage: typeof import('naive-ui')['NImage'] + NInput: typeof import('naive-ui')['NInput'] + NInputNumber: typeof import('naive-ui')['NInputNumber'] + NInputOtp: typeof import('naive-ui')['NInputOtp'] + NMenu: typeof import('naive-ui')['NMenu'] + NMessageProvider: typeof import('naive-ui')['NMessageProvider'] + NModal: typeof import('naive-ui')['NModal'] + NModalProvider: typeof import('naive-ui')['NModalProvider'] + NP: typeof import('naive-ui')['NP'] + NRadio: typeof import('naive-ui')['NRadio'] + NRadioButton: typeof import('naive-ui')['NRadioButton'] + NRadioGroup: typeof import('naive-ui')['NRadioGroup'] + NSelect: typeof import('naive-ui')['NSelect'] + NSwitch: typeof import('naive-ui')['NSwitch'] + NTabPane: typeof import('naive-ui')['NTabPane'] + NTabs: typeof import('naive-ui')['NTabs'] + NTag: typeof import('naive-ui')['NTag'] + NText: typeof import('naive-ui')['NText'] + NUpload: typeof import('naive-ui')['NUpload'] + NUploadDragger: typeof import('naive-ui')['NUploadDragger'] + PageHeader: typeof import('./src/components/PageHeader.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] + ScriptDrawer: typeof import('./src/components/chatroom/ScriptDrawer.vue')['default'] + SelectFileModal: typeof import('./src/components/file/SelectFileModal.vue')['default'] + UploadFileModal: typeof import('./src/components/file/UploadFileModal.vue')['default'] + UploadModal: typeof import('./src/components/UploadModal.vue')['default'] + UserAction: typeof import('./src/components/admin/UserAction.vue')['default'] + UserPasswordModal: typeof import('./src/components/admin/UserPasswordModal.vue')['default'] + VerifyCodeModal: typeof import('./src/components/admin/VerifyCodeModal.vue')['default'] + XamlModal: typeof import('./src/components/XamlModal.vue')['default'] } } // For TSX support declare global { + const AiiModelAddModal: typeof import('./src/components/chatroom/AiiModelAddModal.vue')['default'] + const AiiProviderAddModal: typeof import('./src/components/chatroom/AiiProviderAddModal.vue')['default'] + const ChangeEmailModal: typeof import('./src/components/admin/ChangeEmailModal.vue')['default'] + const ChatControlPanel: typeof import('./src/components/chatroom/ChatControlPanel.vue')['default'] + const ChatMessage: typeof import('./src/components/chatroom/ChatMessage.vue')['default'] + const ChatPromptQuicker: typeof import('./src/components/chatroom/ChatPromptQuicker.vue')['default'] + const ChatroomCard: typeof import('./src/components/chatroom/ChatroomCard.vue')['default'] + const ChatroomCreatorModal: typeof import('./src/components/chatroom/ChatroomCreatorModal.vue')['default'] + const ChatTable: typeof import('./src/components/chatroom/ChatTable.vue')['default'] + const ConfigCard: typeof import('./src/components/admin/ConfigCard.vue')['default'] + const FileModal: typeof import('./src/components/file/FileModal.vue')['default'] + const FileThumbnail: typeof import('./src/components/file/FileThumbnail.vue')['default'] + const InDev: typeof import('./src/components/InDev.vue')['default'] + const NAlert: typeof import('naive-ui')['NAlert'] + const NAvatar: typeof import('naive-ui')['NAvatar'] + const NButton: typeof import('naive-ui')['NButton'] + const NButtonGroup: typeof import('naive-ui')['NButtonGroup'] const NCard: typeof import('naive-ui')['NCard'] + const NCode: typeof import('naive-ui')['NCode'] const NConfigProvider: typeof import('naive-ui')['NConfigProvider'] + const NDataTable: typeof import('naive-ui')['NDataTable'] + const NDrawer: typeof import('naive-ui')['NDrawer'] + const NDrawerContent: typeof import('naive-ui')['NDrawerContent'] + const NEllipsis: typeof import('naive-ui')['NEllipsis'] + const NEmpty: typeof import('naive-ui')['NEmpty'] + const NFlex: typeof import('naive-ui')['NFlex'] + const NForm: typeof import('naive-ui')['NForm'] + const NFormItem: typeof import('naive-ui')['NFormItem'] const NGlobalStyle: typeof import('naive-ui')['NGlobalStyle'] + const NGrid: typeof import('naive-ui')['NGrid'] + const NGridItem: typeof import('naive-ui')['NGridItem'] + const NH2: typeof import('naive-ui')['NH2'] + const NH3: typeof import('naive-ui')['NH3'] + const NImage: typeof import('naive-ui')['NImage'] + const NInput: typeof import('naive-ui')['NInput'] + const NInputNumber: typeof import('naive-ui')['NInputNumber'] + const NInputOtp: typeof import('naive-ui')['NInputOtp'] + const NMenu: typeof import('naive-ui')['NMenu'] + const NMessageProvider: typeof import('naive-ui')['NMessageProvider'] + const NModal: typeof import('naive-ui')['NModal'] + const NModalProvider: typeof import('naive-ui')['NModalProvider'] + const NP: typeof import('naive-ui')['NP'] + const NRadio: typeof import('naive-ui')['NRadio'] + const NRadioButton: typeof import('naive-ui')['NRadioButton'] + const NRadioGroup: typeof import('naive-ui')['NRadioGroup'] + const NSelect: typeof import('naive-ui')['NSelect'] + const NSwitch: typeof import('naive-ui')['NSwitch'] + const NTabPane: typeof import('naive-ui')['NTabPane'] + const NTabs: typeof import('naive-ui')['NTabs'] + const NTag: typeof import('naive-ui')['NTag'] + const NText: typeof import('naive-ui')['NText'] + const NUpload: typeof import('naive-ui')['NUpload'] + const NUploadDragger: typeof import('naive-ui')['NUploadDragger'] + const PageHeader: typeof import('./src/components/PageHeader.vue')['default'] const RouterLink: typeof import('vue-router')['RouterLink'] const RouterView: typeof import('vue-router')['RouterView'] + const ScriptDrawer: typeof import('./src/components/chatroom/ScriptDrawer.vue')['default'] + const SelectFileModal: typeof import('./src/components/file/SelectFileModal.vue')['default'] + const UploadFileModal: typeof import('./src/components/file/UploadFileModal.vue')['default'] + const UploadModal: typeof import('./src/components/UploadModal.vue')['default'] + const UserAction: typeof import('./src/components/admin/UserAction.vue')['default'] + const UserPasswordModal: typeof import('./src/components/admin/UserPasswordModal.vue')['default'] + const VerifyCodeModal: typeof import('./src/components/admin/VerifyCodeModal.vue')['default'] + const XamlModal: typeof import('./src/components/XamlModal.vue')['default'] } \ No newline at end of file diff --git a/webui/package.json b/webui/package.json index a1b5eb6..44c580e 100644 --- a/webui/package.json +++ b/webui/package.json @@ -15,7 +15,11 @@ "format": "oxfmt src/" }, "dependencies": { + "@microsoft/fetch-event-source": "^2.0.1", + "@types/markdown-it": "^14.1.2", + "@unhead/vue": "3.0.0-beta.9", "axios": "^1.15.2", + "markdown-it": "^14.1.1", "pinia": "^3.0.4", "vue": "^3.5.32", "vue-router": "^5.0.4" diff --git a/webui/pnpm-lock.yaml b/webui/pnpm-lock.yaml index ffa7b65..b78ac81 100644 --- a/webui/pnpm-lock.yaml +++ b/webui/pnpm-lock.yaml @@ -23,9 +23,21 @@ importers: .: dependencies: + '@microsoft/fetch-event-source': + specifier: ^2.0.1 + version: 2.0.1 + '@types/markdown-it': + specifier: ^14.1.2 + version: 14.1.2 + '@unhead/vue': + specifier: 3.0.0-beta.9 + version: 3.0.0-beta.9(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(vite@8.0.10(@types/node@24.12.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3))(vue@3.6.0-beta.10(typescript@6.0.3)) axios: specifier: ^1.15.2 version: 1.15.2 + markdown-it: + specifier: ^14.1.1 + version: 14.1.1 pinia: specifier: ^3.0.4 version: 3.0.4(typescript@6.0.3)(vue@3.6.0-beta.10(typescript@6.0.3)) @@ -192,6 +204,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-proposal-decorators@7.29.0': resolution: {integrity: sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==} engines: {node: '>=6.9.0'} @@ -339,6 +356,9 @@ packages: '@juggle/resize-observer@3.4.0': resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} + '@microsoft/fetch-event-source@2.0.1': + resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==} + '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: @@ -357,6 +377,136 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@oxc-parser/binding-android-arm-eabi@0.106.0': + resolution: {integrity: sha512-uoo8Bbc0/UrsQHlpdelqz8+jQ5hQqJs6MKjeiGqSU0E5Dkben2PuxXjg2jmabT+TzclysNEyE7eKHGTA7uVVqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.106.0': + resolution: {integrity: sha512-7+hnrpce0uX96Hu8seWMJXqDnBTtSikibn1xa1yCa/musU1XZOLznhdWKA1usaPnwLBXP+7+h6nrdvKZ4HoT5Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.106.0': + resolution: {integrity: sha512-J7d6j8PwicRXTL4I00eWhqupuq0Pei9EafTzoB7ccluNo5fXNspkIH1NtGpgxPsLyUkZy5Nb5J3Y80TpdX6yQA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.106.0': + resolution: {integrity: sha512-5LhQlSACZPeyxbcE8WNMW1s88ExWGRnk0LQbQ3Co3gYkmgw12x2q6RnPT0N9BC6490VnWsynFafwCMPSrMnjfg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.106.0': + resolution: {integrity: sha512-IInBOOMzB54rV/s8K5Feu6krWNHMR/V52prXy+9B0GhjOSQ2Q7EAd8y1gXWgjKB0NMDychCLgdaInanUn45eyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.106.0': + resolution: {integrity: sha512-p0IQvugmAsA2288b30FP5ncbcp6juBQrsZNZD6SDiWRY3X3g5OH5puVtihE5KMNkeHmmd3S8MEHFCv0G1tYGPA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.106.0': + resolution: {integrity: sha512-VgJPJVygSyFEfFtv6hscx9AbnewsxDUCxWmgrB/GHktoMlDQSDBh9aG1lENiiJnB2FLR8WG15446X3Mw2I4Zog==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.106.0': + resolution: {integrity: sha512-Gqs6q/pwlpgzx5qE2RtlTnY7hJuS1a5PYBT3unpSAMUE0LrbV7kQ8thmQo1ngI1tnCImWpuuXjZ2YbI0iKquXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.106.0': + resolution: {integrity: sha512-Bvtp8SK4MyahReapEPodracfBV9ed7+5WCHyjhSWoljrapJIU4OOLSsRyZ9zV2KhkjuD66DZq/qQv6pC73zzWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.106.0': + resolution: {integrity: sha512-DIXyavnpbBo+F/4G04LZ4xuuGXDY4m9qHB/HWtVj9z+Frb/r+SPAuptqAZFtJ9avcwbAOe3LO+K8BWHmK6+lnw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.106.0': + resolution: {integrity: sha512-VdqTcLTET72nPcJkSz3xrpcxab7q2/z04d6y+Th1mUTyXs2b/9VC3BcDmaFAfmhz8GX/5FVuzUTQzda1mTsh/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.106.0': + resolution: {integrity: sha512-FgHBGg9DHQ0dePOWQ9rNN+DHueJa1XWHc9u0VJCVY+XXAx3iT2ASj21xZ1wA+Rh92CyuuZ7RpQ6Y+O57fieNlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.106.0': + resolution: {integrity: sha512-fEIx2bUggt+s1eTaRVzhy5VgdrO1B8tUKxOPpGwwdF9VSP0KnLPaAv/gA4trJPxuIjjJRRVoK42v9R4O1jkbLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.106.0': + resolution: {integrity: sha512-DbDQkdK8ZuS/jnRx8UbESQ5ypCJpD7VpERB/RWZfSdA2+B4TbonDwNWbTU+q2VJTbh5Xq1X65eQyz4/MIfiFSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.106.0': + resolution: {integrity: sha512-D0PbaLv1MyNFDmjY4UqLQFlC+0GPCvrzI/8VlAvG7ztAZx0KdFYT3pPGsHjKshUJW9+e42JK29abLd0bZ4I95w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.106.0': + resolution: {integrity: sha512-uXSzts/ghlqmWm1cQTctyxdAnvha5dzVW5JkEB30J4M47yj2FcCtzUGdZO/sgXxggD/QM7EANlB66cOyk/NsoA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-wasm32-wasi@0.106.0': + resolution: {integrity: sha512-oU8wkw9U1vhkICQIJLX8uy1lCPJqXf7aAidaqT2wJOce4a9XmGr2YNseEKbmVV/1TQaSHpHZNsDXglYicb4qKQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.106.0': + resolution: {integrity: sha512-zYRSn6MNlL8qcUIPRQWDu1JdgVqZa5iR4Drld8FBue3fHQGL0XrNQEd8qoWmuNo7FI0WiBRRuVgtkPaNoSsYmg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.106.0': + resolution: {integrity: sha512-FRHVO84i5WgQDk0XI4oRt2qDhRUXyot2EGBSogp34LoE5hsondyuZ244+Fod9czgscmgSb6Aon8PaEhHQ0lJYg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.106.0': + resolution: {integrity: sha512-ydMjY15RdfRZZa7RrP+jjeudbDFDqKo5CGDTxvYBJ4jpROvVo0ThqN85vvNfVJ55gEUSjodCqvmA30qNTBZd/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.106.0': + resolution: {integrity: sha512-QdsH3rZq480VnOHSHgPYOhjL8O8LBdcnSjM408BpPCCUc0JYYZPG9Gafl9i3OcGk/7137o+gweb4cCv3WAUykg==} + '@oxc-project/types@0.127.0': resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} @@ -814,12 +964,21 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + '@types/lodash-es@4.17.12': resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} '@types/lodash@4.17.24': resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/node@24.12.2': resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} @@ -882,6 +1041,15 @@ packages: resolution: {integrity: sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@unhead/vue@3.0.0-beta.9': + resolution: {integrity: sha512-X66jeffzB+IH3cXBPLhbBAFCsTuTVYD0+0N5aelNw5MbE9CpNnLJCIcwUyqh/UdEwaBMehRNPlGY+fiRrapRqQ==} + peerDependencies: + vite: '>=6' + vue: '>=3.5.18' + peerDependenciesMeta: + vite: + optional: true + '@vitejs/plugin-vue-jsx@5.1.5': resolution: {integrity: sha512-jIAsvHOEtWpslLOI2MeElGFxH7M8pM83BU/Tor4RLyiwH0FM4nUW3xdvbw20EeU9wc5IspQwMq225K3CMnJEpA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -946,20 +1114,20 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@vue/compiler-core@3.6.0-beta.10': - resolution: {integrity: sha512-hzkiI5eeO3YgCBlXRXM6HT8E7l2aE3FY4bBBdzHkXDEXy+13gDpztU/TdJQmr6OsER51tNponBOpqwkaZvpL7g==} + '@vue/compiler-core@3.6.0-beta.12': + resolution: {integrity: sha512-SrmembMU15mawXHGq4VlQzcFKH8Y6AyD2qGaXDgcSpVYUdC5jVQZqXn1DBd4xanWVUGb3Tj1b/8lqGD7UkV+Xw==} - '@vue/compiler-dom@3.6.0-beta.10': - resolution: {integrity: sha512-syaKfkW2D4VFFndlNYweR/EqYWxpf1zCF2URtDcN8IJXV3jvsfOSZA+p8+kyNSbDtBEq8CAeulUXrRfORrM0mQ==} + '@vue/compiler-dom@3.6.0-beta.12': + resolution: {integrity: sha512-t43TXnDvrpPQam9XIG8FImTDryYSvZ1bk0SPvkp3PaJ4Qz5mJT/X4OUcdaR9ihhyaF+xOuVH/OFvwlO6ZM1NUA==} - '@vue/compiler-sfc@3.6.0-beta.10': - resolution: {integrity: sha512-Pw6EkQB1a4wfh137rIgYwMwqc8v8JcsIwspzfGYG7a+St38gcnNGJFGVvfJh689EOLgfv1Z8was6ktrESfowwQ==} + '@vue/compiler-sfc@3.6.0-beta.12': + resolution: {integrity: sha512-jmaZT/MU62Q4fByoDK3iHHcUMmfScQrlmjk/hG0kYQ1vCzRwWloFeyqsNoRqqaBSS9Q4xmvZi4/sXvwPd1zf0Q==} - '@vue/compiler-ssr@3.6.0-beta.10': - resolution: {integrity: sha512-Pp5QQYgEhtv3C7irbeZP64gAWQjrsSnMlaNtBfgge/Cla/xE+HCQqE+8aJUndirtkmFutEA8CvHJ9K4FDHlOjQ==} + '@vue/compiler-ssr@3.6.0-beta.12': + resolution: {integrity: sha512-jtpElHVq/KD9cKM+eze35bCTY/A5/tCjbMinMbJtqpl6ElHPN9jMJofImeojuzdowdDcpgoE+hDpiS8syg3fzA==} - '@vue/compiler-vapor@3.6.0-beta.10': - resolution: {integrity: sha512-9DqhmWnv06wefy/gBIix0bfphEDba0La96tt8jbNZb9+CbnbB0VyNCz70ynXyhbvrM5K9yhGcIqNfhkNhcepAQ==} + '@vue/compiler-vapor@3.6.0-beta.12': + resolution: {integrity: sha512-p71W1LZI0H8MMxxuWhr0CSa0iMMlmMjh+RscVhMBy8Q+LDYIWVbWUhAgHww38DMlvCELr/rx73xfbTfjpVbGlw==} '@vue/devtools-api@7.7.9': resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==} @@ -998,27 +1166,27 @@ packages: '@vue/language-core@3.2.7': resolution: {integrity: sha512-Gn4q/tRxbpVGLEuARQ43p3YELlNAFgRUVCgW9U5Cr+5q4vfD2bWDWpl3ABbJMXUt5xlE1dF8dkigg2aUq7JYYw==} - '@vue/reactivity@3.6.0-beta.10': - resolution: {integrity: sha512-YOdRI7G9SAI/Ic3dBUE1Mwzvb7J5h4Oqe0FijSi0y3mI4MWkMxNvWSSLkjqALSfAgixxjSRRfSR9Ln2jm53WeQ==} + '@vue/reactivity@3.6.0-beta.12': + resolution: {integrity: sha512-YByP7NXJiWpFhhCLqjkIgRmGKd4gesVRjIkCK0ERbYZf/oj7zd59Ai5Mjf7sFN+iKpISYIHx5EPh6oxyJyndOw==} - '@vue/runtime-core@3.6.0-beta.10': - resolution: {integrity: sha512-5cFbYbxtTm7H2Ch1qEdmUFySrPI/+aSmUJsJpMltG5gQJCuWN3kjVqeZp2AZB7pI3a2Jkq2NJC0gR++4eAw1xw==} + '@vue/runtime-core@3.6.0-beta.12': + resolution: {integrity: sha512-sMbz0ncmTBuoSXqY6dP3tTshhxcg82fUOSxKY8japIbhNAwbsFmsa3m7LABjDrk5nIEeUfKrqtz2TlpKSiJ5Xw==} - '@vue/runtime-dom@3.6.0-beta.10': - resolution: {integrity: sha512-w02s59Gq1NoZtSCpVKRWzk9No9BEq/eq9n1+KLre5WvYMIYjK0XBNN9cltZfE0/j5uZQlb87i4qR51YxFFbHog==} + '@vue/runtime-dom@3.6.0-beta.12': + resolution: {integrity: sha512-iuf+5/7wZh+rp+ddl6DH6ehCsduuV9Ts1AWoIlQoWvznFKv90e1PAG/rutcmvPIpXZHYhNPL0VyXQBgnBWRD4g==} - '@vue/runtime-vapor@3.6.0-beta.10': - resolution: {integrity: sha512-MQOs7WughaIUFB6nL02kIHgjPpmiZeXnCLP303kihdfaS3vdsYzvc9u6cnTTtvfSGbyHq8F15rcz5QoRlF+zew==} + '@vue/runtime-vapor@3.6.0-beta.12': + resolution: {integrity: sha512-9hQhFNnAzdtolLSoAg7Wml6Q6rX6WbfnjmjanStuMwodBsg8CEnzdU4TsVFNyHdKCqJ1rs1DTTAnz7yoIR5pDw==} peerDependencies: - '@vue/runtime-dom': 3.6.0-beta.10 + '@vue/runtime-dom': 3.6.0-beta.12 - '@vue/server-renderer@3.6.0-beta.10': - resolution: {integrity: sha512-K9bq8O1Hv2fHS0/aVkAxvvukkdy+U8W/nfFRQLcCCvQ92uLuMYsBp8+E3KZRdGaQbAAeuIf4GwJVCGXHTQEg9Q==} + '@vue/server-renderer@3.6.0-beta.12': + resolution: {integrity: sha512-vvxrc68syjO7bhwTgvyAErm3nLFB97p01y0VI2qMIJtj25Q6md2FNP0K4Mr53VXD4FaV8MupwlcrcXGTK/Ch9A==} peerDependencies: - vue: 3.6.0-beta.10 + vue: 3.6.0-beta.12 - '@vue/shared@3.6.0-beta.10': - resolution: {integrity: sha512-13JUfIAd06F+IBnObE8mExDAMOknPIBjPBUN2JeemmuQwj5i20GduCLbHLVbxSkpFD0RGH4z2mOxUUdD+8M/Aw==} + '@vue/shared@3.6.0-beta.12': + resolution: {integrity: sha512-vPxq7puT/X3+VZtKFb1FkAdkytZrb30r6ezkS+wFi8nlUo3AnqGGWKlRAfDGX1a4EUqpvbeqVrU9WtEipaf9NQ==} '@vue/tsconfig@0.9.1': resolution: {integrity: sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==} @@ -1055,6 +1223,9 @@ packages: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + ast-kit@2.2.0: resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} engines: {node: '>=20.19.0'} @@ -1204,6 +1375,10 @@ packages: electron-to-chromium@1.5.345: resolution: {integrity: sha512-F9JXQGiMrz6yVNPI2qOVPvB9HzjH5cGzhs8oJ6A28V5L/YnzN/0KsuiibqF+F1Fd9qxFzD1BUnYSd8JfULxTwg==} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -1430,6 +1605,9 @@ packages: hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + hookable@6.1.1: + resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1602,6 +1780,9 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + local-pkg@1.1.2: resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} engines: {node: '>=14'} @@ -1619,6 +1800,9 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-regexp@0.10.0: + resolution: {integrity: sha512-Uly1Bu4lO1hwHUW0CQeSWuRtzCMNO00CmXtS8N6fyvB3B979GOEEeAkiTUDsmbYLAbvpUS/Kt5c4ibosAzVyVg==} + magic-string-ast@1.0.3: resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==} engines: {node: '>=20.19.0'} @@ -1626,10 +1810,17 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + markdown-it@14.1.1: + resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + memorystream@0.3.1: resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} engines: {node: '>= 0.10.0'} @@ -1716,6 +1907,15 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + oxc-parser@0.106.0: + resolution: {integrity: sha512-KSqA8PNgqi+wadUoGJXWyTr0mLuMzEABXQK5hKlj+cEWID+Rhw8xiqLappTDaCUpOqnKCpyO9N5RlzlFxR+TBw==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-walker@0.7.0: + resolution: {integrity: sha512-54B4KUhrzbzc4sKvKwVYm7E2PgeROpGba0/2nlNZMqfDyca+yOor5IMb4WLGBatGDT0nkzYdYuzylg7n3YfB7A==} + peerDependencies: + oxc-parser: '>=0.98.0' + oxfmt@0.45.0: resolution: {integrity: sha512-0o/COoN9fY50bjVeM7PQsNgbhndKurBIeTIcspW033OumksjJJmIVDKjAk5HMwU/GHTxSOdGDdhJ6BRzGPmsHg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1798,6 +1998,10 @@ packages: resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -1806,6 +2010,10 @@ packages: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1828,6 +2036,10 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} + regexp-tree@0.1.27: + resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} + hasBin: true + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -2062,6 +2274,9 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-level-regexp@0.1.17: + resolution: {integrity: sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg==} + typescript-eslint@8.59.1: resolution: {integrity: sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2074,12 +2289,23 @@ packages: engines: {node: '>=14.17'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + unhead@3.0.0-beta.9: + resolution: {integrity: sha512-1GVW+FnpPk3/kdrkqELkhu7XgD6brEezJAESLfy05Y581T1CdpMklcBkKMm7Q5vY1nivrLEVos6C7j9lKEM9oQ==} + peerDependencies: + vite: '>=6' + peerDependenciesMeta: + vite: + optional: true + unimport@5.7.0: resolution: {integrity: sha512-njnL6sp8lEA8QQbZrt+52p/g4X0rw3bnGGmUcJnt1jeG8+iiqO779aGz0PirCtydAIVcuTBRlJ52F0u46z309Q==} engines: {node: '>=18.12.0'} @@ -2422,6 +2648,10 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -2585,6 +2815,8 @@ snapshots: '@juggle/resize-observer@3.4.0': {} + '@microsoft/fetch-event-source@2.0.1': {} + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -2604,6 +2836,73 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@oxc-parser/binding-android-arm-eabi@0.106.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.106.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.106.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.106.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.106.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.106.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.106.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.106.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.106.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.106.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.106.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.106.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.106.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.106.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.106.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.106.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.106.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.106.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.106.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.106.0': + optional: true + + '@oxc-project/types@0.106.0': {} + '@oxc-project/types@0.127.0': {} '@oxfmt/binding-android-arm-eabi@0.45.0': @@ -2851,12 +3150,21 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/linkify-it@5.0.0': {} + '@types/lodash-es@4.17.12': dependencies: '@types/lodash': 4.17.24 '@types/lodash@4.17.24': {} + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdurl@2.0.0': {} + '@types/node@24.12.2': dependencies: undici-types: 7.16.0 @@ -2952,6 +3260,20 @@ snapshots: '@typescript-eslint/types': 8.59.1 eslint-visitor-keys: 5.0.1 + '@unhead/vue@3.0.0-beta.9(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(vite@8.0.10(@types/node@24.12.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3))(vue@3.6.0-beta.10(typescript@6.0.3))': + dependencies: + hookable: 6.1.1 + magic-string: 0.30.21 + oxc-parser: 0.106.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + oxc-walker: 0.7.0(oxc-parser@0.106.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + unhead: 3.0.0-beta.9(vite@8.0.10(@types/node@24.12.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3)) + vue: 3.6.0-beta.10(typescript@6.0.3) + optionalDependencies: + vite: 8.0.10(@types/node@24.12.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + '@vitejs/plugin-vue-jsx@5.1.5(vite@8.0.10(@types/node@24.12.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3))(vue@3.6.0-beta.10(typescript@6.0.3))': dependencies: '@babel/core': 7.29.0 @@ -2984,7 +3306,7 @@ snapshots: '@vue-macros/common@3.1.2(vue@3.6.0-beta.10(typescript@6.0.3))': dependencies: - '@vue/compiler-sfc': 3.6.0-beta.10 + '@vue/compiler-sfc': 3.6.0-beta.12 ast-kit: 2.2.0 local-pkg: 1.1.2 magic-string-ast: 1.0.3 @@ -3006,7 +3328,7 @@ snapshots: '@babel/types': 7.29.0 '@vue/babel-helper-vue-transform-on': 1.5.0 '@vue/babel-plugin-resolve-type': 1.5.0(@babel/core@7.29.0) - '@vue/shared': 3.6.0-beta.10 + '@vue/shared': 3.6.0-beta.12 optionalDependencies: '@babel/core': 7.29.0 transitivePeerDependencies: @@ -3022,7 +3344,7 @@ snapshots: '@babel/types': 7.29.0 '@vue/babel-helper-vue-transform-on': 2.0.1 '@vue/babel-plugin-resolve-type': 2.0.1(@babel/core@7.29.0) - '@vue/shared': 3.6.0-beta.10 + '@vue/shared': 3.6.0-beta.12 optionalDependencies: '@babel/core': 7.29.0 transitivePeerDependencies: @@ -3035,7 +3357,7 @@ snapshots: '@babel/helper-module-imports': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/parser': 7.29.2 - '@vue/compiler-sfc': 3.6.0-beta.10 + '@vue/compiler-sfc': 3.6.0-beta.12 transitivePeerDependencies: - supports-color @@ -3046,46 +3368,46 @@ snapshots: '@babel/helper-module-imports': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/parser': 7.29.2 - '@vue/compiler-sfc': 3.6.0-beta.10 + '@vue/compiler-sfc': 3.6.0-beta.12 transitivePeerDependencies: - supports-color - '@vue/compiler-core@3.6.0-beta.10': + '@vue/compiler-core@3.6.0-beta.12': dependencies: - '@babel/parser': 7.29.2 - '@vue/shared': 3.6.0-beta.10 + '@babel/parser': 7.29.3 + '@vue/shared': 3.6.0-beta.12 entities: 7.0.1 estree-walker: 2.0.2 source-map-js: 1.2.1 - '@vue/compiler-dom@3.6.0-beta.10': + '@vue/compiler-dom@3.6.0-beta.12': dependencies: - '@vue/compiler-core': 3.6.0-beta.10 - '@vue/shared': 3.6.0-beta.10 + '@vue/compiler-core': 3.6.0-beta.12 + '@vue/shared': 3.6.0-beta.12 - '@vue/compiler-sfc@3.6.0-beta.10': + '@vue/compiler-sfc@3.6.0-beta.12': dependencies: - '@babel/parser': 7.29.2 - '@vue/compiler-core': 3.6.0-beta.10 - '@vue/compiler-dom': 3.6.0-beta.10 - '@vue/compiler-ssr': 3.6.0-beta.10 - '@vue/compiler-vapor': 3.6.0-beta.10 - '@vue/shared': 3.6.0-beta.10 + '@babel/parser': 7.29.3 + '@vue/compiler-core': 3.6.0-beta.12 + '@vue/compiler-dom': 3.6.0-beta.12 + '@vue/compiler-ssr': 3.6.0-beta.12 + '@vue/compiler-vapor': 3.6.0-beta.12 + '@vue/shared': 3.6.0-beta.12 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.12 + postcss: 8.5.14 source-map-js: 1.2.1 - '@vue/compiler-ssr@3.6.0-beta.10': + '@vue/compiler-ssr@3.6.0-beta.12': dependencies: - '@vue/compiler-dom': 3.6.0-beta.10 - '@vue/shared': 3.6.0-beta.10 + '@vue/compiler-dom': 3.6.0-beta.12 + '@vue/shared': 3.6.0-beta.12 - '@vue/compiler-vapor@3.6.0-beta.10': + '@vue/compiler-vapor@3.6.0-beta.12': dependencies: - '@babel/parser': 7.29.2 - '@vue/compiler-dom': 3.6.0-beta.10 - '@vue/shared': 3.6.0-beta.10 + '@babel/parser': 7.29.3 + '@vue/compiler-dom': 3.6.0-beta.12 + '@vue/shared': 3.6.0-beta.12 estree-walker: 2.0.2 source-map-js: 1.2.1 @@ -3142,42 +3464,42 @@ snapshots: '@vue/language-core@3.2.7': dependencies: '@volar/language-core': 2.4.28 - '@vue/compiler-dom': 3.6.0-beta.10 - '@vue/shared': 3.6.0-beta.10 + '@vue/compiler-dom': 3.6.0-beta.12 + '@vue/shared': 3.6.0-beta.12 alien-signals: 3.1.2 muggle-string: 0.4.1 path-browserify: 1.0.1 picomatch: 4.0.4 - '@vue/reactivity@3.6.0-beta.10': + '@vue/reactivity@3.6.0-beta.12': dependencies: - '@vue/shared': 3.6.0-beta.10 + '@vue/shared': 3.6.0-beta.12 - '@vue/runtime-core@3.6.0-beta.10': + '@vue/runtime-core@3.6.0-beta.12': dependencies: - '@vue/reactivity': 3.6.0-beta.10 - '@vue/shared': 3.6.0-beta.10 + '@vue/reactivity': 3.6.0-beta.12 + '@vue/shared': 3.6.0-beta.12 - '@vue/runtime-dom@3.6.0-beta.10': + '@vue/runtime-dom@3.6.0-beta.12': dependencies: - '@vue/reactivity': 3.6.0-beta.10 - '@vue/runtime-core': 3.6.0-beta.10 - '@vue/shared': 3.6.0-beta.10 + '@vue/reactivity': 3.6.0-beta.12 + '@vue/runtime-core': 3.6.0-beta.12 + '@vue/shared': 3.6.0-beta.12 csstype: 3.2.3 - '@vue/runtime-vapor@3.6.0-beta.10(@vue/runtime-dom@3.6.0-beta.10)': + '@vue/runtime-vapor@3.6.0-beta.12(@vue/runtime-dom@3.6.0-beta.12)': dependencies: - '@vue/reactivity': 3.6.0-beta.10 - '@vue/runtime-dom': 3.6.0-beta.10 - '@vue/shared': 3.6.0-beta.10 + '@vue/reactivity': 3.6.0-beta.12 + '@vue/runtime-dom': 3.6.0-beta.12 + '@vue/shared': 3.6.0-beta.12 - '@vue/server-renderer@3.6.0-beta.10(vue@3.6.0-beta.10(typescript@6.0.3))': + '@vue/server-renderer@3.6.0-beta.12(vue@3.6.0-beta.10(typescript@6.0.3))': dependencies: - '@vue/compiler-ssr': 3.6.0-beta.10 - '@vue/shared': 3.6.0-beta.10 + '@vue/compiler-ssr': 3.6.0-beta.12 + '@vue/shared': 3.6.0-beta.12 vue: 3.6.0-beta.10(typescript@6.0.3) - '@vue/shared@3.6.0-beta.10': {} + '@vue/shared@3.6.0-beta.12': {} '@vue/tsconfig@0.9.1(typescript@6.0.3)(vue@3.6.0-beta.10(typescript@6.0.3))': optionalDependencies: @@ -3203,6 +3525,8 @@ snapshots: ansis@4.2.0: {} + argparse@2.0.1: {} + ast-kit@2.2.0: dependencies: '@babel/parser': 7.29.2 @@ -3335,6 +3659,8 @@ snapshots: electron-to-chromium@1.5.345: {} + entities@4.5.0: {} + entities@7.0.1: {} error-stack-parser-es@1.0.5: {} @@ -3561,6 +3887,8 @@ snapshots: hookable@5.5.3: {} + hookable@6.1.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -3673,6 +4001,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + local-pkg@1.1.2: dependencies: mlly: 1.8.2 @@ -3691,6 +4023,16 @@ snapshots: dependencies: yallist: 3.1.1 + magic-regexp@0.10.0: + dependencies: + estree-walker: 3.0.3 + magic-string: 0.30.21 + mlly: 1.8.2 + regexp-tree: 0.1.27 + type-level-regexp: 0.1.17 + ufo: 1.6.4 + unplugin: 2.3.11 + magic-string-ast@1.0.3: dependencies: magic-string: 0.30.21 @@ -3699,8 +4041,19 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + markdown-it@14.1.1: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + math-intrinsics@1.1.0: {} + mdurl@2.0.0: {} + memorystream@0.3.1: {} merge2@1.4.1: {} @@ -3803,6 +4156,39 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + oxc-parser@0.106.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): + dependencies: + '@oxc-project/types': 0.106.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.106.0 + '@oxc-parser/binding-android-arm64': 0.106.0 + '@oxc-parser/binding-darwin-arm64': 0.106.0 + '@oxc-parser/binding-darwin-x64': 0.106.0 + '@oxc-parser/binding-freebsd-x64': 0.106.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.106.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.106.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.106.0 + '@oxc-parser/binding-linux-arm64-musl': 0.106.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.106.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.106.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.106.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.106.0 + '@oxc-parser/binding-linux-x64-gnu': 0.106.0 + '@oxc-parser/binding-linux-x64-musl': 0.106.0 + '@oxc-parser/binding-openharmony-arm64': 0.106.0 + '@oxc-parser/binding-wasm32-wasi': 0.106.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@oxc-parser/binding-win32-arm64-msvc': 0.106.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.106.0 + '@oxc-parser/binding-win32-x64-msvc': 0.106.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + oxc-walker@0.7.0(oxc-parser@0.106.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)): + dependencies: + magic-regexp: 0.10.0 + oxc-parser: 0.106.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + oxfmt@0.45.0: dependencies: tinypool: 2.1.0 @@ -3907,10 +4293,18 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.14: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + prelude-ls@1.2.1: {} proxy-from-env@2.1.0: {} + punycode.js@2.3.1: {} + punycode@2.3.1: {} quansync@0.2.11: {} @@ -3927,6 +4321,8 @@ snapshots: readdirp@5.0.0: {} + regexp-tree@0.1.27: {} + reusify@1.1.0: {} rfdc@1.4.1: {} @@ -4127,6 +4523,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-level-regexp@0.1.17: {} + typescript-eslint@8.59.1(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3): dependencies: '@typescript-eslint/eslint-plugin': 8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) @@ -4140,10 +4538,19 @@ snapshots: typescript@6.0.3: {} + uc.micro@2.1.0: {} + ufo@1.6.4: {} undici-types@7.16.0: {} + unhead@3.0.0-beta.9(vite@8.0.10(@types/node@24.12.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3)): + dependencies: + hookable: 6.1.1 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.10(@types/node@24.12.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3) + unimport@5.7.0: dependencies: acorn: 8.16.0 @@ -4267,7 +4674,7 @@ snapshots: '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.29.0) - '@vue/compiler-dom': 3.6.0-beta.10 + '@vue/compiler-dom': 3.6.0-beta.12 kolorist: 1.8.0 magic-string: 0.30.21 vite: 8.0.10(@types/node@24.12.2)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(yaml@2.8.3) @@ -4339,12 +4746,12 @@ snapshots: vue@3.6.0-beta.10(typescript@6.0.3): dependencies: - '@vue/compiler-dom': 3.6.0-beta.10 - '@vue/compiler-sfc': 3.6.0-beta.10 - '@vue/runtime-dom': 3.6.0-beta.10 - '@vue/runtime-vapor': 3.6.0-beta.10(@vue/runtime-dom@3.6.0-beta.10) - '@vue/server-renderer': 3.6.0-beta.10(vue@3.6.0-beta.10(typescript@6.0.3)) - '@vue/shared': 3.6.0-beta.10 + '@vue/compiler-dom': 3.6.0-beta.12 + '@vue/compiler-sfc': 3.6.0-beta.12 + '@vue/runtime-dom': 3.6.0-beta.12 + '@vue/runtime-vapor': 3.6.0-beta.12(@vue/runtime-dom@3.6.0-beta.12) + '@vue/server-renderer': 3.6.0-beta.12(vue@3.6.0-beta.10(typescript@6.0.3)) + '@vue/shared': 3.6.0-beta.12 optionalDependencies: typescript: 6.0.3 diff --git a/webui/src/App.vue b/webui/src/App.vue index c4ec076..9765a43 100644 --- a/webui/src/App.vue +++ b/webui/src/App.vue @@ -1,13 +1,43 @@ - + diff --git a/webui/src/assets/beautiful.scss b/webui/src/assets/beautiful.scss new file mode 100644 index 0000000..2d1ea41 --- /dev/null +++ b/webui/src/assets/beautiful.scss @@ -0,0 +1,39 @@ +/* ===== 简约滚动条美化 ===== */ + +/* 适用于 Webkit 内核(Chrome/Edge/Safari) */ +::-webkit-scrollbar { + width: 6px; /* 垂直滚动条宽度 */ + height: 6px; /* 水平滚动条高度 */ +} + +::-webkit-scrollbar-track { + background: transparent; /* 轨道透明,极简 */ +} + +::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.15); /* 滑块半透明灰 */ + border-radius: 3px; /* 小圆角 */ +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.25); /* 悬停稍微深一点 */ +} + +/* Firefox 兼容 */ +* { + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, 0.15) transparent; +} + +/* ===== a 标签美化 ===== */ + +a { + color: inherit; /* 继承父元素颜色,不区分已访问 */ + text-decoration: none; /* 无下划线 */ +} + +a:hover { + text-decoration: underline; /* hover 显示下划线 */ + text-decoration-color: #22c55e; /* 绿色 */ + text-decoration-thickness: 2px; /* 可选:下划线粗细 */ +} diff --git a/webui/src/assets/chat.scss b/webui/src/assets/chat.scss new file mode 100644 index 0000000..f518abd --- /dev/null +++ b/webui/src/assets/chat.scss @@ -0,0 +1,66 @@ +div.message { + position: relative; + padding: 4px 12px; + font-size: 1rem; + height: max-content; + + .modify-button { + position: absolute; + bottom: 10px; + right: 10px; + } + + .collapse-button { + position: absolute; + bottom: 10px; + right: 50px; + } +} + +div.user-message { + border: 2px solid #e1ff20; + border-radius: 4px; + background: #fffbb1; +} + +div.aii-message { + border: 2px solid #20ff54; + border-radius: 4px; + background: #b1ffd0; +} + +div.aii-message-streaming { + border: 2px solid #20d2ff; + border-radius: 4px; + background: #b1f8ff; + + div.thinking { + border: 1px solid #e9ff20; + border-radius: 4px; + padding: 4px 8px; + margin: 6px 3px; + background: rgb(34 197 94 / 0.2); + } +} + +// 折叠消息 +div.collapse { + overflow: hidden; + min-height: 80px; +} + +div.xaml-block { + background: rgb(102 237 197 / 0.2); + border: 1px solid rgb(102 237 197); + border-radius: 4px; + padding: 10px; + margin-top: 6px; + + .title { + margin: 0; + } + + .text { + margin: 0; + } +} diff --git a/webui/src/assets/main.scss b/webui/src/assets/main.scss index b5a2e7e..430eb00 100644 --- a/webui/src/assets/main.scss +++ b/webui/src/assets/main.scss @@ -1,6 +1,22 @@ +html, +body { + height: 100%; + margin: 0; + padding: 0; + overflow: hidden; +} + +div#app { + height: 100%; + overflow: hidden; +} + div#aapp { height: 100dvh; - + max-height: 100vh; + overflow: hidden; + margin: 0; + padding: 0; display: flex; flex-direction: column; @@ -14,5 +30,17 @@ div#aapp { div.content-container { flex: 1; + min-height: 0; + overflow: hidden; + + display: flex; + flex-direction: column; + align-items: center; } } + +div.nyahome-card { + border-radius: 6px; + border: 1px solid rgb(44 44 44 / 0.4); + padding: 10px; +} diff --git a/webui/src/components/InDev.vue b/webui/src/components/InDev.vue new file mode 100644 index 0000000..ef7ff83 --- /dev/null +++ b/webui/src/components/InDev.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/webui/src/components/PageHeader.vue b/webui/src/components/PageHeader.vue new file mode 100644 index 0000000..8c93f80 --- /dev/null +++ b/webui/src/components/PageHeader.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/webui/src/components/XamlModal.vue b/webui/src/components/XamlModal.vue new file mode 100644 index 0000000..0b6ecd6 --- /dev/null +++ b/webui/src/components/XamlModal.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/webui/src/components/admin/ChangeEmailModal.vue b/webui/src/components/admin/ChangeEmailModal.vue new file mode 100644 index 0000000..209e6e1 --- /dev/null +++ b/webui/src/components/admin/ChangeEmailModal.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/webui/src/components/admin/ConfigCard.vue b/webui/src/components/admin/ConfigCard.vue new file mode 100644 index 0000000..36d0a72 --- /dev/null +++ b/webui/src/components/admin/ConfigCard.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/webui/src/components/admin/UserAction.vue b/webui/src/components/admin/UserAction.vue new file mode 100644 index 0000000..fdb754e --- /dev/null +++ b/webui/src/components/admin/UserAction.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/webui/src/components/admin/UserPasswordModal.vue b/webui/src/components/admin/UserPasswordModal.vue new file mode 100644 index 0000000..d27c8a9 --- /dev/null +++ b/webui/src/components/admin/UserPasswordModal.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/webui/src/components/admin/VerifyCodeModal.vue b/webui/src/components/admin/VerifyCodeModal.vue new file mode 100644 index 0000000..246625c --- /dev/null +++ b/webui/src/components/admin/VerifyCodeModal.vue @@ -0,0 +1,22 @@ + + + + + diff --git a/webui/src/components/chatroom/AiiModelAddModal.vue b/webui/src/components/chatroom/AiiModelAddModal.vue new file mode 100644 index 0000000..e32a2e4 --- /dev/null +++ b/webui/src/components/chatroom/AiiModelAddModal.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/webui/src/components/chatroom/AiiProviderAddModal.vue b/webui/src/components/chatroom/AiiProviderAddModal.vue new file mode 100644 index 0000000..c1f5094 --- /dev/null +++ b/webui/src/components/chatroom/AiiProviderAddModal.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/webui/src/components/chatroom/ChatControlPanel.vue b/webui/src/components/chatroom/ChatControlPanel.vue new file mode 100644 index 0000000..4a09992 --- /dev/null +++ b/webui/src/components/chatroom/ChatControlPanel.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/webui/src/components/chatroom/ChatMessage.vue b/webui/src/components/chatroom/ChatMessage.vue new file mode 100644 index 0000000..1786de7 --- /dev/null +++ b/webui/src/components/chatroom/ChatMessage.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/webui/src/components/chatroom/ChatPromptQuicker.vue b/webui/src/components/chatroom/ChatPromptQuicker.vue new file mode 100644 index 0000000..6539b3e --- /dev/null +++ b/webui/src/components/chatroom/ChatPromptQuicker.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/webui/src/components/chatroom/ChatTable.vue b/webui/src/components/chatroom/ChatTable.vue new file mode 100644 index 0000000..8f6eae0 --- /dev/null +++ b/webui/src/components/chatroom/ChatTable.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/webui/src/components/chatroom/ChatroomCard.vue b/webui/src/components/chatroom/ChatroomCard.vue new file mode 100644 index 0000000..2f50dbb --- /dev/null +++ b/webui/src/components/chatroom/ChatroomCard.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/webui/src/components/chatroom/ChatroomCreatorModal.vue b/webui/src/components/chatroom/ChatroomCreatorModal.vue new file mode 100644 index 0000000..1829bda --- /dev/null +++ b/webui/src/components/chatroom/ChatroomCreatorModal.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/webui/src/components/chatroom/ScriptDrawer.vue b/webui/src/components/chatroom/ScriptDrawer.vue new file mode 100644 index 0000000..8ebabce --- /dev/null +++ b/webui/src/components/chatroom/ScriptDrawer.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/webui/src/components/chatroom/chat-table-messages.tsx b/webui/src/components/chatroom/chat-table-messages.tsx new file mode 100644 index 0000000..ac91293 --- /dev/null +++ b/webui/src/components/chatroom/chat-table-messages.tsx @@ -0,0 +1,38 @@ +import ChatMessage from '@/components/chatroom/ChatMessage.vue' + +interface UserMessage { + role: 'user' + message: string + mode: 'continue' | 'expand' +} + +interface AiiMessage { + role: 'assistant' + message: string +} + +type Message = UserMessage | AiiMessage +type MessageList = Message[] + +export function createChatTableMessages( + content: string, + onMessageEdit: (oldMessage: string, newMessage: string, change: 'aii' | 'user') => void, + onMessageDelete: (message: string, change: 'aii' | 'user') => void, +) { + if (!content) return + const content_list: MessageList = JSON.parse(content) + return ( + <> + {content_list.map((msg: Message) => { + return ( + + ) + })} + + ) +} diff --git a/webui/src/components/file/FileModal.vue b/webui/src/components/file/FileModal.vue new file mode 100644 index 0000000..f3b6d1b --- /dev/null +++ b/webui/src/components/file/FileModal.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/webui/src/components/file/FileThumbnail.vue b/webui/src/components/file/FileThumbnail.vue new file mode 100644 index 0000000..78b4f0d --- /dev/null +++ b/webui/src/components/file/FileThumbnail.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/webui/src/components/file/SelectFileModal.vue b/webui/src/components/file/SelectFileModal.vue new file mode 100644 index 0000000..1acfc26 --- /dev/null +++ b/webui/src/components/file/SelectFileModal.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/webui/src/components/file/UploadFileModal.vue b/webui/src/components/file/UploadFileModal.vue new file mode 100644 index 0000000..814710d --- /dev/null +++ b/webui/src/components/file/UploadFileModal.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/webui/src/components/file/upload-files.tsx b/webui/src/components/file/upload-files.tsx new file mode 100644 index 0000000..a34d427 --- /dev/null +++ b/webui/src/components/file/upload-files.tsx @@ -0,0 +1,30 @@ +import type {UploadFileDto} from "@/types/user.ts"; +import FileThumbnail from "@/components/file/FileThumbnail.vue"; +import {NEmpty, NFlex} from "naive-ui"; + +export function uploadFilesCom(files: UploadFileDto[]) { + if (files.length === 0) { + return + } + return + {files.map((file: UploadFileDto) => { + return ; + })} + +} + +export function selectFilesCom( + files: UploadFileDto[], + onSelect: (file: UploadFileDto) => boolean, + onRemove: (file: UploadFileDto) => boolean +) { + if (files.length === 0) { + return + } + return + {files.map((file: UploadFileDto) => { + return ; + })} + +} diff --git a/webui/src/components/xaml-block.tsx b/webui/src/components/xaml-block.tsx new file mode 100644 index 0000000..dbbd3c8 --- /dev/null +++ b/webui/src/components/xaml-block.tsx @@ -0,0 +1,31 @@ +import { NAlert, NH4, NP } from 'naive-ui' + +export interface Xaml { + name: string + message: string + subXamls: Xaml[] +} + +export function createXamlBlock(xamls: Xaml[]) { + return ( + <> + {xamls.map((xaml) => { + return ( +
+ {xaml.name} + {xaml.message} + {createXamlBlock(xaml.subXamls)} +
+ ) + })} + + ) +} + +export function createErrorBlock(msg: string) { + return ( + + {msg} + + ) +} diff --git a/webui/src/main.ts b/webui/src/main.ts index a409992..7b0d8ef 100644 --- a/webui/src/main.ts +++ b/webui/src/main.ts @@ -1,14 +1,19 @@ import {createApp} from 'vue' import {createPinia} from 'pinia' +import {createHead} from "@unhead/vue/client"; import '@/assets/main.scss' +import '@/assets/beautiful.scss' +import '@/assets/chat.scss' import App from './App.vue' import router from './router' const app = createApp(App) +const head = createHead() app.use(createPinia()) app.use(router) +app.use(head) app.mount('#app') diff --git a/webui/src/pages/AdminPage.vue b/webui/src/pages/AdminPage.vue new file mode 100644 index 0000000..4444cca --- /dev/null +++ b/webui/src/pages/AdminPage.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/webui/src/pages/Chatroom1Page.vue b/webui/src/pages/Chatroom1Page.vue new file mode 100644 index 0000000..f7eecc8 --- /dev/null +++ b/webui/src/pages/Chatroom1Page.vue @@ -0,0 +1,364 @@ + + + + + diff --git a/webui/src/pages/ChatroomPage.vue b/webui/src/pages/ChatroomPage.vue index d879ed0..febcc82 100644 --- a/webui/src/pages/ChatroomPage.vue +++ b/webui/src/pages/ChatroomPage.vue @@ -1,5 +1,65 @@ - + + + + + diff --git a/webui/src/pages/WelcomePage.vue b/webui/src/pages/WelcomePage.vue index b4a8aee..28ed214 100644 --- a/webui/src/pages/WelcomePage.vue +++ b/webui/src/pages/WelcomePage.vue @@ -1,7 +1,25 @@ - + - + diff --git a/webui/src/pages/admin/AdminNyahome.vue b/webui/src/pages/admin/AdminNyahome.vue new file mode 100644 index 0000000..c25eab5 --- /dev/null +++ b/webui/src/pages/admin/AdminNyahome.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/webui/src/pages/admin/AdminOverview.vue b/webui/src/pages/admin/AdminOverview.vue new file mode 100644 index 0000000..10f98b8 --- /dev/null +++ b/webui/src/pages/admin/AdminOverview.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/webui/src/pages/admin/AdminUserInfo.vue b/webui/src/pages/admin/AdminUserInfo.vue new file mode 100644 index 0000000..57ca446 --- /dev/null +++ b/webui/src/pages/admin/AdminUserInfo.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/webui/src/pages/admin/AdminUserScript.vue b/webui/src/pages/admin/AdminUserScript.vue new file mode 100644 index 0000000..d1d5471 --- /dev/null +++ b/webui/src/pages/admin/AdminUserScript.vue @@ -0,0 +1,20 @@ + + + + + diff --git a/webui/src/pages/admin/AdminUserSecurity.vue b/webui/src/pages/admin/AdminUserSecurity.vue new file mode 100644 index 0000000..4d5957f --- /dev/null +++ b/webui/src/pages/admin/AdminUserSecurity.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/webui/src/pages/admin/AdminUserUpload.vue b/webui/src/pages/admin/AdminUserUpload.vue new file mode 100644 index 0000000..8be90a1 --- /dev/null +++ b/webui/src/pages/admin/AdminUserUpload.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/webui/src/router/index.ts b/webui/src/router/index.ts index 7883d9a..1a99e96 100644 --- a/webui/src/router/index.ts +++ b/webui/src/router/index.ts @@ -1,6 +1,14 @@ import {createRouter, createWebHashHistory} from 'vue-router' import ChatroomPage from '@/pages/ChatroomPage.vue' import WelcomePage from '@/pages/WelcomePage.vue' +import Chatroom1Page from "@/pages/Chatroom1Page.vue"; +import AdminPage from "@/pages/AdminPage.vue"; +import AdminOverview from "@/pages/admin/AdminOverview.vue"; +import AdminUserInfo from "@/pages/admin/AdminUserInfo.vue"; +import AdminUserSecurity from "@/pages/admin/AdminUserSecurity.vue"; +import AdminUserUpload from "@/pages/admin/AdminUserUpload.vue"; +import AdminNyahome from "@/pages/admin/AdminNyahome.vue"; +import AdminUserScript from "@/pages/admin/AdminUserScript.vue"; const router = createRouter({ history: createWebHashHistory(import.meta.env.BASE_URL), @@ -10,11 +18,53 @@ const router = createRouter({ path: '/', component: WelcomePage, }, + { + name: 'chatroom-1', + path: '/chatroom/:id', + component: Chatroom1Page, + }, { name: 'chatroom', path: '/chatroom', component: ChatroomPage, }, + { + name: 'admin', + path: '/admin/', + component: AdminPage, + children: [ + { + name: "admin-overview", + path: "", + component: AdminOverview, + }, + { + name: "admin-user-info", + path: "user-info", + component: AdminUserInfo, + }, + { + name: "admin-user-security", + path: "user-security", + component: AdminUserSecurity, + }, + { + name: "admin-user-upload", + path: "user-upload", + component: AdminUserUpload, + }, + { + name: "admin-user-script", + path: "user-script", + component: AdminUserScript, + }, + { + name: "admin-nyahome", + path: "nyahome", + component: AdminNyahome, + } + ] + }, ], }) diff --git a/webui/src/stores/counter.ts b/webui/src/stores/counter.ts deleted file mode 100644 index b6757ba..0000000 --- a/webui/src/stores/counter.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ref, computed } from 'vue' -import { defineStore } from 'pinia' - -export const useCounterStore = defineStore('counter', () => { - const count = ref(0) - const doubleCount = computed(() => count.value * 2) - function increment() { - count.value++ - } - - return { count, doubleCount, increment } -}) diff --git a/webui/src/stores/now-user.ts b/webui/src/stores/now-user.ts new file mode 100644 index 0000000..ab885c8 --- /dev/null +++ b/webui/src/stores/now-user.ts @@ -0,0 +1,63 @@ +import {defineStore} from 'pinia' +import {ref} from 'vue' +import {api, setApiToken} from '@/tools/web.ts' +import type {UserDto} from '@/types/user.ts' + +export const useNowUser = defineStore('now-user', () => { + const isLogin = ref(false) + + const id = ref(0) + const name = ref('') + const display_name = ref('') + const email = ref('') + const phone = ref('') + const avatar_url = ref('') + const background_url = ref('') + const description = ref('') + const is_admin = ref(false) + + async function loadUserInfo(user_id: number, access_token: string) { + id.value = user_id + setApiToken(access_token) + + let user: UserDto + try { + user = await api + .get('/admin/me/') + .then((res) => res.data as UserDto) + } catch (err) { + console.error(`请求用户信息时失败:${err}`) + throw err + } + + await loadWithUserInfo(user) + } + + async function loadWithUserInfo(user: UserDto) { + name.value = user.name + display_name.value = user.display_name + email.value = user.email + phone.value = user.phone + avatar_url.value = user.avatar_url + background_url.value = user.background_url + description.value = user.description + is_admin.value = user.is_admin + + isLogin.value = true + } + + return { + isLogin, + id, + name, + display_name, + email, + phone, + avatar_url, + background_url, + description, + is_admin, + loadUserInfo, + loadWithUserInfo, + } +}) diff --git a/webui/src/tools/md.ts b/webui/src/tools/md.ts new file mode 100644 index 0000000..3c4f6f2 --- /dev/null +++ b/webui/src/tools/md.ts @@ -0,0 +1,3 @@ +import markdownit from 'markdown-it' + +export const md = markdownit({ html: true, breaks: true }) diff --git a/webui/src/tools/web.ts b/webui/src/tools/web.ts new file mode 100644 index 0000000..b03e981 --- /dev/null +++ b/webui/src/tools/web.ts @@ -0,0 +1,21 @@ +import axios from 'axios' + +export const api = axios.create({ + baseURL: '/api', + headers: { + 'Content-Type': 'application/json', + }, +}) + +let authToken: string | null = null + +export function setApiToken(token: string) { + authToken = token +} + +api.interceptors.request.use((config) => { + if (authToken) { + config.headers.Authorization = `Bearer ${authToken}` + } + return config +}) diff --git a/webui/src/types/aii.ts b/webui/src/types/aii.ts new file mode 100644 index 0000000..3dcaecd --- /dev/null +++ b/webui/src/types/aii.ts @@ -0,0 +1,39 @@ +/** + * /api/aii/model 端点返回的模型数据,包含冰冷的 provider id 以及查询得到的温暖的 provider name 和 base url。 + * + * 创建新模型不使用此 interface。 + */ +export interface AiiModelPublic { + id: number + model_name: string + max_context_length: number + provider_id: number + provider_name: string + base_url: string +} + +export interface AiiProviderPublic { + id: number + name: string + base_url: string + api_key: string +} + +export interface AiiProviderPublicWithoutKey { + id: number + name: string + base_url: string +} + +export interface AiiTokenInfo { + type: 'usage' + completion_tokens: number + completion_tokens_details: object + prompt_tokens: number + prompt_tokens_details: object + total_tokens: number + // DeepSeek + prompt_cache_hit_tokens?: number + // DeepSeek + prompt_cache_miss_tokens?: number +} diff --git a/webui/src/types/chatroom.ts b/webui/src/types/chatroom.ts new file mode 100644 index 0000000..37a7beb --- /dev/null +++ b/webui/src/types/chatroom.ts @@ -0,0 +1,26 @@ +export interface ChatroomPublic { + id: number + name: string + description: string + feature_image: string + + script_template_id: number + script_template_version: string +} + +export interface Chatroom extends ChatroomPublic { + script: string + content: string +} + +export interface WordBook { + key_word: string + message: string +} + +export interface ChatScript { + main_prompt: string + user_prefix: string + user_suffix: string + world_books: WordBook[] +} diff --git a/webui/src/types/response.ts b/webui/src/types/response.ts new file mode 100644 index 0000000..1e194eb --- /dev/null +++ b/webui/src/types/response.ts @@ -0,0 +1,5 @@ +export interface ReturnDto { + success: boolean + message?: string + result?: unknown +} diff --git a/webui/src/types/syt.ts b/webui/src/types/syt.ts new file mode 100644 index 0000000..c3d6767 --- /dev/null +++ b/webui/src/types/syt.ts @@ -0,0 +1 @@ +export const SEE_YOU_TOMORROW = '... . . -.-- --- ..- - --- -- --- .-. .-. --- .--' diff --git a/webui/src/types/user.ts b/webui/src/types/user.ts new file mode 100644 index 0000000..55a4239 --- /dev/null +++ b/webui/src/types/user.ts @@ -0,0 +1,22 @@ +export interface UserDto { + id: number + name: string + display_name: string + avatar_url: string + background_url: string + description: string + + email: string + phone: string + + is_admin: boolean +} + +export interface UploadFileDto { + id: number + original_name: string + safe_name: string + download_url: string + + uploader_id: number +} diff --git a/webui/vite.config.ts b/webui/vite.config.ts index 2bece89..36fc383 100644 --- a/webui/vite.config.ts +++ b/webui/vite.config.ts @@ -9,6 +9,7 @@ import {resolve} from "path"; import AutoImport from 'unplugin-auto-import/vite' import {NaiveUiResolver} from 'unplugin-vue-components/resolvers' import Components from 'unplugin-vue-components/vite' +import {unheadVueComposablesImports} from "@unhead/vue"; // 从 package.json 里搞到 WebUI 版本号 const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf-8')); @@ -29,7 +30,8 @@ export default defineConfig({ 'useNotification', 'useLoadingBar' ] - } + }, + unheadVueComposablesImports, ] }), Components({ @@ -55,11 +57,19 @@ export default defineConfig({ server: { proxy: { '/api': { - target: 'http://localhost:8000', + target: 'http://localhost:9000', changeOrigin: true, }, '/admin': { - target: 'http://localhost:8000', + target: 'http://localhost:9000', + changeOrigin: true, + }, + '/nyahome': { + target: 'http://localhost:9000', + changeOrigin: true, + }, + '/download': { + target: 'http://localhost:9000', changeOrigin: true, }, },