feat: open source multi-user AI notebook with observability

This commit is contained in:
wuyang
2026-07-28 14:28:00 +08:00
commit 935c411847
30 changed files with 6522 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
.git
.venv
data
frontend/node_modules
app/static
__pycache__
*.pyc
*.sqlite3
.DS_Store
+19
View File
@@ -0,0 +1,19 @@
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
data/
app/static/
frontend/node_modules/
frontend/*.tsbuildinfo
frontend/vite.config.js
frontend/vite.config.d.ts
.env
.env.*
*.sqlite3
*.sqlite3-*
users.json
access_key*
secrets/
backups/
.DS_Store
+27
View File
@@ -0,0 +1,27 @@
FROM node:22-alpine AS frontend
WORKDIR /build/frontend
COPY frontend/package*.json ./
RUN npm install --no-audit --no-fund
COPY frontend/ ./
RUN npm run build
FROM python:3.12-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
DATA_DIR=/app/data \
USERS_FILE=/run/secrets/users.json \
SESSION_SECRET_FILE=/run/secrets/session_secret \
DEEPSEEK_API_KEY_FILE=/run/secrets/deepseek_api_key \
COOKIE_SECURE=true
WORKDIR /app
RUN addgroup --system note && adduser --system --ingroup note note
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY --from=frontend /build/app/static ./app/static
RUN mkdir -p /app/data && chown -R note:note /app
USER note
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3)"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips", "*"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 wuyang
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+134
View File
@@ -0,0 +1,134 @@
# 续想
一个“先保存,后理解”的私人 AI 笔记本。
记录页只呈现用户写下的原文,输入历史像对话一样始终可回看。后台的单一策展
Agent 使用 DeepSeek 的思考模式,按需搜索已有想法与轨迹,再给出结构化的“想法位势”
提案。Agent 不能直接写数据库;应用只在 schema 和引用完整性校验通过后,用事务提交
更新。
## 设计边界
- 原始片段先持久化,AI 失败不影响记录。
- 捕获流不显示保存状态、AI 标签、关联或建议。
- 不要求用户先建页面、取标题、选分类或填写日期。
- 成熟度是可回退的连续位势,不是阶段、成绩或任务完成百分比。
- 运动、张力和可能动作由模型结合上下文动态生成,不使用固定关卡。
- 用户手动校准的位势优先展示,AI 估计仍独立保留。
- 每个用户的记录、想法、Session 与 Agent 上下文都以 `user_id` 在 SQL 层隔离。
- 管理员能看运行元数据;其他用户的原文与 AI 产物默认脱敏,只有用户主动开启
“调试共享”后才可见。
- 管理后台保存模型轮次、工具调用、耗时、token、错误和结构化决策产物,但不保存或
暴露模型隐藏思维链。
## 架构
```text
React/Vite ── cookie session ── FastAPI ── SQLite
│
└── OpenAI Agents SDK
└── DeepSeek API
```
核心数据流是:
1. `POST /api/fragments` 先提交原文并立即返回;
2. 后台 Agent 读取当前用户自己的想法上下文;
3. Agent 通过 `search_ideas`、`inspect_idea` 工具选择相关材料;
4. 应用校验结构化结果后,以事务写入想法、轨迹和片段关联;
5. 每次运行同时形成 `agent_runs`、`agent_events`,供管理后台聚合分析。
## 本地运行
要求 Python 3.12+、Node.js 22+。
```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
cd frontend
npm install
npm run build
cd ..
AUTH_DISABLED=true COOKIE_SECURE=false .venv/bin/uvicorn app.main:app --reload
```
开发模式会使用第一位配置用户;没有配置用户时使用临时的“本地开发”管理员身份。
### 生产身份配置
生产环境通过 `USERS_FILE` 指向一个只读 JSON 文件。文件只保存 Argon2 哈希,不保存
访问密钥明文:
```json
[
{
"id": "稳定且唯一的 UUID",
"label": "管理员",
"role": "admin",
"access_key_hash": "$argon2id$...",
"debug_sharing": false
},
{
"id": "另一个 UUID",
"label": "用户 2",
"role": "member",
"access_key_hash": "$argon2id$...",
"debug_sharing": false
}
]
```
可以用 Argon2 生成哈希:
```bash
.venv/bin/python -c \
'from argon2 import PasswordHasher; import getpass; print(PasswordHasher().hash(getpass.getpass("访问密钥: ")))'
```
还需要设置:
- `SESSION_SECRET_FILE`:Session HMAC 密钥文件;
- `DEEPSEEK_API_KEY_FILE`:DeepSeek API 密钥文件;
- `DATA_DIR`:SQLite 数据目录,默认 `./data`;
- `COOKIE_SECURE=true`:生产 HTTPS 环境必须开启;
- `DEEPSEEK_MODEL`:默认 `deepseek-v4-pro`。
密钥文件应在仓库和镜像之外,以只读挂载注入容器。
## 多用户与迁移
启动时会幂等创建/更新配置用户。旧版单用户数据库第一次升级时,已有片段、想法和
Session 会归属给配置清单中的第一位管理员;不会把旧数据复制给其他用户。所有列表、
详情、手动校准与 Agent 查询都同时带有当前 `user_id` 条件。
当前是小规模私人部署模型:身份清单来自只读配置文件,而不是开放注册系统。增加、
停用或轮换身份应修改清单并重启服务。
## 管理与分析接口
以下接口要求管理员 Session:
- `GET /api/admin/overview`:24 小时汇总、7 日趋势、错误与审计摘要;
- `GET /api/admin/users`:用户空间、数据量与调试共享状态;
- `GET /api/admin/runs?limit=80`:Agent 运行列表;
- `GET /api/admin/runs/{run_id}`:一次运行的事件、工具、token 与结构化产物;
- `GET /api/admin/audit?limit=120`:登录、启动、记录、校准等应用审计事件。
普通用户可调用 `PATCH /api/account/debug-sharing` 控制自己的调试内容是否向管理员
开放。管理员始终能看到自己的完整运行;对未开放共享的其他用户,仅返回状态、耗时、
token、错误类型等元数据。
## 测试与镜像
```bash
.venv/bin/pytest
cd frontend && npm run build
docker build -t note-zero:local .
```
健康检查位于 `GET /health`。生产发布定义见
[`deploy/docker-compose.override.yml`](deploy/docker-compose.override.yml)。
## 许可证
[MIT](LICENSE)
+2
View File
@@ -0,0 +1,2 @@
"""续想 — a quiet, AI-curated notebook."""
+204
View File
@@ -0,0 +1,204 @@
from __future__ import annotations
import hashlib
import hmac
import secrets
import time
from collections import defaultdict, deque
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerifyMismatchError
from fastapi import HTTPException, Request, Response, status
from .config import Settings
from .db import Database, utc_now
COOKIE_NAME = "xuxiang_session"
SESSION_DAYS = 30
_password_hasher = PasswordHasher()
@dataclass(frozen=True)
class AuthUser:
id: str
label: str
role: str
debug_sharing: bool
@property
def is_admin(self) -> bool:
return self.role == "admin"
def public(self) -> dict[str, object]:
return {
"id": self.id,
"label": self.label,
"role": self.role,
"debug_sharing": self.debug_sharing,
}
class LoginLimiter:
def __init__(self) -> None:
self.attempts: dict[str, deque[float]] = defaultdict(deque)
def check(self, key: str) -> None:
now = time.monotonic()
attempts = self.attempts[key]
while attempts and now - attempts[0] > 900:
attempts.popleft()
if len(attempts) >= 8:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="请稍后再试",
)
def fail(self, key: str) -> None:
self.attempts[key].append(time.monotonic())
def clear(self, key: str) -> None:
self.attempts.pop(key, None)
limiter = LoginLimiter()
def find_user_for_key(access_key: str, db: Database) -> AuthUser | None:
for candidate in db.list_auth_users():
try:
matches = _password_hasher.verify(
candidate["access_key_hash"], access_key
)
except (VerifyMismatchError, InvalidHashError):
matches = False
if matches:
return AuthUser(
id=candidate["id"],
label=candidate["label"],
role=candidate["role"],
debug_sharing=bool(candidate["debug_sharing"]),
)
return None
def _token_hash(token: str, settings: Settings) -> str:
secret = (settings.session_secret or "").encode()
return hmac.new(secret, token.encode(), hashlib.sha256).hexdigest()
def create_session(
response: Response,
db: Database,
settings: Settings,
user_id: str,
) -> None:
token = secrets.token_urlsafe(40)
expires = datetime.now(UTC) + timedelta(days=SESSION_DAYS)
with db.connect() as connection:
connection.execute(
"DELETE FROM sessions WHERE expires_at < ?", (utc_now(),)
)
connection.execute(
"""
INSERT INTO sessions (
token_hash, user_id, created_at, expires_at
) VALUES (?, ?, ?, ?)
""",
(
_token_hash(token, settings),
user_id,
utc_now(),
expires.isoformat(),
),
)
response.set_cookie(
COOKIE_NAME,
token,
max_age=SESSION_DAYS * 24 * 60 * 60,
httponly=True,
secure=settings.cookie_secure,
samesite="strict",
path="/",
)
def delete_session(
request: Request, response: Response, db: Database, settings: Settings
) -> None:
token = request.cookies.get(COOKIE_NAME)
if token:
with db.connect() as connection:
connection.execute(
"DELETE FROM sessions WHERE token_hash = ?",
(_token_hash(token, settings),),
)
response.delete_cookie(
COOKIE_NAME,
path="/",
secure=settings.cookie_secure,
httponly=True,
samesite="strict",
)
def require_auth(
request: Request, db: Database, settings: Settings
) -> AuthUser:
if settings.auth_disabled:
users = db.list_auth_users()
if users:
user = users[0]
return AuthUser(
id=user["id"],
label=user["label"],
role="admin",
debug_sharing=True,
)
return AuthUser(
id="development",
label="本地开发",
role="admin",
debug_sharing=True,
)
if not settings.users or not settings.session_secret:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="应用尚未配置访问身份",
)
token = request.cookies.get(COOKIE_NAME)
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
with db.connect() as connection:
row = connection.execute(
"""
SELECT u.id, u.label, u.role, u.debug_sharing, s.expires_at
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ?
""",
(_token_hash(token, settings),),
).fetchone()
if not row or row["expires_at"] <= utc_now():
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
return AuthUser(
id=row["id"],
label=row["label"],
role=row["role"],
debug_sharing=bool(row["debug_sharing"]),
)
def require_admin(user: AuthUser) -> None:
if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
def require_same_origin_intent(request: Request) -> None:
if request.method in {"GET", "HEAD", "OPTIONS"}:
return
if request.url.path == "/api/login":
return
if request.headers.get("x-note-client") != "xuxiang-web":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from pathlib import Path
def _read_secret(file_var: str, value_var: str) -> str | None:
secret_file = os.getenv(file_var)
if secret_file:
path = Path(secret_file)
if path.is_file():
return path.read_text(encoding="utf-8").strip()
value = os.getenv(value_var)
return value.strip() if value else None
def _as_bool(name: str, default: bool) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.lower() in {"1", "true", "yes", "on"}
@dataclass(frozen=True)
class Settings:
data_dir: Path
database_path: Path
users: tuple["UserSeed", ...]
session_secret: str | None
deepseek_api_key: str | None
deepseek_base_url: str
deepseek_model: str
cookie_secure: bool
auth_disabled: bool
@classmethod
def load(cls) -> "Settings":
data_dir = Path(os.getenv("DATA_DIR", "./data")).resolve()
data_dir.mkdir(parents=True, exist_ok=True)
return cls(
data_dir=data_dir,
database_path=data_dir / "notes.sqlite3",
users=_load_users(),
session_secret=_read_secret("SESSION_SECRET_FILE", "SESSION_SECRET"),
deepseek_api_key=_read_secret(
"DEEPSEEK_API_KEY_FILE", "DEEPSEEK_API_KEY"
),
deepseek_base_url=os.getenv(
"DEEPSEEK_BASE_URL", "https://api.deepseek.com"
).rstrip("/"),
deepseek_model=os.getenv("DEEPSEEK_MODEL", "deepseek-v4-pro"),
cookie_secure=_as_bool("COOKIE_SECURE", True),
auth_disabled=_as_bool("AUTH_DISABLED", False),
)
@dataclass(frozen=True)
class UserSeed:
id: str
label: str
role: str
access_key_hash: str
debug_sharing: bool = False
def _load_users() -> tuple[UserSeed, ...]:
raw = _read_secret("USERS_FILE", "USERS_JSON")
if not raw:
return ()
payload = json.loads(raw)
if not isinstance(payload, list):
raise ValueError("users configuration must be a JSON array")
users: list[UserSeed] = []
seen: set[str] = set()
for item in payload:
if not isinstance(item, dict):
raise ValueError("each configured user must be an object")
user = UserSeed(
id=str(item["id"]),
label=str(item["label"]).strip()[:40],
role=str(item["role"]),
access_key_hash=str(item["access_key_hash"]),
debug_sharing=bool(item.get("debug_sharing", False)),
)
if not user.id or user.id in seen:
raise ValueError("configured user ids must be unique")
if user.role not in {"admin", "member"}:
raise ValueError("configured user role must be admin or member")
if not user.label:
raise ValueError("configured user label cannot be empty")
seen.add(user.id)
users.append(user)
return tuple(users)
+444
View File
@@ -0,0 +1,444 @@
from __future__ import annotations
import asyncio
import json
import logging
import time
from dataclasses import dataclass
from typing import Any
from agents import (
Agent,
ModelSettings,
OpenAIChatCompletionsModel,
RunContextWrapper,
Runner,
function_tool,
set_tracing_disabled,
)
from openai import AsyncOpenAI
from openai.types.shared import Reasoning
from pydantic import ValidationError
from .config import Settings
from .db import Database
from .schemas import CuratorDecision
logger = logging.getLogger(__name__)
PROMPT_VERSION = "maturity-2026-07-28.v2"
CURATOR_INSTRUCTIONS = """
你是一个私人思想笔记本内部的“策展者”。用户界面必须安静,所以你的工作发生在幕后。
你的任务不是给句子贴标签,也不是强迫想法进入固定工作流,而是辨认:新片段是否属于一个
持续演化的想法;如果属于,它此刻大致在哪里、正在怎样运动、内在张力是什么。
判断“成熟度”时使用融合性的人类经验,而不是计数规则。综合考察:
1. 想法是否已经有自己的身份、边界与可复述的核心;
2. 它是否能容纳反例、摩擦、矛盾和真实经验,而不只是顺滑口号;
3. 它是否越来越属于这个具体的人,而非可替换的通用正确话;
4. 它与现实是否有接触:观察、制作、对话、试验、承诺或后果;
5. 它是否开始改变判断与行动,产生了真实影响。
0—100 只是连续的“位势坐标”,不是阶段、成绩或完成百分比。成熟度可以后退;新材料也可能
让一个看似成熟的想法重新打开。禁止用笔记数量、时间、链接数、是否列清单来机械打分。
motion 是你对当下运动的自然语言压缩,例如“向现实探去”“重构中”“暂时沉淀”,不使用
预设流水线。position 说明此刻所处的位置。trajectory 只描述最近发生的变化。possible_moves
是 1—4 个根据当前张力即时生成的可能动作,不是任务清单,不承诺固定的下一关。
谨慎合并。仅因主题词相似不能归到同一想法;关注它们是否共享同一个问题、张力或生成方向。
一个片段最多关联两个想法。若现在还不值得形成或归入想法,让它 standalone,原文仍会保留。
新建想法时 idea_id 必须为 null;更新时必须使用工具返回的准确 id。
先查看候选想法;遇到可能相关或可能冲突的候选时,使用 inspect_idea。最终必须只返回 JSON,
不能用 Markdown 包裹,也不能附加解释。JSON 必须符合输入中给出的 schema。
不要在输出中评价用户、诊断心理、说教或伪造证据。
""".strip()
@dataclass
class CuratorContext:
db: Database
run_id: str
user_id: str
ideas: list[dict[str, Any]]
recent_fragments: list[dict[str, Any]]
search_count: int = 0
inspection_count: int = 0
@function_tool(strict_mode=False)
async def search_ideas(
ctx: RunContextWrapper[CuratorContext], query: str
) -> str:
"""Search existing evolving ideas by a short concept, question, or tension."""
ctx.context.search_count += 1
started = time.perf_counter()
terms = [term.lower() for term in query.split() if term.strip()]
ranked: list[tuple[int, dict[str, Any]]] = []
for idea in ctx.context.ideas:
haystack = " ".join(
str(idea.get(key, ""))
for key in ("title", "summary", "position", "tension", "trajectory")
).lower()
score = sum(haystack.count(term) for term in terms)
if score or not terms:
ranked.append((score, idea))
ranked.sort(key=lambda item: (item[0], item[1].get("updated_at", "")), reverse=True)
compact = [
{
"id": idea["id"],
"title": idea["title"],
"summary": idea["summary"],
"maturity": idea["maturity"],
"motion": idea["motion"],
"position": idea["position"],
"tension": idea["tension"],
}
for _, idea in ranked[:8]
]
ctx.context.db.add_agent_event(
ctx.context.run_id,
ctx.context.user_id,
"tool_search_ideas",
{
"query": query,
"result_count": len(compact),
"candidates": [
{"id": item["id"], "title": item["title"]} for item in compact
],
},
duration_ms=round((time.perf_counter() - started) * 1000),
)
return json.dumps(compact, ensure_ascii=False)
@function_tool(strict_mode=False)
async def inspect_idea(
ctx: RunContextWrapper[CuratorContext], idea_id: str
) -> str:
"""Inspect an idea's recent evidence and movement before deciding to update it."""
ctx.context.inspection_count += 1
started = time.perf_counter()
idea = next(
(candidate for candidate in ctx.context.ideas if candidate["id"] == idea_id),
None,
)
if not idea:
ctx.context.db.add_agent_event(
ctx.context.run_id,
ctx.context.user_id,
"tool_inspect_idea",
{"idea_id": idea_id, "found": False},
duration_ms=round((time.perf_counter() - started) * 1000),
)
return json.dumps({"error": "idea not found"})
payload = {
"id": idea["id"],
"title": idea["title"],
"summary": idea["summary"],
"maturity_ai": idea["maturity_ai"],
"maturity_seen_by_user": idea["maturity"],
"user_has_overridden_position": idea["is_overridden"],
"motion": idea["motion"],
"position": idea["position"],
"tension": idea["tension"],
"trajectory": idea["trajectory"],
"possible_moves": idea["possible_moves"],
"recent_fragments": idea.get("recent_fragments", []),
"recent_snapshots": idea.get("recent_snapshots", []),
}
ctx.context.db.add_agent_event(
ctx.context.run_id,
ctx.context.user_id,
"tool_inspect_idea",
{
"idea_id": idea_id,
"found": True,
"title": idea["title"],
"fragment_count": len(idea.get("recent_fragments", [])),
"snapshot_count": len(idea.get("recent_snapshots", [])),
},
duration_ms=round((time.perf_counter() - started) * 1000),
)
return json.dumps(payload, ensure_ascii=False)
class Curator:
def __init__(self, db: Database, settings: Settings):
self.db = db
self.settings = settings
self.queue: asyncio.Queue[str] = asyncio.Queue()
self._worker: asyncio.Task[None] | None = None
@property
def configured(self) -> bool:
return bool(self.settings.deepseek_api_key)
async def start(self) -> None:
for fragment_id in self.db.pending_fragment_ids():
self.queue.put_nowait(fragment_id)
self._worker = asyncio.create_task(self._run_worker())
async def stop(self) -> None:
if self._worker:
self._worker.cancel()
try:
await self._worker
except asyncio.CancelledError:
pass
def enqueue(self, fragment_id: str) -> None:
self.queue.put_nowait(fragment_id)
async def _run_worker(self) -> None:
while True:
fragment_id = await self.queue.get()
try:
await self.analyze(fragment_id)
except Exception as exc:
logger.exception("Curator failed for fragment %s", fragment_id)
self.db.set_fragment_status(
fragment_id, "error", str(exc)[:500]
)
finally:
self.queue.task_done()
async def analyze(self, fragment_id: str) -> None:
fragment = self.db.get_fragment(fragment_id)
if not fragment:
return
user_id = fragment.get("user_id")
if not user_id:
raise RuntimeError("fragment has no user owner")
if not self.configured:
self.db.set_fragment_status(
fragment_id, "pending", "DeepSeek API key is not configured"
)
return
self.db.set_fragment_status(fragment_id, "processing")
run_id = self.db.start_agent_run(
user_id,
fragment_id,
self.settings.deepseek_model,
PROMPT_VERSION,
"high",
)
run_started = time.perf_counter()
ideas, recent_fragments = self.db.curator_context(user_id)
context = CuratorContext(
db=self.db,
run_id=run_id,
user_id=user_id,
ideas=ideas,
recent_fragments=recent_fragments,
)
self.db.add_agent_event(
run_id,
user_id,
"context_loaded",
{
"fragment_characters": len(fragment["content"]),
"idea_count": len(ideas),
"recent_fragment_count": len(recent_fragments),
},
)
output_schema = CuratorDecision.model_json_schema()
lookup_instruction = (
"必须先调用 search_ideas 搜索共享的问题或张力;如果候选可能相关,"
"再调用 inspect_idea 查看它的真实轨迹后判断。\n\n"
if ideas
else "目前没有已有想法,不要调用搜索工具。\n\n"
)
prompt = (
"请策展这个刚刚保存的新片段:\n"
f"<fragment id=\"{fragment_id}\">{fragment['content']}</fragment>\n\n"
f"当前共有 {len(ideas)} 个已有想法。{lookup_instruction}"
"最终只输出符合以下 JSON Schema 的 JSON 对象:\n"
f"{json.dumps(output_schema, ensure_ascii=False)}"
)
set_tracing_disabled(True)
client = AsyncOpenAI(
api_key=self.settings.deepseek_api_key,
base_url=self.settings.deepseek_base_url,
)
model = OpenAIChatCompletionsModel(
model=self.settings.deepseek_model,
openai_client=client,
)
agent: Agent[CuratorContext] = Agent(
name="私人思想策展者",
instructions=CURATOR_INSTRUCTIONS,
model=model,
tools=[search_ideas, inspect_idea],
model_settings=ModelSettings(
max_tokens=2_400,
reasoning=Reasoning(effort="high"),
extra_body={"thinking": {"type": "enabled"}},
extra_args={"response_format": {"type": "json_object"}},
),
)
decision: CuratorDecision | None = None
last_error: Exception | None = None
attempt_count = 0
model_rounds = 0
usage = {
"input_tokens": 0,
"output_tokens": 0,
"reasoning_tokens": 0,
"cached_tokens": 0,
}
try:
for attempt in range(2):
attempt_count = attempt + 1
repair = (
""
if attempt == 0
else "\n\n上一次输出未通过结构校验。重新完整判断,并只返回合法 JSON。"
)
self.db.add_agent_event(
run_id,
user_id,
"attempt_started",
{"attempt": attempt_count, "is_repair": attempt > 0},
)
attempt_started = time.perf_counter()
result = await Runner.run(
agent,
input=prompt + repair,
context=context,
max_turns=4,
)
attempt_duration = round(
(time.perf_counter() - attempt_started) * 1000
)
current_usage = self._result_usage(result)
model_rounds += current_usage.pop("model_rounds")
for key in usage:
usage[key] += current_usage[key]
self.db.add_agent_event(
run_id,
user_id,
"model_attempt_completed",
{
"attempt": attempt_count,
"model_rounds": len(result.raw_responses),
**current_usage,
},
duration_ms=attempt_duration,
)
try:
raw = result.final_output
if not isinstance(raw, str):
raise TypeError("curator output was not text")
decision = CuratorDecision.model_validate_json(raw)
if ideas and context.search_count == 0:
decision = None
raise ValueError("curator skipped required idea search")
break
except (ValidationError, ValueError, TypeError) as exc:
decision = None
last_error = exc
self.db.add_agent_event(
run_id,
user_id,
"validation_failed",
{
"attempt": attempt_count,
"error_type": type(exc).__name__,
"error": str(exc)[:500],
},
)
logger.warning(
"Curator returned invalid JSON for fragment %s (attempt %s)",
fragment_id,
attempt_count,
)
if decision is None:
raise RuntimeError(
"curator returned invalid JSON twice"
) from last_error
assessments = (
[]
if decision.standalone
else [item.model_dump() for item in decision.assessments]
)
affected = self.db.apply_assessments(
user_id, fragment_id, assessments
)
self.db.add_agent_event(
run_id,
user_id,
"decision_committed",
{
"standalone": decision.standalone,
"reasoning_note": decision.reasoning_note,
"assessments": assessments,
"affected_idea_ids": affected,
},
)
self.db.finish_agent_run(
run_id,
status="success",
duration_ms=round((time.perf_counter() - run_started) * 1000),
attempt_count=attempt_count,
model_rounds=model_rounds,
tool_calls=context.search_count + context.inspection_count,
search_calls=context.search_count,
inspection_calls=context.inspection_count,
**usage,
)
logger.info(
"Curator completed fragment %s with %s search and %s inspection calls",
fragment_id,
context.search_count,
context.inspection_count,
)
except Exception as exc:
self.db.add_agent_event(
run_id,
user_id,
"run_failed",
{
"error_type": type(exc).__name__,
"error": str(exc)[:500],
},
)
self.db.finish_agent_run(
run_id,
status="error",
duration_ms=round((time.perf_counter() - run_started) * 1000),
attempt_count=attempt_count,
model_rounds=model_rounds,
tool_calls=context.search_count + context.inspection_count,
search_calls=context.search_count,
inspection_calls=context.inspection_count,
error=exc,
**usage,
)
raise
@staticmethod
def _result_usage(result: Any) -> dict[str, int]:
totals = {
"model_rounds": len(result.raw_responses),
"input_tokens": 0,
"output_tokens": 0,
"reasoning_tokens": 0,
"cached_tokens": 0,
}
for response in result.raw_responses:
response_usage = response.usage
totals["input_tokens"] += response_usage.input_tokens
totals["output_tokens"] += response_usage.output_tokens
totals["reasoning_tokens"] += (
response_usage.output_tokens_details.reasoning_tokens or 0
)
totals["cached_tokens"] += (
response_usage.input_tokens_details.cached_tokens or 0
)
return totals
+963
View File
@@ -0,0 +1,963 @@
from __future__ import annotations
import hashlib
import json
import sqlite3
import uuid
from contextlib import contextmanager
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any, Iterable, Iterator
from .config import UserSeed
def utc_now() -> str:
return datetime.now(UTC).isoformat(timespec="milliseconds")
def _json(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
def _loads(value: str | None, fallback: Any) -> Any:
if not value:
return fallback
try:
return json.loads(value)
except json.JSONDecodeError:
return fallback
class Database:
def __init__(self, path: Path):
self.path = path
@contextmanager
def connect(self) -> Iterator[sqlite3.Connection]:
connection = sqlite3.connect(self.path, timeout=10)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
connection.execute("PRAGMA busy_timeout = 10000")
try:
yield connection
connection.commit()
except Exception:
connection.rollback()
raise
finally:
connection.close()
def initialize(self, user_seeds: Iterable[UserSeed] = ()) -> None:
seeds = list(user_seeds)
with self.connect() as connection:
connection.executescript(
"""
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin', 'member')),
access_key_hash TEXT NOT NULL,
debug_sharing INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS fragments (
id TEXT PRIMARY KEY,
user_id TEXT,
content TEXT NOT NULL,
created_at TEXT NOT NULL,
analysis_status TEXT NOT NULL DEFAULT 'pending',
analysis_error TEXT
);
CREATE TABLE IF NOT EXISTS ideas (
id TEXT PRIMARY KEY,
user_id TEXT,
title TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
maturity_ai REAL NOT NULL DEFAULT 12,
maturity_override REAL,
confidence REAL NOT NULL DEFAULT 0.5,
motion TEXT NOT NULL DEFAULT '浮现',
position TEXT NOT NULL DEFAULT '',
tension TEXT NOT NULL DEFAULT '',
trajectory TEXT NOT NULL DEFAULT '',
possible_moves TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS idea_fragments (
idea_id TEXT NOT NULL REFERENCES ideas(id) ON DELETE CASCADE,
fragment_id TEXT NOT NULL REFERENCES fragments(id) ON DELETE CASCADE,
relevance REAL NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
PRIMARY KEY (idea_id, fragment_id)
);
CREATE TABLE IF NOT EXISTS idea_snapshots (
id TEXT PRIMARY KEY,
idea_id TEXT NOT NULL REFERENCES ideas(id) ON DELETE CASCADE,
source_fragment_id TEXT REFERENCES fragments(id) ON DELETE SET NULL,
maturity_ai REAL NOT NULL,
motion TEXT NOT NULL,
position TEXT NOT NULL,
tension TEXT NOT NULL,
trajectory TEXT NOT NULL,
possible_moves TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
user_id TEXT,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS agent_runs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
fragment_id TEXT NOT NULL,
status TEXT NOT NULL,
prompt_version TEXT NOT NULL,
model TEXT NOT NULL,
thinking_effort TEXT NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT,
duration_ms INTEGER,
attempt_count INTEGER NOT NULL DEFAULT 0,
model_rounds INTEGER NOT NULL DEFAULT 0,
tool_calls INTEGER NOT NULL DEFAULT 0,
search_calls INTEGER NOT NULL DEFAULT 0,
inspection_calls INTEGER NOT NULL DEFAULT 0,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
cached_tokens INTEGER NOT NULL DEFAULT 0,
error_type TEXT,
error_message TEXT
);
CREATE TABLE IF NOT EXISTS agent_events (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL REFERENCES agent_runs(id) ON DELETE CASCADE,
user_id TEXT NOT NULL,
event_type TEXT NOT NULL,
event_at TEXT NOT NULL,
duration_ms INTEGER,
payload TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS audit_events (
id TEXT PRIMARY KEY,
user_id TEXT,
event_type TEXT NOT NULL,
created_at TEXT NOT NULL,
payload TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS idx_fragments_created
ON fragments(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_fragments_status
ON fragments(analysis_status);
CREATE INDEX IF NOT EXISTS idx_ideas_updated
ON ideas(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_links_fragment
ON idea_fragments(fragment_id);
CREATE INDEX IF NOT EXISTS idx_snapshots_idea
ON idea_snapshots(idea_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_runs_started
ON agent_runs(started_at DESC);
CREATE INDEX IF NOT EXISTS idx_runs_status
ON agent_runs(status, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_run
ON agent_events(run_id, event_at ASC);
CREATE INDEX IF NOT EXISTS idx_audit_created
ON audit_events(created_at DESC);
"""
)
self._ensure_column(connection, "fragments", "user_id", "TEXT")
self._ensure_column(connection, "ideas", "user_id", "TEXT")
self._ensure_column(connection, "sessions", "user_id", "TEXT")
now = utc_now()
for seed in seeds:
connection.execute(
"""
INSERT INTO users (
id, label, role, access_key_hash, debug_sharing,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
label = excluded.label,
role = excluded.role,
access_key_hash = excluded.access_key_hash,
updated_at = excluded.updated_at
""",
(
seed.id,
seed.label,
seed.role,
seed.access_key_hash,
int(seed.debug_sharing),
now,
now,
),
)
if seeds:
primary = next(
(seed for seed in seeds if seed.role == "admin"), seeds[0]
)
for table in ("fragments", "ideas", "sessions"):
connection.execute(
f"""
UPDATE {table} SET user_id = ?
WHERE user_id IS NULL OR user_id = ''
""",
(primary.id,),
)
connection.executescript(
"""
CREATE INDEX IF NOT EXISTS idx_fragments_user_created
ON fragments(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_ideas_user_updated
ON ideas(user_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_user
ON sessions(user_id, expires_at DESC);
CREATE INDEX IF NOT EXISTS idx_runs_user_started
ON agent_runs(user_id, started_at DESC);
"""
)
@staticmethod
def _ensure_column(
connection: sqlite3.Connection, table: str, column: str, definition: str
) -> None:
columns = {
row["name"]
for row in connection.execute(f"PRAGMA table_info({table})").fetchall()
}
if column not in columns:
connection.execute(
f"ALTER TABLE {table} ADD COLUMN {column} {definition}"
)
# Users and sessions -------------------------------------------------
def list_auth_users(self) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(
"""
SELECT id, label, role, access_key_hash, debug_sharing
FROM users ORDER BY created_at ASC
"""
).fetchall()
return [dict(row) for row in rows]
def get_user(self, user_id: str) -> dict[str, Any] | None:
with self.connect() as connection:
row = connection.execute(
"""
SELECT id, label, role, debug_sharing, created_at
FROM users WHERE id = ?
""",
(user_id,),
).fetchone()
if not row:
return None
user = dict(row)
user["debug_sharing"] = bool(user["debug_sharing"])
return user
def list_users(self) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(
"""
SELECT u.id, u.label, u.role, u.debug_sharing, u.created_at,
COUNT(DISTINCT f.id) AS fragment_count,
COUNT(DISTINCT i.id) AS idea_count
FROM users u
LEFT JOIN fragments f ON f.user_id = u.id
LEFT JOIN ideas i ON i.user_id = u.id
GROUP BY u.id
ORDER BY u.created_at ASC
"""
).fetchall()
result = [dict(row) for row in rows]
for user in result:
user["debug_sharing"] = bool(user["debug_sharing"])
return result
def update_debug_sharing(
self, user_id: str, enabled: bool
) -> dict[str, Any] | None:
with self.connect() as connection:
result = connection.execute(
"""
UPDATE users SET debug_sharing = ?, updated_at = ?
WHERE id = ?
""",
(int(enabled), utc_now(), user_id),
)
if result.rowcount == 0:
return None
return self.get_user(user_id)
# Raw fragments ------------------------------------------------------
def create_fragment(self, user_id: str, content: str) -> dict[str, Any]:
fragment = {
"id": str(uuid.uuid4()),
"user_id": user_id,
"content": content,
"created_at": utc_now(),
"analysis_status": "pending",
}
with self.connect() as connection:
connection.execute(
"""
INSERT INTO fragments (
id, user_id, content, created_at, analysis_status
) VALUES (:id, :user_id, :content, :created_at, :analysis_status)
""",
fragment,
)
return fragment
def list_fragments(
self, user_id: str, limit: int = 300
) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(
"""
SELECT id, content, created_at
FROM fragments
WHERE user_id = ?
ORDER BY created_at DESC, rowid DESC
LIMIT ?
""",
(user_id, limit),
).fetchall()
return [dict(row) for row in reversed(rows)]
def get_fragment(
self, fragment_id: str, user_id: str | None = None
) -> dict[str, Any] | None:
query = "SELECT * FROM fragments WHERE id = ?"
params: list[Any] = [fragment_id]
if user_id is not None:
query += " AND user_id = ?"
params.append(user_id)
with self.connect() as connection:
row = connection.execute(query, params).fetchone()
return dict(row) if row else None
def pending_fragment_ids(self, limit: int = 100) -> list[str]:
with self.connect() as connection:
rows = connection.execute(
"""
SELECT id FROM fragments
WHERE analysis_status IN ('pending', 'processing')
AND user_id IS NOT NULL
ORDER BY created_at ASC, rowid ASC LIMIT ?
""",
(limit,),
).fetchall()
connection.execute(
"""
UPDATE fragments SET analysis_status = 'pending'
WHERE analysis_status = 'processing'
"""
)
return [row["id"] for row in rows]
def set_fragment_status(
self, fragment_id: str, status: str, error: str | None = None
) -> None:
with self.connect() as connection:
connection.execute(
"""
UPDATE fragments
SET analysis_status = ?, analysis_error = ?
WHERE id = ?
""",
(status, error, fragment_id),
)
def processing_count(self, user_id: str | None = None) -> int:
query = """
SELECT COUNT(*) AS count FROM fragments
WHERE analysis_status IN ('pending', 'processing')
"""
params: list[Any] = []
if user_id:
query += " AND user_id = ?"
params.append(user_id)
with self.connect() as connection:
row = connection.execute(query, params).fetchone()
return int(row["count"])
# Ideas --------------------------------------------------------------
def list_ideas(self, user_id: str) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(
"""
SELECT i.*,
COUNT(l.fragment_id) AS fragment_count,
MAX(f.created_at) AS latest_fragment_at
FROM ideas i
LEFT JOIN idea_fragments l ON l.idea_id = i.id
LEFT JOIN fragments f
ON f.id = l.fragment_id AND f.user_id = i.user_id
WHERE i.user_id = ?
GROUP BY i.id
ORDER BY i.updated_at DESC
""",
(user_id,),
).fetchall()
return [self._idea_from_row(row) for row in rows]
def get_idea(self, user_id: str, idea_id: str) -> dict[str, Any] | None:
with self.connect() as connection:
row = connection.execute(
"""
SELECT i.*,
COUNT(l.fragment_id) AS fragment_count,
MAX(f.created_at) AS latest_fragment_at
FROM ideas i
LEFT JOIN idea_fragments l ON l.idea_id = i.id
LEFT JOIN fragments f
ON f.id = l.fragment_id AND f.user_id = i.user_id
WHERE i.id = ? AND i.user_id = ?
GROUP BY i.id
""",
(idea_id, user_id),
).fetchone()
if not row:
return None
idea = self._idea_from_row(row)
fragments = connection.execute(
"""
SELECT f.id, f.content, f.created_at
FROM fragments f
JOIN idea_fragments l ON l.fragment_id = f.id
WHERE l.idea_id = ? AND f.user_id = ?
ORDER BY f.created_at ASC, f.rowid ASC
""",
(idea_id, user_id),
).fetchall()
snapshots = connection.execute(
"""
SELECT s.maturity_ai, s.motion, s.position, s.tension,
s.trajectory, s.possible_moves, s.created_at
FROM idea_snapshots s
JOIN ideas i ON i.id = s.idea_id
WHERE s.idea_id = ? AND i.user_id = ?
ORDER BY s.created_at ASC, s.rowid ASC
""",
(idea_id, user_id),
).fetchall()
idea["fragments"] = [dict(row) for row in fragments]
idea["snapshots"] = [
{
**dict(row),
"possible_moves": _loads(row["possible_moves"], []),
}
for row in snapshots
]
return idea
def curator_context(
self, user_id: str
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
ideas = self.list_ideas(user_id)
fragments = self.list_fragments(user_id, limit=80)
for idea in ideas:
detailed = self.get_idea(user_id, idea["id"])
idea["recent_fragments"] = (detailed or {}).get("fragments", [])[-8:]
idea["recent_snapshots"] = (detailed or {}).get("snapshots", [])[-5:]
return ideas, fragments
def apply_assessments(
self,
user_id: str,
fragment_id: str,
assessments: list[dict[str, Any]],
) -> list[str]:
now = utc_now()
affected: list[str] = []
with self.connect() as connection:
fragment_exists = connection.execute(
"""
SELECT 1 FROM fragments
WHERE id = ? AND user_id = ?
""",
(fragment_id, user_id),
).fetchone()
if not fragment_exists:
raise ValueError("fragment not found for user")
seen: set[str] = set()
for assessment in assessments[:2]:
idea_id = assessment.get("idea_id")
if idea_id:
exists = connection.execute(
"""
SELECT 1 FROM ideas
WHERE id = ? AND user_id = ?
""",
(idea_id, user_id),
).fetchone()
if not exists:
idea_id = None
if not idea_id:
idea_id = str(uuid.uuid4())
connection.execute(
"""
INSERT INTO ideas (
id, user_id, title, summary, maturity_ai,
confidence, motion, position, tension, trajectory,
possible_moves, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
idea_id,
user_id,
assessment["title"],
assessment["summary"],
assessment["maturity"],
assessment["confidence"],
assessment["motion"],
assessment["position"],
assessment["tension"],
assessment["trajectory"],
_json(assessment["possible_moves"]),
now,
now,
),
)
elif idea_id not in seen:
connection.execute(
"""
UPDATE ideas SET
title = ?, summary = ?, maturity_ai = ?,
confidence = ?, motion = ?, position = ?,
tension = ?, trajectory = ?, possible_moves = ?,
updated_at = ?
WHERE id = ? AND user_id = ?
""",
(
assessment["title"],
assessment["summary"],
assessment["maturity"],
assessment["confidence"],
assessment["motion"],
assessment["position"],
assessment["tension"],
assessment["trajectory"],
_json(assessment["possible_moves"]),
now,
idea_id,
user_id,
),
)
if idea_id in seen:
continue
seen.add(idea_id)
affected.append(idea_id)
connection.execute(
"""
INSERT OR IGNORE INTO idea_fragments (
idea_id, fragment_id, relevance, created_at
) VALUES (?, ?, ?, ?)
""",
(
idea_id,
fragment_id,
assessment.get("relevance", 1),
now,
),
)
connection.execute(
"""
INSERT INTO idea_snapshots (
id, idea_id, source_fragment_id, maturity_ai, motion,
position, tension, trajectory, possible_moves, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
str(uuid.uuid4()),
idea_id,
fragment_id,
assessment["maturity"],
assessment["motion"],
assessment["position"],
assessment["tension"],
assessment["trajectory"],
_json(assessment["possible_moves"]),
now,
),
)
connection.execute(
"""
UPDATE fragments
SET analysis_status = 'done', analysis_error = NULL
WHERE id = ? AND user_id = ?
""",
(fragment_id, user_id),
)
return affected
def update_idea_override(
self, user_id: str, idea_id: str, maturity_override: float | None
) -> dict[str, Any] | None:
with self.connect() as connection:
result = connection.execute(
"""
UPDATE ideas SET maturity_override = ?, updated_at = ?
WHERE id = ? AND user_id = ?
""",
(maturity_override, utc_now(), idea_id, user_id),
)
if result.rowcount == 0:
return None
return self.get_idea(user_id, idea_id)
@staticmethod
def _idea_from_row(row: sqlite3.Row) -> dict[str, Any]:
idea = dict(row)
idea["possible_moves"] = _loads(idea["possible_moves"], [])
idea["maturity"] = (
idea["maturity_override"]
if idea["maturity_override"] is not None
else idea["maturity_ai"]
)
idea["is_overridden"] = idea["maturity_override"] is not None
idea.pop("user_id", None)
return idea
# Observability ------------------------------------------------------
def start_agent_run(
self,
user_id: str,
fragment_id: str,
model: str,
prompt_version: str,
thinking_effort: str,
) -> str:
run_id = str(uuid.uuid4())
now = utc_now()
with self.connect() as connection:
connection.execute(
"""
INSERT INTO agent_runs (
id, user_id, fragment_id, status, prompt_version, model,
thinking_effort, started_at
) VALUES (?, ?, ?, 'running', ?, ?, ?, ?)
""",
(
run_id,
user_id,
fragment_id,
prompt_version,
model,
thinking_effort,
now,
),
)
self.add_agent_event(
run_id,
user_id,
"run_started",
{
"model": model,
"thinking_effort": thinking_effort,
"prompt_version": prompt_version,
},
)
return run_id
def add_agent_event(
self,
run_id: str,
user_id: str,
event_type: str,
payload: dict[str, Any] | None = None,
duration_ms: int | None = None,
) -> None:
with self.connect() as connection:
connection.execute(
"""
INSERT INTO agent_events (
id, run_id, user_id, event_type, event_at,
duration_ms, payload
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
str(uuid.uuid4()),
run_id,
user_id,
event_type,
utc_now(),
duration_ms,
_json(payload or {}),
),
)
def finish_agent_run(
self,
run_id: str,
*,
status: str,
duration_ms: int,
attempt_count: int,
model_rounds: int,
tool_calls: int,
search_calls: int,
inspection_calls: int,
input_tokens: int,
output_tokens: int,
reasoning_tokens: int,
cached_tokens: int,
error: Exception | None = None,
) -> None:
with self.connect() as connection:
connection.execute(
"""
UPDATE agent_runs SET
status = ?, finished_at = ?, duration_ms = ?,
attempt_count = ?, model_rounds = ?, tool_calls = ?,
search_calls = ?, inspection_calls = ?,
input_tokens = ?, output_tokens = ?,
reasoning_tokens = ?, cached_tokens = ?,
error_type = ?, error_message = ?
WHERE id = ?
""",
(
status,
utc_now(),
duration_ms,
attempt_count,
model_rounds,
tool_calls,
search_calls,
inspection_calls,
input_tokens,
output_tokens,
reasoning_tokens,
cached_tokens,
type(error).__name__ if error else None,
str(error)[:500] if error else None,
run_id,
),
)
def add_audit_event(
self,
event_type: str,
user_id: str | None = None,
payload: dict[str, Any] | None = None,
) -> None:
with self.connect() as connection:
connection.execute(
"""
INSERT INTO audit_events (
id, user_id, event_type, created_at, payload
) VALUES (?, ?, ?, ?, ?)
""",
(
str(uuid.uuid4()),
user_id,
event_type,
utc_now(),
_json(payload or {}),
),
)
def admin_overview(self) -> dict[str, Any]:
since = (datetime.now(UTC) - timedelta(hours=24)).isoformat()
seven_days = (datetime.now(UTC) - timedelta(days=6)).date().isoformat()
with self.connect() as connection:
totals = {
"users": connection.execute(
"SELECT COUNT(*) FROM users"
).fetchone()[0],
"fragments": connection.execute(
"SELECT COUNT(*) FROM fragments"
).fetchone()[0],
"ideas": connection.execute(
"SELECT COUNT(*) FROM ideas"
).fetchone()[0],
"pending": connection.execute(
"""
SELECT COUNT(*) FROM fragments
WHERE analysis_status IN ('pending', 'processing')
"""
).fetchone()[0],
"audit_events": connection.execute(
"SELECT COUNT(*) FROM audit_events"
).fetchone()[0],
}
recent = dict(
connection.execute(
"""
SELECT
COUNT(*) AS runs,
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END)
AS successes,
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END)
AS errors,
COALESCE(AVG(duration_ms), 0) AS avg_duration_ms,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens,
COALESCE(SUM(reasoning_tokens), 0) AS reasoning_tokens,
COALESCE(SUM(cached_tokens), 0) AS cached_tokens,
COALESCE(SUM(model_rounds), 0) AS model_rounds,
COALESCE(SUM(tool_calls), 0) AS tool_calls
FROM agent_runs WHERE started_at >= ?
""",
(since,),
).fetchone()
)
daily = connection.execute(
"""
SELECT substr(started_at, 1, 10) AS day,
COUNT(*) AS runs,
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END)
AS successes,
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END)
AS errors,
COALESCE(SUM(reasoning_tokens), 0) AS reasoning_tokens
FROM agent_runs
WHERE substr(started_at, 1, 10) >= ?
GROUP BY day ORDER BY day ASC
""",
(seven_days,),
).fetchall()
recent_errors = connection.execute(
"""
SELECT r.id, r.started_at, r.error_type, r.error_message,
u.label AS user_label
FROM agent_runs r
JOIN users u ON u.id = r.user_id
WHERE r.status = 'error'
ORDER BY r.started_at DESC LIMIT 5
"""
).fetchall()
audit_24h = dict(
connection.execute(
"""
SELECT
COUNT(*) AS events,
SUM(CASE WHEN event_type = 'login_failed'
THEN 1 ELSE 0 END) AS failed_logins
FROM audit_events WHERE created_at >= ?
""",
(since,),
).fetchone()
)
recent["successes"] = int(recent["successes"] or 0)
recent["errors"] = int(recent["errors"] or 0)
audit_24h["events"] = int(audit_24h["events"] or 0)
audit_24h["failed_logins"] = int(
audit_24h["failed_logins"] or 0
)
recent["success_rate"] = (
round(recent["successes"] / recent["runs"] * 100, 1)
if recent["runs"]
else 100.0
)
return {
"totals": totals,
"last_24h": recent,
"daily": [dict(row) for row in daily],
"recent_errors": [dict(row) for row in recent_errors],
"audit_24h": audit_24h,
}
def list_agent_runs(self, limit: int = 80) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(
"""
SELECT r.*, u.label AS user_label, u.role AS user_role,
u.debug_sharing, length(f.content) AS content_length
FROM agent_runs r
JOIN users u ON u.id = r.user_id
JOIN fragments f ON f.id = r.fragment_id
ORDER BY r.started_at DESC LIMIT ?
""",
(limit,),
).fetchall()
result = [dict(row) for row in rows]
for run in result:
run["debug_sharing"] = bool(run["debug_sharing"])
return result
def get_agent_run(self, run_id: str) -> dict[str, Any] | None:
with self.connect() as connection:
row = connection.execute(
"""
SELECT r.*, u.label AS user_label, u.role AS user_role,
u.debug_sharing, f.content,
length(f.content) AS content_length
FROM agent_runs r
JOIN users u ON u.id = r.user_id
JOIN fragments f ON f.id = r.fragment_id
WHERE r.id = ?
""",
(run_id,),
).fetchone()
if not row:
return None
run = dict(row)
run["debug_sharing"] = bool(run["debug_sharing"])
run["content_sha256"] = hashlib.sha256(
run["content"].encode("utf-8")
).hexdigest()[:16]
return run
def list_agent_events(self, run_id: str) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(
"""
SELECT id, event_type, event_at, duration_ms, payload
FROM agent_events
WHERE run_id = ?
ORDER BY event_at ASC, rowid ASC
""",
(run_id,),
).fetchall()
return [
{**dict(row), "payload": _loads(row["payload"], {})}
for row in rows
]
def list_audit_events(self, limit: int = 80) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(
"""
SELECT a.id, a.event_type, a.created_at, a.payload,
u.label AS user_label
FROM audit_events a
LEFT JOIN users u ON u.id = a.user_id
ORDER BY a.created_at DESC, a.rowid DESC
LIMIT ?
""",
(limit,),
).fetchall()
return [
{**dict(row), "payload": _loads(row["payload"], {})}
for row in rows
]
+363
View File
@@ -0,0 +1,363 @@
from __future__ import annotations
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response, status
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from .auth import (
AuthUser,
create_session,
delete_session,
find_user_for_key,
limiter,
require_admin,
require_auth,
require_same_origin_intent,
)
from .config import Settings, UserSeed
from .curator import Curator, PROMPT_VERSION
from .db import Database
from .schemas import (
DebugSharingUpdate,
FragmentCreate,
IdeaOverride,
LoginRequest,
)
settings = Settings.load()
db = Database(settings.database_path)
curator = Curator(db, settings)
started_at = time.time()
@asynccontextmanager
async def lifespan(_: FastAPI):
seeds = list(settings.users)
if settings.auth_disabled and not seeds:
seeds.append(
UserSeed(
id="development",
label="本地开发",
role="admin",
access_key_hash="authentication-disabled",
debug_sharing=True,
)
)
db.initialize(seeds)
db.add_audit_event(
"application_started",
payload={
"configured_users": len(seeds),
"curator_configured": curator.configured,
"prompt_version": PROMPT_VERSION,
},
)
await curator.start()
yield
await curator.stop()
app = FastAPI(
title="续想",
docs_url=None,
redoc_url=None,
openapi_url=None,
lifespan=lifespan,
)
@app.middleware("http")
async def security_headers(request: Request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains"
)
response.headers["Permissions-Policy"] = (
"camera=(), microphone=(), geolocation=(), payment=()"
)
response.headers["Content-Security-Policy"] = (
"default-src 'self'; script-src 'self'; style-src 'self'; "
"img-src 'self' data:; font-src 'self'; connect-src 'self'; "
"frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
)
return response
def authenticated(request: Request) -> AuthUser:
user = require_auth(request, db, settings)
require_same_origin_intent(request)
return user
def administrator(user: AuthUser = Depends(authenticated)) -> AuthUser:
require_admin(user)
return user
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/api/login")
async def login(payload: LoginRequest, request: Request, response: Response):
if settings.auth_disabled:
user = require_auth(request, db, settings)
return {"authenticated": True, "user": user.public()}
client_key = request.client.host if request.client else "unknown"
limiter.check(client_key)
user = find_user_for_key(payload.access_key, db)
if not user:
limiter.fail(client_key)
db.add_audit_event("login_failed")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="密钥不对"
)
limiter.clear(client_key)
create_session(response, db, settings, user.id)
db.add_audit_event("login_succeeded", user.id)
return {"authenticated": True, "user": user.public()}
@app.post("/api/logout")
async def logout(
request: Request,
response: Response,
user: AuthUser = Depends(authenticated),
):
delete_session(request, response, db, settings)
db.add_audit_event("logout", user.id)
return {"authenticated": False}
@app.get("/api/session")
async def session(user: AuthUser = Depends(authenticated)):
return {"authenticated": True, "user": user.public()}
@app.patch("/api/account/debug-sharing")
async def update_debug_sharing(
payload: DebugSharingUpdate,
user: AuthUser = Depends(authenticated),
):
updated = db.update_debug_sharing(user.id, payload.enabled)
if not updated:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
db.add_audit_event(
"debug_sharing_changed",
user.id,
{"enabled": payload.enabled},
)
return updated
@app.get("/api/fragments")
async def list_fragments(user: AuthUser = Depends(authenticated)):
return {"items": db.list_fragments(user.id)}
@app.post("/api/fragments", status_code=status.HTTP_201_CREATED)
async def create_fragment(
payload: FragmentCreate, user: AuthUser = Depends(authenticated)
):
fragment = db.create_fragment(user.id, payload.content)
db.add_audit_event(
"fragment_created",
user.id,
{"fragment_id": fragment["id"], "characters": len(payload.content)},
)
curator.enqueue(fragment["id"])
return {
"id": fragment["id"],
"content": fragment["content"],
"created_at": fragment["created_at"],
"analysis_status": fragment["analysis_status"],
}
@app.get("/api/ideas")
async def list_ideas(user: AuthUser = Depends(authenticated)):
return {"items": db.list_ideas(user.id)}
@app.get("/api/ideas/{idea_id}")
async def get_idea(idea_id: str, user: AuthUser = Depends(authenticated)):
idea = db.get_idea(user.id, idea_id)
if not idea:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return idea
@app.patch("/api/ideas/{idea_id}/position")
async def update_idea_position(
idea_id: str,
payload: IdeaOverride,
user: AuthUser = Depends(authenticated),
):
idea = db.update_idea_override(user.id, idea_id, payload.maturity)
if not idea:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
db.add_audit_event(
"idea_position_calibrated",
user.id,
{"idea_id": idea_id, "maturity": payload.maturity},
)
return idea
@app.get("/api/system/status")
async def system_status(user: AuthUser = Depends(authenticated)):
return {
"pending": db.processing_count(user.id),
"curator_configured": curator.configured,
}
@app.get("/api/admin/overview")
async def admin_overview(_: AuthUser = Depends(administrator)):
overview = db.admin_overview()
recent = overview["last_24h"]
if recent["runs"] == 0:
narrative = "过去 24 小时没有 Agent 运行。"
else:
narrative = (
f"过去 24 小时运行 {recent['runs']} 次,"
f"成功率 {recent['success_rate']}%,"
f"平均耗时 {round(recent['avg_duration_ms'] / 1000, 1)} 秒;"
f"其中推理 token {recent['reasoning_tokens']}。"
)
return {
**overview,
"runtime": {
"uptime_seconds": round(time.time() - started_at),
"curator_configured": curator.configured,
"queue_depth": curator.queue.qsize(),
"model": settings.deepseek_model,
"prompt_version": PROMPT_VERSION,
},
"narrative": narrative,
}
@app.get("/api/admin/users")
async def admin_users(_: AuthUser = Depends(administrator)):
return {"items": db.list_users()}
@app.get("/api/admin/runs")
async def admin_runs(
limit: int = Query(default=80, ge=1, le=200),
_: AuthUser = Depends(administrator),
):
return {"items": db.list_agent_runs(limit)}
@app.get("/api/admin/audit")
async def admin_audit(
limit: int = Query(default=80, ge=1, le=200),
_: AuthUser = Depends(administrator),
):
return {"items": db.list_audit_events(limit)}
def _redacted_event(event: dict[str, Any]) -> dict[str, Any]:
payload = event["payload"]
event_type = event["event_type"]
safe: dict[str, Any] = {}
if event_type == "run_started":
safe = payload
elif event_type == "context_loaded":
safe = payload
elif event_type in {"attempt_started", "model_attempt_completed"}:
safe = payload
elif event_type == "tool_search_ideas":
safe = {"result_count": payload.get("result_count", 0)}
elif event_type == "tool_inspect_idea":
safe = {
key: payload.get(key)
for key in ("found", "fragment_count", "snapshot_count")
if key in payload
}
elif event_type == "validation_failed":
safe = {
"attempt": payload.get("attempt"),
"error_type": payload.get("error_type"),
}
elif event_type == "decision_committed":
safe = {
"standalone": payload.get("standalone"),
"assessment_count": len(payload.get("assessments", [])),
}
elif event_type == "run_failed":
safe = {"error_type": payload.get("error_type")}
return {**event, "payload": safe}
@app.get("/api/admin/runs/{run_id}")
async def admin_run_detail(
run_id: str, admin: AuthUser = Depends(administrator)
):
run = db.get_agent_run(run_id)
if not run:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
content_visible = run["user_id"] == admin.id or run["debug_sharing"]
events = db.list_agent_events(run_id)
if not content_visible:
events = [_redacted_event(event) for event in events]
content = run.pop("content")
return {
"run": run,
"fragment": (
{"content": content, "content_visible": True}
if content_visible
else {
"content_visible": False,
"content_length": run["content_length"],
"content_sha256": run["content_sha256"],
}
),
"events": events,
"privacy": {
"content_visible": content_visible,
"reasoning_content_stored": False,
"reason": (
"管理员自己的运行"
if run["user_id"] == admin.id
else (
"用户已允许调试共享"
if run["debug_sharing"]
else "用户未允许调试共享,内容已脱敏"
)
),
},
}
static_dir = Path(__file__).parent / "static"
assets_dir = static_dir / "assets"
if assets_dir.is_dir():
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
@app.get("/{full_path:path}", include_in_schema=False)
async def frontend(full_path: str):
if full_path.startswith("api/"):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
target = static_dir / full_path
if full_path and target.is_file() and static_dir in target.resolve().parents:
return FileResponse(target)
index = static_dir / "index.html"
if index.is_file():
return FileResponse(index)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="前端尚未构建",
)
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
from typing import Annotated
from pydantic import (
BaseModel,
Field,
StringConstraints,
field_validator,
model_validator,
)
Content = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
class LoginRequest(BaseModel):
access_key: str = Field(min_length=1, max_length=512)
class FragmentCreate(BaseModel):
content: Content = Field(max_length=20_000)
class IdeaOverride(BaseModel):
maturity: float | None = Field(default=None, ge=0, le=100)
class DebugSharingUpdate(BaseModel):
enabled: bool
class IdeaAssessment(BaseModel):
idea_id: str | None = Field(
description="Existing idea id, or null only when a genuinely new idea is needed."
)
title: str = Field(min_length=1, max_length=80)
summary: str = Field(min_length=1, max_length=280)
maturity: float = Field(ge=0, le=100)
confidence: float = Field(ge=0, le=1)
motion: str = Field(min_length=1, max_length=24)
position: str = Field(min_length=1, max_length=120)
tension: str = Field(min_length=1, max_length=240)
trajectory: str = Field(min_length=1, max_length=280)
possible_moves: list[str] = Field(min_length=1, max_length=4)
relevance: float = Field(default=1, ge=0, le=1)
@field_validator("possible_moves")
@classmethod
def moves_are_brief(cls, moves: list[str]) -> list[str]:
return [move.strip()[:100] for move in moves if move.strip()][:4]
class CuratorDecision(BaseModel):
standalone: bool = Field(
description="True when the fragment should remain only in the raw stream for now."
)
reasoning_note: str = Field(
max_length=200,
description="Brief audit note for the system, never shown in the capture stream.",
)
assessments: list[IdeaAssessment] = Field(default_factory=list, max_length=2)
@model_validator(mode="after")
def decision_is_coherent(self) -> "CuratorDecision":
if self.standalone and self.assessments:
raise ValueError("standalone decisions cannot contain assessments")
if not self.standalone and not self.assessments:
raise ValueError("non-standalone decisions require an assessment")
return self
+19
View File
@@ -0,0 +1,19 @@
services:
note-zero:
labels:
net.unraid.docker.managed: composeman
net.unraid.docker.icon: ""
net.unraid.docker.webui: ""
net.unraid.docker.shell: ""
environment:
DATA_DIR: /app/data
USERS_FILE: /run/secrets/users.json
SESSION_SECRET_FILE: /run/secrets/session_secret
DEEPSEEK_API_KEY_FILE: /run/secrets/deepseek_api_key
COOKIE_SECURE: "true"
DEEPSEEK_MODEL: deepseek-v4-pro
volumes:
- /mnt/user/appdata/note-zero/data:/app/data
- /mnt/user/appdata/note-zero/secrets/users.json:/run/secrets/users.json:ro
- /mnt/user/appdata/note-zero/secrets/session_secret:/run/secrets/session_secret:ro
- /mnt/user/appdata/note-zero/secrets/deepseek_api_key:/run/secrets/deepseek_api_key:ro
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#f3f0e9" />
<meta name="description" content="让零散念头继续生长的私人笔记本" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>续想</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+868
View File
@@ -0,0 +1,868 @@
{
"name": "xuxiang-web",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "xuxiang-web",
"version": "0.1.0",
"dependencies": {
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"@vitejs/plugin-react": "6.0.4",
"react": "19.2.0",
"react-dom": "19.2.0",
"typescript": "5.9.3",
"vite": "8.1.5"
},
"devDependencies": {}
},
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@oxc-project/types": {
"version": "0.139.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
"cpu": [
"ppc64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
"cpu": [
"s390x"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
"cpu": [
"wasm32"
],
"optional": true,
"dependencies": {
"@emnapi/core": "1.11.1",
"@emnapi/runtime": "1.11.1",
"@napi-rs/wasm-runtime": "^1.1.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/react": {
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"dependencies": {
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz",
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz",
"integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==",
"dependencies": {
"@rolldown/pluginutils": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
"babel-plugin-react-compiler": "^1.0.0",
"vite": "^8.0.0"
},
"peerDependenciesMeta": {
"@rolldown/plugin-babel": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
}
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.33.0",
"lightningcss-darwin-arm64": "1.33.0",
"lightningcss-darwin-x64": "1.33.0",
"lightningcss-freebsd-x64": "1.33.0",
"lightningcss-linux-arm-gnueabihf": "1.33.0",
"lightningcss-linux-arm64-gnu": "1.33.0",
"lightningcss-linux-arm64-musl": "1.33.0",
"lightningcss-linux-x64-gnu": "1.33.0",
"lightningcss-linux-x64-musl": "1.33.0",
"lightningcss-win32-arm64-msvc": "1.33.0",
"lightningcss-win32-x64-msvc": "1.33.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
"node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"dependencies": {
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/react": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.0"
}
},
"node_modules/rolldown": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
"dependencies": {
"@oxc-project/types": "=0.139.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.1.5",
"@rolldown/binding-darwin-arm64": "1.1.5",
"@rolldown/binding-darwin-x64": "1.1.5",
"@rolldown/binding-freebsd-x64": "1.1.5",
"@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
"@rolldown/binding-linux-arm64-gnu": "1.1.5",
"@rolldown/binding-linux-arm64-musl": "1.1.5",
"@rolldown/binding-linux-ppc64-gnu": "1.1.5",
"@rolldown/binding-linux-s390x-gnu": "1.1.5",
"@rolldown/binding-linux-x64-gnu": "1.1.5",
"@rolldown/binding-linux-x64-musl": "1.1.5",
"@rolldown/binding-openharmony-arm64": "1.1.5",
"@rolldown/binding-wasm32-wasi": "1.1.5",
"@rolldown/binding-win32-arm64-msvc": "1.1.5",
"@rolldown/binding-win32-x64-msvc": "1.1.5"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"optional": true
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/vite": {
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.5",
"postcss": "^8.5.17",
"rolldown": "~1.1.5",
"tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.3.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
}
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "xuxiang-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint ."
},
"dependencies": {
"@vitejs/plugin-react": "6.0.4",
"vite": "8.1.5",
"typescript": "5.9.3",
"react": "19.2.0",
"react-dom": "19.2.0",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2"
},
"devDependencies": {}
}
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<rect width="128" height="128" rx="32" fill="#152a25"/>
<path d="M31 74c16-2 19-28 35-27 14 1 13 23 29 20" fill="none" stroke="#f3f0e9" stroke-width="9" stroke-linecap="round"/>
<circle cx="31" cy="74" r="7" fill="#d17c57"/>
<circle cx="96" cy="67" r="7" fill="#f3f0e9"/>
</svg>

After

Width:  |  Height:  |  Size: 351 B

+18
View File
@@ -0,0 +1,18 @@
{
"name": "续想",
"short_name": "续想",
"description": "让零散念头继续生长的私人笔记本",
"start_url": "/",
"display": "standalone",
"background_color": "#f3f0e9",
"theme_color": "#f3f0e9",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}
+1018
View File
File diff suppressed because it is too large Load Diff
+231
View File
@@ -0,0 +1,231 @@
export type Fragment = {
id: string;
content: string;
created_at: string;
};
export type User = {
id: string;
label: string;
role: "admin" | "member";
debug_sharing: boolean;
};
export type Snapshot = {
maturity_ai: number;
motion: string;
position: string;
tension: string;
trajectory: string;
possible_moves: string[];
created_at: string;
};
export type Idea = {
id: string;
title: string;
summary: string;
maturity_ai: number;
maturity_override: number | null;
maturity: number;
is_overridden: boolean;
confidence: number;
motion: string;
position: string;
tension: string;
trajectory: string;
possible_moves: string[];
fragment_count: number;
latest_fragment_at: string | null;
updated_at: string;
fragments?: Fragment[];
snapshots?: Snapshot[];
};
export type AdminOverview = {
totals: {
users: number;
fragments: number;
ideas: number;
pending: number;
audit_events: number;
};
last_24h: {
runs: number;
successes: number;
errors: number;
success_rate: number;
avg_duration_ms: number;
input_tokens: number;
output_tokens: number;
reasoning_tokens: number;
cached_tokens: number;
model_rounds: number;
tool_calls: number;
};
runtime: {
uptime_seconds: number;
curator_configured: boolean;
queue_depth: number;
model: string;
prompt_version: string;
};
daily: Array<{
day: string;
runs: number;
successes: number;
errors: number;
reasoning_tokens: number;
}>;
recent_errors: Array<{
id: string;
started_at: string;
error_type: string;
error_message: string;
user_label: string;
}>;
audit_24h: {
events: number;
failed_logins: number;
};
narrative: string;
};
export type AgentRun = {
id: string;
user_id: string;
fragment_id: string;
status: "running" | "success" | "error";
prompt_version: string;
model: string;
thinking_effort: string;
started_at: string;
finished_at: string | null;
duration_ms: number | null;
attempt_count: number;
model_rounds: number;
tool_calls: number;
search_calls: number;
inspection_calls: number;
input_tokens: number;
output_tokens: number;
reasoning_tokens: number;
cached_tokens: number;
error_type: string | null;
error_message: string | null;
user_label: string;
user_role: string;
debug_sharing: boolean;
content_length: number;
};
export type AgentEvent = {
id: string;
event_type: string;
event_at: string;
duration_ms: number | null;
payload: Record<string, unknown>;
};
export type AgentRunDetail = {
run: AgentRun & { content_sha256: string };
fragment:
| { content_visible: true; content: string }
| { content_visible: false; content_length: number; content_sha256: string };
events: AgentEvent[];
privacy: {
content_visible: boolean;
reasoning_content_stored: boolean;
reason: string;
};
};
export type AdminUser = User & {
created_at: string;
fragment_count: number;
idea_count: number;
};
export type AuditEvent = {
id: string;
event_type: string;
created_at: string;
user_label: string | null;
payload: Record<string, unknown>;
};
type ApiOptions = RequestInit & { body?: string };
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
async function request<T>(path: string, options: ApiOptions = {}): Promise<T> {
const response = await fetch(path, {
...options,
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"X-Note-Client": "xuxiang-web",
...(options.headers ?? {}),
},
});
if (!response.ok) {
let message = "请求没有完成";
try {
const body = await response.json();
if (typeof body.detail === "string") message = body.detail;
} catch {
// Keep the calm fallback message.
}
throw new ApiError(response.status, message);
}
return response.json() as Promise<T>;
}
export const api = {
session: () =>
request<{ authenticated: boolean; user: User }>("/api/session"),
login: (accessKey: string) =>
request<{ authenticated: boolean; user: User }>("/api/login", {
method: "POST",
body: JSON.stringify({ access_key: accessKey }),
}),
logout: () =>
request<{ authenticated: boolean }>("/api/logout", { method: "POST" }),
fragments: () => request<{ items: Fragment[] }>("/api/fragments"),
createFragment: (content: string) =>
request<Fragment>("/api/fragments", {
method: "POST",
body: JSON.stringify({ content }),
}),
ideas: () => request<{ items: Idea[] }>("/api/ideas"),
idea: (id: string) => request<Idea>(`/api/ideas/${id}`),
updateMaturity: (id: string, maturity: number | null) =>
request<Idea>(`/api/ideas/${id}/position`, {
method: "PATCH",
body: JSON.stringify({ maturity }),
}),
status: () =>
request<{ pending: number; curator_configured: boolean }>(
"/api/system/status",
),
updateDebugSharing: (enabled: boolean) =>
request<User>("/api/account/debug-sharing", {
method: "PATCH",
body: JSON.stringify({ enabled }),
}),
adminOverview: () => request<AdminOverview>("/api/admin/overview"),
adminUsers: () => request<{ items: AdminUser[] }>("/api/admin/users"),
adminRuns: (limit = 80) =>
request<{ items: AgentRun[] }>(`/api/admin/runs?limit=${limit}`),
adminRun: (id: string) =>
request<AgentRunDetail>(`/api/admin/runs/${id}`),
adminAudit: (limit = 80) =>
request<{ items: AuditEvent[] }>(`/api/admin/audit?limit=${limit}`),
};
+11
View File
@@ -0,0 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler"
},
"include": ["vite.config.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
build: {
outDir: "../app/static",
emptyOutDir: true,
},
server: {
proxy: {
"/api": "http://127.0.0.1:8000",
"/health": "http://127.0.0.1:8000",
},
},
});
+3
View File
@@ -0,0 +1,3 @@
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
+6
View File
@@ -0,0 +1,6 @@
fastapi==0.116.1
uvicorn[standard]==0.35.0
openai-agents==0.19.0
argon2-cffi==25.1.0
python-multipart==0.0.20
+129
View File
@@ -0,0 +1,129 @@
from pathlib import Path
from argon2 import PasswordHasher
from fastapi.testclient import TestClient
from app import main
from app.auth import COOKIE_NAME
from app.config import Settings, UserSeed
from app.curator import Curator
from app.db import Database
WRITE_HEADERS = {"X-Note-Client": "xuxiang-web"}
def test_login_isolation_roles_and_debug_privacy(
tmp_path: Path, monkeypatch
):
hasher = PasswordHasher()
admin_key = "key-admin-test"
member_key = "key-member-test"
users = (
UserSeed(
id="admin",
label="管理员",
role="admin",
access_key_hash=hasher.hash(admin_key),
),
UserSeed(
id="member",
label="用户二",
role="member",
access_key_hash=hasher.hash(member_key),
),
)
settings = Settings(
data_dir=tmp_path,
database_path=tmp_path / "api.sqlite3",
users=users,
session_secret="session-secret-for-tests",
deepseek_api_key=None,
deepseek_base_url="https://api.deepseek.com",
deepseek_model="deepseek-v4-pro",
cookie_secure=False,
auth_disabled=False,
)
database = Database(settings.database_path)
monkeypatch.setattr(main, "settings", settings)
monkeypatch.setattr(main, "db", database)
monkeypatch.setattr(main, "curator", Curator(database, settings))
with TestClient(main.app) as client:
assert client.post(
"/api/login", json={"access_key": "wrong"}
).status_code == 401
admin_login = client.post(
"/api/login", json={"access_key": admin_key}
)
assert admin_login.status_code == 200
assert admin_login.json()["user"]["role"] == "admin"
admin_cookie = client.cookies.get(COOKIE_NAME)
member_login = client.post(
"/api/login", json={"access_key": member_key}
)
assert member_login.status_code == 200
member_cookie = client.cookies.get(COOKIE_NAME)
client.cookies.set(COOKIE_NAME, admin_cookie)
admin_fragment = client.post(
"/api/fragments",
json={"content": "管理员的记录"},
headers=WRITE_HEADERS,
)
assert admin_fragment.status_code == 201
client.cookies.set(COOKIE_NAME, member_cookie)
member_fragment = client.post(
"/api/fragments",
json={"content": "用户二的记录"},
headers=WRITE_HEADERS,
)
assert member_fragment.status_code == 201
assert [item["content"] for item in client.get("/api/fragments").json()["items"]] == [
"用户二的记录"
]
assert client.get("/api/admin/overview").status_code == 403
run_id = database.start_agent_run(
"member",
member_fragment.json()["id"],
"deepseek-v4-pro",
"prompt.v1",
"high",
)
database.add_agent_event(
run_id,
"member",
"decision_committed",
{
"standalone": False,
"assessments": [{"title": "私密产物", "maturity": 42}],
},
)
client.cookies.set(COOKIE_NAME, admin_cookie)
assert [item["content"] for item in client.get("/api/fragments").json()["items"]] == [
"管理员的记录"
]
redacted = client.get(f"/api/admin/runs/{run_id}").json()
assert redacted["fragment"]["content_visible"] is False
assert redacted["events"][-1]["payload"] == {
"standalone": False,
"assessment_count": 1,
}
client.cookies.set(COOKIE_NAME, member_cookie)
shared = client.patch(
"/api/account/debug-sharing",
json={"enabled": True},
headers=WRITE_HEADERS,
)
assert shared.status_code == 200
client.cookies.set(COOKIE_NAME, admin_cookie)
visible = client.get(f"/api/admin/runs/{run_id}").json()
assert visible["fragment"]["content_visible"] is True
assert visible["fragment"]["content"] == "用户二的记录"
+190
View File
@@ -0,0 +1,190 @@
from pathlib import Path
from app.config import UserSeed
from app.db import Database
ADMIN = UserSeed(
id="admin-user",
label="管理员",
role="admin",
access_key_hash="hash-admin",
)
MEMBER = UserSeed(
id="member-user",
label="用户二",
role="member",
access_key_hash="hash-member",
)
def database(tmp_path: Path) -> Database:
db = Database(tmp_path / "test.sqlite3")
db.initialize([ADMIN, MEMBER])
return db
def assessment(idea_id: str | None = None, maturity: float = 38):
return {
"idea_id": idea_id,
"title": "无分类的笔记入口",
"summary": "先保留念头,再让结构在后台形成。",
"maturity": maturity,
"confidence": 0.76,
"motion": "从抱怨聚成原则",
"position": "已经有清楚的交互原则,还没有碰到真实使用。",
"tension": "自由输入与系统内部结构之间需要同时成立。",
"trajectory": "从输入摩擦的感受,收束成了产品边界。",
"possible_moves": ["做一个只保留原文的输入原型"],
"relevance": 1,
}
def test_fragment_is_saved_before_analysis(tmp_path: Path):
db = database(tmp_path)
fragment = db.create_fragment(ADMIN.id, "一个还没有分类的念头")
assert db.list_fragments(ADMIN.id) == [
{
"id": fragment["id"],
"content": "一个还没有分类的念头",
"created_at": fragment["created_at"],
}
]
assert db.get_fragment(fragment["id"], ADMIN.id)["analysis_status"] == "pending"
def test_user_data_is_isolated_at_query_layer(tmp_path: Path):
db = database(tmp_path)
admin_fragment = db.create_fragment(ADMIN.id, "管理员的私密念头")
member_fragment = db.create_fragment(MEMBER.id, "用户二的私密念头")
db.apply_assessments(ADMIN.id, admin_fragment["id"], [assessment()])
admin_idea = db.list_ideas(ADMIN.id)[0]
assert [item["content"] for item in db.list_fragments(ADMIN.id)] == [
"管理员的私密念头"
]
assert [item["content"] for item in db.list_fragments(MEMBER.id)] == [
"用户二的私密念头"
]
assert db.get_fragment(member_fragment["id"], ADMIN.id) is None
assert db.get_idea(MEMBER.id, admin_idea["id"]) is None
assert db.update_idea_override(MEMBER.id, admin_idea["id"], 80) is None
assert db.curator_context(MEMBER.id)[0] == []
def test_apply_assessment_builds_trajectory(tmp_path: Path):
db = database(tmp_path)
first = db.create_fragment(ADMIN.id, "笔记不应该要求先分类")
db.apply_assessments(ADMIN.id, first["id"], [assessment()])
ideas = db.list_ideas(ADMIN.id)
assert len(ideas) == 1
assert ideas[0]["maturity"] == 38
assert ideas[0]["fragment_count"] == 1
second = db.create_fragment(ADMIN.id, "AI 的判断不能出现在记录流里")
db.apply_assessments(
ADMIN.id,
second["id"],
[assessment(ideas[0]["id"], maturity=46)],
)
idea = db.get_idea(ADMIN.id, ideas[0]["id"])
assert idea["fragment_count"] == 2
assert len(idea["snapshots"]) == 2
assert idea["maturity"] == 46
def test_manual_position_is_authoritative(tmp_path: Path):
db = database(tmp_path)
fragment = db.create_fragment(ADMIN.id, "一个想法")
db.apply_assessments(ADMIN.id, fragment["id"], [assessment(maturity=30)])
idea = db.list_ideas(ADMIN.id)[0]
overridden = db.update_idea_override(ADMIN.id, idea["id"], 61)
assert overridden["maturity"] == 61
assert overridden["maturity_ai"] == 30
assert overridden["is_overridden"] is True
restored = db.update_idea_override(ADMIN.id, idea["id"], None)
assert restored["maturity"] == 30
assert restored["is_overridden"] is False
def test_existing_unowned_data_migrates_to_first_admin(tmp_path: Path):
db = Database(tmp_path / "legacy.sqlite3")
db.initialize()
with db.connect() as connection:
connection.execute(
"""
INSERT INTO fragments (
id, user_id, content, created_at, analysis_status
) VALUES ('legacy-fragment', NULL, '旧记录', '2026-01-01', 'done')
"""
)
connection.execute(
"""
INSERT INTO ideas (
id, user_id, title, summary, maturity_ai, confidence,
motion, position, tension, trajectory, possible_moves,
created_at, updated_at
) VALUES (
'legacy-idea', NULL, '旧想法', '摘要', 10, .5,
'浮现', '位置', '张力', '轨迹', '[]',
'2026-01-01', '2026-01-01'
)
"""
)
db.initialize([ADMIN, MEMBER])
assert db.get_fragment("legacy-fragment", ADMIN.id)["content"] == "旧记录"
assert db.get_idea(ADMIN.id, "legacy-idea")["title"] == "旧想法"
assert db.get_fragment("legacy-fragment", MEMBER.id) is None
def test_agent_run_records_metrics_and_events(tmp_path: Path):
db = database(tmp_path)
fragment = db.create_fragment(ADMIN.id, "可观测的一次分析")
run_id = db.start_agent_run(
ADMIN.id,
fragment["id"],
"deepseek-v4-pro",
"prompt.v1",
"high",
)
db.add_agent_event(
run_id,
ADMIN.id,
"tool_search_ideas",
{"query": "可观测", "result_count": 0},
duration_ms=3,
)
db.finish_agent_run(
run_id,
status="success",
duration_ms=1200,
attempt_count=1,
model_rounds=2,
tool_calls=1,
search_calls=1,
inspection_calls=0,
input_tokens=100,
output_tokens=50,
reasoning_tokens=30,
cached_tokens=20,
)
run = db.get_agent_run(run_id)
events = db.list_agent_events(run_id)
overview = db.admin_overview()
assert run["status"] == "success"
assert run["reasoning_tokens"] == 30
assert [event["event_type"] for event in events] == [
"run_started",
"tool_search_ideas",
]
assert overview["last_24h"]["success_rate"] == 100