W07 统一身份接入与工作台框架:统一身份接入、HTTP 路由骨架与工作台前端框架。

按 R2 串行阶段整理提交;包内文件为该阶段交付(含后续小增量),状态以工作包清单为准。
This commit is contained in:
zizi 2026-09-10 19:25:41 +08:00
parent 367d01db21
commit 1cbda23880
35 changed files with 6634 additions and 0 deletions

View File

@ -0,0 +1 @@
"""身份与协议接入;业务行为由所属模块维护。"""

View File

@ -0,0 +1 @@
"""本地具名命令接入。"""

View File

@ -0,0 +1,225 @@
"""Python 命令入口。
安装后 ``python -m muse`` 与 CLI(W07)使用一致的参数、退出码和业务结果。
已实现子命令:自检(W02)、迁移(W03,显式维护命令,不在启动时自动执行)。
未实现或未注入的能力明确失败,不返回伪成功。
"""
from __future__ import annotations
import argparse
import json
import sys
from muse.共享.错误 import Muse错误
from muse.启动 import 构建, 自检
from muse.配置 import 读取配置
_退出码_成功 = 0
_退出码_用法错误 = 2
_退出码_业务失败 = 1
def _解析参数(argv: list[str] | None) -> argparse.Namespace:
解析器 = argparse.ArgumentParser(
prog="python -m muse",
description="Muse 应用命令入口",
)
子命令 = 解析器.add_subparsers(dest="命令", required=True)
自检解析 = 子命令.add_parser("自检", help="报告装配状态;不连接外部服务")
自检解析.add_argument("配置", help="应用配置 TOML 路径")
迁移解析 = 子命令.add_parser("迁移", help="执行数据库迁移(显式维护命令)")
迁移解析.add_argument("配置", help="应用配置 TOML 路径")
迁移解析.add_argument("--目标版本", type=int, default=None, help="执行到该版本为止(默认全部)")
服务解析 = 子命令.add_parser("服务", help="启动已配置的作者工作台")
服务解析.add_argument("配置", help="应用配置 TOML 路径")
任务解析 = 子命令.add_parser("任务", help="查看、暂停、取消或恢复持久任务")
任务解析.add_argument("配置", help="作者配置 TOML 路径")
任务解析.add_argument("动作", choices=["列表", "查看", "事件", "控制"])
任务解析.add_argument("目标", nargs="?", help="任务 ID;控制时为具名请求 JSON 文件")
任务解析.add_argument("--游标", type=int, default=0)
结构解析 = 子命令.add_parser("结构", help="发布内置种子或查看已发布的固定结构")
结构解析.add_argument("配置", help="应用配置 TOML 路径")
结构解析.add_argument("动作", choices=["发布内置", "查看", "维护"])
结构解析.add_argument("目标", nargs="?", help="结构身份")
结构解析.add_argument("--版本", type=int, default=1)
作品解析 = 子命令.add_parser("作品", help="管理作品档案、章节和人工正文版本")
作品解析.add_argument("配置", help="作者配置 TOML 路径")
作品解析.add_argument(
"动作",
choices=[
"结构",
"查看",
"目录",
"节点目录",
"保存节点",
"进度",
"正文",
"保存档案",
"新增章节",
"重排章节",
"保存正文",
"回退正文",
],
)
作品解析.add_argument("目标", help="查询身份或写入请求 JSON 文件")
作品解析.add_argument("--结构", default="work_core")
作品解析.add_argument("--版本", type=int, default=1)
作品解析.add_argument("--分支", default="main")
审阅解析 = 子命令.add_parser("审阅", help="比较候选、修改候选并提交作者决定")
审阅解析.add_argument("配置", help="作者配置TOML路径")
审阅解析.add_argument("动作", choices=["列表", "查看", "建立候选", "修改候选", "打开", "决定"])
审阅解析.add_argument("目标", help="查询身份或操作请求JSON文件")
审阅解析.add_argument("--分支", default="main")
探索解析 = 子命令.add_parser("探索", help="保存私人想法与检查最终整理稿")
探索解析.add_argument("配置", help="作者配置TOML路径")
探索解析.add_argument("动作", choices=["列表", "查看", "保存", "检查", "选定", "查看选定"])
探索解析.add_argument("目标", nargs="?", help="稿件身份或保存请求JSON文件")
探索解析.add_argument("--预期哈希")
规划解析 = 子命令.add_parser("规划", help="保存规划候选、查看版本并执行明确作者决定")
规划解析.add_argument("配置")
规划解析.add_argument(
"动作",
choices=[
"结构",
"列表",
"查看",
"创建候选",
"编辑候选",
"查看候选",
"打开审阅",
"决定",
"读取细纲",
],
)
规划解析.add_argument("目标")
规划解析.add_argument("--结构", default="outline")
规划解析.add_argument("--版本", type=int, default=1)
规划解析.add_argument("--章节")
配置解析 = 子命令.add_parser("配置", help="保存或查看运行配置草案;不自动验证启用")
配置解析.add_argument("配置", help="应用连接配置TOML路径")
配置解析.add_argument("动作", choices=["保存", "查看"])
配置解析.add_argument("配置ID")
配置解析.add_argument("版本")
配置解析.add_argument("--内容", help="运行配置草案TOML路径")
管理解析 = 子命令.add_parser("管理", help="管理当前配置的受控暂存;不访问其他根")
管理解析.add_argument("配置", help="明确的应用连接配置")
管理解析.add_argument("动作", choices=["清理无租约暂存", "查看清理回执"])
管理解析.add_argument("对象ID", nargs="?")
return 解析器.parse_args(argv)
def _运行迁移(参数: argparse.Namespace) -> int:
from muse.基础设施.数据库.迁移 import 迁移目标库
已执行 = 迁移目标库(读取配置(参数.配置), 目标版本=参数.目标版本)
if 已执行:
for 项 in 已执行:
print(f"已应用 V{项.版本:04d} {项.文件名}")
else:
print("无待执行迁移(账本已是最新)")
return _退出码_成功
def main(argv: list[str] | None = None) -> int:
参数 = _解析参数(argv)
try:
if 参数.命令 == "规划":
from muse.接入.cli.作品命令 import 运行规划命令
结果 = 运行规划命令(
构建(读取配置(参数.配置)),
参数.动作,
参数.目标,
结构ID=参数.结构,
版本=参数.版本,
章节ID=参数.章节,
)
print(json.dumps(结果, ensure_ascii=False))
return _退出码_成功
if 参数.命令 == "探索":
from muse.接入.cli.作品命令 import 运行探索命令
结果 = 运行探索命令(
构建(读取配置(参数.配置)), 参数.动作, 参数.目标, 预期哈希=参数.预期哈希
)
print(json.dumps(结果, ensure_ascii=False))
return (
_退出码_业务失败
if isinstance(结果, dict) and 结果.get("valid") is False
else _退出码_成功
)
if 参数.命令 == "审阅":
from muse.接入.cli.审阅命令 import 运行审阅命令
结果 = 运行审阅命令(构建(读取配置(参数.配置)), 参数.动作, 参数.目标, 分支=参数.分支)
print(json.dumps(结果, ensure_ascii=False))
return _退出码_成功
if 参数.命令 == "管理":
from muse.接入.cli.管理命令 import 运行管理命令
结果 = 运行管理命令(构建(读取配置(参数.配置)), 参数.动作, 参数.对象ID)
print(json.dumps(结果, ensure_ascii=False))
return _退出码_成功
if 参数.命令 == "配置":
from muse.接入.cli.管理命令 import 运行配置命令
结果 = 运行配置命令(
构建(读取配置(参数.配置)), 参数.动作, 参数.配置ID, 参数.版本, 参数.内容
)
print(json.dumps(结果, ensure_ascii=False))
return _退出码_成功
if 参数.命令 == "作品":
from muse.接入.cli.作品命令 import 运行作品命令
结果 = 运行作品命令(
构建(读取配置(参数.配置)),
参数.动作,
参数.目标,
结构ID=参数.结构,
版本=参数.版本,
分支=参数.分支,
)
print(json.dumps(结果, ensure_ascii=False))
return _退出码_成功
if 参数.命令 == "结构":
from muse.接入.cli.结构命令 import 运行结构命令
结果 = 运行结构命令(构建(读取配置(参数.配置)), 参数.动作, 参数.目标, 参数.版本)
print(json.dumps(结果, ensure_ascii=False))
return _退出码_成功
if 参数.命令 == "任务":
from muse.接入.cli.任务命令 import 运行任务命令
结果 = 运行任务命令(构建(读取配置(参数.配置)), 参数.动作, 参数.目标, 参数.游标)
print(json.dumps(结果, ensure_ascii=False, default=str))
return _退出码_成功
if 参数.命令 == "自检":
装配 = 构建(读取配置(参数.配置))
for 键, 值 in 自检(装配).items():
print(f"{键}: {值}")
return _退出码_成功
if 参数.命令 == "迁移":
return _运行迁移(参数)
if 参数.命令 == "服务":
import uvicorn
from muse.基础设施.可观测性 import 配置日志
from muse.接入.http.应用 import 创建应用
配置 = 读取配置(参数.配置)
应用 = 创建应用(配置)
配置日志()
assert 配置.HTTP is not None
uvicorn.run(应用, host=配置.HTTP.监听地址, port=配置.HTTP.端口, access_log=False)
return _退出码_成功
except Muse错误 as exc:
print(json.dumps(exc.呈现(), ensure_ascii=False), file=sys.stderr)
return _退出码_业务失败
return _退出码_用法错误
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1 @@
"""单作者 HTTP 接入。"""

