feat: open source multi-user AI notebook with observability
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.venv
|
||||
data
|
||||
frontend/node_modules
|
||||
app/static
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.sqlite3
|
||||
.DS_Store
|
||||
|
||||
+19
@@ -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
@@ -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", "*"]
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -0,0 +1,2 @@
|
||||
"""续想 — a quiet, AI-curated notebook."""
|
||||
|
||||
+204
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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="前端尚未构建",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
Generated
+868
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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}`),
|
||||
};
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler"
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
testpaths = ["tests"]
|
||||
@@ -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
|
||||
|
||||
@@ -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"] == "用户二的记录"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user