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 @@
+
+
+
+
+
+
+ Xaml
+ 可视化
+
+
+
+ Xaml 格式的提示词便于简洁地向 AI 提供复杂的设定。你也可以不使用 Xaml 格式。
+ 你可以拖动此模态框,但不允许在保持本窗口开启的状态下从遮罩下方复制代码……
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ 你需要使用新的邮件地址接收一个验证码来完成修改。
+
+
+
+
+ 获取验证码
+ 直接输入验证码
+ 验证码有效期为 5 分钟,且不允许多个同时有效。
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
![User Background]()
+
+
+
+ {{ NOWUSER.display_name ? NOWUSER.display_name : NOWUSER.name }}
+
+ {{ NOWUSER.name }}
+
+
+ 管理
+
+
+ 主页
+
+
+ 注销
+
+
+
+
+
+
+
+ 登录
+ 注册
+
+
+
+
+ 用户名
+ 邮箱
+ 手机
+
+
+
+
+ 登录
+ 忘记密码
+
+
+
+
+
+
+
+
+ 注册
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+ 修改已添加的提供商?请前往管理中心
+ 刷新
+
+ 添加
+
+
+
+
+
+
+
+ {{ m }}
+
+
+ 获取模型列表
+
+
+
+
+
+ K
+
+
+
+
+ 检测
+ 确认
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+ ⚙️
+
+
{
+ if (isCollapse) {
+ disableCollapse()
+ } else {
+ enableCollapse()
+ }
+ self!.scrollIntoView({ behavior: 'smooth' })
+ }
+ "
+ >
+ 🪟
+
+
+
+ 编辑中
+
+
+
+
+
+ 复制
+
+ 编辑
+
+
+ 删除
+
+
+ 取消
+
+ {
+ onMessageEdit(msg, editorMessage, role)
+ showEditor = false
+ }
+ "
+ >
+ 保存
+
+ 保存不会触发 AI 调用。
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+ {{ name }}
+
+ {{ description !== '' ? description : '此聊天室没有任何介绍……神秘的喵!' }}
+
+
+
+ 前往
+
+
+
+
+
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 @@
+
+
+
+
+
+
+ 剧本编辑器
+
+
+
+ 以下内容会被拼接在提示词以及用户输入中,在向 LLM 发送请求时携带。
+
+
+ Xaml 可视化
+
+
+
+ 这些内容每一轮请求都会被携带
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 仅在被提及时才会被动态拼接并携带的细分设定
+
+
+
+
+ 保存
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
{{ file.original_name }}
+
保存文件名:{{ file.safe_name }}
+
上传用户ID:{{ file.uploader_id }}
+ 你
+
+
+
+
+ 永久链接
+
+ 删除文件
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+ {{ tip_1 }}
+ {{ tip_2 }}
+
+
+
+ 确认选择
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ NyaHome 管理后台
+
+
+
+ 获取设置
+ 保存设置
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 如果您需要将 NyaHome 的前后端分开部署,则需要在此设置后端地址。您需要自行处理跨域问题。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ JWT(Json Web Token)签名需要一个密钥,你可以手动提供一个,或者自行生成一个。
+ 修改此密钥会导致所有用户的登录状态丢失(你也会),请一次性设置一个足够安全的。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ NayHome 无法自己发送邮件,需要配置 SMTP 服务。
+ 或者你也可以关闭邮件功能,当然芒果还是建议你配置一下的。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 你可以在这里测试 NayHome 的邮件系统能否使用上述 SMTP
+ 设置工作,这会发送一封测试邮件。
+
+
+ 发送测试邮件
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ 密码
+
+
+
+
+ 您正在使用 NyaHome 初始化时创建的管理员账号,此账号的默认密码为 admin。
+ 您应该及时修改默认密码。
+ 如果您已修改密码,请忽略。
+
+
+ 修改密码
+ 忘记密码
+
+
+
+
+
+
+
+
+ 其他登录方式
+
+
+
+
+ 在这里连接第三方账户之后,可以使用它们进行登录。
+ 必须先在这里连接后才能使用第三方账户进行登录。
+
+
+
+
+
+
+
+
+ 两步验证
+
+
+
+
+ 启用两步验证可以更好地保护您的账户,这会强制此账号在登录时进行额外验证。
+
+
+
+
+
+
+
+
+ 安全事件记录
+
+
+
+ 查询(更新)
+
+
+
+
+
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,
},
},