View File

@ -0,0 +1,95 @@
"""SSE 与有限事件页共用 PostgreSQL 持久化游标。"""
import asyncio
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Header, Query, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from starlette.concurrency import run_in_threadpool
from muse.任务运行.接口 import 事件续接
from muse.接入.http.作者会话 import 作者依赖
from muse.接入.http.依赖 import 取得任务服务, 读取作者任务
路由 = APIRouter(prefix="/api/v1/tasks", tags=["任务事件"])
class 事件响应(BaseModel):
event_id: str
sequence: int
event_type: str
occurred_at: datetime
payload: dict[str, Any]
class 事件页(BaseModel):
items: list[事件响应]
reset_required: bool
first_sequence: int
last_sequence: int
def 呈现事件(结果: 事件续接) -> 事件页:
return 事件页(
items=[
事件响应(
event_id=项.事件ID,
sequence=项.序号,
event_type=项.类型.value,
occurred_at=项.发生时间,
payload=项.载荷,
)
for 项 in 结果.事件
],
reset_required=结果.需要快照,
first_sequence=结果.可用首序号,
last_sequence=结果.最后序号,
)
@路由.get("/{task_id}/events", response_model=事件页, operation_id="read_task_events")
def 读取事件(
task_id: str,
request: Request,
作者: 作者依赖,
cursor: int = Query(0, ge=0),
work_id: str | None = None,
) -> 事件页:
读取作者任务(request, task_id, work_id)
return 呈现事件(取得任务服务(request).续接事件(task_id, cursor))
@路由.get("/{task_id}/events/stream", operation_id="stream_task_events")
async def 订阅事件(
task_id: str,
request: Request,
作者: 作者依赖,
cursor: int = Query(0, ge=0),
work_id: str | None = None,
last_event_id: int | None = Header(None, ge=0),
):
await run_in_threadpool(读取作者任务, request, task_id, work_id)
服务 = 取得任务服务(request)
async def 数据流():
游标 = max(cursor, last_event_id or 0)
while not await request.is_disconnected():
结果 = await run_in_threadpool(服务.续接事件, task_id, 游标)
页 = 呈现事件(结果)
if 页.reset_required:
yield f"event: reset\ndata: {页.model_dump_json()}\n\n"
return
for 事件 in 页.items:
yield f"id: {事件.sequence}\nevent: task-event\ndata: {事件.model_dump_json()}\n\n"
游标 = 事件.sequence
if not 页.items:
yield ": keep-alive\n\n"
await asyncio.sleep(1)
return StreamingResponse(
数据流(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)

View File

@ -0,0 +1,53 @@
"""作者凭据只用于建立服务端签名会话,不接受客户端自报身份。"""
import secrets
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, ConfigDict, SecretStr
from muse.配置 import 服务配置
class 登录请求(BaseModel):
model_config = ConfigDict(extra="forbid")
password: SecretStr
class 会话响应(BaseModel):
author_id: str
def 要求作者(request: Request) -> str:
配置: 服务配置 = request.app.state.服务配置
if request.session.get("author_id") != 配置.作者ID:
raise HTTPException(401, "请先连接作者工作台")
return 配置.作者ID
作者依赖 = Annotated[str, Depends(要求作者)]
def 会话路由() -> APIRouter:
路由 = APIRouter(prefix="/api/v1/session", tags=["作者会话"])
@路由.post("", response_model=会话响应, operation_id="open_session")
def 登录(请求: 登录请求, request: Request) -> 会话响应:
配置: 服务配置 = request.app.state.服务配置
口令: str = request.app.state.口令
if not secrets.compare_digest(请求.password.get_secret_value().encode(), 口令.encode()):
raise HTTPException(401, "访问口令不正确")
request.session.clear()
request.session["author_id"] = 配置.作者ID
return 会话响应(author_id=配置.作者ID)
@路由.get("", response_model=会话响应, operation_id="read_session")
def 读取(作者: 作者依赖) -> 会话响应:
return 会话响应(author_id=作者)
@路由.delete("", operation_id="close_session")
def 退出(request: Request, 作者: 作者依赖) -> dict[str, bool]:
request.session.clear()
return {"closed": True}
return 路由

View File

@ -0,0 +1,18 @@
"""从已认证请求和应用装配取得任务能力,不接受客户端作者身份。"""
from fastapi import HTTPException, Request
from muse.任务运行.接口 import 任务快照, 任务服务
from muse.接入.http.作者会话 import 要求作者
def 取得任务服务(request: Request) -> 任务服务:
return request.app.state.装配.任务运行
def 读取作者任务(request: Request, 任务ID: str, 作品ID: str | None = None) -> 任务快照:
作者 = 要求作者(request)
任务 = 取得任务服务(request).读取任务(任务ID)
if 任务.作者 != 作者 or (作品ID is not None and 任务.冻结输入.get("作品ID") != 作品ID):
raise HTTPException(403, "任务不属于当前作者或作品")
return 任务

View File

@ -0,0 +1,25 @@
"""分别报告进程存活和依赖就绪,不推断业务任务成功。"""
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from muse.共享.错误 import Muse错误, 资源缺失错误
from muse.资源加载 import 核对资源
路由 = APIRouter(tags=["系统状态"])
@路由.get("/health", operation_id="read_health")
def 存活() -> dict[str, str]:
return {"status": "alive"}
@路由.get("/ready", operation_id="read_readiness")
def 就绪(request: Request) -> JSONResponse:
try:
request.app.state.装配.要求数据库().检查连接()
if 核对资源()["发布身份"] != request.app.state.装配.配置.资源发布身份:
raise 资源缺失错误("应用配置与资源发布身份不一致")
except Muse错误 as exc:
return JSONResponse(status_code=503, content={"status": "unavailable", "code": exc.错误码})
return JSONResponse({"status": "ready"})

View File

@ -0,0 +1,94 @@
"""装配 HTTP 生命周期、单作者会话、来源校验与具名业务路由。"""
import hashlib
from contextlib import asynccontextmanager
from time import monotonic
from uuid import uuid4
from fastapi import FastAPI, Request
from starlette.middleware.sessions import SessionMiddleware
from muse.共享.错误 import 配置错误
from muse.启动 import 构建
from muse.基础设施.凭据读取 import 读取凭据
from muse.基础设施.可观测性 import 记录运行
from muse.接入.http.事件订阅 import 路由 as 事件路由
from muse.接入.http.作者会话 import 会话路由
from muse.接入.http.健康检查 import 路由 as 健康路由
from muse.接入.http.路由.任务运行 import 路由 as 任务路由
from muse.接入.http.路由.作品规划 import 路由 as 作品路由
from muse.接入.http.路由.作者审阅 import 路由 as 审阅路由
from muse.接入.http.路由.元数据 import 路由 as 元数据路由
from muse.接入.http.路由.故事世界 import 路由 as 世界路由
from muse.接入.http.路由.正文写作 import 路由 as 正文路由
from muse.接入.http.错误响应 import 协议错误, 安装错误响应
from muse.接入.http.静态资源 import 挂载工作台
from muse.配置 import 应用配置
def 创建应用(配置: 应用配置) -> FastAPI:
服务 = 配置.HTTP
if 服务 is None:
raise 配置错误("启动工作台需要明确的 HTTP 与作者口令配置")
口令 = 读取凭据("受控存储", 服务.口令文件)
装配 = 构建(配置)
@asynccontextmanager
async def 生命周期(app: FastAPI):
app.state.装配 = 装配
app.state.服务配置 = 服务
yield
应用 = FastAPI(
title="Muse 作者工作台", version="0.1.0", lifespan=生命周期, docs_url=None, redoc_url=None
)
应用.state.口令 = 口令
应用.state.装配 = 装配
应用.state.服务配置 = 服务
应用.add_middleware(
SessionMiddleware,
secret_key=hashlib.sha256(("muse-session:" + 口令).encode()).hexdigest(),
session_cookie="muse_session",
same_site="strict",
max_age=12 * 3600,
https_only=服务.公开地址.startswith("https://"),
)
@应用.middleware("http")
async def 写入来源(request: Request, call_next):
开始 = monotonic()
请求ID = str(uuid4())
request.state.request_id = 请求ID
if request.method not in {"GET", "HEAD", "OPTIONS"}:
if request.headers.get("origin") not in 服务.允许来源:
return 协议错误("SCOPE_DENIED", "写入请求的来源不在允许范围", 403)
响应 = await call_next(request)
响应.headers["X-Request-ID"] = 请求ID
响应.headers["X-Content-Type-Options"] = "nosniff"
记录运行("http.completed", 请求ID=请求ID, 状态=响应.status_code, 耗时秒=monotonic() - 开始)
return 响应
安装错误响应(应用)
注册API(应用)
挂载工作台(应用)
return 应用
def 注册API(应用: FastAPI) -> None:
"""运行和合同生成共享同一组路由;不创建连接或加载凭据。"""
应用.include_router(任务路由)
应用.include_router(元数据路由)
应用.include_router(作品路由)
应用.include_router(正文路由)
应用.include_router(审阅路由)
应用.include_router(世界路由)
应用.include_router(事件路由)
应用.include_router(健康路由)
应用.include_router(会话路由())
def 导出合同() -> dict:
"""生成静态外壳,动态作品结构由 S04 运行时提供。"""
应用 = FastAPI(title="Muse 作者工作台", version="0.1.0")
注册API(应用)
return 应用.openapi()

View File

@ -0,0 +1 @@
"""具名业务 HTTP 路由。"""

View File

@ -0,0 +1,57 @@
"""统一错误外壳;验证错误不回显请求值。"""
from typing import Any
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException
from muse.共享.错误 import Muse错误
from muse.接入.主会话 import 参数合同错误
def 错误内容(错误: Muse错误, *, target_ref: str | None = None) -> dict[str, Any]:
return {**错误.呈现(), "target_ref": target_ref}
def 协议错误(code: str, message: str, status: int, details: Any = None) -> JSONResponse:
return JSONResponse(
status_code=status,
content={
"code": code,
"message": message,
"target_ref": None,
"retryable": False,
"details": details or {},
"actions": [],
},
)
def 安装错误响应(应用: FastAPI) -> None:
@应用.exception_handler(Muse错误)
async def 业务错误(request: Request, exc: Muse错误) -> JSONResponse:
code = exc.错误码
if code in {"SCOPE_DENIED", "MUSE_PURPOSE_VIOLATION"}:
status = 403
elif code.endswith("NOT_FOUND"):
status = 404
elif code.endswith(("STALE", "CONFLICT", "MISMATCH")):
status = 409
elif exc.可重试:
status = 503
else:
status = 422
return JSONResponse(status_code=status, content=错误内容(exc))
@应用.exception_handler(RequestValidationError)
async def 参数错误(request: Request, exc: RequestValidationError) -> JSONResponse:
return JSONResponse(status_code=422, content=参数合同错误(exc.errors()).呈现())
@应用.exception_handler(HTTPException)
async def 访问错误(request: Request, exc: HTTPException) -> JSONResponse:
code = {401: "UNAUTHENTICATED", 403: "SCOPE_DENIED", 404: "NOT_FOUND"}.get(
exc.status_code, "HTTP_ERROR"
)
return 协议错误(code, str(exc.detail), exc.status_code)

View File

@ -0,0 +1,25 @@
"""只提供已发布工作台资源,SPA 路由不接管 API 错误。"""
import mimetypes
from pathlib import PurePosixPath
from fastapi import FastAPI, HTTPException, Response
from muse.共享.错误 import 资源缺失错误
from muse.资源加载 import 打开资源
def 挂载工作台(应用: FastAPI) -> None:
@应用.get("/{path:path}", include_in_schema=False)
def 工作台文件(path: str) -> Response:
if path.startswith("api/"):
raise HTTPException(404, "接口不存在")
名称 = path if PurePosixPath(path).suffix else "index.html"
try:
数据 = 打开资源(f"工作台/{名称}")
except 资源缺失错误:
raise HTTPException(404, "工作台资源不存在,请核对安装包") from None
缓存 = "no-cache" if 名称 == "index.html" else "public, max-age=31536000, immutable"
return Response(
数据, media_type=mimetypes.guess_type(名称)[0], headers={"Cache-Control": 缓存}
)

View File

@ -0,0 +1,58 @@
"""主会话、HTTP 和 CLI 共用的具名任务控制输入与转交。"""
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from muse.任务运行.接口 import 任务服务, 任务状态
from muse.元数据.接口 import 有效结构, 生成结构合同
from muse.共享.错误 import Muse错误
def 参数合同错误(字段错误, *, 前缀: tuple[str, ...] = ()) -> Muse错误:
错误 = Muse错误(
"请求字段不符合接口合同",
上下文={"fields": [{"field": [*前缀, *e["loc"]], "type": e["type"]} for e in 字段错误]},
)
错误.错误码 = "INVALID_REQUEST"
return 错误
class 结构读取结果(BaseModel):
schema_id: str
schema_version: int
type_id: str
effective_schema_hash: str
contract: dict
def 呈现结构(结构: 有效结构) -> 结构读取结果:
return 结构读取结果(
schema_id=结构.基础.schema_id,
schema_version=结构.基础.schema_version,
type_id=结构.基础.type_id,
effective_schema_hash=结构.effective_schema_hash,
contract=生成结构合同(结构),
)
class 任务控制请求(BaseModel):
model_config = ConfigDict(extra="forbid")
command_id: str = Field(min_length=1)
target_ref: str = Field(min_length=1)
expected_state: 任务状态
action: Literal["暂停", "取消", "恢复"]
class 任务控制回执(BaseModel):
command_id: str
task_id: str
state: 任务状态
last_sequence: int
def 执行任务控制(服务: 任务服务, 作者: str, 请求: 任务控制请求) -> 任务控制回执:
回执 = 服务.控制任务(
请求.target_ref, 作者, 请求.expected_state, 请求.action, 命令ID=请求.command_id
)
return 任务控制回执.model_validate(回执)

View File

@ -0,0 +1,189 @@
"""真实 ASGI、CLI 与隔离 PostgreSQL 共用任务合同;不使用仓储替身。"""
import json
import subprocess
import sys
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from muse.任务运行.接口 import 任务服务, 任务请求, 步骤处理器, 步骤结果, 步骤计划
from muse.共享.调用身份 import 内容用途, 用途
from muse.接入.http.应用 import 创建应用
from muse.编排.接口 import 流程定义, 流程服务, 流程登记
from muse.配置 import 应用配置, 服务配置
pytestmark = pytest.mark.数据库
def test_具名CLI按字段传参不依赖JSON排列__793c2a(任务接入环境, tmp_path: Path) -> None:
服务, 身份, _, 配置文件 = 任务接入环境
请求文件 = tmp_path / "作者决定.json"
请求文件.write_text(
json.dumps(
{
"action": "取消",
"expected_state": "queued",
"target_ref": 身份,
"command_id": "作者决定-1",
},
ensure_ascii=False,
)
)
进程 = subprocess.run(
[sys.executable, "-m", "muse", "任务", str(配置文件), "控制", str(请求文件)],
cwd="/",
capture_output=True,
text=True,
timeout=20,
)
assert 进程.returncode == 0, 进程.stderr
assert json.loads(进程.stdout)["command_id"] == "作者决定-1"
assert 服务.读取任务(身份).状态.value == "cancelled"
@pytest.mark.parametrize(
"请求文本,额外参数,退出码",
[
("{}", [], 1),
('["x", 1, null]', [], 1),
("{}", ["--stdin"], 2),
("not json", [], 1),
('{"a": 1}', [], 1),
('{"sql": "CREATE TABLE t (id int)"}', [], 1),
('{"sql": "DELETE FROM t"}', [], 1),
('{"sql": "ALTER TABLE t ADD COLUMN x int", "params": []}', [], 1),
('{"sql": "DELETE FROM t", "params": []}', [], 1),
('{"sql": "UPDATE t SET x=1 WHERE id=1"}', [], 1),
('{"sql": "DELETE FROM t WHERE id=%s", "params": [1]}', [], 1),
],
ids=[
"empty",
"typed-array",
"competing-input",
"invalid-json",
"unknown-object",
"create-sql",
"bare-delete",
"alter-params",
"delete-params",
"update-where",
"delete-where",
],
)
def test_具名CLI拒绝未登记输入且任务不变__a71002(
任务接入环境,
tmp_path: Path,
请求文本: str,
额外参数: list[str],
退出码: int,
) -> None:
"""旧通用 SQL 入口已退出;只接受具名命令合同,不能以 WHERE 或参数列表换取写能力。"""
服务, 身份, _, 配置文件 = 任务接入环境
请求文件 = tmp_path / "请求.json"
请求文件.write_text(请求文本)
进程 = subprocess.run(
[sys.executable, "-m", "muse", "任务", str(配置文件), "控制", str(请求文件), *额外参数],
cwd="/",
capture_output=True,
text=True,
timeout=20,
)
assert 进程.returncode == 退出码, 进程.stdout
if 退出码 == 1:
assert json.loads(进程.stderr)["code"] == "INVALID_REQUEST"
assert 服务.读取任务(身份).状态.value == "queued"
@pytest.fixture
def 任务接入环境(应用测试库, tmp_path: Path):
registry = 流程登记()
registry.登记处理器(步骤处理器("synthetic", "1", lambda _: 步骤结果({"ok": True}), "v1", "v1"))
registry.登记类型("synthetic", 必需保护=())
service = 任务服务(应用测试库[用途.生产], registry)
flow = 流程服务(service, registry)
flow.发布(流程定义("synthetic", "1", (步骤计划("合成步骤", "synthetic", "1"),)))
req = 任务请求(
"合成任务",
"create",
"author-local",
用途.生产,
内容用途.抽取,
{},
"v1",
"test",
{
"source_scope": {},
"schema_versions": {},
"authorization": "synthetic",
"budget": {},
"stop_conditions": [],
},
作品ID="work-A",
)
task_id = flow.发起(req, "synthetic", "1")
token = tmp_path / "口令.txt"
token.write_text("synthetic-password")
config = 应用配置(
应用测试库[用途.生产].引用,
"test",
HTTP=服务配置(str(token), 公开地址="http://testserver", 允许来源=("http://testserver",)),
)
file = tmp_path / "应用.toml"
file.write_text(f"""["数据库"]
"取值方式" = "受控存储"
"位置" = {json.dumps(str(tmp_path / "production.txt"))}
["资源"]
"发布身份" = "test"
[HTTP]
"口令文件" = {json.dumps(str(token))}
""")
return service, task_id, config, file
def test_HTTP与CLI控制回执和拒绝保持一致__a71001(任务接入环境, tmp_path: Path) -> None:
service, task_id, config, file = 任务接入环境
with TestClient(创建应用(config)) as client:
assert client.get(f"/api/v1/tasks/{task_id}").status_code == 401
client.headers["Origin"] = "http://testserver"
assert (
client.post("/api/v1/session", json={"password": "synthetic-password"}).status_code
== 200
)
assert (
client.get("/api/v1/tasks", params={"work_id": "work-A"}).json()[0]["task_id"]
== task_id
)
assert (
client.get(f"/api/v1/tasks/{task_id}", params={"work_id": "work-B"}).status_code == 403
)
body = {
"command_id": "cancel-once",
"target_ref": task_id,
"expected_state": "queued",
"action": "取消",
}
response = client.post(f"/api/v1/tasks/{task_id}/controls", json=body)
assert response.status_code == 200
request = tmp_path / "请求.json"
request.write_text(json.dumps(body))
args = [sys.executable, "-m", "muse", "任务", str(file), "控制", str(request)]
cli = subprocess.run(args, cwd="/", capture_output=True, text=True, timeout=20)
assert cli.returncode == 0, cli.stderr
assert json.loads(cli.stdout) == response.json()
body["action"] = "暂停"
request.write_text(json.dumps(body))
response = client.post(f"/api/v1/tasks/{task_id}/controls", json=body)
cli = subprocess.run(args, cwd="/", capture_output=True, text=True, timeout=20)
assert response.status_code == 409 and cli.returncode == 1
assert response.json() == json.loads(cli.stderr)
events = client.get(f"/api/v1/tasks/{task_id}/events").json()
assert [event["event_type"] for event in events["items"]] == [
"task.created",
"task.cancelled",
]
assert (
client.get(f"/api/v1/tasks/{task_id}/events", params={"cursor": 2}).json()["items"]
== []
)

14
web/src/main.tsx Normal file
View File

@ -0,0 +1,14 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { Ui应用 } from "./应用/应用";
import { Ui依赖装配 } from "./应用/依赖装配";
import "./界面/主题.css";
import "./界面/排版.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<Ui依赖装配>
<Ui应用 />
</Ui依赖装配>
</React.StrictMode>,
);

