Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/packages/app/src/windup_app/bootstrap/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Quick Start Agent 对话持久化。"""

from .model import QuickStartAgentConversation

__all__ = ["QuickStartAgentConversation"]
Original file line number Diff line number Diff line change
@@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

但关键是,有这个会话记录存在对应的表单,后续你要如何治理。你想清楚一点。比如用户想要在几天前的对话记录在新加记录,你要如何加?

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),
)
Original file line number Diff line number Diff line change
@@ -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()
117 changes: 116 additions & 1 deletion backend/packages/app/src/windup_app/web/api/workflow_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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} 软删除

Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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,
)


# ── 归属校验 ─────────────────────────────────────────────────────────────────


Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -127,6 +128,7 @@ def engine():
User.__table__,
Character.__table__,
WorkflowRun.__table__,
QuickStartAgentConversation.__table__,
CreditAccount.__table__,
CreditRedemptionCode.__table__,
CreditTransaction.__table__,
Expand Down
34 changes: 34 additions & 0 deletions backend/tests/test_quick_start_conversation_service.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading