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