1
web/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1 @@
export { Ui任务列表 } from "./任务列表";

View File

@ -0,0 +1,148 @@
import { useState, type FormEvent } from "react";
import { useQuery } from "@tanstack/react-query";
import { 客户端 } from "../../接口/生成/客户端";
import { Ui反馈状态 } from "../../界面/反馈状态";
import { useMuse作品会话 } from "../../应用/作品会话";
import { 查询键 } from "../../接口/查询键";
import { 状态 } from "./数据";
import { Ui任务详情 } from "./任务详情";
export function Ui任务列表() {
const work = useMuse作品会话();
const allTasks = useQuery({
queryKey: ["任务总览"],
queryFn: async ({ signal }) =>
(await 客户端.GET("/api/v1/tasks", { signal })).data!,
});
const works = [
...new Set(
(allTasks.data ?? []).flatMap((item) =>
item.work_id ? [item.work_id] : [],
),
),
];
const list = useQuery({
queryKey: 查询键(work.workId, "任务列表"),
queryFn: async ({ signal }) =>
(
await 客户端.GET("/api/v1/tasks", {
params: { query: { work_id: work.workId ?? undefined } },
signal,
})
).data!,
});
const [input, setInput] = useState("");
const [taskId, setTaskId] = useState("");
const task = useQuery({
queryKey: 查询键(work.workId, taskId),
enabled: !!taskId,
queryFn: async ({ signal }) =>
(
await 客户端.GET("/api/v1/tasks/{task_id}", {
params: {
path: { task_id: taskId },
query: { work_id: work.workId ?? undefined },
},
signal,
})
).data!,
});
function find(event: FormEvent) {
event.preventDefault();
setTaskId(input.trim());
}
return (
<main className="content">
<div className="page-heading">
<div>
<p className="eyebrow">创作进程</p>
<h1>每一步,都可以接续</h1>
<p className="muted">查看已发起的任务,跟进进度与处理结果。</p>
</div>
<span className="page-number">01 / 任务记录</span>
</div>
<div className="scope-filter">
<label htmlFor="work-scope">作品范围</label>
<select
id="work-scope"
value={work.workId ?? ""}
onChange={(event) => {
work.change(event.target.value || null);
setTaskId("");
setInput("");
}}
>
<option value="">全部作品</option>
{works.map((id) => (
<option key={id} value={id}>
{id}
</option>
))}
</select>
</div>
<form className="task-search" onSubmit={find}>
<label htmlFor="task-id">任务编号</label>
<div>
<input
id="task-id"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="粘贴已有任务编号"
required
/>
<button className="primary">查看任务</button>
</div>
</form>
{!taskId ? (
<section className="recent-tasks">
<div className="panel-heading">
<h2>最近任务</h2>
<span className="muted">{list.data?.length ?? 0} 条记录</span>
</div>
{list.isPending ? (
<Ui反馈状态 message="正在读取任务记录…" />
) : list.error ? (
<Ui反馈状态 message={list.error.message} error />
) : list.data?.length ? (
<ul>
{list.data.map((item) => (
<li key={item.task_id}>
<button
onClick={() => {
setTaskId(item.task_id);
setInput(item.task_id);
}}
>
<span>
<strong>{item.task_type}</strong>
<small>
{item.work_id ?? "工作台任务"} · {item.steps.length}{" "}
个步骤
</small>
</span>
<span className="state-pill">{状态[item.state]}</span>
</button>
</li>
))}
</ul>
) : (
<div className="empty-state">
<h2>这里会保留每一步进度</h2>
<p>当前范围还没有任务记录。</p>
</div>
)}
</section>
) : task.isPending ? (
<Ui反馈状态 message="正在读取任务…" />
) : task.error ? (
<Ui反馈状态 error message={task.error.message} />
) : task.data ? (
<Ui任务详情
key={task.data.task_id}
task={task.data}
scope={work.workId}
/>
) : null}
</main>
);
}

