commit 935c41184742234f3fe8e12a517924b478816a80
Author: wuyang <5700876+banisherwy@user.noreply.gitee.com>
Date: Tue Jul 28 14:28:00 2026 +0800
feat: open source multi-user AI notebook with observability
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..18a2706
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,10 @@
+.git
+.venv
+data
+frontend/node_modules
+app/static
+__pycache__
+*.pyc
+*.sqlite3
+.DS_Store
+
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..add6c4a
--- /dev/null
+++ b/.gitignore
@@ -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
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..f10eaee
--- /dev/null
+++ b/Dockerfile
@@ -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", "*"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..540752d
--- /dev/null
+++ b/LICENSE
@@ -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.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..9466c9e
--- /dev/null
+++ b/README.md
@@ -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)
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..377edd2
--- /dev/null
+++ b/app/__init__.py
@@ -0,0 +1,2 @@
+"""续想 — a quiet, AI-curated notebook."""
+
diff --git a/app/auth.py b/app/auth.py
new file mode 100644
index 0000000..d09bffa
--- /dev/null
+++ b/app/auth.py
@@ -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)
diff --git a/app/config.py b/app/config.py
new file mode 100644
index 0000000..c6eb125
--- /dev/null
+++ b/app/config.py
@@ -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)
diff --git a/app/curator.py b/app/curator.py
new file mode 100644
index 0000000..d2c7979
--- /dev/null
+++ b/app/curator.py
@@ -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['content']}\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
diff --git a/app/db.py b/app/db.py
new file mode 100644
index 0000000..c37f585
--- /dev/null
+++ b/app/db.py
@@ -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
+ ]
diff --git a/app/main.py b/app/main.py
new file mode 100644
index 0000000..7fc505f
--- /dev/null
+++ b/app/main.py
@@ -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="前端尚未构建",
+ )
diff --git a/app/schemas.py b/app/schemas.py
new file mode 100644
index 0000000..2a5db2f
--- /dev/null
+++ b/app/schemas.py
@@ -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
diff --git a/deploy/docker-compose.override.yml b/deploy/docker-compose.override.yml
new file mode 100644
index 0000000..a51d247
--- /dev/null
+++ b/deploy/docker-compose.override.yml
@@ -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
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..8390096
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+ 续想
+
+
+
+
+
+
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..ab6c7e4
--- /dev/null
+++ b/frontend/package-lock.json
@@ -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
+ }
+ }
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..d794dc8
--- /dev/null
+++ b/frontend/package.json
@@ -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": {}
+}
+
diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg
new file mode 100644
index 0000000..cd51f50
--- /dev/null
+++ b/frontend/public/icon.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest
new file mode 100644
index 0000000..aeb7ae6
--- /dev/null
+++ b/frontend/public/manifest.webmanifest
@@ -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"
+ }
+ ]
+}
+
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..47f4e2a
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -0,0 +1,1018 @@
+import {
+ FormEvent,
+ KeyboardEvent,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import {
+ api,
+ AdminOverview,
+ AdminUser,
+ AgentEvent,
+ AgentRun,
+ AgentRunDetail,
+ AuditEvent,
+ ApiError,
+ Fragment,
+ Idea,
+ User,
+} from "./api";
+
+type View = "capture" | "ideas" | "admin";
+
+const DRAFT_KEY = "xuxiang:draft";
+
+function Login({ onSuccess }: { onSuccess: (user: User) => void }) {
+ const [accessKey, setAccessKey] = useState("");
+ const [error, setError] = useState("");
+ const [busy, setBusy] = useState(false);
+
+ async function submit(event: FormEvent) {
+ event.preventDefault();
+ if (!accessKey || busy) return;
+ setBusy(true);
+ setError("");
+ try {
+ const session = await api.login(accessKey);
+ setAccessKey("");
+ onSuccess(session.user);
+ } catch (reason) {
+ setError(reason instanceof Error ? reason.message : "没能打开");
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
+
+
+
+
+ 续想
+ 让零散的念头,继续往前走。
+
+
+
+ );
+}
+
+function dayLabel(value: string) {
+ const date = new Date(value);
+ const now = new Date();
+ const yesterday = new Date();
+ yesterday.setDate(now.getDate() - 1);
+ const sameDay = (a: Date, b: Date) =>
+ a.getFullYear() === b.getFullYear() &&
+ a.getMonth() === b.getMonth() &&
+ a.getDate() === b.getDate();
+ if (sameDay(date, now)) return "今天";
+ if (sameDay(date, yesterday)) return "昨天";
+ return new Intl.DateTimeFormat("zh-CN", {
+ month: "long",
+ day: "numeric",
+ weekday: "short",
+ }).format(date);
+}
+
+function timeLabel(value: string) {
+ return new Intl.DateTimeFormat("zh-CN", {
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ }).format(new Date(value));
+}
+
+function Capture() {
+ const [fragments, setFragments] = useState([]);
+ const [draft, setDraft] = useState(() => localStorage.getItem(DRAFT_KEY) ?? "");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+ const endRef = useRef(null);
+ const textareaRef = useRef(null);
+
+ useEffect(() => {
+ api.fragments().then((data) => setFragments(data.items));
+ }, []);
+
+ useEffect(() => {
+ localStorage.setItem(DRAFT_KEY, draft);
+ const textarea = textareaRef.current;
+ if (textarea) {
+ textarea.style.height = "0px";
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 168)}px`;
+ }
+ }, [draft]);
+
+ useEffect(() => {
+ endRef.current?.scrollIntoView({ behavior: "instant", block: "end" });
+ }, [fragments.length]);
+
+ const groups = useMemo(() => {
+ const result: { label: string; items: Fragment[] }[] = [];
+ fragments.forEach((fragment) => {
+ const label = dayLabel(fragment.created_at);
+ const current = result[result.length - 1];
+ if (!current || current.label !== label) {
+ result.push({ label, items: [fragment] });
+ } else {
+ current.items.push(fragment);
+ }
+ });
+ return result;
+ }, [fragments]);
+
+ async function save() {
+ const content = draft.trim();
+ if (!content || saving) return;
+ setSaving(true);
+ setError("");
+ try {
+ const fragment = await api.createFragment(content);
+ setFragments((current) => [...current, fragment]);
+ setDraft((current) => (current.trim() === content ? "" : current));
+ requestAnimationFrame(() => textareaRef.current?.focus());
+ } catch (reason) {
+ setError(
+ reason instanceof Error
+ ? `${reason.message}。文字还在这里。`
+ : "没有保存成功,文字还在这里。",
+ );
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ function keyDown(event: KeyboardEvent) {
+ if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
+ event.preventDefault();
+ void save();
+ }
+ }
+
+ return (
+
+
+ {groups.length === 0 && (
+
+ 从此刻开始
+
+ )}
+ {groups.map((group) => (
+
+ {group.label}
+ {group.items.map((fragment) => (
+
+ {fragment.content}
+
+
+ ))}
+
+ ))}
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+ );
+}
+
+function IdeaAxis({
+ idea,
+ large = false,
+}: {
+ idea: Idea;
+ large?: boolean;
+}) {
+ const position = Math.max(0, Math.min(100, idea.maturity));
+ return (
+
+
+ 模糊
+ 生效
+
+
+
+
+
+
+ {idea.motion}
+ ≈{Math.round(position)}
+
+
+ );
+}
+
+function IdeaDetail({
+ initial,
+ onClose,
+ onUpdate,
+}: {
+ initial: Idea;
+ onClose: () => void;
+ onUpdate: (idea: Idea) => void;
+}) {
+ const [idea, setIdea] = useState(initial);
+ const [calibrating, setCalibrating] = useState(false);
+ const [slider, setSlider] = useState(initial.maturity);
+ const [saving, setSaving] = useState(false);
+
+ useEffect(() => {
+ api.idea(initial.id).then((loaded) => {
+ setIdea(loaded);
+ setSlider(loaded.maturity);
+ });
+ }, [initial.id]);
+
+ async function saveCalibration(value: number | null) {
+ setSaving(true);
+ try {
+ const updated = await api.updateMaturity(idea.id, value);
+ setIdea(updated);
+ setSlider(updated.maturity);
+ onUpdate(updated);
+ setCalibrating(false);
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
+
event.stopPropagation()}
+ >
+
+
+ {idea.fragment_count} 个片段
+
+
+
{idea.title}
+
{idea.summary}
+
+
+ {calibrating && (
+
+
+ 你的判断
+ {Math.round(slider)}
+
+
setSlider(Number(event.target.value))}
+ aria-label="手动调整想法位势"
+ />
+
+ {idea.is_overridden && (
+
+ )}
+
+
+
+ )}
+
+
+ 此刻的位置
+ {idea.position}
+
+
+ 牵引它的张力
+ {idea.tension}
+
+
+ 最近的变化
+ {idea.trajectory}
+
+
+ 此刻可能
+
+ {idea.possible_moves.map((move) => (
+ - {move}
+ ))}
+
+
+
+ {idea.fragments && idea.fragments.length > 0 && (
+
+ 构成这个想法的片段
+
+ {idea.fragments.map((fragment) => (
+
{fragment.content}
+ ))}
+
+
+ )}
+
+
+
+ );
+}
+
+function Ideas({ pending }: { pending: number }) {
+ const [ideas, setIdeas] = useState([]);
+ const [selected, setSelected] = useState(null);
+
+ async function refresh() {
+ const data = await api.ideas();
+ setIdeas(data.items);
+ }
+
+ useEffect(() => {
+ void refresh();
+ const timer = window.setInterval(() => void refresh(), pending ? 3500 : 12000);
+ return () => window.clearInterval(timer);
+ }, [pending]);
+
+ function updateIdea(updated: Idea) {
+ setIdeas((current) =>
+ current.map((idea) => (idea.id === updated.id ? { ...idea, ...updated } : idea)),
+ );
+ }
+
+ return (
+
+
+
+ 正在形成的东西
+
想法
+
+ {pending > 0 && (
+
+
+
+ )}
+
+ {ideas.length === 0 ? (
+
+
还没有什么需要被定形。
+
先自由地写。足够的牵引出现时,想法会在这里浮上来。
+
+ ) : (
+
+ {ideas.map((idea) => (
+
+ ))}
+
+ )}
+ {selected && (
+ setSelected(null)}
+ onUpdate={updateIdea}
+ />
+ )}
+
+ );
+}
+
+const eventNames: Record = {
+ run_started: "开始分析",
+ context_loaded: "装入当前用户的上下文",
+ attempt_started: "发起模型判断",
+ tool_search_ideas: "搜索已有想法",
+ tool_inspect_idea: "查看想法轨迹",
+ model_attempt_completed: "模型返回",
+ validation_failed: "结构校验未通过",
+ decision_committed: "提交分析结果",
+ run_failed: "运行失败",
+};
+
+const auditNames: Record = {
+ application_started: "应用启动",
+ login_failed: "登录失败",
+ login_succeeded: "登录成功",
+ logout: "退出登录",
+ fragment_created: "保存新片段",
+ idea_position_calibrated: "人工校准位势",
+ debug_sharing_changed: "调整调试共享",
+};
+
+function readableDuration(milliseconds: number | null) {
+ if (milliseconds === null) return "运行中";
+ if (milliseconds < 1_000) return `${milliseconds} ms`;
+ return `${(milliseconds / 1_000).toFixed(1)} s`;
+}
+
+function compactNumber(value: number) {
+ return new Intl.NumberFormat("zh-CN", { notation: "compact" }).format(value);
+}
+
+function EventPayload({ event }: { event: AgentEvent }) {
+ const payload = event.payload;
+ if (event.event_type === "tool_search_ideas") {
+ return (
+
+ {typeof payload.query === "string" && <>以“{payload.query}”搜索,>}
+ 找到 {Number(payload.result_count ?? 0)} 个候选。
+
+ );
+ }
+ if (event.event_type === "tool_inspect_idea") {
+ return (
+
+ {payload.found === false
+ ? "候选想法不存在。"
+ : `读取了 ${Number(payload.fragment_count ?? 0)} 个近期片段和 ${Number(
+ payload.snapshot_count ?? 0,
+ )} 次位势变化。`}
+
+ );
+ }
+ if (event.event_type === "model_attempt_completed") {
+ return (
+
+ {Number(payload.model_rounds ?? 0)} 轮模型调用 · 输入{" "}
+ {compactNumber(Number(payload.input_tokens ?? 0))} · 输出{" "}
+ {compactNumber(Number(payload.output_tokens ?? 0))} · 推理{" "}
+ {compactNumber(Number(payload.reasoning_tokens ?? 0))}
+
+ );
+ }
+ if (event.event_type === "validation_failed") {
+ return (
+
+ 第 {Number(payload.attempt ?? 0)} 次输出未通过{" "}
+ {String(payload.error_type ?? "结构")} 校验,系统自动重试。
+
+ );
+ }
+ if (event.event_type === "decision_committed") {
+ const assessments = Array.isArray(payload.assessments)
+ ? (payload.assessments as Array>)
+ : [];
+ if (payload.standalone) return 这个片段暂时独立保留,没有强行归入想法。
;
+ return (
+
+ {assessments.map((assessment, index) => (
+
+
{String(assessment.title ?? "未命名想法")}
+
+ 位势 ≈{Math.round(Number(assessment.maturity ?? 0))} ·{" "}
+ {String(assessment.motion ?? "")}
+
+
{String(assessment.position ?? "")}
+
+ ))}
+
+ );
+ }
+ if (event.event_type === "context_loaded") {
+ return (
+
+ {Number(payload.idea_count ?? 0)} 个想法 ·{" "}
+ {Number(payload.recent_fragment_count ?? 0)} 个近期片段;输入{" "}
+ {Number(payload.fragment_characters ?? 0)} 字。
+
+ );
+ }
+ if (event.event_type === "run_failed") {
+ return {String(payload.error_type ?? "未知错误")}
;
+ }
+ return null;
+}
+
+function RunDetail({
+ runId,
+ onClose,
+}: {
+ runId: string;
+ onClose: () => void;
+}) {
+ const [detail, setDetail] = useState(null);
+
+ useEffect(() => {
+ api.adminRun(runId).then(setDetail);
+ }, [runId]);
+
+ return (
+
+
event.stopPropagation()}
+ >
+
+
+ {detail?.run.user_label ?? "读取中"}
+
+ {!detail ? (
+ 正在装入运行轨迹……
+ ) : (
+
+
+
+
+
+ {detail.run.status === "success"
+ ? "分析完成"
+ : detail.run.status === "error"
+ ? "分析失败"
+ : "分析进行中"}
+
+
+
{readableDuration(detail.run.duration_ms)}
+
+
+ {detail.run.model}
+ {detail.run.model_rounds} 轮调用
+ {detail.run.tool_calls} 次工具
+ {compactNumber(detail.run.reasoning_tokens)} 推理 token
+
+
+
+ 这次收到的片段
+ {detail.fragment.content_visible ? (
+ {detail.fragment.content}
+ ) : (
+
+ 内容已隔离 · {detail.fragment.content_length} 字 · 指纹{" "}
+ {detail.fragment.content_sha256}
+
+ )}
+ {detail.privacy.reason}
+
+
+
+ 运行轨迹
+ {detail.events.map((event) => (
+
+
+
+
+ {eventNames[event.event_type] ?? event.event_type}
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+ );
+}
+
+function AdminDashboard() {
+ const [overview, setOverview] = useState(null);
+ const [runs, setRuns] = useState([]);
+ const [users, setUsers] = useState([]);
+ const [audit, setAudit] = useState([]);
+ const [selectedRun, setSelectedRun] = useState(null);
+
+ async function refresh() {
+ const [nextOverview, nextRuns, nextUsers, nextAudit] = await Promise.all([
+ api.adminOverview(),
+ api.adminRuns(),
+ api.adminUsers(),
+ api.adminAudit(30),
+ ]);
+ setOverview(nextOverview);
+ setRuns(nextRuns.items);
+ setUsers(nextUsers.items);
+ setAudit(nextAudit.items);
+ }
+
+ useEffect(() => {
+ void refresh();
+ const timer = window.setInterval(() => void refresh(), 10_000);
+ return () => window.clearInterval(timer);
+ }, []);
+
+ if (!overview) return 正在读取后台……
;
+ const maximumRuns = Math.max(1, ...overview.daily.map((day) => day.runs));
+
+ return (
+
+
+
+ {overview.narrative}
+
+
+
+ 24 小时运行
+ {overview.last_24h.runs}
+ {overview.last_24h.success_rate}% 成功
+
+
+ 平均耗时
+ {readableDuration(overview.last_24h.avg_duration_ms)}
+ {overview.last_24h.model_rounds} 轮模型调用
+
+
+ 推理用量
+ {compactNumber(overview.last_24h.reasoning_tokens)}
+ {compactNumber(overview.last_24h.cached_tokens)} 命中缓存
+
+
+ 等待处理
+ {overview.totals.pending}
+ {overview.runtime.queue_depth} 个仍在队列
+
+
+
+
+
+
+
+ 最近七天
+
Agent 活动
+
+ {overview.last_24h.tool_calls} 次工具调用 / 24h
+
+
+ {overview.daily.length === 0 && (
+
还没有可画出的运行记录
+ )}
+ {overview.daily.map((day) => (
+
+
+
+ {new Intl.DateTimeFormat("zh-CN", {
+ month: "numeric",
+ day: "numeric",
+ }).format(new Date(`${day.day}T00:00:00Z`))}
+
+
+ ))}
+
+
+
+
+
+ 数据边界
+ {overview.totals.users} 个隔离空间
+
+
+ {users.map((user) => (
+
+ {user.label.slice(0, 1)}
+
+ {user.label}
+
+ {user.fragment_count} 条记录 · {user.idea_count} 个想法
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+ 逐次复盘
+
最近的分析
+
+
+
+ {runs.length === 0 ? (
+ 下一次输入以后,完整轨迹会出现在这里。
+ ) : (
+
+ {runs.map((run) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+ 应用事件
+
运行日志
+
+
+ 24h {overview.audit_24h.events} 个事件 ·{" "}
+ {overview.audit_24h.failed_logins} 次失败登录
+
+
+
+ {audit.map((event) => (
+
+
+ {auditNames[event.event_type] ?? event.event_type}
+ {event.user_label ?? "系统"}
+
+ ))}
+
+
+
+ {selectedRun && (
+ setSelectedRun(null)} />
+ )}
+
+ );
+}
+
+function AccountMenu({
+ user,
+ onUserUpdate,
+ onLogout,
+}: {
+ user: User;
+ onUserUpdate: (user: User) => void;
+ onLogout: () => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const [saving, setSaving] = useState(false);
+
+ async function setSharing(enabled: boolean) {
+ setSaving(true);
+ try {
+ const updated = await api.updateDebugSharing(enabled);
+ onUserUpdate(updated);
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
+
+ {open && (
+
+
+ {user.label}
+ {user.role === "admin" ? "管理员" : "独立空间"}
+
+ {user.role !== "admin" && (
+
+ )}
+
+
+ )}
+
+ );
+}
+
+function Notebook({
+ user,
+ onUserUpdate,
+ onLogout,
+}: {
+ user: User;
+ onUserUpdate: (user: User) => void;
+ onLogout: () => void;
+}) {
+ const viewFromHash = (): View => {
+ if (window.location.hash === "#ideas") return "ideas";
+ if (window.location.hash === "#admin" && user.role === "admin") return "admin";
+ return "capture";
+ };
+ const [view, setView] = useState(viewFromHash);
+ const [pending, setPending] = useState(0);
+
+ useEffect(() => {
+ const update = () => api.status().then((state) => setPending(state.pending));
+ void update();
+ const timer = window.setInterval(update, 5000);
+ return () => window.clearInterval(timer);
+ }, []);
+
+ useEffect(() => {
+ const followHash = () => setView(viewFromHash());
+ window.addEventListener("hashchange", followHash);
+ return () => window.removeEventListener("hashchange", followHash);
+ }, [user.role]);
+
+ function switchView(next: View) {
+ window.location.hash =
+ next === "ideas" ? "ideas" : next === "admin" ? "admin" : "";
+ setView(next);
+ }
+
+ return (
+
+
+
+
+
+
+ {view === "capture" ? (
+
+ ) : view === "ideas" ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+export default function App() {
+ const [user, setUser] = useState(undefined);
+
+ useEffect(() => {
+ api
+ .session()
+ .then((session) => setUser(session.user))
+ .catch((reason) => {
+ if (reason instanceof ApiError && reason.status === 401) {
+ setUser(null);
+ } else {
+ setUser(null);
+ }
+ });
+ }, []);
+
+ async function logout() {
+ await api.logout();
+ window.location.hash = "";
+ setUser(null);
+ }
+
+ if (user === undefined) return ;
+ if (user === null) return ;
+ return (
+ void logout()}
+ />
+ );
+}
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
new file mode 100644
index 0000000..956ac71
--- /dev/null
+++ b/frontend/src/api.ts
@@ -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;
+};
+
+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;
+};
+
+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(path: string, options: ApiOptions = {}): Promise {
+ 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;
+}
+
+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("/api/fragments", {
+ method: "POST",
+ body: JSON.stringify({ content }),
+ }),
+ ideas: () => request<{ items: Idea[] }>("/api/ideas"),
+ idea: (id: string) => request(`/api/ideas/${id}`),
+ updateMaturity: (id: string, maturity: number | null) =>
+ request(`/api/ideas/${id}/position`, {
+ method: "PATCH",
+ body: JSON.stringify({ maturity }),
+ }),
+ status: () =>
+ request<{ pending: number; curator_configured: boolean }>(
+ "/api/system/status",
+ ),
+ updateDebugSharing: (enabled: boolean) =>
+ request("/api/account/debug-sharing", {
+ method: "PATCH",
+ body: JSON.stringify({ enabled }),
+ }),
+ adminOverview: () => request("/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(`/api/admin/runs/${id}`),
+ adminAudit: (limit = 80) =>
+ request<{ items: AuditEvent[] }>(`/api/admin/audit?limit=${limit}`),
+};
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..60b661a
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -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(
+
+
+ ,
+);
+
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
new file mode 100644
index 0000000..589cc3d
--- /dev/null
+++ b/frontend/src/styles.css
@@ -0,0 +1,1576 @@
+:root {
+ font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
+ color: #17251f;
+ background: #f3f0e9;
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ --paper: #f3f0e9;
+ --ink: #17251f;
+ --muted: #7c8078;
+ --line: rgba(23, 37, 31, 0.12);
+ --deep: #17342d;
+ --warm: #ca7655;
+ --card: rgba(255, 254, 250, 0.72);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#root {
+ min-height: 100%;
+ margin: 0;
+}
+
+body {
+ min-width: 320px;
+ background:
+ radial-gradient(circle at 14% 2%, rgba(202, 118, 85, 0.08), transparent 28rem),
+ var(--paper);
+}
+
+button,
+input,
+textarea {
+ font: inherit;
+}
+
+button {
+ color: inherit;
+}
+
+button:focus-visible,
+input:focus-visible,
+textarea:focus-visible,
+summary:focus-visible {
+ outline: 2px solid rgba(23, 52, 45, 0.44);
+ outline-offset: 3px;
+}
+
+.boot {
+ min-height: 100vh;
+}
+
+.login-page {
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+ padding: 28px;
+}
+
+.login-card {
+ width: min(360px, 100%);
+ text-align: center;
+ animation: appear 600ms ease both;
+}
+
+.brand-mark {
+ position: relative;
+ width: 62px;
+ height: 34px;
+ margin: 0 auto 26px;
+}
+
+.brand-mark::before {
+ content: "";
+ position: absolute;
+ left: 8px;
+ top: 16px;
+ width: 46px;
+ height: 1px;
+ background: var(--deep);
+ transform: rotate(-8deg);
+}
+
+.brand-mark span,
+.brand-mark i {
+ position: absolute;
+ top: 12px;
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+}
+
+.brand-mark span {
+ left: 4px;
+ background: var(--warm);
+}
+
+.brand-mark i {
+ right: 4px;
+ background: var(--deep);
+}
+
+.login-card h1 {
+ margin: 0;
+ font: 500 30px/1.3 "Songti SC", "STSong", serif;
+ letter-spacing: 0.18em;
+}
+
+.login-card > p {
+ color: var(--muted);
+ margin: 12px 0 46px;
+ font-size: 14px;
+ letter-spacing: 0.06em;
+}
+
+.login-card form {
+ text-align: left;
+}
+
+.login-card label {
+ display: block;
+ margin-bottom: 8px;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.password-row {
+ display: flex;
+ border-bottom: 1px solid rgba(23, 37, 31, 0.3);
+}
+
+.password-row input {
+ flex: 1;
+ min-width: 0;
+ border: 0;
+ background: transparent;
+ padding: 12px 2px;
+ font-size: 16px;
+ outline: none;
+}
+
+.password-row button {
+ border: 0;
+ background: transparent;
+ padding: 0 2px 0 20px;
+ cursor: pointer;
+ font-size: 13px;
+}
+
+.password-row button:disabled {
+ opacity: 0.35;
+}
+
+.form-message {
+ min-height: 24px;
+ color: #a9573f;
+ font-size: 12px;
+ padding-top: 9px;
+}
+
+.notebook {
+ width: min(920px, 100%);
+ min-height: 100vh;
+ margin: 0 auto;
+}
+
+.app-header {
+ position: fixed;
+ z-index: 20;
+ top: 0;
+ left: 50%;
+ transform: translateX(-50%);
+ width: min(920px, 100%);
+ height: 72px;
+ padding: 0 28px;
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ align-items: center;
+ background: rgba(243, 240, 233, 0.88);
+ backdrop-filter: blur(18px);
+ border-bottom: 1px solid rgba(23, 37, 31, 0.06);
+}
+
+.wordmark,
+.logout,
+.app-header nav button {
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+}
+
+.wordmark {
+ justify-self: start;
+ padding: 8px 0;
+ font: 500 16px/1 "Songti SC", "STSong", serif;
+ letter-spacing: 0.16em;
+}
+
+.app-header nav {
+ display: flex;
+ gap: 30px;
+}
+
+.app-header nav button {
+ position: relative;
+ padding: 24px 0 21px;
+ color: var(--muted);
+ font-size: 13px;
+ letter-spacing: 0.08em;
+}
+
+.app-header nav button.active {
+ color: var(--ink);
+}
+
+.app-header nav button.active::after {
+ content: "";
+ position: absolute;
+ left: 50%;
+ bottom: 13px;
+ width: 4px;
+ height: 4px;
+ border-radius: 50%;
+ background: var(--warm);
+ transform: translateX(-50%);
+}
+
+.app-header nav button i {
+ position: absolute;
+ right: -7px;
+ top: 22px;
+ width: 4px;
+ height: 4px;
+ border-radius: 50%;
+ background: var(--warm);
+}
+
+.logout {
+ justify-self: end;
+ width: 34px;
+ height: 34px;
+ display: grid;
+ place-items: center;
+ border: 1px solid rgba(23, 37, 31, 0.12);
+ border-radius: 50%;
+ color: var(--deep);
+ font-size: 12px;
+ font-weight: 500;
+ letter-spacing: 0;
+}
+
+.account {
+ position: relative;
+ justify-self: end;
+}
+
+.account-menu {
+ position: absolute;
+ z-index: 40;
+ top: 46px;
+ right: 0;
+ width: 286px;
+ overflow: hidden;
+ border: 1px solid rgba(23, 37, 31, 0.12);
+ border-radius: 8px;
+ background: #fbf9f3;
+ box-shadow: 0 18px 50px rgba(23, 31, 27, 0.14);
+ animation: appear 160ms ease both;
+}
+
+.account-menu header {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ padding: 18px;
+ border-bottom: 1px solid var(--line);
+}
+
+.account-menu header strong {
+ font: 500 15px/1.4 "Songti SC", "STSong", serif;
+}
+
+.account-menu header span {
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.account-menu > label {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ padding: 16px 18px;
+ border-bottom: 1px solid var(--line);
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.account-menu label span {
+ display: grid;
+ gap: 3px;
+}
+
+.account-menu label small {
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.account-menu input {
+ width: 30px;
+ accent-color: var(--warm);
+}
+
+.account-logout {
+ width: 100%;
+ padding: 14px 18px;
+ text-align: left;
+ border: 0;
+ background: transparent;
+ color: #8b4a39;
+ cursor: pointer;
+ font-size: 12px;
+}
+
+.capture-view {
+ min-height: 100vh;
+ padding: 92px 28px 164px;
+}
+
+.stream {
+ width: min(660px, 100%);
+ margin: 0 auto;
+}
+
+.day-group {
+ margin-bottom: 44px;
+}
+
+.day-group h2 {
+ margin: 28px 0 28px;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 400;
+ letter-spacing: 0.12em;
+}
+
+.fragment {
+ position: relative;
+ padding: 4px 54px 4px 0;
+ margin: 0 0 27px;
+}
+
+.fragment p {
+ margin: 0;
+ color: #1c2823;
+ font: 400 17px/1.9 "Songti SC", "STSong", serif;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+.fragment time {
+ position: absolute;
+ top: 10px;
+ right: 0;
+ color: rgba(23, 37, 31, 0.36);
+ font-size: 11px;
+ opacity: 0;
+ transition: opacity 160ms ease;
+}
+
+.fragment:hover time,
+.fragment:focus-within time {
+ opacity: 1;
+}
+
+.empty-capture {
+ height: calc(100vh - 280px);
+ min-height: 300px;
+ display: grid;
+ place-items: center;
+ color: rgba(23, 37, 31, 0.24);
+ font: 400 14px/1 "Songti SC", "STSong", serif;
+ letter-spacing: 0.12em;
+}
+
+.composer-wrap {
+ position: fixed;
+ z-index: 10;
+ left: 50%;
+ bottom: 0;
+ transform: translateX(-50%);
+ width: min(920px, 100%);
+ padding: 18px 28px max(26px, env(safe-area-inset-bottom));
+ background: linear-gradient(
+ to bottom,
+ rgba(243, 240, 233, 0),
+ rgba(243, 240, 233, 0.96) 24%,
+ var(--paper) 70%
+ );
+}
+
+.composer {
+ width: min(680px, 100%);
+ margin: 0 auto;
+ display: flex;
+ align-items: flex-end;
+ gap: 10px;
+ padding: 12px 12px 12px 18px;
+ border: 1px solid rgba(23, 37, 31, 0.13);
+ border-radius: 20px;
+ background: rgba(255, 254, 250, 0.9);
+ box-shadow:
+ 0 16px 40px rgba(23, 37, 31, 0.06),
+ 0 2px 8px rgba(23, 37, 31, 0.03);
+ transition:
+ border-color 180ms ease,
+ box-shadow 180ms ease;
+}
+
+.composer:focus-within {
+ border-color: rgba(23, 52, 45, 0.3);
+ box-shadow:
+ 0 18px 48px rgba(23, 37, 31, 0.08),
+ 0 2px 8px rgba(23, 37, 31, 0.03);
+}
+
+.composer textarea {
+ flex: 1;
+ min-height: 30px;
+ max-height: 168px;
+ resize: none;
+ overflow-y: auto;
+ border: 0;
+ outline: none;
+ background: transparent;
+ color: var(--ink);
+ font: 400 16px/1.75 "Songti SC", "STSong", serif;
+}
+
+.composer textarea::placeholder {
+ color: rgba(23, 37, 31, 0.35);
+}
+
+.send {
+ flex: 0 0 36px;
+ width: 36px;
+ height: 36px;
+ display: grid;
+ place-items: center;
+ border: 0;
+ border-radius: 50%;
+ background: var(--deep);
+ color: #f8f5ee;
+ cursor: pointer;
+ font-size: 20px;
+ line-height: 1;
+ transition:
+ transform 160ms ease,
+ opacity 160ms ease;
+}
+
+.send:not(:disabled):hover {
+ transform: translateY(-1px);
+}
+
+.send:disabled {
+ opacity: 0.25;
+ cursor: default;
+}
+
+.saving-ring {
+ width: 12px;
+ height: 12px;
+ border: 1.5px solid rgba(255, 255, 255, 0.35);
+ border-top-color: #fff;
+ border-radius: 50%;
+ animation: spin 800ms linear infinite;
+}
+
+.save-error {
+ width: min(680px, 100%);
+ margin: 0 auto 8px;
+ padding-left: 18px;
+ color: #a9573f;
+ font-size: 12px;
+}
+
+.ideas-view {
+ min-height: 100vh;
+ padding: 128px 42px 90px;
+}
+
+.ideas-heading {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ margin-bottom: 44px;
+}
+
+.eyebrow,
+.section-kicker {
+ display: block;
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 400;
+ letter-spacing: 0.13em;
+}
+
+.ideas-heading h1 {
+ margin: 7px 0 0;
+ font: 500 34px/1.3 "Songti SC", "STSong", serif;
+}
+
+.curator-breath {
+ width: 30px;
+ height: 30px;
+ display: grid;
+ place-items: center;
+}
+
+.curator-breath i {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--warm);
+ animation: breathe 2.2s ease-in-out infinite;
+}
+
+.idea-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+}
+
+.idea-card {
+ min-height: 224px;
+ padding: 25px 24px 22px;
+ text-align: left;
+ border: 1px solid rgba(23, 37, 31, 0.09);
+ border-radius: 7px;
+ background: var(--card);
+ cursor: pointer;
+ box-shadow: 0 5px 20px rgba(23, 37, 31, 0.025);
+ transition:
+ transform 200ms ease,
+ border-color 200ms ease,
+ box-shadow 200ms ease;
+}
+
+.idea-card:hover {
+ transform: translateY(-2px);
+ border-color: rgba(23, 37, 31, 0.17);
+ box-shadow: 0 12px 30px rgba(23, 37, 31, 0.05);
+}
+
+.idea-title-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 20px;
+}
+
+.idea-title-row h2 {
+ margin: 0;
+ font: 500 20px/1.5 "Songti SC", "STSong", serif;
+}
+
+.idea-title-row > span {
+ color: rgba(23, 37, 31, 0.35);
+ font-size: 11px;
+ padding-top: 7px;
+}
+
+.idea-card > p {
+ display: -webkit-box;
+ overflow: hidden;
+ margin: 21px 0 0;
+ color: #59615c;
+ font-size: 13px;
+ line-height: 1.75;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+}
+
+.idea-axis {
+ margin-top: 31px;
+}
+
+.axis-labels,
+.axis-reading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 10px;
+ color: rgba(23, 37, 31, 0.42);
+}
+
+.axis-track {
+ position: relative;
+ height: 10px;
+ margin: 7px 4px;
+}
+
+.axis-track::before {
+ content: "";
+ position: absolute;
+ top: 4px;
+ left: 0;
+ right: 0;
+ height: 1px;
+ background: rgba(23, 37, 31, 0.17);
+}
+
+.axis-fill {
+ position: absolute;
+ top: 4px;
+ left: 0;
+ height: 1px;
+ background: var(--deep);
+}
+
+.axis-dot {
+ position: absolute;
+ top: 0;
+ width: 9px;
+ height: 9px;
+ border: 2px solid var(--card);
+ border-radius: 50%;
+ background: var(--warm);
+ box-shadow: 0 0 0 1px rgba(23, 37, 31, 0.2);
+ transform: translateX(-50%);
+}
+
+.axis-reading {
+ margin-top: 5px;
+ color: var(--deep);
+ font-size: 12px;
+}
+
+.idea-axis.large {
+ margin-top: 40px;
+}
+
+.idea-axis.large .axis-track {
+ margin-top: 11px;
+ margin-bottom: 10px;
+}
+
+.idea-axis.large .axis-reading {
+ font-size: 13px;
+}
+
+.empty-ideas {
+ min-height: 52vh;
+ display: grid;
+ place-content: center;
+ text-align: center;
+}
+
+.empty-ideas p {
+ margin: 0 0 12px;
+ font: 400 18px/1.6 "Songti SC", "STSong", serif;
+}
+
+.empty-ideas span {
+ max-width: 360px;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.8;
+}
+
+.sheet-backdrop {
+ position: fixed;
+ z-index: 50;
+ inset: 0;
+ display: flex;
+ justify-content: flex-end;
+ background: rgba(23, 31, 27, 0.16);
+ backdrop-filter: blur(3px);
+ animation: fade 180ms ease both;
+}
+
+.idea-sheet {
+ width: min(590px, 100%);
+ height: 100%;
+ overflow-y: auto;
+ background: #f7f4ed;
+ box-shadow: -20px 0 70px rgba(23, 31, 27, 0.12);
+ animation: slide 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
+}
+
+.sheet-header {
+ position: sticky;
+ z-index: 1;
+ top: 0;
+ height: 68px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 28px;
+ background: rgba(247, 244, 237, 0.9);
+ backdrop-filter: blur(16px);
+}
+
+.sheet-header > span {
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.close-button {
+ width: 34px;
+ height: 34px;
+ margin-left: -8px;
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ color: var(--muted);
+ font: 300 28px/1 sans-serif;
+}
+
+.sheet-content {
+ padding: 34px 54px 100px;
+}
+
+.sheet-content > h2 {
+ margin: 0;
+ font: 500 30px/1.5 "Songti SC", "STSong", serif;
+}
+
+.idea-summary {
+ margin: 12px 0 0;
+ color: #626963;
+ font-size: 14px;
+ line-height: 1.8;
+}
+
+.calibrate-trigger {
+ margin-top: 12px;
+ padding: 5px 0;
+ border: 0;
+ background: transparent;
+ color: var(--muted);
+ font-size: 11px;
+ cursor: pointer;
+ text-decoration: underline;
+ text-decoration-color: rgba(23, 37, 31, 0.18);
+ text-underline-offset: 4px;
+}
+
+.calibration {
+ margin-top: 14px;
+ padding: 17px 18px;
+ border: 1px solid var(--line);
+ border-radius: 5px;
+ background: rgba(255, 255, 255, 0.4);
+}
+
+.calibration > div:first-child {
+ display: flex;
+ justify-content: space-between;
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.calibration strong {
+ color: var(--deep);
+ font-weight: 500;
+}
+
+.calibration input {
+ width: 100%;
+ accent-color: var(--warm);
+ margin: 16px 0 12px;
+}
+
+.calibration-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.calibration-actions button {
+ padding: 7px 10px;
+ border: 0;
+ border-radius: 3px;
+ background: transparent;
+ font-size: 11px;
+ cursor: pointer;
+}
+
+.calibration-actions button.primary {
+ background: var(--deep);
+ color: #f8f5ee;
+}
+
+.idea-section {
+ margin-top: 43px;
+}
+
+.idea-section p {
+ margin: 10px 0 0;
+ font: 400 16px/1.9 "Songti SC", "STSong", serif;
+}
+
+.idea-section.tension {
+ padding: 20px 22px;
+ margin-left: -22px;
+ margin-right: -22px;
+ border-left: 2px solid var(--warm);
+ background: rgba(202, 118, 85, 0.055);
+}
+
+.possible ul {
+ padding: 0;
+ margin: 12px 0 0;
+ list-style: none;
+}
+
+.possible li {
+ position: relative;
+ padding: 9px 0 9px 20px;
+ color: #37453f;
+ font-size: 14px;
+ line-height: 1.7;
+ border-bottom: 1px solid rgba(23, 37, 31, 0.07);
+}
+
+.possible li::before {
+ content: "↗";
+ position: absolute;
+ left: 0;
+ color: var(--warm);
+}
+
+.evidence {
+ margin-top: 50px;
+ border-top: 1px solid var(--line);
+ color: var(--muted);
+}
+
+.evidence summary {
+ padding: 18px 0;
+ cursor: pointer;
+ font-size: 11px;
+ letter-spacing: 0.05em;
+}
+
+.evidence blockquote {
+ margin: 0;
+ padding: 15px 0;
+ color: #4e5752;
+ font: 400 14px/1.85 "Songti SC", "STSong", serif;
+ border-bottom: 1px solid rgba(23, 37, 31, 0.07);
+}
+
+.admin-view {
+ min-height: 100vh;
+ padding: 122px 32px 100px;
+}
+
+.admin-heading {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+}
+
+.admin-heading h1 {
+ margin: 7px 0 0;
+ font: 500 34px/1.3 "Songti SC", "STSong", serif;
+}
+
+.live-state {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.live-state i {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: #9b6b5d;
+}
+
+.live-state i.online {
+ background: #4c7a68;
+ box-shadow: 0 0 0 4px rgba(76, 122, 104, 0.09);
+}
+
+.admin-narrative {
+ max-width: 640px;
+ margin: 28px 0 34px;
+ color: #4f5a54;
+ font: 400 15px/1.9 "Songti SC", "STSong", serif;
+}
+
+.metric-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ overflow: hidden;
+ border: 1px solid var(--line);
+ border-radius: 7px;
+ background: rgba(255, 254, 250, 0.56);
+}
+
+.metric-grid article {
+ min-width: 0;
+ padding: 22px;
+ border-right: 1px solid var(--line);
+}
+
+.metric-grid article:last-child {
+ border-right: 0;
+}
+
+.metric-grid span,
+.metric-grid small {
+ display: block;
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.metric-grid strong {
+ display: block;
+ overflow: hidden;
+ margin: 11px 0 8px;
+ font: 500 25px/1.2 "Songti SC", "STSong", serif;
+ text-overflow: ellipsis;
+}
+
+.admin-columns {
+ display: grid;
+ grid-template-columns: minmax(0, 1.6fr) minmax(250px, 1fr);
+ gap: 16px;
+ margin-top: 16px;
+}
+
+.activity-panel,
+.tenant-panel,
+.runs-panel,
+.audit-panel {
+ border: 1px solid var(--line);
+ border-radius: 7px;
+ background: rgba(255, 254, 250, 0.56);
+}
+
+.activity-panel,
+.tenant-panel {
+ min-height: 270px;
+ padding: 22px;
+}
+
+.activity-panel > header,
+.runs-panel > header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+}
+
+.activity-panel h2,
+.tenant-panel h2,
+.runs-panel h2 {
+ margin: 6px 0 0;
+ font: 500 18px/1.4 "Songti SC", "STSong", serif;
+}
+
+.activity-panel > header > span {
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.activity-bars {
+ height: 158px;
+ display: flex;
+ align-items: flex-end;
+ gap: 13px;
+ margin-top: 22px;
+ padding-top: 12px;
+ border-top: 1px solid rgba(23, 37, 31, 0.06);
+}
+
+.activity-bars > div:not(.no-activity) {
+ flex: 1;
+ height: 100%;
+ min-width: 12px;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ align-items: center;
+ gap: 7px;
+}
+
+.activity-bars > div > span {
+ width: min(22px, 70%);
+ min-height: 8px;
+ border-radius: 2px 2px 0 0;
+ background: linear-gradient(to top, var(--deep), #6e8a7f);
+ opacity: 0.86;
+}
+
+.activity-bars small {
+ color: var(--muted);
+ font-size: 9px;
+}
+
+.no-activity {
+ width: 100%;
+ align-self: center;
+ text-align: center;
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.tenant-panel > div {
+ margin-top: 17px;
+}
+
+.tenant-panel article {
+ display: grid;
+ grid-template-columns: 32px 1fr auto;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 0;
+ border-bottom: 1px solid rgba(23, 37, 31, 0.06);
+}
+
+.tenant-panel article:last-child {
+ border-bottom: 0;
+}
+
+.user-avatar {
+ width: 30px;
+ height: 30px;
+ display: grid;
+ place-items: center;
+ border-radius: 50%;
+ background: rgba(23, 52, 45, 0.08);
+ color: var(--deep);
+ font: 500 12px/1 "Songti SC", "STSong", serif;
+}
+
+.tenant-panel article div {
+ display: grid;
+ gap: 2px;
+}
+
+.tenant-panel article strong {
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.tenant-panel article small {
+ color: var(--muted);
+ font-size: 9px;
+}
+
+.tenant-panel article > i {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: rgba(23, 37, 31, 0.18);
+}
+
+.tenant-panel article > i.shared {
+ background: var(--warm);
+}
+
+.runs-panel {
+ margin-top: 16px;
+ overflow: hidden;
+}
+
+.audit-panel {
+ margin-top: 16px;
+ padding: 22px;
+}
+
+.audit-panel > header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ padding-bottom: 16px;
+ border-bottom: 1px solid var(--line);
+}
+
+.audit-panel h2 {
+ margin: 6px 0 0;
+ font: 500 18px/1.4 "Songti SC", "STSong", serif;
+}
+
+.audit-panel > header > span {
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.audit-panel article {
+ display: grid;
+ grid-template-columns: 120px 1fr auto;
+ gap: 16px;
+ padding: 11px 0;
+ border-bottom: 1px solid rgba(23, 37, 31, 0.06);
+}
+
+.audit-panel article:last-child {
+ border-bottom: 0;
+}
+
+.audit-panel time,
+.audit-panel article span {
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.audit-panel article strong {
+ font-size: 11px;
+ font-weight: 500;
+}
+
+.runs-panel > header {
+ padding: 22px;
+ border-bottom: 1px solid var(--line);
+}
+
+.runs-panel > header button {
+ border: 0;
+ background: transparent;
+ color: var(--muted);
+ cursor: pointer;
+ font-size: 10px;
+}
+
+.run-list > button {
+ width: 100%;
+ display: grid;
+ grid-template-columns: 8px minmax(130px, 1.2fr) 0.7fr 0.8fr 0.8fr 0.65fr;
+ align-items: center;
+ gap: 14px;
+ padding: 15px 22px;
+ text-align: left;
+ border: 0;
+ border-bottom: 1px solid rgba(23, 37, 31, 0.065);
+ background: transparent;
+ cursor: pointer;
+ transition: background 150ms ease;
+}
+
+.run-list > button:last-child {
+ border-bottom: 0;
+}
+
+.run-list > button:hover {
+ background: rgba(255, 255, 255, 0.5);
+}
+
+.run-status {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: #bc8a53;
+}
+
+.run-status.success {
+ background: #4c7a68;
+}
+
+.run-status.error {
+ background: #b45e49;
+}
+
+.run-list button > div {
+ display: grid;
+ gap: 2px;
+}
+
+.run-list button > div strong,
+.run-list button > strong {
+ font-size: 11px;
+ font-weight: 500;
+}
+
+.run-list button > div small,
+.run-list button > span {
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.empty-runs,
+.admin-loading {
+ min-height: 200px;
+ display: grid;
+ place-items: center;
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.admin-loading {
+ min-height: 100vh;
+}
+
+.admin-run-sheet {
+ width: min(680px, 100%);
+}
+
+.run-detail {
+ padding: 30px 48px 90px;
+}
+
+.run-detail-title {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 20px;
+}
+
+.run-detail-title > div {
+ display: flex;
+ align-items: center;
+ gap: 11px;
+}
+
+.run-detail-title h2 {
+ margin: 0;
+ font: 500 26px/1.5 "Songti SC", "STSong", serif;
+}
+
+.run-detail-title > strong {
+ padding-top: 10px;
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 400;
+}
+
+.run-facts {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 7px;
+ margin-top: 20px;
+}
+
+.run-facts span {
+ padding: 5px 8px;
+ border-radius: 3px;
+ background: rgba(23, 52, 45, 0.055);
+ color: #5d6762;
+ font-size: 9px;
+}
+
+.observed-input {
+ margin-top: 40px;
+}
+
+.observed-input blockquote,
+.redacted-content {
+ margin: 12px 0 8px;
+ padding: 18px 20px;
+ border-left: 2px solid var(--warm);
+ background: rgba(202, 118, 85, 0.05);
+ font: 400 15px/1.85 "Songti SC", "STSong", serif;
+}
+
+.redacted-content {
+ color: var(--muted);
+ font: 400 11px/1.8 monospace;
+}
+
+.observed-input small {
+ color: var(--muted);
+ font-size: 9px;
+}
+
+.event-timeline {
+ margin-top: 44px;
+}
+
+.event-timeline > article {
+ position: relative;
+ display: grid;
+ grid-template-columns: 10px 1fr;
+ gap: 13px;
+ padding: 14px 0;
+}
+
+.event-timeline > article::before {
+ content: "";
+ position: absolute;
+ left: 4px;
+ top: 25px;
+ bottom: -15px;
+ width: 1px;
+ background: var(--line);
+}
+
+.event-timeline > article:last-child::before {
+ display: none;
+}
+
+.event-timeline > article > i {
+ z-index: 1;
+ width: 9px;
+ height: 9px;
+ margin-top: 4px;
+ border: 2px solid #f7f4ed;
+ border-radius: 50%;
+ background: var(--deep);
+ box-shadow: 0 0 0 1px rgba(23, 37, 31, 0.2);
+}
+
+.event-timeline article header {
+ display: flex;
+ justify-content: space-between;
+ gap: 20px;
+}
+
+.event-timeline article header strong {
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.event-timeline article time {
+ color: var(--muted);
+ font-size: 9px;
+}
+
+.event-timeline article p {
+ margin: 6px 0 0;
+ color: #66706a;
+ font-size: 11px;
+ line-height: 1.7;
+}
+
+.decision-output {
+ display: grid;
+ gap: 8px;
+ margin-top: 9px;
+}
+
+.decision-output > div {
+ display: grid;
+ gap: 4px;
+ padding: 12px 14px;
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.35);
+}
+
+.decision-output strong {
+ font: 500 13px/1.5 "Songti SC", "STSong", serif;
+}
+
+.decision-output span {
+ color: var(--deep);
+ font-size: 10px;
+}
+
+.decision-output p {
+ margin: 0 !important;
+}
+
+.reasoning-boundary {
+ margin-top: 45px;
+ padding-top: 16px;
+ border-top: 1px solid var(--line);
+ color: var(--muted);
+ font-size: 9px;
+ line-height: 1.7;
+}
+
+@keyframes appear {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+}
+
+@keyframes fade {
+ from {
+ opacity: 0;
+ }
+}
+
+@keyframes slide {
+ from {
+ transform: translateX(24px);
+ opacity: 0;
+ }
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes breathe {
+ 0%,
+ 100% {
+ opacity: 0.28;
+ transform: scale(0.75);
+ }
+ 50% {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+@media (max-width: 680px) {
+ .app-header {
+ height: 62px;
+ padding: 0 18px;
+ }
+
+ .app-header nav {
+ gap: 17px;
+ }
+
+ .app-header nav button {
+ padding-top: 19px;
+ padding-bottom: 17px;
+ }
+
+ .app-header nav button.active::after {
+ bottom: 8px;
+ }
+
+ .app-header nav button i {
+ top: 16px;
+ }
+
+ .capture-view {
+ padding: 78px 20px 148px;
+ }
+
+ .day-group h2 {
+ margin-top: 22px;
+ }
+
+ .fragment {
+ padding-right: 0;
+ margin-bottom: 25px;
+ }
+
+ .fragment p {
+ font-size: 16px;
+ line-height: 1.85;
+ }
+
+ .fragment time {
+ position: static;
+ display: block;
+ margin-top: 4px;
+ opacity: 0;
+ height: 0;
+ }
+
+ .fragment:active time {
+ opacity: 1;
+ height: auto;
+ }
+
+ .composer-wrap {
+ padding: 16px 14px max(16px, env(safe-area-inset-bottom));
+ }
+
+ .composer {
+ border-radius: 17px;
+ padding-left: 15px;
+ }
+
+ .ideas-view {
+ padding: 100px 18px 70px;
+ }
+
+ .ideas-heading {
+ margin-bottom: 30px;
+ }
+
+ .ideas-heading h1 {
+ font-size: 30px;
+ }
+
+ .idea-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .idea-card {
+ min-height: 205px;
+ }
+
+ .sheet-content {
+ padding: 26px 28px 90px;
+ }
+
+ .sheet-content > h2 {
+ font-size: 26px;
+ }
+
+ .idea-section.tension {
+ margin-left: -14px;
+ margin-right: -14px;
+ padding-left: 14px;
+ }
+
+ .account-menu {
+ position: fixed;
+ top: 64px;
+ right: 12px;
+ width: min(286px, calc(100vw - 24px));
+ }
+
+ .admin-view {
+ padding: 96px 16px 70px;
+ }
+
+ .admin-heading h1 {
+ font-size: 30px;
+ }
+
+ .metric-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .metric-grid article {
+ border-bottom: 1px solid var(--line);
+ }
+
+ .metric-grid article:nth-child(2) {
+ border-right: 0;
+ }
+
+ .metric-grid article:nth-child(n + 3) {
+ border-bottom: 0;
+ }
+
+ .admin-columns {
+ grid-template-columns: 1fr;
+ }
+
+ .run-list > button {
+ grid-template-columns: 8px 1fr auto;
+ }
+
+ .run-list button > span:nth-of-type(n + 2) {
+ display: none;
+ }
+
+ .run-list button > strong {
+ display: none;
+ }
+
+ .run-detail {
+ padding: 24px 26px 80px;
+ }
+
+ .audit-panel article {
+ grid-template-columns: 1fr auto;
+ }
+
+ .audit-panel time {
+ display: none;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+}
diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json
new file mode 100644
index 0000000..a9dd22c
--- /dev/null
+++ b/frontend/tsconfig.app.json
@@ -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"]
+}
+
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..e891e30
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
+
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
new file mode 100644
index 0000000..c67e8a6
--- /dev/null
+++ b/frontend/tsconfig.node.json
@@ -0,0 +1,9 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler"
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..6aa1117
--- /dev/null
+++ b/frontend/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",
+ },
+ },
+});
+
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..f0e4c8a
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,3 @@
+[tool.pytest.ini_options]
+pythonpath = ["."]
+testpaths = ["tests"]
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..f801e54
--- /dev/null
+++ b/requirements.txt
@@ -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
+
diff --git a/tests/test_api.py b/tests/test_api.py
new file mode 100644
index 0000000..45667ae
--- /dev/null
+++ b/tests/test_api.py
@@ -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"] == "用户二的记录"
diff --git a/tests/test_db.py b/tests/test_db.py
new file mode 100644
index 0000000..17bb344
--- /dev/null
+++ b/tests/test_db.py
@@ -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