diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 543960e..1d6d81b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,13 +144,14 @@ jobs: with: python-version-file: ".python-version" + - name: Setup uv + uses: astral-sh/setup-uv@v7 + - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install -e .[dev] + run: uv sync --extra dev - name: Backend syntax check - run: python -m compileall backend + run: uv run python -m compileall backend # Coverage visibility, not a gate: pyproject.toml bakes in # `--cov-fail-under=80` via [tool.pytest.ini_options].addopts, but the @@ -167,7 +168,7 @@ jobs: # The upstream oracle stays opt-in (DATAFLOW_RUN_UPSTREAM_ORACLE=1) and # self-skips in this job. - name: Backend tests (with coverage report) - run: python -m pytest tests/unit tests/compat tests/integration -m "not live" --cov=backend --cov-report=term-missing --cov-fail-under=0 + run: uv run pytest tests/unit tests/compat tests/integration -m "not live" --cov=backend --cov-report=term-missing --cov-fail-under=0 frontend-workflow-checks: runs-on: ubuntu-latest @@ -237,28 +238,29 @@ jobs: with: python-version-file: ".python-version" + - name: Setup uv + uses: astral-sh/setup-uv@v7 + - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install -e .[dev] + run: uv sync --extra dev - name: Wait for Postgres - run: python -c "import time; time.sleep(2)" + run: uv run python -c "import time; time.sleep(2)" - name: alembic upgrade head - run: alembic upgrade head + run: uv run alembic upgrade head # Catches broken downgrade() bodies / non-reversible migrations before # merge: a chain that only ever gets tested via upgrade() can silently # rot (dropped columns with no re-add, wrong op order, etc.). - name: alembic downgrade/upgrade smoke test run: | - alembic downgrade -1 - alembic upgrade head + uv run alembic downgrade -1 + uv run alembic upgrade head - name: Native intelligence and event spine PostgreSQL conformance run: >- - pytest tests/integration/test_intelligence_session_store.py + uv run pytest tests/integration/test_intelligence_session_store.py tests/integration/test_intelligence_session_migration.py tests/unit/test_workflow_run_events.py tests/integration/test_workflow_event_spine_migrations.py @@ -274,7 +276,7 @@ jobs: # is caught. --no-cov: this is a targeted single-test step, not a coverage # run (pyproject's addopts would otherwise fail it under the 80% gate). - name: cursor FOR UPDATE locking (Postgres) - run: python -m pytest tests/unit/pipeline/test_db_cursor_store.py -k postgres --no-cov -p no:cacheprovider + run: uv run pytest tests/unit/pipeline/test_db_cursor_store.py -k postgres --no-cov -p no:cacheprovider cargo: runs-on: ubuntu-latest diff --git a/.nvmrc b/.nvmrc index 5c8be01..a45fd52 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -26.3.1 +24 diff --git a/DESIGN.md b/DESIGN.md index 1d85ab0..a652485 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,3 +1,7 @@ +--- +name: OpenCLI Admin design system +--- + # Design ## Source of truth @@ -400,3 +404,36 @@ - [ ] Workflow UX / `manifest.presentation` 的 experience descriptor 首版字段名和版本如何定义,使内建专用编辑器可声明但不把任意插件前端带入平台? - [ ] Workflow UX / 跨工作流复制首版是否仅支持同一 OpenCLI 实例,还是同时定义跨实例剪贴板格式与兼容性报告? - [ ] Workbench engines / Perspective + DuckDB-Wasm 与 OpenTelemetry + Langfuse 的首个生产适配器边界、数据量阈值和许可证复核何时进入 ADR?当前页面只验证 OpenCLI 内的信息架构与真实数据交互。 + +## Product Context + +OpenCLI Admin 是面向自托管操作者的研究、采集和工作流控制台。界面必须优先表达真实状态、明确权限边界,并为失败提供可执行的恢复路径。 + +## Overview + +产品采用桌面优先、信息密集、渐进披露的控制台体验。首次部署必须能够完成安全初始化,后续高频操作不暴露底层部署凭据。 + +## Colors + +沿用 `docs/DESIGN_SYSTEM.md` 与 `frontend/app/globals.css` 的语义色;橙色用于主要操作和品牌信号,状态不能只依赖颜色表达。 + +## Typography + +正文使用现有界面字体栈,运行标识、端口和令牌类内容使用等宽字体。紧凑布局不得牺牲标签、错误信息和正文可读性。 + +## Layout + +控制台保持现有侧栏、内容区和检查器边界。认证界面在窄屏采用单列,在桌面保留产品背景与固定宽度表单,并保证表单顺序稳定。 + +## Do's and Don'ts + +- Do:先显示用户要完成的任务,再按需揭示实现细节和恢复入口。 +- Do:为加载、空、错误、阻塞和成功状态提供明确文案与下一步。 +- Don't:把环境变量、OIDC 术语或 Fleet Token 当作新用户必须理解的产品概念。 +- Don't:使用装饰动画遮盖状态变化,或把不可用能力表现为可执行。 + +## Source Decisions + +- Adopted:现有 Dark Ops Console 视觉体系、shadcn 组件、渐进披露和可恢复状态原则。 +- Rejected:要求首次用户先配置外部身份提供方或从 `.env` 中寻找日常登录凭据的流程。 +- Active change:`openspec/changes/local-admin-onboarding`。 diff --git a/MOTION.md b/MOTION.md new file mode 100644 index 0000000..6965147 --- /dev/null +++ b/MOTION.md @@ -0,0 +1,41 @@ +--- +schema: design-pipeline.motion-foundation.v0.1 +name: OpenCLI operator motion language +posture: minimal +primitiveRegistry: design-pipeline.motion-primitives.v1 +--- + +## Motion Thesis + +Motion confirms a completed operator action or a changed system state. It never delays access to credentials, recovery, or operational data. + +## Motion Principles + +- Keep authentication transitions short, interruptible, and secondary to the active form state. +- Never move focused controls or change their order while the user is typing. +- Prefer opacity and color feedback over layout movement for repeated operational use. + +## Motion Vocabulary + +- primitive: reveal.trim-line + - Use only for a non-blocking transition between login states. + +## Procedural Motion + +No procedural motion is used for authentication or recovery surfaces. + +## Runtime Policy + +CSS transitions are the default adapter for small state changes. The existing Motion React adapter may preserve the selected primitive where it is already loaded; no new animation runtime is introduced. + +## Reduced Motion + +When `prefers-reduced-motion` is enabled, state changes use immediate opacity changes and do not animate position, scale, or background effects. + +Fallback: every animated confirmation has an immediate static state change with the same text and focus result. + +## Source Decisions + +- Adopted: the existing login surface's short, non-blocking confirmation transitions; this keeps the new authentication states consistent with repeated console use. +- Rejected: decorative background and position animation for password and recovery states; these make an access-critical form less legible and are not required for the operator workflow. +- Authored for `openspec/changes/local-admin-onboarding`; no external motion implementation or visual reference is adopted. diff --git a/README.md b/README.md index b93b5cf..12d107b 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,10 @@ Invoke-WebRequest https://raw.githubusercontent.com/2233admin/opencli-Razormind/ 安装完成后,终端会打印: -- `BOOTSTRAP_ADMIN_TOKEN`:首次进入管理界面使用; +- `BOOTSTRAP_ADMIN_TOKEN`:首次创建本地管理员及紧急恢复时使用; - `API_AUTH_TOKEN`:Fleet、Agent、API 和 MCP 访问使用。 -两者同时保存在安装目录的 `.env`。不要公开 noVNC、令牌或浏览器调试端口;远程部署建议使用 HTTPS、反向代理或 SSH 隧道。 +首次进入控制台时设置本地管理员密码并输入一次 `BOOTSTRAP_ADMIN_TOKEN`;后续直接使用管理员密码登录。令牌同时保存在安装目录的 `.env`,仅供恢复使用。不要公开 noVNC、令牌或浏览器调试端口;远程部署建议使用 HTTPS、反向代理或 SSH 隧道。 ## 正常的研究流程 @@ -167,39 +167,44 @@ flowchart LR ## 从源码开发 -前置要求:Python 3.13+、Node.js 26.3.1(见 `.nvmrc`)、uv、pnpm。 +前置要求:Python 3.13+、Node.js 24(见 `.nvmrc`)、uv、pnpm。 ~~~bash git clone https://github.com/2233admin/opencli-Razormind.git cd opencli-Razormind -uv sync -uv run uvicorn backend.main:app --host 127.0.0.1 --port 8031 +uv sync --extra dev +npm run doctor +npm run dev:backend ~~~ 另开终端: ~~~bash -cd frontend -pnpm install -pnpm dev --hostname 127.0.0.1 --port 3010 +npm run dev:frontend ~~~ 常用验证: ~~~bash -npm run lint:frontend -npm run typecheck:frontend -npm run build:frontend -uv run pytest +npm run check +npm run test:backend ~~~ +可选能力在启动前使用同一套环境预检:`npm run doctor:agent`、 +`npm run doctor:celery`、`npm run doctor:ai`、`npm run doctor:dify`、 +`npm run doctor:kats`、`npm run doctor:image-studio`。对应 Docker 入口为 +`npm run docker:agent|docker:celery|docker:dify|docker:kats|docker:image-studio`。 +`CHROME_SUFFIX` 在默认栈中应为空; +启用远程 Agent 的内置 Chrome 镜像时必须设为 `-chrome`,并运行 +`node scripts/dev-environment.mjs --profiles=agent,embedded-chrome`。预检只报告变量名,不输出密钥值。 + 从源码构建完整 Docker 栈: ~~~bash cp .env.docker.example .env # 设置 API_AUTH_TOKEN、BOOTSTRAP_ADMIN_TOKEN、SECRET_KEY、CREDENTIAL_ENCRYPTION_KEY -docker compose -f docker-compose.yml -f docker-compose.build.yml up --build -d +npm run docker:up ~~~ ## 发布镜像 diff --git a/backend/api/v1/__init__.py b/backend/api/v1/__init__.py index e42907e..5c687b6 100644 --- a/backend/api/v1/__init__.py +++ b/backend/api/v1/__init__.py @@ -16,6 +16,7 @@ geo_acquisition, identity, image_studio, + local_auth, model_defaults, nodes, notifications, @@ -80,6 +81,7 @@ v1_router.include_router(dashboard.router) v1_router.include_router(system.router) v1_router.include_router(identity.router) +v1_router.include_router(local_auth.router) v1_router.include_router(workspaces.router) v1_router.include_router(workspace_sources.router) v1_router.include_router(project_source_bindings.router) diff --git a/backend/api/v1/chat.py b/backend/api/v1/chat.py index ee4f636..825a605 100644 --- a/backend/api/v1/chat.py +++ b/backend/api/v1/chat.py @@ -11,18 +11,22 @@ v1 薄闭环: 唯一写动作 = 启停 source。验证通后按同模式扩 trigger_task / update_schedule。 """ +import asyncio import json import logging import re -from typing import Any, Literal, Optional +from contextvars import ContextVar +from typing import Any, Awaitable, Callable, Literal, Optional from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import StreamingResponse from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from backend.control.agent_control import ACTION_REGISTRY, agent_control_service -from backend.database import get_db +from backend.database import AsyncSessionLocal, get_db +from backend.models.agent_run import AgentRun, AgentRunEvent, AgentSession from backend.models.provider import ModelProvider from backend.schemas.common import ApiResponse from backend.security.identity import RequestIdentity, get_request_identity @@ -35,6 +39,41 @@ MAX_TOOL_STEPS = 5 +ActivitySink = Callable[[dict[str, Any]], Awaitable[None]] +_activity_sink: ContextVar[ActivitySink | None] = ContextVar("chat_activity_sink", default=None) +_background_runs: set[asyncio.Task] = set() + +_PUBLIC_TOOL_LABELS = { + "list_sources": ("检查数据源", "数据源"), + "list_schedules": ("检查调度计划", "调度计划"), + "list_tasks": ("检查最近任务", "采集任务"), + "list_providers": ("检查模型连接", "模型提供商"), + "toggle_source": ("变更数据源状态", "数据源"), + "trigger_task": ("启动采集任务", "数据源"), + "update_schedule": ("更新调度计划", "调度计划"), + "update_provider": ("更新模型配置", "模型提供商"), +} + + +async def _emit_activity(event_type: str, label: str, detail: str, **extra: Any) -> None: + sink = _activity_sink.get() + if sink is not None: + await sink({"type": event_type, "label": label, "detail": detail, **extra}) + + +def _tool_public_description(name: str, args: dict[str, Any]) -> tuple[str, str, str | None]: + label, target_type = _PUBLIC_TOOL_LABELS.get(name, ("执行操作", "系统对象")) + target_id = next((str(args[key]) for key in ("source_id", "schedule_id", "provider_id") if args.get(key)), None) + return label, target_type, target_id + + +def _result_public_summary(result: Any) -> str: + if isinstance(result, list): + return f"找到 {len(result)} 项可用信息" + if isinstance(result, dict) and result.get("error"): + return "未能读取目标信息" + return "已读取目标信息" + SYSTEM_PROMPT = """你是 opencli-admin 的全局操作助手。用户可能位于任意产品页面。\ 你的职责: 根据当前页面和对象上下文解释系统状态,并在已有工具覆盖范围内按用户意图查询或修改后端配置。 @@ -171,6 +210,7 @@ class ChatRequest(BaseModel): provider_id: Optional[str] = None # 当前页面、项目或选中对象上下文,注入给 agent 当指代背景 context: Optional[dict[str, Any]] = None + session_id: Optional[str] = None class Proposal(BaseModel): @@ -193,6 +233,58 @@ class ConfirmRequest(BaseModel): proposal: Proposal +async def _create_durable_run(body: ChatRequest, identity: RequestIdentity | None) -> AgentRun: + """Create a durable run before work begins so clients can reconnect immediately.""" + async with AsyncSessionLocal() as session: + agent_session: AgentSession | None = None + if body.session_id: + agent_session = await session.get(AgentSession, body.session_id) + if agent_session is None: + agent_session = AgentSession( + workspace_id=_workspace_id(body.context), + actor_subject=identity.subject if identity else None, + context=body.context or {}, + ) + session.add(agent_session) + await session.flush() + goal = next((message.content for message in reversed(body.messages) if message.role == "user"), "") + run = AgentRun( + session_id=agent_session.id, + status="queued", + goal=goal, + request_payload={"messages": [message.model_dump() for message in body.messages], "context": body.context or {}}, + ) + session.add(run) + await session.commit() + await session.refresh(run) + return run + + +async def _record_durable_event(run_id: str, event: dict[str, Any]) -> dict[str, Any]: + """Append one public event atomically. Event payloads are deliberately already redacted.""" + async with AsyncSessionLocal() as session: + run = await session.get(AgentRun, run_id, with_for_update=True) + if run is None: + raise RuntimeError("Agent run disappeared") + sequence = run.next_event_sequence + run.next_event_sequence += 1 + payload = {"sequence": sequence, **event} + session.add(AgentRunEvent(run_id=run.id, sequence=sequence, event_type=event["type"], payload=payload)) + await session.commit() + return payload + + +async def _finish_durable_run(run_id: str, *, reply: dict[str, Any] | None = None, error: str | None = None) -> None: + async with AsyncSessionLocal() as session: + run = await session.get(AgentRun, run_id) + if run is None: + return + run.status = "failed" if error else "completed" + run.reply_payload = reply + run.error_message = error + await session.commit() + + # ── provider → AsyncOpenAI client ─────────────────────────────────────────── async def _pick_provider(db: AsyncSession, provider_id: Optional[str]) -> ModelProvider: if provider_id: @@ -347,9 +439,21 @@ async def chat( identity: RequestIdentity | None = Depends(_optional_request_identity), db: AsyncSession = Depends(get_db), ) -> ApiResponse: + await _emit_activity( + "phase.changed", + "理解目标", + "正在结合当前页面、工作区和选中对象理解请求。", + state="completed", + ) provider = await _pick_provider(db, body.provider_id) client = await _build_client(provider) model = provider.default_model or "gpt-4o-mini" + await _emit_activity( + "phase.changed", + "制定执行路径", + "已选择可用模型,正在判断需要读取的信息和可能的操作。", + state="active", + ) system = SYSTEM_PROMPT if body.context: @@ -362,6 +466,12 @@ async def chat( messages += [{"role": m.role, "content": m.content} for m in body.messages] for _step in range(MAX_TOOL_STEPS): + await _emit_activity( + "phase.changed", + "分析当前状态", + "正在根据已获得的信息决定下一步。", + state="active", + ) try: response = await client.chat.completions.create( model=model, messages=messages, tools=TOOLS, tool_choice="auto" @@ -374,12 +484,26 @@ async def chat( tool_calls = msg.tool_calls or [] if not tool_calls: + await _emit_activity( + "run.completed", + "处理完成", + "已生成基于本次执行信息的结果摘要。", + state="completed", + ) return ApiResponse.ok(ChatReply(type="message", content=msg.content or "")) # 写工具命中 → 立即返回 proposal (不执行, 不继续推理) for tc in tool_calls: if tc.function.name in WRITE_TOOLS: args = _safe_json(tc.function.arguments) + label, target_type, target_id = _tool_public_description(tc.function.name, args) + await _emit_activity( + "tool.completed", + label, + "已定位目标并准备变更方案。", + state="completed", + target={"type": target_type, "id": target_id}, + ) proposal = await _build_proposal( db, tc.function.name, @@ -387,6 +511,13 @@ async def chat( identity=_require_write_identity(identity), workspace_id=_workspace_id(body.context), ) + await _emit_activity( + "approval.required", + "等待确认", + proposal.summary, + state="attention", + target={"type": target_type, "id": target_id}, + ) return ApiResponse.ok(ChatReply(type="proposal", proposal=proposal)) # 只读工具 → 执行, 喂回结果, 继续循环 @@ -405,7 +536,23 @@ async def chat( } ) for tc in tool_calls: - result = await _run_read_tool(db, tc.function.name, _safe_json(tc.function.arguments)) + args = _safe_json(tc.function.arguments) + label, target_type, target_id = _tool_public_description(tc.function.name, args) + await _emit_activity( + "tool.started", + label, + f"正在读取{target_type}的当前状态。", + state="active", + target={"type": target_type, "id": target_id}, + ) + result = await _run_read_tool(db, tc.function.name, args) + await _emit_activity( + "tool.completed", + label, + _result_public_summary(result), + state="completed", + target={"type": target_type, "id": target_id}, + ) messages.append( {"role": "tool", "tool_call_id": tc.id, "content": json.dumps(result, ensure_ascii=False)} ) @@ -413,6 +560,120 @@ async def chat( return ApiResponse.ok(ChatReply(type="message", content="(达到工具调用步数上限, 请换个说法再试)")) +@router.post("/stream") +async def chat_stream( + body: ChatRequest, + identity: RequestIdentity | None = Depends(_optional_request_identity), + db: AsyncSession = Depends(get_db), +) -> StreamingResponse: + """Stream public execution facts as NDJSON while the existing chat run executes. + + Events deliberately contain no model reasoning, raw tool arguments, credentials, or + unbounded tool results. The terminal ``reply`` event preserves the established ChatReply + contract so confirmation continues through the governed endpoint. + """ + + durable_run = await _create_durable_run(body, identity) + + async def event_source(): + queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue() + + async def emit(event: dict[str, Any]) -> None: + await queue.put(await _record_durable_event(durable_run.id, event)) + + async def produce() -> None: + token = _activity_sink.set(emit) + try: + async with AsyncSessionLocal() as run_db: + run = await run_db.get(AgentRun, durable_run.id) + if run: + run.status = "running" + await run_db.commit() + await emit( + { + "type": "run.started", + "label": "开始处理", + "detail": "已接收请求,正在建立执行上下文。", + "state": "active", + } + ) + async with AsyncSessionLocal() as run_db: + response = await chat(body, identity, run_db) + await emit( + { + "type": "reply", + "label": "结果已就绪", + "detail": "本次处理已返回结果。", + "state": "completed", + "reply": response.data.model_dump(mode="json"), + } + ) + await _finish_durable_run(durable_run.id, reply=response.data.model_dump(mode="json")) + except HTTPException as exc: + await emit( + { + "type": "run.failed", + "label": "处理未完成", + "detail": str(exc.detail), + "state": "failed", + "status": exc.status_code, + "recovery": "检查连接或目标状态后重试。", + } + ) + await _finish_durable_run(durable_run.id, error=str(exc.detail)) + except Exception: + logger.exception("chat stream failed") + await emit( + { + "type": "run.failed", + "label": "处理未完成", + "detail": "Agent 暂时无法完成这项任务。", + "state": "failed", + "status": 500, + "recovery": "稍后重试,或调整请求后继续。", + } + ) + await _finish_durable_run(durable_run.id, error="Agent run failed") + finally: + _activity_sink.reset(token) + await queue.put(None) + + task = asyncio.create_task(produce()) + _background_runs.add(task) + task.add_done_callback(_background_runs.discard) + try: + while True: + event = await queue.get() + if event is None: + break + yield json.dumps(event, ensure_ascii=False) + "\n" + finally: + # A disconnected client can replay the persisted events; do not cancel work. + pass + + return StreamingResponse( + event_source(), + media_type="application/x-ndjson", + headers={"Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no", "X-Agent-Run-Id": durable_run.id, "X-Agent-Session-Id": durable_run.session_id}, + ) + + +@router.get("/runs/{run_id}", response_model=ApiResponse[dict[str, Any]]) +async def get_chat_run(run_id: str, db: AsyncSession = Depends(get_db)) -> ApiResponse: + run = await db.get(AgentRun, run_id) + if run is None: + raise HTTPException(status_code=404, detail="Agent run not found") + return ApiResponse.ok({"id": run.id, "session_id": run.session_id, "status": run.status, "goal": run.goal, "reply": run.reply_payload, "error": run.error_message, "created_at": run.created_at, "updated_at": run.updated_at}) + + +@router.get("/runs/{run_id}/events", response_model=ApiResponse[list[dict[str, Any]]]) +async def get_chat_run_events(run_id: str, after_sequence: int = 0, db: AsyncSession = Depends(get_db)) -> ApiResponse: + if await db.get(AgentRun, run_id) is None: + raise HTTPException(status_code=404, detail="Agent run not found") + events = (await db.scalars(select(AgentRunEvent).where(AgentRunEvent.run_id == run_id).where(AgentRunEvent.sequence > after_sequence).order_by(AgentRunEvent.sequence))).all() + return ApiResponse.ok([event.payload for event in events]) + + @router.post("/confirm", response_model=ApiResponse[dict]) async def confirm( body: ConfirmRequest, diff --git a/backend/api/v1/local_auth.py b/backend/api/v1/local_auth.py new file mode 100644 index 0000000..785cd05 --- /dev/null +++ b/backend/api/v1/local_auth.py @@ -0,0 +1,81 @@ +"""First-run local administrator setup and password login.""" + +from hmac import compare_digest +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.config import get_settings +from backend.database import get_db +from backend.models.identity import LocalAdmin +from backend.schemas.common import ApiResponse +from backend.security.local_auth import ( + hash_password, + issue_session, + login_attempt_limiter, + verify_password, +) + +router = APIRouter(prefix="/auth/local", tags=["auth"]) + + +class PasswordInput(BaseModel): + password: str = Field(min_length=12, max_length=256) + + +class SetupInput(PasswordInput): + bootstrap_token: str = Field(min_length=1, max_length=1024) + + +async def _admin(db: AsyncSession) -> LocalAdmin | None: + return (await db.execute(select(LocalAdmin))).scalar_one_or_none() + + +@router.get("/status", response_model=ApiResponse[dict]) +async def local_status(db: Annotated[AsyncSession, Depends(get_db)]) -> ApiResponse: + return ApiResponse.ok({"configured": await _admin(db) is not None}) + + +@router.post("/setup", response_model=ApiResponse[dict]) +async def setup_local_admin( + body: SetupInput, + request: Request, + db: Annotated[AsyncSession, Depends(get_db)], +) -> ApiResponse: + if await _admin(db) is not None: + raise HTTPException(status.HTTP_409_CONFLICT, "Local administrator is already configured") + expected = get_settings().bootstrap_admin_token + client_id = request.client.host if request.client else "unknown" + login_attempt_limiter.check(client_id) + if not expected or not compare_digest(body.bootstrap_token, expected): + login_attempt_limiter.record_failure(client_id) + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid recovery credential") + db.add(LocalAdmin(id="local-admin", password_hash=hash_password(body.password))) + try: + await db.flush() + except IntegrityError as exc: + raise HTTPException( + status.HTTP_409_CONFLICT, "Local administrator is already configured" + ) from exc + login_attempt_limiter.reset(client_id) + return ApiResponse.ok({"access_token": issue_session()}) + + +@router.post("/login", response_model=ApiResponse[dict]) +async def local_login( + body: PasswordInput, + request: Request, + db: Annotated[AsyncSession, Depends(get_db)], +) -> ApiResponse: + client_id = request.client.host if request.client else "unknown" + login_attempt_limiter.check(client_id) + admin = await _admin(db) + if admin is None or not verify_password(body.password, admin.password_hash): + login_attempt_limiter.record_failure(client_id) + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid administrator password") + login_attempt_limiter.reset(client_id) + return ApiResponse.ok({"access_token": issue_session()}) diff --git a/backend/config.py b/backend/config.py index 5b19992..f9aee35 100644 --- a/backend/config.py +++ b/backend/config.py @@ -58,6 +58,9 @@ class Settings(BaseSettings): # Empty (default) = auth disabled — dev posture, which the startup bind # guard only allows on a localhost bind. Env: API_AUTH_TOKEN. api_auth_token: str = "" + # Emergency first-run/recovery credential. Local administrator setup + # verifies this value but never persists it as a daily login secret. + bootstrap_admin_token: str = "" # CLI channel binary allowlist (ADR-0005, audit P0-4). The cli channel is # an arbitrary-binary-execution surface, so it only runs binaries the diff --git a/backend/migrations/versions/a8b9c0d1e2f3_add_durable_agent_runs.py b/backend/migrations/versions/a8b9c0d1e2f3_add_durable_agent_runs.py new file mode 100644 index 0000000..d70e25c --- /dev/null +++ b/backend/migrations/versions/a8b9c0d1e2f3_add_durable_agent_runs.py @@ -0,0 +1,62 @@ +"""add durable Agent sessions, runs, and public events + +Revision ID: a8b9c0d1e2f3 +Revises: z7a8b9c0d1e2 +Create Date: 2026-08-06 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "a8b9c0d1e2f3" +down_revision = "z7a8b9c0d1e2" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "agent_sessions", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("workspace_id", sa.String(36), nullable=True), + sa.Column("actor_subject", sa.String(255), nullable=True), + sa.Column("context", sa.JSON(), nullable=False), + ) + op.create_index("ix_agent_sessions_workspace_id", "agent_sessions", ["workspace_id"]) + op.create_index("ix_agent_sessions_actor_subject", "agent_sessions", ["actor_subject"]) + op.create_table( + "agent_runs", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("session_id", sa.String(36), sa.ForeignKey("agent_sessions.id", ondelete="CASCADE"), nullable=False), + sa.Column("kind", sa.String(32), nullable=False), + sa.Column("status", sa.String(32), nullable=False), + sa.Column("goal", sa.Text(), nullable=False), + sa.Column("request_payload", sa.JSON(), nullable=False), + sa.Column("reply_payload", sa.JSON(), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("next_event_sequence", sa.Integer(), nullable=False), + ) + op.create_index("ix_agent_runs_session_id", "agent_runs", ["session_id"]) + op.create_index("ix_agent_runs_status", "agent_runs", ["status"]) + op.create_table( + "agent_run_events", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("run_id", sa.String(36), sa.ForeignKey("agent_runs.id", ondelete="CASCADE"), nullable=False), + sa.Column("sequence", sa.Integer(), nullable=False), + sa.Column("event_type", sa.String(64), nullable=False), + sa.Column("payload", sa.JSON(), nullable=False), + sa.UniqueConstraint("run_id", "sequence", name="ux_agent_run_events_run_id_sequence"), + ) + op.create_index("ix_agent_run_events_run_id", "agent_run_events", ["run_id"]) + + +def downgrade() -> None: + op.drop_table("agent_run_events") + op.drop_table("agent_runs") + op.drop_table("agent_sessions") diff --git a/backend/migrations/versions/z7a8b9c0d1e2_add_local_admin.py b/backend/migrations/versions/z7a8b9c0d1e2_add_local_admin.py new file mode 100644 index 0000000..c144cea --- /dev/null +++ b/backend/migrations/versions/z7a8b9c0d1e2_add_local_admin.py @@ -0,0 +1,27 @@ +"""add local administrator credential + +Revision ID: z7a8b9c0d1e2 +Revises: k8l9m0n1o2p3 +""" +import sqlalchemy as sa +from alembic import op + +revision = "z7a8b9c0d1e2" +down_revision = "k8l9m0n1o2p3" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "local_admin_credentials", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("password_hash", sa.String(length=255), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade() -> None: + op.drop_table("local_admin_credentials") diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 0da337e..856d54d 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -8,6 +8,7 @@ from backend.models.cookie_jar import CookieJarEntry from backend.models.edge_node import EdgeNode, EdgeNodeEvent from backend.models.identity import ( + LocalAdmin, ServiceIdentity, Team, TeamMembership, @@ -75,6 +76,7 @@ from backend.models.worker import WorkerNode from backend.models.workflow import Project, Workflow, WorkflowDraft, WorkflowVersion from backend.models.workflow_run import WorkflowRun, WorkflowRunEvent +from backend.models.agent_run import AgentRun, AgentRunEvent, AgentSession __all__ = [ "TimestampMixin", @@ -95,6 +97,7 @@ "Team", "TeamMembership", "ServiceIdentity", + "LocalAdmin", "OperationsWorkItem", "OperationsAgentIdentity", "AgentPermissionProfile", @@ -152,4 +155,7 @@ "WorkflowVersion", "WorkflowRun", "WorkflowRunEvent", + "AgentSession", + "AgentRun", + "AgentRunEvent", ] diff --git a/backend/models/agent_run.py b/backend/models/agent_run.py new file mode 100644 index 0000000..888943f --- /dev/null +++ b/backend/models/agent_run.py @@ -0,0 +1,52 @@ +"""Durable public execution records for interactive Agent runs.""" + +from sqlalchemy import JSON, ForeignKey, Index, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from backend.models.base import TimestampMixin + + +class AgentSession(TimestampMixin): + __tablename__ = "agent_sessions" + + workspace_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + actor_subject: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + context: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) + + runs: Mapped[list["AgentRun"]] = relationship( + "AgentRun", back_populates="session", cascade="all, delete-orphan" + ) + + +class AgentRun(TimestampMixin): + __tablename__ = "agent_runs" + + session_id: Mapped[str] = mapped_column( + String(36), ForeignKey("agent_sessions.id", ondelete="CASCADE"), nullable=False, index=True + ) + kind: Mapped[str] = mapped_column(String(32), nullable=False, default="chat") + status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued", index=True) + goal: Mapped[str] = mapped_column(Text, nullable=False, default="") + request_payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) + reply_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + next_event_sequence: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + + session: Mapped[AgentSession] = relationship("AgentSession", back_populates="runs") + events: Mapped[list["AgentRunEvent"]] = relationship( + "AgentRunEvent", back_populates="run", cascade="all, delete-orphan", order_by="AgentRunEvent.sequence" + ) + + +class AgentRunEvent(TimestampMixin): + __tablename__ = "agent_run_events" + __table_args__ = (Index("ux_agent_run_events_run_id_sequence", "run_id", "sequence", unique=True),) + + run_id: Mapped[str] = mapped_column( + String(36), ForeignKey("agent_runs.id", ondelete="CASCADE"), nullable=False, index=True + ) + sequence: Mapped[int] = mapped_column(Integer, nullable=False) + event_type: Mapped[str] = mapped_column(String(64), nullable=False) + payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) + + run: Mapped[AgentRun] = relationship("AgentRun", back_populates="events") diff --git a/backend/models/identity.py b/backend/models/identity.py index 317a1f1..3a8dfb5 100644 --- a/backend/models/identity.py +++ b/backend/models/identity.py @@ -23,6 +23,12 @@ class User(TimestampMixin): disabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) +class LocalAdmin(TimestampMixin): + __tablename__ = "local_admin_credentials" + + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + + class Workspace(TimestampMixin): __tablename__ = "workspaces" diff --git a/backend/security/fleet_auth.py b/backend/security/fleet_auth.py index 9464238..923b68b 100644 --- a/backend/security/fleet_auth.py +++ b/backend/security/fleet_auth.py @@ -19,6 +19,10 @@ - ``/docs``, ``/redoc``, ``/openapi.json`` — outside the ``/api`` prefix. They disclose the API *schema* but no data; issue 04's scope is "every /api route". Tighten separately if schema disclosure becomes a concern. +- ``/api/v1/auth/local/status``, ``/setup``, and ``/login`` — the minimum + unauthenticated surface required to establish a local administrator session. + Setup still requires the Bootstrap credential, login is rate-limited, and + status returns only a boolean. Websocket endpoints under ``/api`` (the agent reverse channel in api/v1/nodes.py and api/v1/browsers.py) are guarded by this same middleware. @@ -73,9 +77,17 @@ from starlette.websockets import WebSocketClose from backend.config import get_settings +from backend.security.local_auth import is_local_session #: Path prefixes guarded by :class:`FleetAuthMiddleware`. PROTECTED_PREFIXES = ("/api", "/mcp") +PUBLIC_LOCAL_AUTH_PATHS = frozenset( + { + "/api/v1/auth/local/status", + "/api/v1/auth/local/setup", + "/api/v1/auth/local/login", + } +) _LOCALHOST_HOSTS = frozenset({"localhost", "::1"}) @@ -156,6 +168,9 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: ): await self.app(scope, receive, send) return + if scope["type"] == "http" and scope["path"] in PUBLIC_LOCAL_AUTH_PATHS: + await self.app(scope, receive, send) + return # Read per request: get_settings() is lru_cached (cheap), but # api/v1/system.py may cache_clear() it at runtime after a config @@ -179,8 +194,20 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: return headers = Headers(scope=scope) - credential = headers.get("x-api-token", "") or _bearer_credential(headers) - if credential and _token_matches(credential, token): + credentials = ( + headers.get("x-api-token", ""), + _bearer_credential(headers), + ) + bootstrap = get_settings().bootstrap_admin_token + if any( + credential + and ( + _token_matches(credential, token) + or (bootstrap and _token_matches(credential, bootstrap)) + or is_local_session(credential) + ) + for credential in credentials + ): await self.app(scope, receive, send) return diff --git a/backend/security/identity.py b/backend/security/identity.py index 6b1a0f8..eadc4db 100644 --- a/backend/security/identity.py +++ b/backend/security/identity.py @@ -12,6 +12,8 @@ from fastapi import HTTPException, Request, status from jose import JWTError, jwt +from backend.security.local_auth import is_local_session + @dataclass(frozen=True) class IdentitySettings: @@ -138,6 +140,13 @@ async def get_request_identity(request: Request) -> RequestIdentity: is_platform_admin=True, auth_method="bootstrap", ) + if is_local_session(token): + return RequestIdentity( + subject="local-admin", + name="Local Administrator", + is_platform_admin=True, + auth_method="local", + ) return await oidc.verify(token) return get_request_identity diff --git a/backend/security/local_auth.py b/backend/security/local_auth.py new file mode 100644 index 0000000..dd9a452 --- /dev/null +++ b/backend/security/local_auth.py @@ -0,0 +1,136 @@ +"""Local single-admin authentication for self-hosted first-run deployments.""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import secrets +import time +from collections import defaultdict, deque +from datetime import UTC, datetime, timedelta +from threading import Lock + +from fastapi import HTTPException, status +from jose import JWTError, jwt + +from backend.config import get_settings + +_ALGORITHM = "HS256" +_SUBJECT = "local-admin" +_SCRYPT_N = 2**14 +_SCRYPT_R = 8 +_SCRYPT_P = 1 + + +class LoginAttemptLimiter: + """Small per-process guard against rapid password guessing.""" + + def __init__( + self, max_attempts: int = 10, window_seconds: int = 60, max_clients: int = 1024 + ) -> None: + self.max_attempts = max_attempts + self.window_seconds = window_seconds + self.max_clients = max_clients + self._attempts: dict[str, deque[float]] = defaultdict(deque) + self._lock = Lock() + + def check(self, client_id: str) -> None: + now = time.monotonic() + with self._lock: + attempts = self._attempts.get(client_id) + if not attempts: + return + while attempts and now - attempts[0] >= self.window_seconds: + attempts.popleft() + if not attempts: + self._attempts.pop(client_id, None) + return + if len(attempts) >= self.max_attempts: + retry_after = max(1, int(self.window_seconds - (now - attempts[0]))) + raise HTTPException( + status.HTTP_429_TOO_MANY_REQUESTS, + "Too many authentication attempts", + headers={"Retry-After": str(retry_after)}, + ) + + def record_failure(self, client_id: str) -> None: + with self._lock: + if client_id not in self._attempts and len(self._attempts) >= self.max_clients: + self._attempts.pop(next(iter(self._attempts))) + self._attempts[client_id].append(time.monotonic()) + + def reset(self, client_id: str) -> None: + with self._lock: + self._attempts.pop(client_id, None) + + +login_attempt_limiter = LoginAttemptLimiter() + + +def validate_password(password: str) -> str: + if len(password) < 12: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + "Password must be at least 12 characters", + ) + if len(password) > 256: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Password is too long") + return password + + +def hash_password(password: str) -> str: + encoded = validate_password(password).encode("utf-8") + salt = secrets.token_bytes(16) + digest = hashlib.scrypt( + encoded, salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=32 + ) + return "$".join( + ( + "scrypt", + str(_SCRYPT_N), + str(_SCRYPT_R), + str(_SCRYPT_P), + base64.urlsafe_b64encode(salt).decode("ascii"), + base64.urlsafe_b64encode(digest).decode("ascii"), + ) + ) + + +def verify_password(password: str, password_hash: str) -> bool: + try: + scheme, n, r, p, salt, expected = password_hash.split("$", 5) + if scheme != "scrypt" or (int(n), int(r), int(p)) != ( + _SCRYPT_N, + _SCRYPT_R, + _SCRYPT_P, + ): + return False + digest = hashlib.scrypt( + password.encode("utf-8"), + salt=base64.urlsafe_b64decode(salt), + n=_SCRYPT_N, + r=_SCRYPT_R, + p=_SCRYPT_P, + dklen=32, + ) + except (ValueError, TypeError): + return False + return hmac.compare_digest(base64.urlsafe_b64encode(digest).decode("ascii"), expected) + + +def issue_session() -> str: + now = datetime.now(UTC) + return jwt.encode( + {"sub": _SUBJECT, "typ": "local-admin", "iat": now, "exp": now + timedelta(hours=12)}, + get_settings().secret_key, + algorithm=_ALGORITHM, + ) + + +def is_local_session(token: str) -> bool: + try: + claims = jwt.decode(token, get_settings().secret_key, algorithms=[_ALGORITHM]) + except JWTError: + return False + return claims.get("sub") == _SUBJECT and claims.get("typ") == "local-admin" diff --git a/docker-compose.yml b/docker-compose.yml index 84ef3f6..eaa7a4f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -144,6 +144,9 @@ services: # ── Backend API ─────────────────────────────────────────────────────────── api: <<: *backend-common + # 0.4.1 上游镜像缺 2 个 alembic 迁移文件(a8b9c0d1e2f3/z7a8b9c0d1e2)导致旧库无法升级 + # 用本地补全文件重建的修复版镜像,其余服务仍用官方 0.4.1 + image: ${DOCKER_REGISTRY:-ghcr.io/}${DOCKER_IMAGE_NAMESPACE:-2233admin}/opencli-admin-api:0.4.1-fixed # Run as root so the API can manage Docker containers (agent pool) via # the mounted socket. Acceptable for a self-hosted admin panel. user: root diff --git a/docs/adr/0043-use-local-administrator-password-after-bootstrap.md b/docs/adr/0043-use-local-administrator-password-after-bootstrap.md new file mode 100644 index 0000000..4074520 --- /dev/null +++ b/docs/adr/0043-use-local-administrator-password-after-bootstrap.md @@ -0,0 +1,38 @@ +--- +status: accepted +--- + +# Use a Local Administrator Password After Bootstrap + +## Context + +ADR-0005 protects the fleet API with a static token and deliberately avoids a multi-user identity system. That token is appropriate for agents and API clients, but requiring an operator to retrieve and paste deployment credentials for every browser session makes a fresh self-hosted console difficult to enter. OIDC cannot be the only human login path because a single-operator installation may not have an identity provider. + +## Decision + +Keep the single-operator model and add one persistent local administrator credential: + +- First-run setup requires the existing `BOOTSTRAP_ADMIN_TOKEN` and can create only the fixed `local-admin` record. +- The operator chooses a password of at least 12 characters. The backend stores only a salted scrypt hash with fixed, reviewed work parameters. +- Successful setup or password login returns a server-signed, 12-hour local administrator session. +- A valid local session crosses the Fleet middleware for browser API requests and resolves to the existing platform-administrator identity. +- Bootstrap remains valid as an explicit recovery credential; it is not the primary daily login control. +- OIDC remains optional and independent. Static Fleet tokens remain the machine-client credential. +- Only local-auth status, setup, and login are public API paths. Failed credential attempts are rate-limited per client. + +This decision does not add registration, invitations, multiple local accounts, workspace-specific local roles, or password-reset email. + +## Consequences + +- A new self-hosted deployment can establish a human login without configuring OIDC. +- Daily browser access no longer exposes deployment tokens in the main login flow. +- Compromise of the signing key can forge local sessions, so production deployments must continue generating a strong `SECRET_KEY`. +- Rate limiting is process-local; deployments with multiple API workers should enforce an additional shared limit at the reverse proxy. +- Losing both the local password and Bootstrap credential still requires operator access to the deployment environment. + +## Rejected Alternatives + +- Keep Bootstrap as the daily login: preserves an opaque operational-token workflow and increases routine exposure of a high-privilege recovery secret. +- Allow the first visitor to create an administrator without a credential: permits remote takeover when a new console is network reachable. +- Require OIDC before first use: makes the default single-operator deployment depend on external identity infrastructure. +- Add a general user database and password-reset system: exceeds the single-operator threat model and creates unnecessary identity lifecycle scope. diff --git a/docs/ptt-acceptance.md b/docs/ptt-acceptance.md index b66c538..c9499cf 100644 --- a/docs/ptt-acceptance.md +++ b/docs/ptt-acceptance.md @@ -26,11 +26,11 @@ LAN is allowed only for a local dry run. ## PTT-0 Local Commands -Run these from the backend repository root. On this Windows workstation, use the -repository virtualenv explicitly: +Run these from the repository root. `uv sync --extra dev` creates and maintains +the repository-local `.venv`; do not call its interpreter directly. ```powershell -.\.venv\Scripts\python.exe -m pytest ` +uv run pytest ` tests/unit/test_agent_image_runtime_packaging.py ` tests/unit/test_agent_server.py ` tests/unit/test_ws_agent_manager.py ` diff --git a/frontend/app/(app)/operations-agents/page.tsx b/frontend/app/(app)/operations-agents/page.tsx index 2476c97..07cb5fa 100644 --- a/frontend/app/(app)/operations-agents/page.tsx +++ b/frontend/app/(app)/operations-agents/page.tsx @@ -51,6 +51,15 @@ function parseJsonObject(value: string, label: string) { return parsed as Record } +function publicRunSummary(payload: Record | null) { + if (!payload) return null + const values = Object.entries(payload) + .filter(([, value]) => ['string', 'number', 'boolean'].includes(typeof value)) + .slice(0, 4) + .map(([key, value]) => `${key}: ${String(value)}`) + return values.length ? values.join(' · ') : '已生成结构化执行结果,可在运行记录中审计。' +} + function ContractEditor({ workspaceId, agent }: { workspaceId: string; agent: OperationsAgent }) { const draft = useOperationsAgentDraft(workspaceId, agent.id) const versions = useOperationsAgentVersions(workspaceId, agent.id) @@ -346,7 +355,7 @@ export default function OperationsAgentsPage() {
SESSION OUTPUT
- {(activity.data ?? []).filter((run) => run.operations_agent_id === selectedAgent.id).length ?
{(activity.data ?? []).filter((run) => run.operations_agent_id === selectedAgent.id).map((run) =>
[{run.status}] {new Date(run.updated_at).toLocaleString()}
{run.trigger_type} → {run.target_resource_type}/{run.target_resource_id}
profile v{run.profile_version} · agent v{run.published_version}
{run.error_message ?
{run.error_message}
: null}{run.output_payload ?
{JSON.stringify(run.output_payload, null, 2)}
: null}
)}
:

还没有会话输出

智能体收到任务后,这里会显示真实的 CLI 活动和运行状态。

} + {(activity.data ?? []).filter((run) => run.operations_agent_id === selectedAgent.id).length ?
{(activity.data ?? []).filter((run) => run.operations_agent_id === selectedAgent.id).map((run) =>
{run.status === 'queued' ? '等待执行' : run.status === 'running' ? '正在执行' : run.status === 'completed' ? '已完成' : run.status === 'paused' ? '等待确认' : run.status === 'cancelled' ? '已取消' : '执行失败'}

目标:{run.target_resource_type} · {run.target_resource_id}

{run.error_message ?
{run.error_message}

检查目标状态后可以重新启动。

: null}{publicRunSummary(run.output_payload) ?
结果:{publicRunSummary(run.output_payload)}
: null}
)}
:

还没有执行活动

智能体收到任务后,这里会显示目标、当前状态和结果摘要。

}
diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 1644793..227813a 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { Droplets, Grid3X3, KeyRound, LoaderCircle, ShieldCheck, SquareTerminal } from 'lucide-react' +import { Droplets, Grid3X3, LoaderCircle, ShieldCheck, SquareTerminal } from 'lucide-react' import { AnimatePresence, motion } from 'motion/react' import { useRouter, useSearchParams } from 'next/navigation' import { Suspense, useEffect, useState } from 'react' @@ -9,6 +9,7 @@ import { toast } from 'sonner' import FaultyTerminal from '@/components/FaultyTerminal' import Dither from '@/components/Dither' import { useAuth } from '@/components/auth/auth-provider' +import { LocalAdminAccess } from '@/components/auth/local-admin-access' import { PixelLiquidBg } from '@/components/unlumen-ui/pixel-liquid-bg' import { Button } from '@/components/ui/button' import { @@ -19,7 +20,7 @@ import { CardHeader, CardTitle, } from '@/components/ui/card' -import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field' +import { Field, FieldDescription, FieldLabel } from '@/components/ui/field' import { Input } from '@/components/ui/input' import { Separator } from '@/components/ui/separator' import RevealText from '@/components/ui/smoothui/reveal-text' @@ -137,12 +138,10 @@ function LoginForm() { oidcEnabled, developmentLoginEnabled, signInWithOidc, - signInWithBootstrap, enterDevelopmentMode, } = useAuth() - const [identityToken, setIdentityToken] = useState('') const [fleetToken, setFleetToken] = useState('') - const [submitting, setSubmitting] = useState<'oidc' | 'bootstrap' | 'development' | null>(null) + const [submitting, setSubmitting] = useState<'oidc' | 'development' | null>(null) const [reduceMotion, setReduceMotion] = useState(true) const [backdrop, setBackdrop] = useState('liquid') const [headlineWord, setHeadlineWord] = useState(0) @@ -185,19 +184,6 @@ function LoginForm() { } } - async function handleBootstrapLogin(event: React.FormEvent) { - event.preventDefault() - setSubmitting('bootstrap') - try { - await signInWithBootstrap(identityToken, optionalFleetToken) - toast.success('管理员身份验证成功') - router.replace(returnTo) - } catch (error) { - toast.error(error instanceof Error ? error.message : '身份验证失败') - setSubmitting(null) - } - } - function handleDevelopmentLogin() { setSubmitting('development') try { @@ -211,7 +197,7 @@ function LoginForm() { return ( 登录控制台 - 使用组织账号登录;Bootstrap Admin 仅用于首次部署和紧急恢复。 + 首次部署创建本地管理员;组织登录可在需要时接入。 - {oidcEnabled ? ( + router.replace(returnTo)} /> + + {oidcEnabled ? <> +
+ + 组织登录 + +
- ) : ( -
- 当前未配置组织登录。请配置 OIDC issuer、client ID 和授权端点。 -
- )} - -
- - 紧急管理员访问 - -
+ : null} -
- - - 管理员身份令牌 - setIdentityToken(event.target.value)} - autoComplete="off" - /> - 验证成功后仅保存在当前标签页会话中。 - - + {oidcEnabled || developmentLoginEnabled ? ( +
+ + Fleet 连接设置 + + Fleet API 令牌(可选) - 后端启用 Fleet Auth 时填写;留空沿用部署配置或浏览器中已有值。 + 仅在组织登录或开发模式需要单独通过 Fleet Auth 时填写。 - - +
+ ) : null} +
- {developmentLoginEnabled ? ( +
+ ) + } + + const configured = localAdminStatus === 'configured' + + return ( +
+
+

+ {recoveryMode ? '紧急恢复' : configured ? '本地管理员登录' : '创建本地管理员'} +

+

+ {recoveryMode + ? '使用部署环境中保存的 Bootstrap Admin 令牌进入控制台。' + : configured + ? '使用首次部署时设置的管理员密码。' + : '首次部署只需完成一次;以后直接使用管理员密码。'} +

+
+ + {!configured || recoveryMode ? ( + + + {recoveryMode ? '紧急恢复令牌' : '首次部署令牌'} + + setBootstrapToken(event.target.value)} + autoComplete="off" + required + /> + + {recoveryMode + ? '使用部署目录 .env 中的 BOOTSTRAP_ADMIN_TOKEN。' + : '安装程序会在完成部署后直接打印该令牌。'} + + + ) : null} + {!recoveryMode ? + 管理员密码 + setPassword(event.target.value)} + autoComplete={configured ? 'current-password' : 'new-password'} + minLength={12} + maxLength={256} + required + /> + 至少 12 个字符。 + : null} + {!configured && !recoveryMode ? ( + + 确认管理员密码 + setConfirmation(event.target.value)} + autoComplete="new-password" + minLength={12} + maxLength={256} + required + /> + + ) : null} + + + {configured ? ( + + ) : null} +
+ ) +} diff --git a/frontend/components/shell/global-agent-dock.tsx b/frontend/components/shell/global-agent-dock.tsx index 148599d..17fc2c7 100644 --- a/frontend/components/shell/global-agent-dock.tsx +++ b/frontend/components/shell/global-agent-dock.tsx @@ -1,7 +1,7 @@ 'use client' import { useQueryClient } from '@tanstack/react-query' -import { Bot, Check, Loader2, Send, ShieldCheck, X } from 'lucide-react' +import { Bot, Check, CircleAlert, CircleCheck, Clock3, Loader2, Monitor, RotateCcw, Send, ShieldCheck, Sparkles, X } from 'lucide-react' import { usePathname } from 'next/navigation' import { FormEvent, KeyboardEvent, useState } from 'react' @@ -16,7 +16,7 @@ import { } from '@/components/ui/sheet' import { Textarea } from '@/components/ui/textarea' import { apiClient } from '@/lib/api/client' -import type { ApiResponse } from '@/lib/api/types' +import { getApiAuthHeaders } from '@/lib/api/auth-headers' import { ROUTE_LABELS } from '@/lib/navigation' type AgentMessage = { @@ -40,6 +40,45 @@ type AgentReply = { proposal?: AgentProposal | null } +type ActivityState = 'active' | 'complete' | 'attention' + +type Activity = { + label: string + detail: string + state: ActivityState + target?: { type?: string; id?: string | null } +} + +type AgentRunEvent = { + sequence: number + type: string + label: string + detail: string + state?: 'active' | 'completed' | 'attention' | 'failed' + target?: { type?: string; id?: string | null } + recovery?: string + reply?: AgentReply +} + +function activityFromEvent(event: AgentRunEvent): Activity { + return { + label: event.label, + detail: event.recovery ? `${event.detail} ${event.recovery}` : event.detail, + state: event.state === 'completed' ? 'complete' : event.state === 'failed' || event.state === 'attention' ? 'attention' : 'active', + target: event.target, + } +} + +function activityForReply(reply: AgentReply): Activity[] { + if (reply.type === 'proposal' && reply.proposal) { + return [ + { label: '已定位操作对象', detail: reply.proposal.summary, state: 'complete' }, + { label: '等待你的确认', detail: '这是一次会改变软件状态的操作。确认后才会执行。', state: 'attention' }, + ] + } + return [{ label: '已完成处理', detail: '已基于当前可访问的数据生成结果。', state: 'complete' }] +} + export function GlobalAgentDock({ open, onOpenChange, @@ -55,6 +94,12 @@ export function GlobalAgentDock({ const [error, setError] = useState(null) const [sending, setSending] = useState(false) const [confirming, setConfirming] = useState(false) + const [goal, setGoal] = useState(null) + const [activities, setActivities] = useState([]) + const [lastFailedProposal, setLastFailedProposal] = useState(null) + const [showLiveSurface, setShowLiveSurface] = useState(false) + const [agentSessionId, setAgentSessionId] = useState(null) + const [agentRunId, setAgentRunId] = useState(null) async function sendMessage(event?: FormEvent) { event?.preventDefault() @@ -66,6 +111,12 @@ export function GlobalAgentDock({ setInput('') setError(null) setSending(true) + setGoal(content) + setActivities([ + { label: '理解你的目标', detail: '正在结合当前页面和选中对象梳理任务。', state: 'complete' }, + { label: '检查可用信息', detail: '正在判断是否需要读取数据或准备操作。', state: 'active' }, + ]) + let activeRunId: string | null = null try { const searchParams = new URLSearchParams(window.location.search) const workspaceId = searchParams.get('workspace') @@ -76,8 +127,12 @@ export function GlobalAgentDock({ const sourceId = searchParams.get('source') ?? pathname.match(/^\/sources\/([^/]+)/)?.[1] ?? null - const response = await apiClient.post>('/chat', { + const response = await fetch('/api/v1/chat/stream', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...getApiAuthHeaders() }, + body: JSON.stringify({ messages: nextMessages, + session_id: agentSessionId, context: { surface: ROUTE_LABELS[pathname] ?? pathname, pathname, @@ -87,8 +142,44 @@ export function GlobalAgentDock({ workflow_id: workflowId, source_id: sourceId, }, + }), }) - const reply = response.data.data + if (!response.ok || !response.body) throw new Error(`Agent 请求失败(${response.status})`) + + const receivedRunId = response.headers.get('X-Agent-Run-Id') + const receivedSessionId = response.headers.get('X-Agent-Session-Id') + if (receivedRunId) { + activeRunId = receivedRunId + setAgentRunId(receivedRunId) + } + if (receivedSessionId) setAgentSessionId(receivedSessionId) + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + let reply: AgentReply | null = null + let streamError: string | null = null + while (true) { + const { value, done } = await reader.read() + buffer += decoder.decode(value, { stream: !done }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const line of lines) { + if (!line.trim()) continue + const runEvent = JSON.parse(line) as AgentRunEvent + if (runEvent.type === 'reply' && runEvent.reply) { + reply = runEvent.reply + } else { + setActivities((current) => { + const next = [...current.filter((item) => item.state !== 'active'), activityFromEvent(runEvent)] + return next.slice(-8) + }) + } + if (runEvent.type === 'run.failed') streamError = runEvent.detail + } + if (done) break + } + if (streamError) throw new Error(streamError) + if (!reply) throw new Error('Agent 执行结束但没有返回结果') if (reply.type === 'proposal' && reply.proposal) { setProposal(reply.proposal) } else { @@ -97,25 +188,54 @@ export function GlobalAgentDock({ { role: 'assistant', content: reply.content?.trim() || '没有返回内容。' }, ]) } + setActivities((current) => [...current.filter((item) => item.state !== 'active'), ...activityForReply(reply)].slice(-8)) } catch (reason) { - setError(reason instanceof Error ? reason.message : 'Agent 暂时不可用') + const recoverableRunId = activeRunId ?? agentRunId + if (recoverableRunId) { + try { + const recovery = await apiClient.get(`/chat/runs/${recoverableRunId}/events`) + for (const replayed of recovery.data ?? []) { + if (replayed.type === 'reply' && replayed.reply) { + if (replayed.reply.type === 'proposal' && replayed.reply.proposal) setProposal(replayed.reply.proposal) + else setMessages((current) => [...current, { role: 'assistant', content: replayed.reply?.content?.trim() || '' }]) + } + } + } catch { + // Keep the stream error as the primary recovery signal. + } + } + const message = reason instanceof Error ? reason.message : 'Agent 暂时不可用' + setError(message) + setActivities([ + { label: '暂时无法完成理解', detail: message, state: 'attention' }, + { label: '恢复方式', detail: '请检查模型连接后重试,或换一种说法继续。', state: 'attention' }, + ]) } finally { setSending(false) } } - async function confirmProposal() { - if (!proposal || confirming) return + async function confirmProposal(proposalToConfirm = proposal) { + if (!proposalToConfirm || confirming) return setError(null) setConfirming(true) + setLastFailedProposal(null) + setActivities([ + { label: '已获得你的确认', detail: proposalToConfirm.summary, state: 'complete' }, + { label: '正在执行操作', detail: '系统正在应用这项变更。', state: 'active' }, + ]) try { - await apiClient.post('/chat/confirm', { proposal }) + await apiClient.post('/chat/confirm', { proposal: proposalToConfirm }) setMessages((current) => [ ...current, - { role: 'assistant', content: `已执行:${proposal.summary}` }, + { role: 'assistant', content: `已完成:${proposalToConfirm.summary}` }, ]) setProposal(null) await queryClient.invalidateQueries() + setActivities([ + { label: '操作已完成', detail: proposalToConfirm.summary, state: 'complete' }, + { label: '界面已同步', detail: '已刷新相关数据;你现在看到的是最新状态。', state: 'complete' }, + ]) } catch (reason) { const status = reason instanceof Error && 'status' in reason ? reason.status : undefined const message = reason instanceof Error ? reason.message : '操作执行失败' @@ -124,6 +244,11 @@ export function GlobalAgentDock({ ? `提案已失效或目标已变化:${message}。请拒绝后重新发起。` : message, ) + setLastFailedProposal(proposalToConfirm) + setActivities([ + { label: '操作未完成', detail: message, state: 'attention' }, + { label: '可恢复', detail: '检查目标状态后,可重新执行或回到对话调整请求。', state: 'attention' }, + ]) } finally { setConfirming(false) } @@ -178,14 +303,66 @@ export function GlobalAgentDock({ Agent 正在处理
) : null} + {goal ? ( +
+
+ + 正在处理 +
+

目标:{goal}

+
    + {activities.map((activity, index) => { + const Icon = activity.state === 'complete' ? CircleCheck : activity.state === 'attention' ? CircleAlert : Clock3 + return ( +
  1. + +
    +

    {activity.label}

    +

    {activity.detail}

    + {activity.target?.type ? ( +

    + 对象:{activity.target.type}{activity.target.id ? ` · ${activity.target.id}` : ''} +

    + ) : null} +
    +
  2. + ) + })} +
+
+ ) : null} + {goal ? ( +
+
+
+

+ + 软件现场 +

+

查看内置浏览器正在发生的实际变化。

+
+ +
+ {showLiveSurface ? ( +