View File

@ -0,0 +1,79 @@
import { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Ui恢复控制 } from "./恢复控制";
import { 订阅任务事件 } from "../../接口/事件连接";
import { 查询键 } from "../../接口/查询键";
import { Ui反馈状态 } from "../../界面/反馈状态";
import { 状态, 事件名, type 任务, type 任务事件 } from "./数据";
export function Ui任务详情({
task,
scope,
}: {
task: 任务;
scope: string | null;
}) {
const cache = useQueryClient();
const [events, setEvents] = useState<任务事件[]>([]);
const [notice, setNotice] = useState("");
useEffect(
() =>
订阅任务事件(task.task_id, {
收到: (事件) => {
setEvents((旧) => [...旧, 事件].slice(-40));
setNotice("");
void cache.invalidateQueries({
queryKey: 查询键(scope, task.task_id),
});
},
缺口: () => setNotice("较早的事件已归档,请重新读取任务进度。"),
重连: () => setNotice("连接正在恢复,任务会继续保留。"),
}),
[task.task_id, scope, cache],
);
return (
<div className="task-columns">
<section className="paper-panel">
<div className="panel-heading">
<h2>任务进度</h2>
<span className="state-pill">{状态[task.state]}</span>
</div>
<p className="mono muted">{task.task_id}</p>
<Ui恢复控制 task={task} />
<ol className="step-list">
{task.steps.map((step, index) => (
<li key={step.step_id}>
<span className="step-number">
{String(index + 1).padStart(2, "0")}
</span>
<div>
<strong>{step.step_id}</strong>
<span>{状态[step.state] ?? step.state}</span>
</div>
</li>
))}
</ol>
</section>
<section className="event-panel">
<div className="panel-heading">
<h2>过程记录</h2>
<span className="muted">实时更新</span>
</div>
{notice && <Ui反馈状态 message={notice} />}
<ol className="event-list" aria-live="polite">
{events.map((event) => (
<li key={event.event_id}>
<time>
{new Date(event.occurred_at).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
})}
</time>
<span>{事件名[event.event_type] ?? "进度更新"}</span>
</li>
))}
</ol>
</section>
</div>
);
}

