From 935c41184742234f3fe8e12a517924b478816a80 Mon Sep 17 00:00:00 2001 From: wuyang <5700876+banisherwy@user.noreply.gitee.com> Date: Tue, 28 Jul 2026 14:28:00 +0800 Subject: [PATCH] feat: open source multi-user AI notebook with observability --- .dockerignore | 10 + .gitignore | 19 + Dockerfile | 27 + LICENSE | 21 + README.md | 134 +++ app/__init__.py | 2 + app/auth.py | 204 ++++ app/config.py | 95 ++ app/curator.py | 444 ++++++++ app/db.py | 963 ++++++++++++++++ app/main.py | 363 ++++++ app/schemas.py | 70 ++ deploy/docker-compose.override.yml | 19 + frontend/index.html | 17 + frontend/package-lock.json | 868 ++++++++++++++ frontend/package.json | 22 + frontend/public/icon.svg | 7 + frontend/public/manifest.webmanifest | 18 + frontend/src/App.tsx | 1018 +++++++++++++++++ frontend/src/api.ts | 231 ++++ frontend/src/main.tsx | 11 + frontend/src/styles.css | 1576 ++++++++++++++++++++++++++ frontend/tsconfig.app.json | 21 + frontend/tsconfig.json | 8 + frontend/tsconfig.node.json | 9 + frontend/vite.config.ts | 17 + pyproject.toml | 3 + requirements.txt | 6 + tests/test_api.py | 129 +++ tests/test_db.py | 190 ++++ 30 files changed, 6522 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/auth.py create mode 100644 app/config.py create mode 100644 app/curator.py create mode 100644 app/db.py create mode 100644 app/main.py create mode 100644 app/schemas.py create mode 100644 deploy/docker-compose.override.yml create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/icon.svg create mode 100644 frontend/public/manifest.webmanifest create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/styles.css create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 tests/test_api.py create mode 100644 tests/test_db.py 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 ( +
+
+ +

续想

+

让零散的念头,继续往前走。

+
+ +
+ setAccessKey(event.target.value)} + autoFocus + /> + +
+
+ {error} +
+
+
+
+ ); +} + +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} +
+ )} +
+