From 39da348834176b0adedfd0bc311f5e3fb3f0a32b Mon Sep 17 00:00:00 2001 From: xyh202131 Date: Thu, 27 Aug 2026 19:15:26 +0800 Subject: [PATCH] feat(quick-start): persist agent conversations --- .../app/src/windup_app/bootstrap/app.py | 3 + .../quick_start_conversation/__init__.py | 5 + .../server/quick_start_conversation/model.py | 40 ++ .../quick_start_conversation/service.py | 99 +++ .../src/windup_app/web/api/workflow_run.py | 117 +++- backend/tests/conftest.py | 2 + .../test_quick_start_conversation_service.py | 34 ++ backend/tests/test_workflow_run_api.py | 138 +++++ frontend/src/entities/index.ts | 12 + .../quick-start-conversation/api.test.ts | 78 +++ .../entities/quick-start-conversation/api.ts | 111 ++++ .../quick-start-conversation/index.ts | 25 + frontend/src/pages/quick-start/index.test.tsx | 273 ++++++++- frontend/src/pages/quick-start/index.tsx | 571 ++++++++++++++---- openapi.json | 238 ++++++++ 15 files changed, 1608 insertions(+), 138 deletions(-) create mode 100644 backend/packages/app/src/windup_app/server/quick_start_conversation/__init__.py create mode 100644 backend/packages/app/src/windup_app/server/quick_start_conversation/model.py create mode 100644 backend/packages/app/src/windup_app/server/quick_start_conversation/service.py create mode 100644 backend/tests/test_quick_start_conversation_service.py create mode 100644 frontend/src/entities/quick-start-conversation/api.test.ts create mode 100644 frontend/src/entities/quick-start-conversation/api.ts create mode 100644 frontend/src/entities/quick-start-conversation/index.ts diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py index 74aec2f1..2cbdf6e2 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -23,6 +23,9 @@ from windup_app.server.character.service import service as character_service from windup_app.server.project.model import Project # noqa: F401 from windup_app.server.project.service import service as project_service +from windup_app.server.quick_start_conversation.model import ( # noqa: F401 + QuickStartAgentConversation, +) from windup_app.server.quota import model as quota_model # noqa: F401 from windup_app.server.sensitive_word.model import SensitiveWord # noqa: F401 from windup_app.server.sensitive_word.seed import seed_sensitive_words diff --git a/backend/packages/app/src/windup_app/server/quick_start_conversation/__init__.py b/backend/packages/app/src/windup_app/server/quick_start_conversation/__init__.py new file mode 100644 index 00000000..968b68c0 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/quick_start_conversation/__init__.py @@ -0,0 +1,5 @@ +"""Quick Start Agent 对话持久化。""" + +from .model import QuickStartAgentConversation + +__all__ = ["QuickStartAgentConversation"] diff --git a/backend/packages/app/src/windup_app/server/quick_start_conversation/model.py b/backend/packages/app/src/windup_app/server/quick_start_conversation/model.py new file mode 100644 index 00000000..81d2cbff --- /dev/null +++ b/backend/packages/app/src/windup_app/server/quick_start_conversation/model.py @@ -0,0 +1,40 @@ +"""Quick Start Agent 对话侧车模型。""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, JSON +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from windup_framework.db import Base + + +class QuickStartAgentConversation(Base): + """与 WorkflowRun 一对一的 Agent 对话快照。""" + + __tablename__ = "windup_quick_start_agent_conversation" + + workflow_run_id: Mapped[int] = mapped_column( + BigInteger, + ForeignKey("windup_workflow_run.id", ondelete="CASCADE"), + primary_key=True, + ) + turns: Mapped[list[dict]] = mapped_column( + JSON().with_variant(JSONB, "postgresql"), + nullable=False, + default=list, + ) + schema_version: Mapped[int] = mapped_column(Integer, nullable=False, default=2) + version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/packages/app/src/windup_app/server/quick_start_conversation/service.py b/backend/packages/app/src/windup_app/server/quick_start_conversation/service.py new file mode 100644 index 00000000..0818a18d --- /dev/null +++ b/backend/packages/app/src/windup_app/server/quick_start_conversation/service.py @@ -0,0 +1,99 @@ +"""Quick Start Agent 对话快照的 SQLAlchemy 服务。""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from windup_common.enums.biz_code import BizCode +from windup_common.exceptions import BizException + +from .model import QuickStartAgentConversation + + +def _version_conflict() -> None: + raise BizException( + "Agent 对话版本冲突,请刷新后重试", + code=BizCode.CONFLICT, + ) + + +class QuickStartConversationService: + """读取和乐观锁写入每条 WorkflowRun 的完整对话快照。""" + + def get( + self, + session: Session, + workflow_run_id: int, + ) -> QuickStartAgentConversation | None: + return session.get(QuickStartAgentConversation, workflow_run_id) + + def save( + self, + session: Session, + workflow_run_id: int, + *, + expected_version: int, + schema_version: int, + turns: list[dict], + ) -> QuickStartAgentConversation: + conversation = self.get(session, workflow_run_id) + if conversation is None: + if expected_version != 0: + _version_conflict() + candidate = QuickStartAgentConversation( + workflow_run_id=workflow_run_id, + turns=turns, + schema_version=schema_version, + ) + try: + # 两个首次 PUT 可同时读到空记录;savepoint 只回滚输掉的 INSERT, + # 不破坏外层请求事务,随后按幂等规则读取胜出的快照。 + with session.begin_nested(): + session.add(candidate) + session.flush() + return candidate + except IntegrityError: + session.expire_all() + conversation = self.get(session, workflow_run_id) + if conversation is None: + raise + if ( + conversation.turns == turns + and conversation.schema_version == schema_version + ): + return conversation + _version_conflict() + + if ( + conversation.turns == turns + and conversation.schema_version == schema_version + ): + return conversation + if conversation.version != expected_version: + _version_conflict() + + result = session.execute( + update(QuickStartAgentConversation) + .where( + QuickStartAgentConversation.workflow_run_id == workflow_run_id, + QuickStartAgentConversation.version == expected_version, + ) + .values( + turns=turns, + schema_version=schema_version, + version=expected_version + 1, + updated_at=datetime.now(timezone.utc), + ) + .execution_options(synchronize_session="fetch") + ) + if result.rowcount == 0: + _version_conflict() + session.refresh(conversation) + return conversation + + +service = QuickStartConversationService() diff --git a/backend/packages/app/src/windup_app/web/api/workflow_run.py b/backend/packages/app/src/windup_app/web/api/workflow_run.py index f9ce000d..436bd486 100644 --- a/backend/packages/app/src/windup_app/web/api/workflow_run.py +++ b/backend/packages/app/src/windup_app/web/api/workflow_run.py @@ -5,6 +5,8 @@ POST /workflow-runs 创建执行记录 GET /workflow-runs?project_id=... 分页列表 GET /workflow-runs/{id} 获取执行记录(含 nodes) +GET /workflow-runs/{id}/agent-conversation 获取 Quick Start 对话 +PUT /workflow-runs/{id}/agent-conversation 保存 Quick Start 对话 PATCH /workflow-runs/{id} 全量更新(含 nodes) DELETE /workflow-runs/{id} 软删除 @@ -16,11 +18,13 @@ from __future__ import annotations +import json import logging from datetime import datetime +from typing import Any, Literal from fastapi import APIRouter, Depends, Query, Request -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from sqlalchemy.orm import Session from windup_common.enums.biz_code import BizCode @@ -29,6 +33,10 @@ from windup_framework.db import get_session from windup_app.server.project.model import Project +from windup_app.server.quick_start_conversation.model import QuickStartAgentConversation +from windup_app.server.quick_start_conversation.service import ( + service as conversation_service, +) from windup_app.server.workflow_run.model import RunStatus from windup_app.server.workflow_run.service import service @@ -77,6 +85,73 @@ class WorkflowRunOut(BaseModel): created_at: datetime +class AgentConversationTurn(BaseModel): + """服务端只约束稳定外壳,proposal 等扩展字段保持前端原样。""" + + model_config = ConfigDict(extra="allow") + + role: Literal["user", "assistant"] + content: str = Field(min_length=1, max_length=8_000) + + @field_validator("content") + @classmethod + def content_must_not_be_blank(cls, value: str) -> str: + if not value.strip(): + raise ValueError("content 不能为空") + return value + + +class AgentConversationUpdate(BaseModel): + """完整替换一条运行记录的 Agent 对话。""" + + version: int = Field(ge=0) + schema_version: Literal[2] = 2 + turns: list[AgentConversationTurn] = Field(max_length=256) + + @model_validator(mode="after") + def payload_must_fit_snapshot_limit(self): + payload = [ + turn.model_dump(mode="json", exclude_none=True) for turn in self.turns + ] + encoded = json.dumps( + payload, ensure_ascii=False, separators=(",", ":") + ).encode() + if len(encoded) > 256 * 1_024: + raise ValueError("Agent 对话快照不能超过 256 KiB") + return self + + +class AgentConversationOut(BaseModel): + """Quick Start Agent 对话快照响应。""" + + run_id: int + turns: list[dict[str, Any]] + schema_version: int + version: int + updated_at: datetime | None + + +def _conversation_out( + run_id: int, + conversation: QuickStartAgentConversation | None, +) -> AgentConversationOut: + if conversation is None: + return AgentConversationOut( + run_id=run_id, + turns=[], + schema_version=2, + version=0, + updated_at=None, + ) + return AgentConversationOut( + run_id=run_id, + turns=conversation.turns, + schema_version=conversation.schema_version, + version=conversation.version, + updated_at=conversation.updated_at, + ) + + # ── 归属校验 ───────────────────────────────────────────────────────────────── @@ -158,6 +233,46 @@ def get_run( return Response.success(WorkflowRunOut.model_validate(run)) +@router.get( + "/{run_id}/agent-conversation", + response_model=Response[AgentConversationOut], +) +def get_agent_conversation( + run_id: int, + request: Request, + session: Session = Depends(get_session), +) -> Response[AgentConversationOut]: + """读取运行记录的 Agent 对话;尚未保存时返回空快照。""" + user_id = request.state.current_user.id + _get_run_with_auth(session, run_id, user_id) + conversation = conversation_service.get(session, run_id) + return Response.success(_conversation_out(run_id, conversation)) + + +@router.put( + "/{run_id}/agent-conversation", + response_model=Response[AgentConversationOut], +) +def save_agent_conversation( + run_id: int, + body: AgentConversationUpdate, + request: Request, + session: Session = Depends(get_session), +) -> Response[AgentConversationOut]: + """以独立乐观锁保存完整 Agent 对话,不修改 WorkflowRun 版本。""" + user_id = request.state.current_user.id + _get_run_with_auth(session, run_id, user_id) + turns = [turn.model_dump(mode="json", exclude_none=True) for turn in body.turns] + conversation = conversation_service.save( + session, + run_id, + expected_version=body.version, + schema_version=body.schema_version, + turns=turns, + ) + return Response.success(_conversation_out(run_id, conversation), message="保存成功") + + @router.patch("/{run_id}", response_model=Response[WorkflowRunOut]) def update_run( run_id: int, diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 6a0176e7..fe982f4f 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -24,6 +24,7 @@ from windup_app.bootstrap.app import create_app from windup_app.server.character.model import Character from windup_app.server.project.model import Project +from windup_app.server.quick_start_conversation.model import QuickStartAgentConversation from windup_app.server.quota.model import ( CreditAccount, CreditRedemptionCode, @@ -127,6 +128,7 @@ def engine(): User.__table__, Character.__table__, WorkflowRun.__table__, + QuickStartAgentConversation.__table__, CreditAccount.__table__, CreditRedemptionCode.__table__, CreditTransaction.__table__, diff --git a/backend/tests/test_quick_start_conversation_service.py b/backend/tests/test_quick_start_conversation_service.py new file mode 100644 index 00000000..e9334d86 --- /dev/null +++ b/backend/tests/test_quick_start_conversation_service.py @@ -0,0 +1,34 @@ +from unittest.mock import MagicMock, Mock + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from windup_app.server.quick_start_conversation.model import QuickStartAgentConversation +from windup_app.server.quick_start_conversation.service import ( + QuickStartConversationService, +) + + +def test_concurrent_first_insert_returns_the_identical_winning_snapshot(): + turns = [{"role": "user", "content": "像素骑士"}] + winner = QuickStartAgentConversation( + workflow_run_id=18, + turns=turns, + schema_version=2, + version=1, + ) + session = MagicMock(spec=Session) + session.flush.side_effect = IntegrityError("duplicate", {}, Exception("unique")) + service = QuickStartConversationService() + service.get = Mock(side_effect=[None, winner]) + + saved = service.save( + session, + 18, + expected_version=0, + schema_version=2, + turns=turns, + ) + + assert saved is winner + session.expire_all.assert_called_once_with() diff --git a/backend/tests/test_workflow_run_api.py b/backend/tests/test_workflow_run_api.py index 4e9bcfbf..83d0b1bf 100644 --- a/backend/tests/test_workflow_run_api.py +++ b/backend/tests/test_workflow_run_api.py @@ -173,6 +173,144 @@ def test_get_other_users_run_returns_404(auth_client, auth_client_b): assert resp.json()["code"] == 404 +# -- GET/PUT /workflow-runs/{id}/agent-conversation -------------------------- + + +def _conversation_turns() -> list[dict]: + return [ + {"role": "user", "content": "创建一个像素骑士"}, + { + "role": "assistant", + "content": "我整理了一版生成方案。", + "kind": "proposal", + "proposalId": "proposal-1", + "optimizedPrompt": "像素骑士,银色盔甲", + "optimizationSummary": "补充了材质和轮廓", + "proposalStatus": "confirmed", + }, + ] + + +def test_get_missing_agent_conversation_returns_empty_snapshot(auth_client): + project = _create_project(auth_client) + run = auth_client.post( + "/workflow-runs", + json=_payload(project["id"]), + ).json()["data"] + + resp = auth_client.get(f"/workflow-runs/{run['id']}/agent-conversation") + + assert resp.status_code == 200 + assert resp.json()["data"] == { + "run_id": run["id"], + "turns": [], + "schema_version": 2, + "version": 0, + "updated_at": None, + } + + +def test_put_agent_conversation_persists_full_snapshot(auth_client): + project = _create_project(auth_client) + run = auth_client.post( + "/workflow-runs", + json=_payload(project["id"]), + ).json()["data"] + turns = _conversation_turns() + + saved = auth_client.put( + f"/workflow-runs/{run['id']}/agent-conversation", + json={"turns": turns, "schema_version": 2, "version": 0}, + ) + loaded = auth_client.get(f"/workflow-runs/{run['id']}/agent-conversation") + + assert saved.status_code == 200 + assert saved.json()["data"]["version"] == 1 + assert saved.json()["data"]["updated_at"] + assert loaded.json()["data"]["turns"] == turns + assert loaded.json()["data"]["version"] == 1 + + +def test_put_agent_conversation_replays_the_same_snapshot_idempotently(auth_client): + project = _create_project(auth_client) + run = auth_client.post( + "/workflow-runs", + json=_payload(project["id"]), + ).json()["data"] + url = f"/workflow-runs/{run['id']}/agent-conversation" + payload = {"turns": _conversation_turns(), "schema_version": 2, "version": 0} + + created = auth_client.put(url, json=payload) + replayed = auth_client.put(url, json=payload) + + assert created.status_code == 200 + assert replayed.status_code == 200 + assert replayed.json()["data"]["turns"] == payload["turns"] + assert replayed.json()["data"]["version"] == 1 + + +def test_put_agent_conversation_rejects_stale_version(auth_client): + project = _create_project(auth_client) + run = auth_client.post( + "/workflow-runs", + json=_payload(project["id"]), + ).json()["data"] + url = f"/workflow-runs/{run['id']}/agent-conversation" + auth_client.put( + url, + json={"turns": _conversation_turns(), "schema_version": 2, "version": 0}, + ) + + resp = auth_client.put( + url, + json={ + "turns": [{"role": "user", "content": "覆盖为旧数据"}], + "schema_version": 2, + "version": 0, + }, + ) + + assert resp.json()["code"] == 409 + assert "冲突" in resp.json()["message"] + + +def test_agent_conversation_is_hidden_from_other_users(auth_client, auth_client_b): + project = _create_project(auth_client) + run = auth_client.post( + "/workflow-runs", + json=_payload(project["id"]), + ).json()["data"] + url = f"/workflow-runs/{run['id']}/agent-conversation" + + assert auth_client_b.get(url).json()["code"] == 404 + assert ( + auth_client_b.put( + url, + json={"turns": _conversation_turns(), "schema_version": 2, "version": 0}, + ).json()["code"] + == 404 + ) + + +def test_agent_conversation_rejects_oversized_content(auth_client): + project = _create_project(auth_client) + run = auth_client.post( + "/workflow-runs", + json=_payload(project["id"]), + ).json()["data"] + + resp = auth_client.put( + f"/workflow-runs/{run['id']}/agent-conversation", + json={ + "turns": [{"role": "user", "content": "x" * 8_001}], + "schema_version": 2, + "version": 0, + }, + ) + + assert resp.json()["code"] == 400 + + # -- PATCH /workflow-runs/{id} ------------------------------------------------- diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 52aa4406..346ce357 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -192,3 +192,15 @@ export type { WorkflowRunStorageStatus, WorkflowRun, } from './workflow-run' + +/* Quick Start Agent 对话 —— 服务端快照为权威,本地存储只作恢复缓存。 */ +export { + QuickStartConversationConflictError, + quickStartConversationApis, +} from './quick-start-conversation' +export type { + QuickStartConversation, + QuickStartConversationApis, + QuickStartConversationTurn, + SaveQuickStartConversationInput, +} from './quick-start-conversation' diff --git a/frontend/src/entities/quick-start-conversation/api.test.ts b/frontend/src/entities/quick-start-conversation/api.test.ts new file mode 100644 index 00000000..b17760c7 --- /dev/null +++ b/frontend/src/entities/quick-start-conversation/api.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +afterEach(() => { + vi.unstubAllEnvs() + vi.unstubAllGlobals() + vi.resetModules() +}) + +function envelope(data: unknown, code = 200, message = 'success') { + return new Response(JSON.stringify({ code, message, data }), { + headers: { 'content-type': 'application/json' }, + }) +} + +async function loadApis(fetchFn: typeof fetch) { + vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') + vi.stubGlobal('fetch', fetchFn) + return import('./api') +} + +describe('quickStartConversationApis', () => { + it('loads a server snapshot and maps transport field names', async () => { + const { quickStartConversationApis } = await loadApis(async () => + envelope({ + run_id: 18, + turns: [{ role: 'user', content: '像素骑士' }], + schema_version: 2, + version: 3, + updated_at: '2026-08-27T10:00:00Z', + }), + ) + + await expect(quickStartConversationApis.get('18')).resolves.toEqual({ + runId: '18', + turns: [{ role: 'user', content: '像素骑士' }], + schemaVersion: 2, + version: 3, + updatedAt: '2026-08-27T10:00:00Z', + }) + }) + + it('saves a complete snapshot with an independently replayable PUT', async () => { + let request: Request | undefined + const { quickStartConversationApis } = await loadApis(async (input, init) => { + request = new Request(input, init) + return envelope({ + run_id: 18, + turns: [{ role: 'user', content: '像素骑士' }], + schema_version: 2, + version: 1, + updated_at: '2026-08-27T10:00:00Z', + }) + }) + + await quickStartConversationApis.save('18', { + turns: [{ role: 'user', content: '像素骑士' }], + version: 0, + }) + + expect(request?.url).toBe('https://api.windup.test/workflow-runs/18/agent-conversation') + expect(request?.method).toBe('PUT') + await expect(request?.json()).resolves.toEqual({ + turns: [{ role: 'user', content: '像素骑士' }], + schema_version: 2, + version: 0, + }) + }) + + it('turns a 409 business response into a typed conflict', async () => { + const { quickStartConversationApis, QuickStartConversationConflictError } = await loadApis( + async () => envelope(null, 409, 'Agent 对话版本冲突'), + ) + + await expect( + quickStartConversationApis.save('18', { turns: [], version: 0 }), + ).rejects.toBeInstanceOf(QuickStartConversationConflictError) + }) +}) diff --git a/frontend/src/entities/quick-start-conversation/api.ts b/frontend/src/entities/quick-start-conversation/api.ts new file mode 100644 index 00000000..833259b5 --- /dev/null +++ b/frontend/src/entities/quick-start-conversation/api.ts @@ -0,0 +1,111 @@ +import { ApiError, createApiClient, getApiAccessToken } from '@/shared/api' +import type { + QuickStartConversation, + QuickStartConversationApis, + QuickStartConversationTurn, +} from './index' + +interface ConversationDto { + run_id: number + turns: unknown[] + schema_version: number + version: number + updated_at: string | null +} + +const CONVERSATION_READ_TIMEOUT_MS = 5_000 +const CONVERSATION_WRITE_TIMEOUT_MS = 10_000 + +export class QuickStartConversationConflictError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'QuickStartConversationConflictError' + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isTurn(value: unknown): value is QuickStartConversationTurn { + return ( + isRecord(value) && + (value.role === 'user' || value.role === 'assistant') && + typeof value.content === 'string' && + value.content.trim().length > 0 + ) +} + +function mapConversation(dto: ConversationDto): QuickStartConversation { + if ( + !isRecord(dto) || + !Number.isSafeInteger(dto.run_id) || + dto.run_id <= 0 || + !Array.isArray(dto.turns) || + !dto.turns.every(isTurn) || + dto.schema_version !== 2 || + !Number.isSafeInteger(dto.version) || + dto.version < 0 || + !(dto.updated_at === null || typeof dto.updated_at === 'string') + ) { + throw new ApiError('后端 Quick Start Agent 对话响应格式无效', { + kind: 'invalid-response', + data: dto, + }) + } + return { + runId: String(dto.run_id), + turns: structuredClone(dto.turns), + schemaVersion: 2, + version: dto.version, + updatedAt: dto.updated_at, + } +} + +function toBackendId(runId: string): number { + const parsed = Number(runId) + if (Number.isSafeInteger(parsed) && parsed > 0) return parsed + throw new TypeError('runId 必须是正整数 ID') +} + +function getApiClient() { + return createApiClient({ getAccessToken: getApiAccessToken }) +} + +export const quickStartConversationApis: QuickStartConversationApis = { + async get(runId) { + toBackendId(runId) + return mapConversation( + await getApiClient().request( + `/workflow-runs/${encodeURIComponent(runId)}/agent-conversation`, + { signal: AbortSignal.timeout(CONVERSATION_READ_TIMEOUT_MS) }, + ), + ) + }, + + async save(runId, input) { + toBackendId(runId) + try { + return mapConversation( + await getApiClient().request( + `/workflow-runs/${encodeURIComponent(runId)}/agent-conversation`, + { + method: 'PUT', + replayAfterAuth: true, + signal: AbortSignal.timeout(CONVERSATION_WRITE_TIMEOUT_MS), + json: { + turns: input.turns, + schema_version: 2, + version: input.version, + }, + }, + ), + ) + } catch (cause) { + if (cause instanceof ApiError && cause.kind === 'business' && cause.code === 409) { + throw new QuickStartConversationConflictError(cause.message, { cause }) + } + throw cause + } + }, +} diff --git a/frontend/src/entities/quick-start-conversation/index.ts b/frontend/src/entities/quick-start-conversation/index.ts new file mode 100644 index 00000000..634fb1b2 --- /dev/null +++ b/frontend/src/entities/quick-start-conversation/index.ts @@ -0,0 +1,25 @@ +export interface QuickStartConversationTurn { + role: 'user' | 'assistant' + content: string + [key: string]: unknown +} + +export interface QuickStartConversation { + runId: string + turns: readonly QuickStartConversationTurn[] + schemaVersion: 2 + version: number + updatedAt: string | null +} + +export interface SaveQuickStartConversationInput { + turns: readonly QuickStartConversationTurn[] + version: number +} + +export interface QuickStartConversationApis { + get(runId: string): Promise + save(runId: string, input: SaveQuickStartConversationInput): Promise +} + +export { QuickStartConversationConflictError, quickStartConversationApis } from './api' diff --git a/frontend/src/pages/quick-start/index.test.tsx b/frontend/src/pages/quick-start/index.test.tsx index e0f70ed3..4b7cd5cf 100644 --- a/frontend/src/pages/quick-start/index.test.tsx +++ b/frontend/src/pages/quick-start/index.test.tsx @@ -22,6 +22,8 @@ import { type CharacterSummaryApis, type Project, type ProjectApis, + type QuickStartConversation, + type QuickStartConversationApis, type WorkflowRun, } from '@/entities' import { ApiError } from '@/shared/api' @@ -47,6 +49,40 @@ function workflow(nodes: WorkflowRun['nodes'], id = 'run-1'): WorkflowRun { return { id, projectId: 'project-1', version: 1, storageStatus: 'active', nodes } } +function conversationStore( + initial: readonly QuickStartConversation[] = [], +): QuickStartConversationApis & { + get: ReturnType> + save: ReturnType> +} { + const conversations = new Map(initial.map((conversation) => [conversation.runId, conversation])) + const get = vi.fn(async (runId) => + structuredClone( + conversations.get(runId) ?? { + runId, + turns: [], + schemaVersion: 2, + version: 0, + updatedAt: null, + }, + ), + ) + const save = vi.fn(async (runId, input) => { + const current = conversations.get(runId) + if (current && current.version !== input.version) throw new Error('Agent 对话版本冲突') + const saved: QuickStartConversation = { + runId, + turns: structuredClone(input.turns), + schemaVersion: 2, + version: input.version + 1, + updatedAt: '2026-08-27T10:00:00Z', + } + conversations.set(runId, saved) + return structuredClone(saved) + }) + return { get, save } +} + function setupAndTemplate( template: Partial> = {}, ): WorkflowRun['nodes'] { @@ -219,7 +255,15 @@ describe('Quick Start workflow Agent', () => { ], })), }) - renderAt(`/quick-start/${run.id}`, service, agentFor({ planner })) + const conversations = conversationStore() + renderAt( + `/quick-start/${run.id}`, + service, + agentFor({ planner }), + projectReader(), + characterReader(), + conversations, + ) await screen.findAllByRole('button', { name: /选择角色方案/u }) const composer = screen.getByRole('textbox', { name: '继续描述你的想法' }) @@ -235,6 +279,76 @@ describe('Quick Start workflow Agent', () => { ), ) expect(service.confirmCandidate).not.toHaveBeenCalled() + await waitFor(() => + expect(conversations.save).toHaveBeenCalledWith( + run.id, + expect.objectContaining({ + turns: expect.arrayContaining([ + expect.objectContaining({ role: 'user', content: '把第二张的牛角缩短' }), + ]), + }), + ), + ) + }) + + it('reconciles a committed save whose response was lost before saving later turns', async () => { + const run = workflow(setupAndTemplate()) + const planner = vi.fn(async () => ({ + text: '', + finishReason: 'tool-calls', + toolCalls: [ + { + toolName: 'refine_character_template', + input: { adjustmentPrompt: '把披风改成深蓝色' }, + }, + ], + })) + const service = serviceFor(run, { + getTemplateCandidates: vi.fn(async () => + eastCandidates( + 'https://example.test/character-1.png', + 'https://example.test/character-2.png', + 'https://example.test/character-3.png', + ), + ), + getWorkflowAgentContext: vi.fn(() => ({ + availableTools: ['regenerate_character_template', 'refine_character_template'] as const, + })), + }) + const conversations = conversationStore() + const saveSnapshot = conversations.save.getMockImplementation()! + let loseResponse = true + conversations.save.mockImplementation(async (runId, input) => { + const saved = await saveSnapshot(runId, input) + if (loseResponse) { + loseResponse = false + throw new DOMException('response lost', 'TimeoutError') + } + return saved + }) + renderAt( + `/quick-start/${run.id}`, + service, + agentFor({ planner }), + projectReader(), + characterReader(), + conversations, + ) + + const composer = await screen.findByRole('textbox', { name: '继续描述你的想法' }) + fireEvent.change(composer, { target: { value: '把披风改成深蓝色' } }) + fireEvent.click(screen.getByRole('button', { name: '发送' })) + + expect(await screen.findByText('已提交角色母版微调。')).toBeTruthy() + await waitFor(async () => { + const saved = await conversations.get(run.id) + expect(saved.turns).toEqual( + expect.arrayContaining([ + expect.objectContaining({ role: 'user', content: '把披风改成深蓝色' }), + expect.objectContaining({ role: 'assistant', content: '已提交角色母版微调。' }), + ]), + ) + }) }) it('continues character regeneration below the Agent acknowledgement', async () => { @@ -357,6 +471,130 @@ describe('Quick Start workflow Agent', () => { ).toBeTruthy() expect(screen.getByText('已提交角色母版微调。')).toBeTruthy() }) + + it('uses the server Agent conversation when it differs from the local cache', async () => { + const run = actionWorkflow({ fullStatus: 'passed', reviewStatus: 'passed' }) + const localKey = `windup.quick-start.agent-chat.v2:run:7:${run.id}` + window.localStorage.setItem( + localKey, + JSON.stringify({ turns: [{ role: 'user', content: '本地旧对话' }] }), + ) + const conversations = conversationStore([ + { + runId: run.id, + turns: [{ role: 'user', content: '服务端历史对话' }], + schemaVersion: 2, + version: 3, + updatedAt: '2026-08-27T10:00:00Z', + }, + ]) + + renderAt( + `/quick-start/${run.id}`, + serviceFor(run), + agentFor(), + projectReader(), + characterReader(), + conversations, + ) + + expect(await screen.findByText('服务端历史对话')).toBeTruthy() + expect(screen.queryByText('本地旧对话')).toBeNull() + expect(window.localStorage.getItem(localKey)).toContain('服务端历史对话') + }) + + it('migrates a matching local Agent conversation when the server has no snapshot', async () => { + const run = actionWorkflow({ fullStatus: 'passed', reviewStatus: 'passed' }) + window.localStorage.setItem( + `windup.quick-start.agent-chat.v2:run:7:${run.id}`, + JSON.stringify({ turns: [{ role: 'user', content: '待迁移的本地对话' }] }), + ) + const conversations = conversationStore() + + renderAt( + `/quick-start/${run.id}`, + serviceFor(run), + agentFor(), + projectReader(), + characterReader(), + conversations, + ) + + expect(await screen.findByText('待迁移的本地对话')).toBeTruthy() + await waitFor(() => + expect(conversations.save).toHaveBeenCalledWith(run.id, { + turns: [{ role: 'user', content: '待迁移的本地对话' }], + version: 0, + }), + ) + }) + + it('falls back to the local Agent conversation when the server read fails', async () => { + const run = actionWorkflow({ fullStatus: 'passed', reviewStatus: 'passed' }) + window.localStorage.setItem( + `windup.quick-start.agent-chat.v2:run:7:${run.id}`, + JSON.stringify({ turns: [{ role: 'user', content: '离线可恢复对话' }] }), + ) + const conversations = conversationStore() + conversations.get.mockRejectedValueOnce(new Error('network unavailable')) + + renderAt( + `/quick-start/${run.id}`, + serviceFor(run), + agentFor(), + projectReader(), + characterReader(), + conversations, + ) + + expect(await screen.findByText('离线可恢复对话')).toBeTruthy() + expect(await screen.findByRole('textbox', { name: '继续描述你的想法' })).toBeTruthy() + }) + + it('merges a dirty local snapshot with newer server turns instead of dropping either side', async () => { + const run = actionWorkflow({ fullStatus: 'passed', reviewStatus: 'passed' }) + window.localStorage.setItem( + `windup.quick-start.agent-chat.v2:run:7:${run.id}`, + JSON.stringify({ + turns: [{ role: 'user', content: '本地待同步消息' }], + serverVersion: 0, + dirty: true, + }), + ) + const conversations = conversationStore([ + { + runId: run.id, + turns: [{ role: 'assistant', content: '另一个标签页的消息', kind: 'reply' }], + schemaVersion: 2, + version: 1, + updatedAt: '2026-08-27T10:00:00Z', + }, + ]) + + renderAt( + `/quick-start/${run.id}`, + serviceFor(run), + agentFor(), + projectReader(), + characterReader(), + conversations, + ) + + expect(await screen.findByText('另一个标签页的消息')).toBeTruthy() + expect(screen.getByText('本地待同步消息')).toBeTruthy() + await waitFor(() => + expect(conversations.save).toHaveBeenCalledWith( + run.id, + expect.objectContaining({ + version: 1, + turns: expect.arrayContaining([ + expect.objectContaining({ content: '另一个标签页的消息' }), + expect.objectContaining({ content: '本地待同步消息' }), + ]), + }), + ), + ) + }) }) function eastCandidates(...imageUrls: string[]): readonly QuickStartCandidate[] { @@ -565,6 +803,7 @@ function renderAt( CharacterApis & CharacterSummaryApis, 'get' | 'listSummariesByProject' > = characterReader(), + conversations: QuickStartConversationApis = conversationStore(), ) { function PlaytestLocation() { const location = useLocation() @@ -587,6 +826,7 @@ function renderAt( agent={agent} projectApis={projects} characterApis={characters} + conversationApis={conversations} /> } /> @@ -599,6 +839,7 @@ function renderAt( agent={agent} projectApis={projects} characterApis={characters} + conversationApis={conversations} /> } /> @@ -612,13 +853,21 @@ function renderAt( function renderInBrowserHistory( service: QuickStartEntryService, agent: CreateQuickStartAgentOptions = agentFor(), + conversations: QuickStartConversationApis = conversationStore(), ) { return render( } + element={ + + } /> , @@ -639,6 +888,7 @@ function renderWithRunSwitcher( nextRunId: string, ) { const agent = agentFor() + const conversations = conversationStore() function Controls() { const navigate = useNavigate() const location = useLocation() @@ -658,7 +908,14 @@ function renderWithRunSwitcher( } + element={ + + } /> , @@ -1426,7 +1683,7 @@ describe('QuickStartPage', () => { const runLayout = await screen.findByTestId('quick-start-run') expect(runLayout.getAttribute('data-layout')).toBe('agent-shell') expect(runLayout.querySelector('[data-layout="quick-start-scroll-region"]')).toBeTruthy() - expect(screen.getByTestId('quick-start-composer').getAttribute('data-position')).toBe( + expect((await screen.findByTestId('quick-start-composer')).getAttribute('data-position')).toBe( 'floating', ) expect(runLayout.querySelector('aside')).toBeNull() @@ -1715,12 +1972,16 @@ describe('QuickStartPage', () => { getWorkflow: vi.fn(() => createdRun), resume: vi.fn(async () => createdRun), }) + const conversations = conversationStore() renderAt( '/quick-start', service, agentFor({ startCharacterGeneration: vi.fn(async () => ({ runId: 'run-created' })), }), + projectReader(), + characterReader(), + conversations, ) fireEvent.change(screen.getByLabelText('创作指令'), { @@ -1751,6 +2012,10 @@ describe('QuickStartPage', () => { expect( window.sessionStorage.getItem(`windup.quick-start.agent-chat.v2:draft:7:${draftId}`), ).toBeNull() + expect(conversations.save).toHaveBeenCalledWith( + 'run-created', + expect.objectContaining({ version: 0 }), + ) }) it('loads Agent turns only from the matching run sidecar', async () => { diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx index cf4f0ce6..4bab1571 100644 --- a/frontend/src/pages/quick-start/index.tsx +++ b/frontend/src/pages/quick-start/index.tsx @@ -59,8 +59,11 @@ import { type WorkflowRun, WorkflowRunConflictError, projectApis as defaultProjectApis, + quickStartConversationApis as defaultQuickStartConversationApis, type Project, type ProjectApis, + type QuickStartConversationApis, + type QuickStartConversationTurn, } from '@/entities' import { forgetActiveRun, isMissingActiveRunError, syncActiveRun } from '@/features/active-run' import { useOptionalAuthSession } from '@/features/auth-session' @@ -234,6 +237,9 @@ type AgentConversationTurn = type AgentConversationRecord = { turns: readonly AgentConversationTurn[] + /** 最近一次已确认的服务端版本;dirty=true 时 turns 仍含待同步内容。 */ + serverVersion?: number + dirty?: boolean /** 入口处选的画风;不随草稿存住的话,刷新后画风选择器已隐藏而值悄悄回到不指定。 */ gameStyle?: ArtStyle /** 默认开启;关闭时保留像素风提示词,只跳过自动像素后处理。 */ @@ -342,115 +348,191 @@ function createAgentDraftId(): string { return draftId } -function readAgentConversation( - storageName: AgentConversationStorageName, - key: string, +function normalizeAgentConversationTurns( + turns: readonly unknown[], ): readonly AgentConversationTurn[] { - try { - const stored = window[storageName].getItem(key) - if (!stored) return [] - const parsed: unknown = JSON.parse(stored) - if (typeof parsed !== 'object' || parsed === null || !('turns' in parsed)) return [] - if (!Array.isArray(parsed.turns)) return [] - return parsed.turns.flatMap((turn): AgentConversationTurn[] => { - if ( - typeof turn !== 'object' || - turn === null || - !('role' in turn) || - !('content' in turn) || - typeof turn.content !== 'string' || - !turn.content.trim() - ) { - return [] - } - const scope = - 'scope' in turn && (turn.scope === 'workflow' || turn.scope === 'add-action') - ? turn.scope - : undefined - if (turn.role === 'user') { - const referenceMedia = - 'referenceMedia' in turn && + return turns.flatMap((turn): AgentConversationTurn[] => { + if ( + typeof turn !== 'object' || + turn === null || + !('role' in turn) || + !('content' in turn) || + typeof turn.content !== 'string' || + !turn.content.trim() + ) { + return [] + } + const scope = + 'scope' in turn && (turn.scope === 'workflow' || turn.scope === 'add-action') + ? turn.scope + : undefined + if (turn.role === 'user') { + const referenceMedia = + 'referenceMedia' in turn && + Array.isArray(turn.referenceMedia) && + turn.referenceMedia.every( + (value: unknown) => + typeof value === 'string' && value.length <= 2_048 && /^https?:\/\//u.test(value), + ) + ? turn.referenceMedia + : [] + return [ + { + role: 'user', + content: turn.content, + ...(referenceMedia.length ? { referenceMedia } : {}), + ...(scope ? { scope } : {}), + }, + ] + } + if (turn.role !== 'assistant') return [] + + if ( + 'kind' in turn && + turn.kind === 'proposal' && + 'proposalId' in turn && + typeof turn.proposalId === 'string' && + 'optimizedPrompt' in turn && + typeof turn.optimizedPrompt === 'string' && + 'optimizationSummary' in turn && + typeof turn.optimizationSummary === 'string' && + 'proposalStatus' in turn && + (turn.proposalStatus === 'pending' || + turn.proposalStatus === 'superseded' || + turn.proposalStatus === 'adopted' || + turn.proposalStatus === 'confirmed') + ) { + return [ + { + role: 'assistant', + content: turn.content, + kind: 'proposal', + proposalId: turn.proposalId, + optimizedPrompt: turn.optimizedPrompt, + ...('actionPrompt' in turn && typeof turn.actionPrompt === 'string' + ? { actionPrompt: turn.actionPrompt } + : {}), + ...('actionType' in turn && + (turn.actionType === 'idle' || + turn.actionType === 'walk' || + turn.actionType === 'attack' || + turn.actionType === 'jump') + ? { actionType: turn.actionType } + : {}), + ...('locomotion' in turn && turn.locomotion === true + ? { locomotion: true as const } + : {}), + optimizationSummary: turn.optimizationSummary, + suggestPixelPerfect: 'suggestPixelPerfect' in turn && turn.suggestPixelPerfect === true, + ...('referenceMedia' in turn && Array.isArray(turn.referenceMedia) && turn.referenceMedia.every( (value: unknown) => typeof value === 'string' && value.length <= 2_048 && /^https?:\/\//u.test(value), ) - ? turn.referenceMedia - : [] - return [ - { - role: 'user', - content: turn.content, - ...(referenceMedia.length ? { referenceMedia } : {}), - ...(scope ? { scope } : {}), - }, - ] - } - if (turn.role !== 'assistant') return [] - - if ( - 'kind' in turn && - turn.kind === 'proposal' && - 'proposalId' in turn && - typeof turn.proposalId === 'string' && - 'optimizedPrompt' in turn && - typeof turn.optimizedPrompt === 'string' && - 'optimizationSummary' in turn && - typeof turn.optimizationSummary === 'string' && - 'proposalStatus' in turn && - (turn.proposalStatus === 'pending' || - turn.proposalStatus === 'superseded' || - turn.proposalStatus === 'adopted' || - turn.proposalStatus === 'confirmed') - ) { - return [ - { - role: 'assistant', - content: turn.content, - kind: 'proposal', - proposalId: turn.proposalId, - optimizedPrompt: turn.optimizedPrompt, - ...('actionPrompt' in turn && typeof turn.actionPrompt === 'string' - ? { actionPrompt: turn.actionPrompt } - : {}), - ...('actionType' in turn && - (turn.actionType === 'idle' || - turn.actionType === 'walk' || - turn.actionType === 'attack' || - turn.actionType === 'jump') - ? { actionType: turn.actionType } - : {}), - ...('locomotion' in turn && turn.locomotion === true - ? { locomotion: true as const } - : {}), - optimizationSummary: turn.optimizationSummary, - suggestPixelPerfect: 'suggestPixelPerfect' in turn && turn.suggestPixelPerfect === true, - ...('referenceMedia' in turn && - Array.isArray(turn.referenceMedia) && - turn.referenceMedia.every( - (value: unknown) => - typeof value === 'string' && value.length <= 2_048 && /^https?:\/\//u.test(value), - ) - ? { referenceMedia: turn.referenceMedia } - : {}), - proposalStatus: turn.proposalStatus, - ...(scope ? { scope } : {}), - }, - ] + ? { referenceMedia: turn.referenceMedia } + : {}), + proposalStatus: turn.proposalStatus, + ...(scope ? { scope } : {}), + }, + ] + } + + const kind = + 'kind' in turn && + (turn.kind === 'reply' || turn.kind === 'clarification' || turn.kind === 'blocked') + ? turn.kind + : 'reply' + return [{ role: 'assistant', content: turn.content, kind, ...(scope ? { scope } : {}) }] + }) +} + +function conversationTurnsEqual( + left: readonly AgentConversationTurn[], + right: readonly AgentConversationTurn[], +): boolean { + return JSON.stringify(left) === JSON.stringify(right) +} + +const PROPOSAL_STATUS_RANK = { + pending: 0, + superseded: 1, + adopted: 2, + confirmed: 3, +} as const + +/** 合并并发标签页或结果不明的全量快照,保留两边唯一消息与更先进的 proposal 状态。 */ +function mergeAgentConversationTurns( + remote: readonly AgentConversationTurn[], + local: readonly AgentConversationTurn[], +): readonly AgentConversationTurn[] { + if (conversationTurnsEqual(remote, local)) return remote + const sharedLength = Math.min(remote.length, local.length) + const sharedPrefix = Array.from({ length: sharedLength }).every((_, index) => + conversationTurnsEqual([remote[index]], [local[index]]), + ) + if (sharedPrefix) return remote.length >= local.length ? remote : local + const merged = [...remote] + for (const localTurn of local) { + if (localTurn.role === 'assistant' && localTurn.kind === 'proposal') { + const matchingIndex = merged.findIndex( + (remoteTurn) => + remoteTurn.role === 'assistant' && + remoteTurn.kind === 'proposal' && + remoteTurn.proposalId === localTurn.proposalId, + ) + if (matchingIndex >= 0) { + const remoteTurn = merged[matchingIndex] + if ( + remoteTurn.role === 'assistant' && + remoteTurn.kind === 'proposal' && + PROPOSAL_STATUS_RANK[localTurn.proposalStatus] >= + PROPOSAL_STATUS_RANK[remoteTurn.proposalStatus] + ) { + merged[matchingIndex] = localTurn + } + continue } + } + if (!merged.some((remoteTurn) => conversationTurnsEqual([remoteTurn], [localTurn]))) { + merged.push(localTurn) + } + } + return merged +} - const kind = - 'kind' in turn && - (turn.kind === 'reply' || turn.kind === 'clarification' || turn.kind === 'blocked') - ? turn.kind - : 'reply' - return [{ role: 'assistant', content: turn.content, kind, ...(scope ? { scope } : {}) }] - }) +function readAgentConversationRecord( + storageName: AgentConversationStorageName, + key: string, +): AgentConversationRecord | null { + try { + const stored = window[storageName].getItem(key) + if (!stored) return null + const parsed: unknown = JSON.parse(stored) + if (typeof parsed !== 'object' || parsed === null || !('turns' in parsed)) return null + if (!Array.isArray(parsed.turns)) return null + return { + turns: normalizeAgentConversationTurns(parsed.turns), + ...('serverVersion' in parsed && + typeof parsed.serverVersion === 'number' && + Number.isSafeInteger(parsed.serverVersion) && + parsed.serverVersion >= 0 + ? { serverVersion: parsed.serverVersion } + : {}), + ...('dirty' in parsed && parsed.dirty === true ? { dirty: true } : {}), + } } catch { - return [] + return null } } +function readAgentConversation( + storageName: AgentConversationStorageName, + key: string, +): readonly AgentConversationTurn[] { + return readAgentConversationRecord(storageName, key)?.turns ?? [] +} + function createAgentSeed(turns: readonly AgentConversationTurn[]): { messages: readonly PlannerMessage[] clarificationUsed: boolean @@ -515,18 +597,18 @@ function removeAgentConversation(storageName: AgentConversationStorageName, key: } } -function readAgentRunConversation( +function readAgentRunConversationRecord( userId: string | null, runId: string, -): readonly AgentConversationTurn[] { +): AgentConversationRecord { const runKey = agentRunConversationStorageKey(userId, runId) - const currentTurns = readAgentConversation('localStorage', runKey) - if (currentTurns.length > 0) return currentTurns + const current = readAgentConversationRecord('localStorage', runKey) + if (current) return current const legacyKey = legacyAgentConversationStorageKey(userId) try { const stored = window.localStorage.getItem(legacyKey) - if (!stored) return [] + if (!stored) return { turns: [], serverVersion: 0 } const parsed: unknown = JSON.parse(stored) if ( typeof parsed !== 'object' || @@ -534,18 +616,19 @@ function readAgentRunConversation( !('runId' in parsed) || parsed.runId !== runId ) { - return [] + return { turns: [], serverVersion: 0 } } // v1 只有用户级 key;仅迁移已绑定当前运行的记录,避免复活未绑定的全局草稿。 const legacyTurns = readAgentConversation('localStorage', legacyKey) - if (legacyTurns.length === 0) return [] - if (writeAgentConversation('localStorage', runKey, { turns: legacyTurns })) { + if (legacyTurns.length === 0) return { turns: [], serverVersion: 0 } + const migrated = { turns: legacyTurns, serverVersion: 0, dirty: true } + if (writeAgentConversation('localStorage', runKey, migrated)) { removeAgentConversation('localStorage', legacyKey) } - return legacyTurns + return migrated } catch { - return [] + return { turns: [], serverVersion: 0 } } } @@ -566,6 +649,7 @@ export interface QuickStartPageProps { agent: CreateQuickStartAgentOptions projectApis?: Pick characterApis?: Pick + conversationApis?: QuickStartConversationApis } /** Quick Start 独立完成 AI 入口;它不跳转 Workflow Editor。 */ @@ -575,6 +659,7 @@ export function QuickStartPage({ agent, projectApis = defaultProjectApis, characterApis = defaultCharacterApis, + conversationApis = defaultQuickStartConversationApis, }: QuickStartPageProps) { const { runId } = useParams() const location = useLocation() @@ -607,6 +692,7 @@ export function QuickStartPage({ onInitialSessionConsumed={consumeCreatedSession} activeRunUserId={activeRunUserId} agent={agent} + conversationApis={conversationApis} /> ) : ( )} @@ -810,12 +897,14 @@ function QuickStartInput({ activeRunUserId, projectApis, characterApis, + conversationApis, }: { service: QuickStartEntryService agent: CreateQuickStartAgentOptions activeRunUserId: string | null projectApis: Pick characterApis: Pick + conversationApis: QuickStartConversationApis }) { const navigate = useNavigate() const [entrySearchParams] = useSearchParams() @@ -1058,11 +1147,12 @@ function QuickStartInput({ const persistRunConversation = useCallback( (turns: readonly AgentConversationTurn[], runId: string) => { conversationTurnsRef.current = turns - const stored = writeAgentConversation( - 'localStorage', - agentRunConversationStorageKey(activeRunUserId, runId), - { turns }, - ) + const runKey = agentRunConversationStorageKey(activeRunUserId, runId) + const stored = writeAgentConversation('localStorage', runKey, { + turns, + serverVersion: 0, + dirty: true, + }) if (stored) { const draftId = draftIdRef.current if (draftId) { @@ -1072,8 +1162,23 @@ function QuickStartInput({ ) } } + void conversationApis + .save(runId, { + turns: turns as readonly QuickStartConversationTurn[], + version: 0, + }) + .then((saved) => { + const current = readAgentConversationRecord('localStorage', runKey) + if (current && conversationTurnsEqual(current.turns, turns)) { + writeAgentConversation('localStorage', runKey, { + turns, + serverVersion: saved.version, + }) + } + }) + .catch(() => undefined) }, - [activeRunUserId], + [activeRunUserId, conversationApis], ) const appendConversationTurn = useCallback( @@ -2690,15 +2795,7 @@ function RestoringConversation({ turns }: { turns: readonly AgentConversationTur ) } -function QuickStartRun({ - service, - runId, - initialSession, - onSessionCreated, - onInitialSessionConsumed, - activeRunUserId, - agent, -}: { +interface QuickStartRunProps { service: QuickStartEntryService runId: string initialSession: QuickStartSession | null @@ -2706,7 +2803,128 @@ function QuickStartRun({ onInitialSessionConsumed: (session: QuickStartSession) => void activeRunUserId: string | null agent: CreateQuickStartAgentOptions -}) { + conversationApis: QuickStartConversationApis +} + +interface LoadedAgentConversation { + turns: readonly AgentConversationTurn[] + version: number +} + +function QuickStartRun(props: QuickStartRunProps) { + const { activeRunUserId, conversationApis, runId } = props + const localRecord = useRef(readAgentRunConversationRecord(activeRunUserId, runId)).current + const localTurns = localRecord.turns + const [conversation, setConversation] = useState(null) + + useEffect(() => { + let active = true + + async function loadConversation() { + try { + const remote = await conversationApis.get(runId) + if (!active) return + const remoteTurns = normalizeAgentConversationTurns(remote.turns) + if (localRecord.dirty) { + const mergedTurns = mergeAgentConversationTurns(remoteTurns, localTurns) + if (conversationTurnsEqual(mergedTurns, remoteTurns)) { + writeAgentConversation( + 'localStorage', + agentRunConversationStorageKey(activeRunUserId, runId), + { turns: remoteTurns, serverVersion: remote.version }, + ) + setConversation({ turns: remoteTurns, version: remote.version }) + return + } + try { + const reconciled = await conversationApis.save(runId, { + turns: mergedTurns as readonly QuickStartConversationTurn[], + version: remote.version, + }) + if (!active) return + writeAgentConversation( + 'localStorage', + agentRunConversationStorageKey(activeRunUserId, runId), + { turns: mergedTurns, serverVersion: reconciled.version }, + ) + setConversation({ turns: mergedTurns, version: reconciled.version }) + return + } catch { + if (!active) return + writeAgentConversation( + 'localStorage', + agentRunConversationStorageKey(activeRunUserId, runId), + { turns: mergedTurns, serverVersion: remote.version, dirty: true }, + ) + setConversation({ turns: mergedTurns, version: remote.version }) + return + } + } + if (remote.version > 0 || remote.turns.length > 0) { + writeAgentConversation( + 'localStorage', + agentRunConversationStorageKey(activeRunUserId, runId), + { turns: remoteTurns, serverVersion: remote.version }, + ) + setConversation({ turns: remoteTurns, version: remote.version }) + return + } + if (localTurns.length > 0) { + try { + const migrated = await conversationApis.save(runId, { + turns: localTurns as readonly QuickStartConversationTurn[], + version: 0, + }) + if (!active) return + writeAgentConversation( + 'localStorage', + agentRunConversationStorageKey(activeRunUserId, runId), + { turns: localTurns, serverVersion: migrated.version }, + ) + setConversation({ turns: localTurns, version: migrated.version }) + return + } catch { + // 旧浏览器缓存迁移失败时仍可继续本地会话,后续增量保存会再次尝试。 + } + } + if (active) { + setConversation({ + turns: localTurns, + version: localRecord.serverVersion ?? 0, + }) + } + } catch { + // 服务端暂时不可用时回退到缓存,不能阻断已有工作流的恢复。 + if (active) { + setConversation({ + turns: localTurns, + version: localRecord.serverVersion ?? 0, + }) + } + } + } + + void loadConversation() + return () => { + active = false + } + }, [activeRunUserId, conversationApis, localRecord, localTurns, runId]) + + if (!conversation) return + return +} + +function QuickStartRunContent({ + service, + runId, + initialSession, + onSessionCreated, + onInitialSessionConsumed, + activeRunUserId, + agent, + conversationApis, + initialConversation, +}: QuickStartRunProps & { initialConversation: LoadedAgentConversation }) { const navigate = useNavigate() const location = useLocation() const [searchParams] = useSearchParams() @@ -2744,9 +2962,13 @@ function QuickStartRun({ const [confirmingFirstFrame, setConfirmingFirstFrame] = useState(false) const [addingAction, setAddingAction] = useState(false) const [promptCopied, setPromptCopied] = useState(false) - const initialAgentConversation = useRef(readAgentRunConversation(activeRunUserId, runId)).current + const initialAgentConversation = initialConversation.turns const [agentConversationTurns, setAgentConversationTurns] = useState(initialAgentConversation) const agentConversationTurnsRef = useRef(agentConversationTurns) + const conversationVersionRef = useRef(initialConversation.version) + const confirmedConversationTurnsRef = useRef(initialAgentConversation) + const pendingConversationTurnsRef = useRef(null) + const conversationSaveLoopRef = useRef(false) const initialWorkflowAgentSeed = useRef( createAgentSeed(initialAgentConversation.filter((turn) => turn.scope !== 'add-action')), ).current @@ -2954,18 +3176,103 @@ function QuickStartRun({ setSelectedFirstFrames((current) => keepAvailableSelections(current, firstFrameCandidates)) }, [firstFrameCandidates]) + const persistRunConversationSnapshot = useCallback( + (turns: readonly AgentConversationTurn[]) => { + const runKey = agentRunConversationStorageKey(activeRunUserId, runId) + writeAgentConversation('localStorage', runKey, { + turns, + serverVersion: conversationVersionRef.current, + dirty: true, + }) + pendingConversationTurnsRef.current = pendingConversationTurnsRef.current + ? mergeAgentConversationTurns(pendingConversationTurnsRef.current, turns) + : turns + if (conversationSaveLoopRef.current) return + conversationSaveLoopRef.current = true + + void (async () => { + try { + while (pendingConversationTurnsRef.current) { + const pending = pendingConversationTurnsRef.current + pendingConversationTurnsRef.current = null + let candidate = mergeAgentConversationTurns( + confirmedConversationTurnsRef.current, + pending, + ) + let confirmed: readonly AgentConversationTurn[] | null = null + + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + const saved = await conversationApis.save(runId, { + turns: candidate as readonly QuickStartConversationTurn[], + version: conversationVersionRef.current, + }) + conversationVersionRef.current = saved.version + confirmedConversationTurnsRef.current = candidate + confirmed = candidate + break + } catch { + try { + const latest = await conversationApis.get(runId) + const latestTurns = normalizeAgentConversationTurns(latest.turns) + conversationVersionRef.current = latest.version + confirmedConversationTurnsRef.current = latestTurns + candidate = mergeAgentConversationTurns(latestTurns, candidate) + if (conversationTurnsEqual(candidate, latestTurns)) { + confirmed = latestTurns + break + } + } catch { + break + } + } + } + + if (!confirmed) { + const current = agentConversationTurnsRef.current + writeAgentConversation('localStorage', runKey, { + turns: current, + serverVersion: conversationVersionRef.current, + dirty: true, + }) + continue + } + + const visibleTurns = mergeAgentConversationTurns( + confirmed, + agentConversationTurnsRef.current, + ) + if (!conversationTurnsEqual(visibleTurns, agentConversationTurnsRef.current)) { + agentConversationTurnsRef.current = visibleTurns + if (mountedRef.current) setAgentConversationTurns(visibleTurns) + } + if (!conversationTurnsEqual(visibleTurns, confirmed)) { + pendingConversationTurnsRef.current = pendingConversationTurnsRef.current + ? mergeAgentConversationTurns(pendingConversationTurnsRef.current, visibleTurns) + : visibleTurns + } + writeAgentConversation('localStorage', runKey, { + turns: visibleTurns, + serverVersion: conversationVersionRef.current, + ...(pendingConversationTurnsRef.current ? { dirty: true } : {}), + }) + } + } finally { + conversationSaveLoopRef.current = false + } + })() + }, + [activeRunUserId, conversationApis, runId], + ) + const appendRunConversationTurn = useCallback( (turn: AgentConversationTurn) => { const next = [...agentConversationTurnsRef.current, turn] agentConversationTurnsRef.current = next setAgentConversationTurns(next) - writeAgentConversation( - 'localStorage', - agentRunConversationStorageKey(activeRunUserId, runId), - { turns: next }, - ) + persistRunConversationSnapshot(next) }, - [activeRunUserId, runId], + [persistRunConversationSnapshot], ) useEffect(() => { @@ -3509,9 +3816,7 @@ function QuickStartRun({ ) agentConversationTurnsRef.current = next setAgentConversationTurns(next) - writeAgentConversation('localStorage', agentRunConversationStorageKey(activeRunUserId, runId), { - turns: next, - }) + persistRunConversationSnapshot(next) } async function confirmAddActionProposal(proposalId: string) { diff --git a/openapi.json b/openapi.json index 5f84a030..e91dd95c 100644 --- a/openapi.json +++ b/openapi.json @@ -70,6 +70,108 @@ "title": "AdminAccessOut", "type": "object" }, + "AgentConversationOut": { + "description": "Quick Start Agent 对话快照响应。", + "properties": { + "run_id": { + "title": "Run Id", + "type": "integer" + }, + "schema_version": { + "title": "Schema Version", + "type": "integer" + }, + "turns": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Turns", + "type": "array" + }, + "updated_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Updated At" + }, + "version": { + "title": "Version", + "type": "integer" + } + }, + "required": [ + "run_id", + "turns", + "schema_version", + "version", + "updated_at" + ], + "title": "AgentConversationOut", + "type": "object" + }, + "AgentConversationTurn": { + "additionalProperties": true, + "description": "服务端只约束稳定外壳,proposal 等扩展字段保持前端原样。", + "properties": { + "content": { + "maxLength": 8000, + "minLength": 1, + "title": "Content", + "type": "string" + }, + "role": { + "enum": [ + "user", + "assistant" + ], + "title": "Role", + "type": "string" + } + }, + "required": [ + "role", + "content" + ], + "title": "AgentConversationTurn", + "type": "object" + }, + "AgentConversationUpdate": { + "description": "完整替换一条运行记录的 Agent 对话。", + "properties": { + "schema_version": { + "const": 2, + "default": 2, + "title": "Schema Version", + "type": "integer" + }, + "turns": { + "items": { + "$ref": "#/components/schemas/AgentConversationTurn" + }, + "maxItems": 256, + "title": "Turns", + "type": "array" + }, + "version": { + "minimum": 0.0, + "title": "Version", + "type": "integer" + } + }, + "required": [ + "version", + "turns" + ], + "title": "AgentConversationUpdate", + "type": "object" + }, "ArtStyle": { "description": "项目的美术风格。\n\n只有 ``PIXEL`` 改变管线行为(出帧吸附母版像素网格、颜色吸回母版色板),其余取值\n只进提示词。像素网格密度不在这一维 —— 它由目标分辨率定,两处各定一遍会打架。", "enum": [ @@ -2871,6 +2973,48 @@ "title": "Response[AdminAccessOut]", "type": "object" }, + "Response_AgentConversationOut_": { + "properties": { + "code": { + "default": 200, + "description": "业务状态码:成功 200,失败非 200", + "title": "Code", + "type": "integer" + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/AgentConversationOut" + }, + { + "type": "null" + } + ], + "description": "业务数据" + }, + "message": { + "default": "success", + "description": "提示信息", + "title": "Message", + "type": "string" + }, + "timestamp": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "响应时间;默认不携带,不携带时省略", + "title": "Timestamp" + } + }, + "title": "Response[AgentConversationOut]", + "type": "object" + }, "Response_CharacterOut_": { "properties": { "code": { @@ -6658,6 +6802,100 @@ "workflow-run" ] } + }, + "/workflow-runs/{run_id}/agent-conversation": { + "get": { + "description": "读取运行记录的 Agent 对话;尚未保存时返回空快照。", + "operationId": "get_agent_conversation_workflow_runs__run_id__agent_conversation_get", + "parameters": [ + { + "in": "path", + "name": "run_id", + "required": true, + "schema": { + "title": "Run Id", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response_AgentConversationOut_" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Agent Conversation", + "tags": [ + "workflow-run" + ] + }, + "put": { + "description": "以独立乐观锁保存完整 Agent 对话,不修改 WorkflowRun 版本。", + "operationId": "save_agent_conversation_workflow_runs__run_id__agent_conversation_put", + "parameters": [ + { + "in": "path", + "name": "run_id", + "required": true, + "schema": { + "title": "Run Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentConversationUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response_AgentConversationOut_" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Save Agent Conversation", + "tags": [ + "workflow-run" + ] + } } } }