View File

@ -0,0 +1,65 @@
import { useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { 客户端 } from "../../接口/生成/客户端";
import { Ui反馈状态 } from "../../界面/反馈状态";
import type { 任务 } from "./数据";
export function Ui恢复控制({ task }: { task: 任务 }) {
const cache = useQueryClient();
const [busy, setBusy] = useState(false);
const [controlError, setControlError] = useState("");
const pending = useRef<{
action: "暂停" | "取消" | "恢复";
id: string;
} | null>(null);
async function 控制(action: "暂停" | "取消" | "恢复") {
if (pending.current?.action !== action)
pending.current = { action, id: crypto.randomUUID() };
setBusy(true);
setControlError("");
try {
await 客户端.POST("/api/v1/tasks/{task_id}/controls", {
params: { path: { task_id: task.task_id } },
body: {
command_id: pending.current.id,
target_ref: task.task_id,
expected_state: task.state,
action,
},
});
pending.current = null;
await cache.invalidateQueries();
} catch (e) {
setControlError(e instanceof Error ? e.message : "控制请求未完成。");
} finally {
setBusy(false);
}
}
return (
<>
{(["queued", "running"] as string[]).includes(task.state) && (
<div className="task-actions">
<button disabled={busy} onClick={() => 控制("暂停")}>
暂停任务
</button>
<button disabled={busy} onClick={() => 控制("取消")}>
取消任务
</button>
</div>
)}
{(["paused", "failed", "reconciling"] as string[]).includes(
task.state,
) && (
<div className="task-actions">
<button disabled={busy} onClick={() => 控制("恢复")}>
核对并恢复
</button>
<button disabled={busy} onClick={() => 控制("取消")}>
取消任务
</button>
</div>
)}
{controlError && <Ui反馈状态 message={controlError} error />}
</>
);
}

View File

@ -0,0 +1,29 @@
import type { operations } from "../../接口/生成/类型";
export type 任务 =
operations["read_task"]["responses"][200]["content"]["application/json"];
export type { 任务事件 } from "../../接口/事件连接";
export const 状态: Record<string, string> = {
queued: "等待运行",
running: "正在进行",
paused: "已暂停",
reconciling: "等待核对",
failed: "未能完成",
cancelled: "已取消",
completed: "已完成",
pending: "待开始",
unknown: "等待核对",
};
export const 事件名: Record<string, string> = {
"task.created": "任务已建立",
"step.started": "开始处理",
"step.completed": "步骤完成",
"task.completed": "任务完成",
"task.cancelled": "任务已取消",
"task.paused": "任务已暂停",
"task.resumed": "继续处理",
"step.failed": "步骤未完成",
"text.delta": "正文更新",
"step.checkpoint": "进度已保存",
"call.unknown": "等待结果核对",
};

View File

@ -0,0 +1,13 @@
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
/** 缓存身份和取消共用 React Query,避免维护第二套请求代次。 */
export function useMuse作品会话() {
const [workId, setWorkId] = useState<string | null>(null);
const cache = useQueryClient();
function change(next: string | null) {
void cache.cancelQueries({ queryKey: ["作品", workId] });
setWorkId(next);
}
return { workId, change };
}

View File

@ -0,0 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import { 客户端 } from "../接口/生成/客户端";
export function useMuse作者会话() {
return useQuery({
queryKey: ["作者会话"],
queryFn: async () => (await 客户端.GET("/api/v1/session")).data!,
});
}

View File

@ -0,0 +1,14 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type PropsWithChildren } from "react";
export function Ui依赖装配({ children }: PropsWithChildren) {
const [client] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: { retry: false, refetchOnWindowFocus: false },
},
}),
);
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

View File

@ -0,0 +1,62 @@
import { useState, type PropsWithChildren } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { NavLink } from "react-router-dom";
import { 客户端 } from "../接口/生成/客户端";
import { Ui反馈状态 } from "../界面/反馈状态";
export function Ui工作台布局({ children }: PropsWithChildren) {
const cache = useQueryClient();
const [disconnectError, setDisconnectError] = useState("");
async function 断开() {
try {
await 客户端.DELETE("/api/v1/session");
await cache.cancelQueries();
cache.setQueryData(["作者会话"], null);
cache.removeQueries({
predicate: (query) => query.queryKey[0] !== "作者会话",
});
} catch (e) {
setDisconnectError(e instanceof Error ? e.message : "断开请求未完成。");
}
}
return (
<div className="workbench">
<aside className="sidebar">
<div className="brand">
<span className="brand-mark small">M</span>
<div>
<strong>Muse</strong>
<span>创作工作台</span>
</div>
</div>
<p className="nav-caption">工作空间</p>
<nav>
<NavLink to="/works">
我的作品<span>01</span>
</NavLink>
<NavLink to="/tasks">
任务记录<span>02</span>
</NavLink>
<NavLink to="/system">
连接状态<span>03</span>
</NavLink>
</nav>
<div className="sidebar-note">
<span className="status-dot" />
作者会话已连接<p>关掉页面后,任务仍会保留。</p>
</div>
<button className="text-button" onClick={断开}>
断开会话
</button>
</aside>
<div className="workspace">
<header className="topbar">
<span>我的工作台</span>
<span className="muted">每一步,都有记录</span>
</header>
{disconnectError && <Ui反馈状态 error message={disconnectError} />}
{children}
</div>
</div>
);
}

96
web/src/应用/应用.tsx Normal file
View File

@ -0,0 +1,96 @@
import { useState, type FormEvent } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { BrowserRouter } from "react-router-dom";
import { 客户端 } from "../接口/生成/客户端";
import { 接口错误 } from "../接口/传输";
import { Ui反馈状态 } from "../界面/反馈状态";
import { useMuse作者会话 } from "./作者会话";
import { Ui工作台布局 } from "./工作台布局";
import { Ui页面路由 } from "./路由";
function Ui连接表单() {
const cache = useQueryClient();
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function 连接(event: FormEvent) {
event.preventDefault();
setBusy(true);
setError("");
try {
const result = await 客户端.POST("/api/v1/session", {
body: { password },
});
setPassword("");
cache.setQueryData(["作者会话"], result.data);
} catch (e) {
setError(e instanceof Error ? e.message : "连接未完成。");
} finally {
setBusy(false);
}
}
return (
<main className="connect-page">
<div className="connect-intro">
<span className="brand-mark">M</span>
<p className="eyebrow">MUSE · 创作工作台</p>
<h1>
把故事
<br />
接着写下去。
</h1>
<p>
整理想法,守住每一次选择。
<br />
从你的工作台继续。
</p>
</div>
<form className="connect-form" onSubmit={连接}>
<p className="eyebrow">作者会话</p>
<h2>连接工作台</h2>
<p className="muted">使用本机配置的访问口令。</p>
<label htmlFor="password">访问口令</label>
<input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
{error && <Ui反馈状态 message={error} error />}
<button className="primary" disabled={busy}>
{busy ? "连接中…" : "进入工作台"}
</button>
</form>
</main>
);
}
export function Ui应用() {
const session = useMuse作者会话();
if (session.isPending)
return (
<div className="loading-page">
<Ui反馈状态 message="正在连接工作台…" />
</div>
);
if (
session.error &&
!(session.error instanceof 接口错误 && session.error.status === 401)
)
return (
<div className="loading-page">
<Ui反馈状态 message={session.error.message} error />
<button onClick={() => session.refetch()}>重新连接</button>
</div>
);
if (!session.data) return <Ui连接表单 />;
return (
<BrowserRouter>
<Ui工作台布局>
<Ui页面路由 />
</Ui工作台布局>
</BrowserRouter>
);
}

59
web/src/应用/路由.tsx Normal file
View File

@ -0,0 +1,59 @@
import { useState } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { Ui任务列表 } from "../功能/任务运行";
import {
Ui作品首页,
Ui作品档案,
Ui探索工作区,
Ui规划工作区,
} from "../功能/作品规划";
import { Ui候选审阅页, Ui正文页 } from "../功能/正文写作";
import { 客户端 } from "../接口/生成/客户端";
export function Ui页面路由() {
return (
<Routes>
<Route path="/works" element={<Ui作品首页 />} />
<Route path="/explorations/:draft_id?" element={<Ui探索工作区 />} />
<Route path="/works/:work_id/profile" element={<Ui作品档案 />} />
<Route path="/works/:work_id/plans" element={<Ui规划工作区 />} />
<Route path="/works/:work_id/write/:chapter_id?" element={<Ui正文页 />} />
<Route
path="/works/:work_id/candidates/:chapter_id"
element={<Ui候选审阅页 />}
/>
<Route path="/tasks" element={<Ui任务列表 />} />
<Route path="/system" element={<Ui连接状态 />} />
<Route path="*" element={<Navigate to="/works" replace />} />
</Routes>
);
}
function Ui连接状态() {
const [message, setMessage] = useState("查看工作台当前的连接情况。");
const [busy, setBusy] = useState(false);
async function 检查() {
setBusy(true);
try {
await 客户端.GET("/ready");
setMessage("连接正常,工作台已就绪。");
} catch (e) {
setMessage(e instanceof Error ? e.message : "连接暂不可用。");
} finally {
setBusy(false);
}
}
return (
<main className="content">
<p className="eyebrow">工作台</p>
<h1>连接状态</h1>
<section className="paper-panel">
<h2>服务连接</h2>
<p className="muted">{message}</p>
<button onClick={检查} disabled={busy}>
{busy ? "检查中…" : "检查连接"}
</button>
</section>
</main>
);
}

View File

@ -0,0 +1,34 @@
import type { operations } from "./生成/类型";
export type 任务事件 =
operations["read_task_events"]["responses"][200]["content"]["application/json"]["items"][number];
/** 原生 EventSource 携带 Last-Event-ID 重连;页面离开时显式关闭。 */
export function 订阅任务事件(
任务ID: string,
回调: { 收到: (事件: 任务事件) => void; 缺口: () => void; 重连: () => void },
) {
const 连接 = new EventSource(
`/api/v1/tasks/${encodeURIComponent(任务ID)}/events/stream`,
);
let 活跃 = true;
let 最后序号 = 0;
连接.addEventListener("task-event", (消息) => {
if (!活跃) return;
const 事件 = JSON.parse((消息 as MessageEvent).data) as 任务事件;
if (事件.sequence <= 最后序号) return;
最后序号 = 事件.sequence;
回调.收到(事件);
});
连接.addEventListener("reset", () => {
if (活跃) 回调.缺口();
连接.close();
});
连接.onerror = () => {
if (活跃) 回调.重连();
};
return () => {
活跃 = false;
连接.close();
};
}

34
web/src/接口/传输.ts Normal file
View File

@ -0,0 +1,34 @@
/** 统一凭据、取消和错误;服务器错误保留稳定代码与中文说明。 */
export class 接口错误 extends Error {
constructor(
public code: string,
message: string,
public status: number,
) {
super(message);
}
}
export async function 传输(request: Request): Promise<Response> {
let response: Response;
try {
response = await fetch(request);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError")
throw error;
throw new 接口错误(
"NETWORK_UNAVAILABLE",
"暂时无法连接工作台,请稍后重试。",
0,
);
}
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new 接口错误(
body.code ?? "HTTP_ERROR",
body.message ?? "请求未完成,请检查连接后重试。",
response.status,
);
}
return response;
}

View File

@ -0,0 +1,9 @@
/** 查询身份包含作品和目标,切换作品不会共用同一条缓存。 */
export function 查询键(
作品: string | null,
目标: string,
版本: string = "current",
用途: string = "production",
) {
return ["作品", 作品, 目标, 版本, 用途] as const;
}

View File

@ -0,0 +1,9 @@
// 由工具/生成接口.py生成;稳定外壳来自同一OpenAPI。
import createClient from "openapi-fetch";
import type { paths } from "./类型";
import { 传输 } from "../传输";
export const 客户端 = createClient<paths>({
baseUrl: globalThis.location.origin, fetch: 传输,
credentials: "same-origin"
});

File diff suppressed because it is too large Load Diff

563
web/src/界面/主题.css Normal file
View File

@ -0,0 +1,563 @@
:root {
font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
color: #30372e;
background: #f4f1e9;
font-synthesis: none;
--paper: #fffdf7;
--line: #dddccf;
--accent: #405e43;
--muted: #7d8073;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
}
button,
input {
font: inherit;
}
button {
border: 1px solid var(--line);
padding: 12px 19px;
background: var(--paper);
color: inherit;
border-radius: 5px;
cursor: pointer;
transition:
transform 0.12s,
background 0.12s;
}
button:active {
transform: scale(0.98);
}
button:disabled {
opacity: 0.55;
cursor: wait;
}
button.primary {
background: var(--accent);
color: #fffdf5;
border-color: var(--accent);
}
button:hover {
filter: brightness(0.97);
}
input {
border: 1px solid #d7d7c9;
background: #fffcf6;
color: #30372e;
border-radius: 5px;
padding: 13px 15px;
min-width: 0;
}
input:focus,
button:focus-visible,
a:focus-visible {
outline: 2px solid #789472;
outline-offset: 3px;
}
label {
font-size: 13px;
font-weight: 600;
}
h1 {
font-size: 32px;
letter-spacing: -0.9px;
line-height: 1.3;
font-weight: 550;
margin: 10px 0 16px;
}
h2 {
font-size: 18px;
font-weight: 550;
margin: 0 0 16px;
}
p {
line-height: 1.9;
}
.muted {
color: var(--muted);
font-size: 13px;
}
.eyebrow {
font-size: 11px;
font-weight: 600;
letter-spacing: 2.5px;
color: #77806a;
margin: 0;
}
.brand-mark {
display: inline-grid;
place-items: center;
width: 52px;
height: 52px;
border: 1px solid #687361;
color: #53644c;
border-radius: 14px;
font-size: 30px;
font-family: inherit;
font-style: italic;
}
.brand-mark.small {
width: 38px;
height: 38px;
font-size: 23px;
border-radius: 10px;
}
.connect-page {
max-width: 1120px;
min-height: 100dvh;
margin: auto;
display: grid;
grid-template-columns: 1.25fr 1fr;
align-items: center;
gap: 90px;
padding: 70px;
}
.connect-intro h1 {
font-size: 58px;
line-height: 1.45;
letter-spacing: 1px;
margin: 32px 0 20px;
}
.connect-intro > .eyebrow {
margin-top: 28px;
}
.connect-intro > p:last-child {
color: #7c8272;
line-height: 2.2;
font-size: 15px;
}
.connect-form {
background: var(--paper);
border: 1px solid var(--line);
border-radius: 9px;
padding: 45px;
box-shadow: 0 18px 50px #41472d08;
}
.connect-form h2 {
font-size: 25px;
margin-top: 12px;
}
.connect-form label {
display: block;
margin: 25px 0 9px;
}
.connect-form input,
.connect-form button {
width: 100%;
}
.connect-form button {
margin-top: 22px;
}
.loading-page {
min-height: 100dvh;
display: grid;
place-content: center;
gap: 20px;
}
.workbench {
display: grid;
grid-template-columns: 218px 1fr;
min-height: 100dvh;
}
.sidebar {
border-right: 1px solid var(--line);
padding: 31px 23px;
display: flex;
flex-direction: column;
background: #eeeee4;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 60px;
}
.brand strong {
font-size: 23px;
font-weight: 550;
letter-spacing: 0.4px;
display: block;
}
.brand div > span {
display: block;
font-size: 10px;
color: var(--muted);
letter-spacing: 2px;
margin-top: 4px;
}
.nav-caption {
color: #929788;
font-size: 10px;
letter-spacing: 2px;
padding-left: 13px;
margin-bottom: 15px;
}
nav {
display: grid;
gap: 8px;
}
nav a {
padding: 13px;
color: #727969;
text-decoration: none;
border-radius: 5px;
font-size: 13px;
display: flex;
justify-content: space-between;
}
nav a.active {
background: #dde5d4;
color: #3f5a3b;
}
nav a > span {
font-size: 10px;
opacity: 0.55;
padding-top: 2px;
}
.sidebar-note {
margin-top: auto;
padding: 30px 3px 5px;
color: #68705e;
font-size: 11px;
}
.sidebar-note p {
font-size: 10px;
color: #9a9e90;
line-height: 1.8;
}
.status-dot {
display: inline-block;
width: 5px;
height: 5px;
border-radius: 50%;
background: #6f8c60;
margin-right: 7px;
}
.text-button {
border: 0;
padding: 8px 2px;
background: none;
text-align: left;
color: #959b8c;
font-size: 11px;
}
.workspace {
min-width: 0;
}
.topbar {
height: 72px;
border-bottom: 1px solid var(--line);
padding: 0 48px;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: #757b6d;
}
.topbar > .muted {
font-size: 10px;
letter-spacing: 1px;
}
.content {
max-width: 1400px;
padding: 43px 48px 64px;
margin: 0 auto;
}
.page-heading {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 35px;
}
.page-heading .muted {
margin: 0;
}
.page-number {
font-size: 10px;
color: #a0a492;
letter-spacing: 1px;
padding-top: 7px;
}
.task-search {
padding: 24px 28px;
background: #eeede4;
border: 1px solid var(--line);
border-radius: 7px;
}
.task-search > div {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
margin-top: 12px;
}
.task-search input {
font-size: 13px;
}
.empty-state {
text-align: center;
padding: 73px 20px 60px;
color: #7e8574;
}
.empty-symbol {
font-size: 35px;
color: #a5ae94;
display: block;
margin-bottom: 20px;
}
.empty-state h2 {
color: #59674e;
font-size: 17px;
font-weight: 500;
letter-spacing: 0.5px;
}
.empty-state p {
font-size: 12px;
line-height: 2.2;
color: #9a9f90;
}
.task-columns {
display: grid;
grid-template-columns: 1.35fr 1fr;
gap: 27px;
margin-top: 26px;
}
.paper-panel {
background: var(--paper);
border: 1px solid var(--line);
border-radius: 7px;
padding: 28px;
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.panel-heading h2 {
margin: 0;
font-size: 16px;
}
.state-pill {
padding: 5px 9px;
border: 1px solid #d5dfc9;
background: #edf1e5;
color: #617551;
font-size: 10px;
border-radius: 3px;
}
.mono {
font-family: ui-monospace, monospace;
font-size: 10px;
overflow-wrap: anywhere;
}
.step-list,
.event-list {
list-style: none;
padding: 0;
margin: 27px 0 0;
}
.step-list li {
display: flex;
gap: 20px;
padding: 19px 0;
border-top: 1px solid #e8e7de;
}
.step-number {
font-size: 11px;
color: #a3ab98;
padding-top: 3px;
}
.step-list strong {
font-size: 13px;
font-weight: 500;
}
.step-list li div > span {
display: block;
font-size: 11px;
color: #8a9380;
margin-top: 7px;
}
.event-panel {
padding: 28px 0;
}
.event-list li {
display: flex;
gap: 18px;
font-size: 12px;
line-height: 1.8;
padding: 11px 0;
border-bottom: 1px solid #e4e4d9;
}
.event-list time {
color: #9ca18f;
font-family: ui-monospace, monospace;
font-size: 10px;
padding-top: 2px;
}
.feedback {
padding: 15px 18px;
border: 1px solid #d7dfca;
background: #edf0e3;
color: #70815d;
font-size: 12px;
border-radius: 4px;
margin-top: 15px;
}
.feedback.error {
background: #f2eae1;
color: #8e5b3a;
border-color: #e3cfb9;
}
@media (max-width: 900px) {
.connect-page {
gap: 35px;
padding: 40px;
}
.connect-intro h1 {
font-size: 42px;
}
.workbench {
grid-template-columns: 175px 1fr;
}
.sidebar {
padding: 25px 15px;
}
.topbar {
padding: 0 26px;
}
.content {
padding: 32px 26px;
}
.task-columns {
grid-template-columns: 1fr;
}
.page-number {
display: none;
}
}
@media (max-width: 600px) {
.connect-page {
grid-template-columns: 1fr;
padding: 35px 22px;
gap: 26px;
}
.connect-intro h1 {
font-size: 34px;
margin: 20px 0 12px;
}
.connect-intro > p:last-child {
display: none;
}
.connect-form {
padding: 28px;
}
.workbench {
grid-template-columns: 1fr;
}
.sidebar {
border-right: 0;
border-bottom: 1px solid var(--line);
padding: 18px 22px;
display: block;
}
.brand {
margin: 0 0 17px;
}
.nav-caption,
.sidebar-note,
.text-button {
display: none;
}
nav {
display: flex;
}
nav a {
gap: 35px;
}
.topbar {
height: 55px;
padding: 0 22px;
}
.content {
padding: 29px 22px;
}
h1 {
font-size: 26px;
}
.task-search {
padding: 19px;
}
.task-search > div {
grid-template-columns: 1fr;
}
.empty-state {
padding-top: 45px;
}
}
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
scroll-behavior: auto !important;
}
}
.scope-filter {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 18px;
}
.scope-filter select {
font: inherit;
font-size: 12px;
color: #59674e;
padding: 9px 13px;
border: 1px solid var(--line);
border-radius: 4px;
background: #fffdf7;
}
.recent-tasks {
margin-top: 34px;
}
.recent-tasks ul {
list-style: none;
padding: 0;
margin-top: 20px;
}
.recent-tasks li {
border-top: 1px solid var(--line);
}
.recent-tasks li button {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
border: 0;
background: none;
padding: 21px 5px;
text-align: left;
}
.recent-tasks strong {
font-size: 14px;
font-weight: 500;
}
.recent-tasks small {
display: block;
color: #919985;
font-size: 11px;
margin-top: 8px;
}
.task-actions {
display: flex;
gap: 12px;
margin-top: 23px;
}
.task-actions button {
font-size: 12px;
padding: 9px 14px;
}

View File

@ -0,0 +1,16 @@
export function Ui反馈状态({
message,
error = false,
}: {
message: string;
error?: boolean;
}) {
return (
<div
className={error ? "feedback error" : "feedback"}
role={error ? "alert" : "status"}
>
{message}
</div>
);
}

View File

@ -0,0 +1,37 @@
/** 组件级会话观察:传输替身不计真实登录旅程。 */
import { afterEach, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { Ui应用 } from "../../src/应用/应用";
import { Ui依赖装配 } from "../../src/应用/依赖装配";
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("NC-ui-session-close:退出成功卸载任务并回到连接表单", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (request: Request) => {
const path = new URL(request.url).pathname;
const data = path.endsWith("session")
? request.method === "DELETE"
? { closed: true }
: { author_id: "author-local" }
: [];
return new Response(JSON.stringify(data), {
headers: { "Content-Type": "application/json" },
});
}),
);
render(
<Ui依赖装配>
<Ui应用 />
</Ui依赖装配>,
);
fireEvent.click(await screen.findByRole("button", { name: "断开会话" }));
expect(
await screen.findByRole("button", { name: "进入工作台" }),
).toBeTruthy();
expect(screen.queryByRole("button", { name: "断开会话" })).toBeNull();
});