diff --git a/backend/app/api/conversations.py b/backend/app/api/conversations.py new file mode 100644 index 00000000..a64e1be2 --- /dev/null +++ b/backend/app/api/conversations.py @@ -0,0 +1,346 @@ +"""会话:把一串问答串成一次对话。 + +在此之前"对话"只是前端内存里的一个数组——刷新页面就没了,而每一轮又各自 +从零建图,所以"上一轮问过什么"在系统里无处可查。这个模块把它落到库里, +同时服务两件事:给用户看的历史列表,和给模型看的上下文(`_history_text` +被 copilot 拿去拼进 prompt)。 + +轮次的写入由前端驱动,分两步:开轮(只有问题)→ 回填(图、run、答案)。 +让 copilot 或 runner 自己去写会更"自动",但建图失败、运行失败、用户中途 +取消这三种半途而废的情况它们各自只知道一半,最后还是要在三个地方各接一次。 +前端本来就完整地知道这一轮走到哪了。 +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.base import get_session, utcnow +from app.db.models import Conversation, ConversationTurn + +router = APIRouter(prefix="/api/conversations", tags=["conversations"]) + +#: 喂给模型的历史轮数上限。再多意义不大,而 prompt 是要花钱的 +HISTORY_TURNS = 6 +#: 单轮问答在上下文里的截断长度。一次查询结果可能有几千字,整段塞进去 +#: 会把后面几轮挤掉——那还不如少说点、多留几轮 +HISTORY_ANSWER_CHARS = 600 + + +def title_from(question: str, limit: int = 24) -> str: + """用第一问当标题。没有标题的会话在列表里是一排"新对话",等于没有列表。""" + text = " ".join((question or "").split()) + if not text: + return "新对话" + return text if len(text) <= limit else text[:limit] + "…" + + +# -------------------------------------------------------------------------- +# 出入参 +# -------------------------------------------------------------------------- + + +class ConversationIn(BaseModel): + kind: str = Field(default="chat", pattern="^(chat|canvas)$") + workflow_id: str | None = None + title: str = "" + + +class ConversationPatch(BaseModel): + title: str | None = None + archived: bool | None = None + + +class TurnIn(BaseModel): + question: str = "" + + +class TurnPatch(BaseModel): + """回填。全是可选——一轮可能在任何一步停下,能填多少填多少。""" + + question: str | None = None + answer: str | None = None + explanation: str | None = None + graph: dict[str, Any] | None = None + run_id: str | None = None + status: str | None = Field(default=None, pattern="^(running|done|error)$") + error: str | None = None + + +class TurnOut(BaseModel): + id: str + seq: int + question: str + answer: str + explanation: str + graph: dict[str, Any] | None = None + run_id: str | None = None + status: str + error: str + created_at: Any = None + + model_config = {"from_attributes": True} + + +class ConversationOut(BaseModel): + id: str + title: str + kind: str + workflow_id: str | None = None + archived: bool + created_at: Any = None + last_active_at: Any = None + turn_count: int = 0 + #: 列表里给个副标题,让人一眼认出是哪次聊天 + last_question: str = "" + + model_config = {"from_attributes": True} + + +class ConversationDetail(ConversationOut): + turns: list[TurnOut] = Field(default_factory=list) + + # from_attributes 不能直接吃 Conversation:pydantic 会去读同名的 turns + # 关系属性,而那是个未加载的 lazy relationship,在异步上下文里一读就是 + # MissingGreenlet。turns 一律显式传进来 + model_config = {"from_attributes": False} + + +def _detail(row: Conversation, turns: list[ConversationTurn]) -> ConversationDetail: + base = ConversationOut.model_validate(row) + return ConversationDetail( + **base.model_dump(), + turns=[TurnOut.model_validate(t) for t in turns], + ) + + +# -------------------------------------------------------------------------- +# 会话 +# -------------------------------------------------------------------------- + + +async def _get(session: AsyncSession, conversation_id: str) -> Conversation: + row = await session.get(Conversation, conversation_id) + if not row: + raise HTTPException(404, f"会话 {conversation_id} 不存在") + return row + + +@router.get("", response_model=list[ConversationOut]) +async def list_conversations( + kind: str = "chat", + workflow_id: str | None = None, + include_archived: bool = False, + session: AsyncSession = Depends(get_session), +) -> list[ConversationOut]: + query = select(Conversation).where(Conversation.kind == kind) + if workflow_id: + query = query.where(Conversation.workflow_id == workflow_id) + if not include_archived: + query = query.where(Conversation.archived.is_(False)) + rows = list((await session.execute( + query.order_by(Conversation.last_active_at.desc()) + )).scalars()) + if not rows: + return [] + + # 轮数和最后一问一次查完。每条会话各查一次的话,列表长了就是几十次往返 + ids = [r.id for r in rows] + counts = dict((await session.execute( + select(ConversationTurn.conversation_id, func.count(ConversationTurn.id)) + .where(ConversationTurn.conversation_id.in_(ids)) + .group_by(ConversationTurn.conversation_id) + )).all()) + last_seq = dict((await session.execute( + select(ConversationTurn.conversation_id, func.max(ConversationTurn.seq)) + .where(ConversationTurn.conversation_id.in_(ids)) + .group_by(ConversationTurn.conversation_id) + )).all()) + last_questions: dict[str, str] = {} + if last_seq: + pairs = list(last_seq.items()) + rows_q = list((await session.execute( + select(ConversationTurn.conversation_id, ConversationTurn.seq, ConversationTurn.question) + .where(ConversationTurn.conversation_id.in_([c for c, _ in pairs])) + )).all()) + for conv_id, seq, question in rows_q: + if last_seq.get(conv_id) == seq: + last_questions[conv_id] = question or "" + + out: list[ConversationOut] = [] + for row in rows: + item = ConversationOut.model_validate(row) + item.turn_count = int(counts.get(row.id, 0)) + item.last_question = last_questions.get(row.id, "") + out.append(item) + return out + + +@router.post("", response_model=ConversationDetail, status_code=201) +async def create_conversation( + payload: ConversationIn, session: AsyncSession = Depends(get_session) +) -> ConversationDetail: + row = Conversation( + kind=payload.kind, + workflow_id=payload.workflow_id, + title=payload.title or "新对话", + ) + session.add(row) + await session.commit() + await session.refresh(row) + return _detail(row, []) + + +@router.get("/{conversation_id}", response_model=ConversationDetail) +async def get_conversation( + conversation_id: str, session: AsyncSession = Depends(get_session) +) -> ConversationDetail: + row = await _get(session, conversation_id) + turns = list((await session.execute( + select(ConversationTurn) + .where(ConversationTurn.conversation_id == conversation_id) + .order_by(ConversationTurn.seq) + )).scalars()) + out = _detail(row, turns) + out.turn_count = len(turns) + out.last_question = turns[-1].question if turns else "" + return out + + +@router.patch("/{conversation_id}", response_model=ConversationOut) +async def patch_conversation( + conversation_id: str, + payload: ConversationPatch, + session: AsyncSession = Depends(get_session), +) -> ConversationOut: + row = await _get(session, conversation_id) + if payload.title is not None: + row.title = payload.title.strip() or row.title + if payload.archived is not None: + row.archived = payload.archived + await session.commit() + await session.refresh(row) + return ConversationOut.model_validate(row) + + +@router.delete("/{conversation_id}", status_code=204) +async def delete_conversation( + conversation_id: str, session: AsyncSession = Depends(get_session) +) -> None: + row = await _get(session, conversation_id) + # 显式删 turns 而不是只靠 ondelete=CASCADE:SQLite 的外键级联要 PRAGMA + # 打开才生效(base.py 里是打开的),但这条路径不该依赖一个连接级的开关 + await session.execute( + delete(ConversationTurn).where(ConversationTurn.conversation_id == conversation_id) + ) + await session.delete(row) + await session.commit() + + +# -------------------------------------------------------------------------- +# 轮次 +# -------------------------------------------------------------------------- + + +@router.post("/{conversation_id}/turns", response_model=TurnOut, status_code=201) +async def create_turn( + conversation_id: str, + payload: TurnIn, + session: AsyncSession = Depends(get_session), +) -> TurnOut: + conversation = await _get(session, conversation_id) + next_seq = int((await session.execute( + select(func.coalesce(func.max(ConversationTurn.seq), -1)) + .where(ConversationTurn.conversation_id == conversation_id) + )).scalar_one()) + 1 + + turn = ConversationTurn( + conversation_id=conversation_id, seq=next_seq, + question=payload.question, status="running", + ) + session.add(turn) + + conversation.last_active_at = utcnow() + # 第一轮的问题就是这次对话的标题。用户改过名就不要再覆盖回去 + if next_seq == 0 and conversation.title in ("", "新对话"): + conversation.title = title_from(payload.question) + + await session.commit() + await session.refresh(turn) + return TurnOut.model_validate(turn) + + +@router.patch("/{conversation_id}/turns/{turn_id}", response_model=TurnOut) +async def patch_turn( + conversation_id: str, + turn_id: str, + payload: TurnPatch, + session: AsyncSession = Depends(get_session), +) -> TurnOut: + turn = await session.get(ConversationTurn, turn_id) + if not turn or turn.conversation_id != conversation_id: + raise HTTPException(404, f"轮次 {turn_id} 不存在") + + for field in ("question", "answer", "explanation", "graph", "run_id", "status", "error"): + value = getattr(payload, field) + if value is not None: + setattr(turn, field, value) + + conversation = await session.get(Conversation, conversation_id) + if conversation: + conversation.last_active_at = utcnow() + await session.commit() + await session.refresh(turn) + return TurnOut.model_validate(turn) + + +# -------------------------------------------------------------------------- +# 给模型看的那一份 +# -------------------------------------------------------------------------- + + +def _answer_of(turn: ConversationTurn) -> str: + """这一轮的结果。问数据页是 answer,画布那条路径上只有 explanation。""" + text = (turn.answer or turn.explanation or "").strip() + if turn.status == "error" and turn.error: + return f"(这一轮失败了:{turn.error[:200]})" + if len(text) > HISTORY_ANSWER_CHARS: + return text[:HISTORY_ANSWER_CHARS] + "…(略)" + return text + + +async def recent_turns( + session: AsyncSession, conversation_id: str, limit: int = HISTORY_TURNS +) -> list[ConversationTurn]: + """最近 limit 轮,按时间正序。当前这一轮(还没有答案)不算。""" + rows = list((await session.execute( + select(ConversationTurn) + .where(ConversationTurn.conversation_id == conversation_id) + .where(ConversationTurn.status != "running") + .order_by(ConversationTurn.seq.desc()) + .limit(limit) + )).scalars()) + return list(reversed(rows)) + + +async def history_text( + session: AsyncSession, conversation_id: str | None, limit: int = HISTORY_TURNS +) -> str: + """纯文本的问答历史,给运行时的图用(`{{ input.history }}`)。 + + 管的是指代:用户问"它们平均多少",图里的 agent 得知道"它们"是上一轮 + 那 12 家店。Copilot 侧的上下文解决不了这个——那只影响图长什么样, + 不影响图跑起来的时候模型看到什么。 + """ + if not conversation_id: + return "" + turns = await recent_turns(session, conversation_id, limit) + if not turns: + return "" + lines = [f"问:{t.question}\n答:{_answer_of(t)}" for t in turns if t.question] + return "\n\n".join(lines) diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index 2d41a96c..66031ae6 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -200,6 +200,8 @@ class GenerateIn(BaseModel): model: str | None = None #: build = 用户在描述一个流程(画布);answer = 用户在问一个问题(问数据页) intent: str = Field(default="build") + #: 属于哪次对话。带上它,这一轮才知道前面聊过什么 + conversation_id: str | None = None def _user_message(payload: GenerateIn, *, patch: bool) -> str: @@ -232,11 +234,78 @@ def _user_message(payload: GenerateIn, *, patch: bool) -> str: "入口节点**不要声明任何字段**,也不会有人再填一次表单\n" "- 涉及数据的问题必须用 db_query__* / db_schema__* 去真查," "不要只对问题本身做文字加工(改写、摘要、分类都不是回答)\n" - "- 出口节点给出的应当是这个问题的答案本身" + "- 出口节点给出的应当是这个问题的答案本身\n" + # 只把历史放进 prompt 是不够的:模型拿到上一轮的图,默认仍然会 + # 重新设计一张"更完整"的。而用户说"再按月份拆一下"时要的是上一张 + # 图改个 SQL——重建出来的那张经常接到另一张表上,答案对不上前一轮。 + # + # "完整输出"这半句是必须的。只说"在它基础上改",模型会照着改图的 + # 协议只发 update_node——而这条路径的累加器是从空图起步的,那些 + # 操作全都落在不存在的节点上,最后得到一张空图,用户看到的是 + # "图校验未通过:图是空的,先拖一个节点进来"。真踩过。 + "- 如果上面给了上一轮的工作流,就沿用它的节点 id 和 config(数据源、" + "工具、表都已经对好了),只改需要改的地方(通常是 SQL 或提示词)\n" + "- 但**每个节点都要用 add_node 完整输出一遍**,包括没有改动的。" + "这一轮是从空图开始搭的,只发改动操作会得到一张空图\n" + "- 之前几轮的问答由系统注入成 `{{ input.history }}`。用户用「它们」「这些」" + "这类指代时,要让回答的节点引用它,否则模型不知道指的是上一轮那批数据" ) return f"请设计一个工作流:{payload.instruction}" +async def _previous_graph( + session: AsyncSession, conversation_id: str | None +) -> dict[str, Any] | None: + """这次对话上一轮建出来的图。没有就是 None。""" + from app.api.conversations import recent_turns + + if not conversation_id: + return None + turns = await recent_turns(session, conversation_id) + return next((t.graph for t in reversed(turns) if t.graph), None) + + +async def _history_section(session: AsyncSession, conversation_id: str | None) -> str: + """把之前几轮拼成给模型的上下文。没有会话或没有历史时返回空串。 + + 带上一轮的图是关键的那一半。只给问答文字的话,"再按月份拆一下"会让模型 + 重新设计一张图——它得从头猜该接哪个数据源、哪张表,而这些上一轮已经对好了。 + 实测这种重建经常接到另一张表上,答案对不上前一轮,用户完全无从判断。 + + 图走 _slim 去掉坐标噪音,和 base_graph 那条路径用的是同一份。 + """ + from app.api.conversations import _answer_of, recent_turns + + if not conversation_id: + return "" + turns = await recent_turns(session, conversation_id) + if not turns: + return "" + + lines = [ + f"第 {i} 轮\n 问:{t.question}\n 答:{_answer_of(t) or '(没有结果)'}" + for i, t in enumerate(turns, 1) + if t.question + ] + if not lines: + return "" + + block = "# 这次对话之前聊过的\n" + "\n".join(lines) + + last_graph = next((t.graph for t in reversed(turns) if t.graph), None) + if last_graph: # 和 _previous_graph 取的是同一张,兜底重放靠的就是它 + block += ( + "\n\n上一轮用的工作流(数据源和表都已经对好了):\n" + f"{json.dumps(_slim(last_graph), ensure_ascii=False, indent=2)}" + ) + return block + + +def _with_history(user: str, history: str) -> str: + """历史垫在请求前面。空历史时原样返回,不留一个空标题。""" + return f"{history}\n\n---\n\n{user}" if history else user + + COPILOT_SETTING_KEY = "copilot" @@ -386,7 +455,8 @@ async def generate( except ProviderNotConfigured as e: raise HTTPException(400, str(e)) from e - messages = [("system", system), ("human", user)] + messages = [("system", system), + ("human", _with_history(user, await _history_section(session, payload.conversation_id)))] try: raw = await model.with_structured_output(GRAPH_SCHEMA).ainvoke(messages) if not isinstance(raw, dict): @@ -642,10 +712,14 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g except ProviderNotConfigured as e: raise HTTPException(400, str(e)) from e - messages = [("system", system), ("human", user)] + messages = [("system", system), + ("human", _with_history(user, await _history_section(session, payload.conversation_id)))] + # 兜底用:模型只发了改动操作时,把它们重放到这张图上 + prev_graph = await _previous_graph(session, payload.conversation_id) async def event_stream(): explanation = "" + seen_ops: list[dict[str, Any]] = [] yield f"data: {json.dumps({'op': 'model', 'model': model_id}, ensure_ascii=False)}\n\n" try: async for op in _with_heartbeat(_iter_ops(model, messages)): @@ -656,6 +730,7 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g continue if kind == "done": explanation = str(op.get("explanation", "")) + seen_ops.append(op) changed = _apply_op(nodes, edges, op) if changed or kind in ("plan", "done"): yield f"data: {json.dumps(op, ensure_ascii=False)}\n\n" @@ -663,6 +738,27 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g yield f"data: {json.dumps({'op': 'error', 'message': f'{type(e).__name__}: {e}'}, ensure_ascii=False)}\n\n" return + # 兜底:一个操作都没落地,而上一轮有图。 + # + # 这说明模型把这一轮当成"改上一张图",只发了 update_node/add_edge, + # 而累加器是从空图起步的,那些操作全落在不存在的节点上。上面的 prompt + # 已经要求它完整重发,但那是约定,不是保证——约定落空的代价是用户收到 + # 一句"图是空的,先拖一个节点进来",而他只是问了句追问。 + # + # 与其报错,不如按它本来的意思做:把这些操作重放到上一轮那张图上。 + if not nodes and prev_graph and seen_ops: + for n in prev_graph.get("nodes") or []: + if n.get("id"): + nodes[n["id"]] = n + # 原地改,不能重新绑定:给 edges 赋值会让它变成 event_stream 的局部 + # 变量,而它在上面 _apply_op 那一路已经被读过了 —— UnboundLocalError + edges[:] = [ + {k: v for k, v in e.items() if k in ("source", "target", "sourceHandle") and v} + for e in prev_graph.get("edges") or [] + ] + for op in seen_ops: + _apply_op(nodes, edges, op) + # 收尾:排版 + 校验,把最终图整体交付 try: spec = auto_layout( diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 4d980cf9..4e6d8249 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -3,10 +3,12 @@ from __future__ import annotations from datetime import datetime from typing import Any -from sqlalchemy import Float, ForeignKey, Index, Integer, LargeBinary, String, Text, UniqueConstraint +from sqlalchemy import ( + DateTime, Float, ForeignKey, Index, Integer, LargeBinary, String, Text, UniqueConstraint, +) from sqlalchemy.orm import Mapped, mapped_column, relationship -from app.db.base import Base, TimestampMixin, new_id +from app.db.base import Base, TimestampMixin, new_id, utcnow # -------------------------------------------------------------------------- # 设置:模型供应商 / 用户偏好 / MCP / 自定义工具 @@ -310,3 +312,75 @@ class Skill(Base, TimestampMixin): suggested_tools: Mapped[list[Any]] = mapped_column(default=list) tags: Mapped[list[Any]] = mapped_column(default=list) enabled: Mapped[bool] = mapped_column(default=True) + + +# -------------------------------------------------------------------------- +# 会话:把一串问答串成一次对话 +# -------------------------------------------------------------------------- + + +class Conversation(Base, TimestampMixin): + """一次对话。 + + 在此之前"对话"只是前端内存里的一个数组:刷新页面就没了,而每一轮又各自 + 从零建图、各起一条 checkpoint 线程,所以"上一轮问过什么"在系统里根本 + 无处可查。追问「再按月份拆一下」会重新找一遍数据源,可能接到别的表。 + + 落成一张表之后,它同时承担两件事:给用户看的历史列表,和给模型看的上下文。 + + kind 分两种,因为这两件事像但不是一回事: + chat —— 问数据页,一轮 = 一次「建图 → 跑图 → 出答案」,进左侧列表 + canvas —— 画布右栏的 Copilot,依附某张图,一轮 = 一次改图,不进列表 + """ + + __tablename__ = "conversations" + + id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id) + title: Mapped[str] = mapped_column(String(200), default="") + kind: Mapped[str] = mapped_column(String(20), default="chat", index=True) + workflow_id: Mapped[str | None] = mapped_column( + ForeignKey("workflows.id", ondelete="CASCADE"), default=None, index=True + ) + archived: Mapped[bool] = mapped_column(default=False) + # 列表按活跃度排。不能用 updated_at —— 改个标题就会把这条顶到最前面, + # 而用户对"最近聊过的"的预期是按说话时间排,不是按编辑时间 + last_active_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, index=True + ) + + turns: Mapped[list["ConversationTurn"]] = relationship( + back_populates="conversation", + cascade="all, delete-orphan", + order_by="ConversationTurn.seq", + ) + + +class ConversationTurn(Base, TimestampMixin): + """一轮问答。 + + run_id 可以为空:建图阶段就失败时压根没有 run,但这一轮仍然发生过, + 用户也该在历史里看到它失败了——只记成功的轮次,历史就成了一份美化过的 + 记录,而人再回来时最想搞清楚的恰恰是上次卡在哪。 + """ + + __tablename__ = "conversation_turns" + __table_args__ = (Index("ix_conversation_turns_conv_seq", "conversation_id", "seq"),) + + id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id) + conversation_id: Mapped[str] = mapped_column( + ForeignKey("conversations.id", ondelete="CASCADE"), index=True + ) + seq: Mapped[int] = mapped_column(Integer, default=0) + question: Mapped[str] = mapped_column(Text, default="") + answer: Mapped[str] = mapped_column(Text, default="") + # Copilot 对这张图的说明。画布那条路径上只有它,没有 answer + explanation: Mapped[str] = mapped_column(Text, default="") + graph: Mapped[dict[str, Any] | None] = mapped_column(default=None) + run_id: Mapped[str | None] = mapped_column( + ForeignKey("runs.id", ondelete="SET NULL"), default=None + ) + # running | done | error + status: Mapped[str] = mapped_column(String(20), default="running") + error: Mapped[str] = mapped_column(Text, default="") + + conversation: Mapped[Conversation] = relationship(back_populates="turns") diff --git a/backend/app/main.py b/backend/app/main.py index 04d08b7e..c2df608e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,7 +8,10 @@ from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse -from app.api import datasources, artifacts, copilot, governance, knowledge, runs, sandbox, settings as settings_api, tools, workflows +from app.api import ( + artifacts, conversations, copilot, datasources, governance, knowledge, runs, + sandbox, settings as settings_api, tools, workflows, +) from app.core.config import settings from app.db.base import init_db from app.engine.runner import run_manager @@ -70,6 +73,7 @@ for router in ( knowledge.skills_router, sandbox.router, copilot.router, + conversations.router, artifacts.router, governance.router, datasources.router, diff --git a/backend/tests/test_conversations.py b/backend/tests/test_conversations.py new file mode 100644 index 00000000..fcc7cfdf --- /dev/null +++ b/backend/tests/test_conversations.py @@ -0,0 +1,374 @@ +"""会话:持久化、列表,以及上下文真的进了 prompt。 + +起因是三条 run(d983c4e6 / 14103db5 / 08c3de1c)在用户看来是一次对话, +系统里却毫无关联:每轮 base_graph=None 从零建图,每轮一条独立的 thread_id, +而前端的 turns 只活在内存里——刷新页面就全没了。 + +这里最要紧的不是 CRUD,是最后那组:**上下文确实到了模型手上**。拼字符串的 +函数单独测通过、但没接到 messages 里,是这类功能最典型的失败方式,而且界面 +上完全看不出来——它只是"答得不太对"。 +""" + +from __future__ import annotations + +import json + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.api.conversations import HISTORY_TURNS, history_text, title_from +from app.db.base import SessionLocal +from app.db.models import Conversation, ConversationTurn +from app.main import app + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +async def _conversation(client, **kw) -> dict: + r = await client.post("/api/conversations", json={"kind": "chat", **kw}) + assert r.status_code == 201, r.text + return r.json() + + +async def _turn(client, conv_id: str, question: str, **patch) -> dict: + r = await client.post(f"/api/conversations/{conv_id}/turns", json={"question": question}) + assert r.status_code == 201, r.text + turn = r.json() + if patch: + r = await client.patch(f"/api/conversations/{conv_id}/turns/{turn['id']}", json=patch) + assert r.status_code == 200, r.text + turn = r.json() + return turn + + +# -------------------------------------------------------------------------- +# 会话本身 +# -------------------------------------------------------------------------- + + +async def test_a_conversation_survives_and_lists(client) -> None: + conv = await _conversation(client) + await _turn(client, conv["id"], "shop 里有几家店", answer="12 家", status="done") + + # 列表里认得出是哪次聊天 + rows = (await client.get("/api/conversations")).json() + mine = next(r for r in rows if r["id"] == conv["id"]) + assert mine["turn_count"] == 1 + assert mine["last_question"] == "shop 里有几家店" + + # 完整取回:这是刷新页面后重建界面的唯一依据 + detail = (await client.get(f"/api/conversations/{conv['id']}")).json() + assert [t["question"] for t in detail["turns"]] == ["shop 里有几家店"] + assert detail["turns"][0]["answer"] == "12 家" + + +async def test_title_comes_from_the_first_question(client) -> None: + """一排"新对话"等于没有列表。""" + conv = await _conversation(client) + assert conv["title"] == "新对话" + await _turn(client, conv["id"], "shop 里有几家店") + assert (await client.get(f"/api/conversations/{conv['id']}")).json()["title"] == "shop 里有几家店" + + # 第二轮不该再改标题 + await _turn(client, conv["id"], "再按月份拆一下") + assert (await client.get(f"/api/conversations/{conv['id']}")).json()["title"] == "shop 里有几家店" + + +async def test_a_renamed_conversation_keeps_its_name(client) -> None: + conv = await _conversation(client) + await client.patch(f"/api/conversations/{conv['id']}", json={"title": "门店盘点"}) + await _turn(client, conv["id"], "shop 里有几家店") + assert (await client.get(f"/api/conversations/{conv['id']}")).json()["title"] == "门店盘点" + + +def test_long_titles_are_cut_not_wrapped() -> None: + assert title_from("短问题") == "短问题" + assert title_from("") == "新对话" + long = title_from("请帮我统计一下过去十二个月每个门店每个月的订单总额并按同比排序") + assert len(long) <= 25 and long.endswith("…") + + +async def test_deleting_a_conversation_takes_its_turns(client) -> None: + conv = await _conversation(client) + turn = await _turn(client, conv["id"], "问点什么") + assert (await client.delete(f"/api/conversations/{conv['id']}")).status_code == 204 + assert (await client.get(f"/api/conversations/{conv['id']}")).status_code == 404 + async with SessionLocal() as session: + assert await session.get(ConversationTurn, turn["id"]) is None + + +async def test_archived_conversations_leave_the_list(client) -> None: + conv = await _conversation(client) + await client.patch(f"/api/conversations/{conv['id']}", json={"archived": True}) + assert conv["id"] not in {r["id"] for r in (await client.get("/api/conversations")).json()} + rows = (await client.get("/api/conversations?include_archived=true")).json() + assert conv["id"] in {r["id"] for r in rows} + + +async def test_canvas_conversations_stay_out_of_the_chat_list(client) -> None: + """画布的对话依附工作流,和问数据页不是一回事,混在一起反而难找。""" + canvas = await _conversation(client, kind="canvas") + assert canvas["id"] not in {r["id"] for r in (await client.get("/api/conversations")).json()} + rows = (await client.get("/api/conversations?kind=canvas")).json() + assert canvas["id"] in {r["id"] for r in rows} + + +async def test_a_failed_turn_is_still_recorded(client) -> None: + """只记成功的轮次,历史就成了一份美化过的记录——而人回来最想搞清楚的 + 恰恰是上次卡在哪。""" + conv = await _conversation(client) + await _turn(client, conv["id"], "查点什么", status="error", error="模型不存在") + detail = (await client.get(f"/api/conversations/{conv['id']}")).json() + assert detail["turns"][0]["status"] == "error" + assert "模型不存在" in detail["turns"][0]["error"] + + +# -------------------------------------------------------------------------- +# 给模型看的那一份 +# -------------------------------------------------------------------------- + + +GRAPH = { + "nodes": [ + {"id": "ask", "type": "input", "position": {"x": 1, "y": 2}, "data": {"config": {}}}, + {"id": "shop_agent", "type": "agent", "position": {"x": 9, "y": 9}, + "data": {"label": "查门店", "config": {"tools": ["db_query__shop"]}}}, + ], + "edges": [{"source": "ask", "target": "shop_agent"}], +} + + +async def _seeded(client) -> str: + conv = await _conversation(client) + await _turn(client, conv["id"], "shop 里有几家店", + answer="一共 12 家", graph=GRAPH, status="done") + return conv["id"] + + +async def test_history_text_is_plain_qa(client) -> None: + conv_id = await _seeded(client) + async with SessionLocal() as session: + text = await history_text(session, conv_id) + assert "shop 里有几家店" in text and "一共 12 家" in text + + +async def test_history_text_is_empty_without_a_conversation(client) -> None: + async with SessionLocal() as session: + assert await history_text(session, None) == "" + assert await history_text(session, (await _conversation(client))["id"]) == "" + + +async def test_the_turn_in_flight_is_not_its_own_history(client) -> None: + """当前这一轮还没有答案,把它算进上下文只会得到一句自己的问题。""" + conv = await _conversation(client) + await _turn(client, conv["id"], "正在问的这句") # 保持 running + async with SessionLocal() as session: + assert await history_text(session, conv["id"]) == "" + + +async def test_history_keeps_only_the_recent_turns(client) -> None: + conv = await _conversation(client) + for i in range(HISTORY_TURNS + 3): + await _turn(client, conv["id"], f"第{i}问", answer=f"第{i}答", status="done") + async with SessionLocal() as session: + text = await history_text(session, conv["id"]) + assert "第0问" not in text # 最早的挤出去了 + assert f"第{HISTORY_TURNS + 2}问" in text + assert text.count("问:") == HISTORY_TURNS + + +async def test_a_huge_answer_does_not_crowd_out_the_other_turns(client) -> None: + conv = await _conversation(client) + await _turn(client, conv["id"], "给我全部数据", answer="x" * 50_000, status="done") + await _turn(client, conv["id"], "那总共多少", answer="12", status="done") + async with SessionLocal() as session: + text = await history_text(session, conv["id"]) + assert len(text) < 2000 + assert "那总共多少" in text # 后一轮没被前一轮挤掉 + + +async def test_history_section_carries_the_previous_graph(client) -> None: + """只给问答文字不够:追问会让模型重新设计一张图,可能接到别的表上。""" + from app.api.copilot import _history_section + + conv_id = await _seeded(client) + async with SessionLocal() as session: + block = await _history_section(session, conv_id) + + assert "shop 里有几家店" in block + assert "shop_agent" in block and "db_query__shop" in block + assert '"x"' not in block and '"position"' not in block # _slim 去掉了坐标噪音 + + +# -------------------------------------------------------------------------- +# 上下文真的到了模型手上 +# -------------------------------------------------------------------------- + + +async def test_the_previous_turn_reaches_the_prompt(client, monkeypatch) -> None: + """整件事的关键一环。 + + 拼字符串的函数测通过、却没接进 messages,是这类功能最典型的失败方式: + 界面上完全看不出来,它只是"答得不太对"。所以这里拦在模型入口上, + 看送出去的那条 human message 里到底有没有上一轮。 + """ + import app.api.copilot as copilot + + conv_id = await _seeded(client) + captured: list = [] + + class _Recorder: + def with_structured_output(self, *_a, **_k): + return self + + async def ainvoke(self, messages): + captured.append(messages) + return {"nodes": [], "edges": []} + + async def _model(*_a, **_k): + return _Recorder(), "mock-fast" + + monkeypatch.setattr(copilot, "get_chat_model", _model) + + r = await client.post("/api/copilot/generate", json={ + "instruction": "再按月份拆一下", "intent": "answer", "conversation_id": conv_id, + }) + assert r.status_code == 200, r.text + + human = next(text for role, text in captured[0] if role == "human") + assert "shop 里有几家店" in human # 上一轮问的 + assert "一共 12 家" in human # 上一轮答的 + assert "shop_agent" in human # 上一轮的图 + assert "再按月份拆一下" in human # 这一轮问的 + + +async def test_no_conversation_means_no_history_header(client, monkeypatch) -> None: + """不带 conversation_id 时不能凭空多出一个空的历史标题。""" + import app.api.copilot as copilot + + captured: list = [] + + class _Recorder: + def with_structured_output(self, *_a, **_k): + return self + + async def ainvoke(self, messages): + captured.append(messages) + return {"nodes": [], "edges": []} + + async def _model(*_a, **_k): + return _Recorder(), "mock-fast" + + monkeypatch.setattr(copilot, "get_chat_model", _model) + + r = await client.post("/api/copilot/generate", json={ + "instruction": "查一下门店", "intent": "answer", + }) + assert r.status_code == 200, r.text + human = next(text for role, text in captured[0] if role == "human") + assert "之前聊过的" not in human + assert human.startswith("用户问了下面这个问题") + + +# -------------------------------------------------------------------------- +# 追问不该把图搞丢 +# -------------------------------------------------------------------------- + + +class _Streamer: + """按脚本吐操作行的假模型。""" + + def __init__(self, lines: list[str]) -> None: + self._lines = lines + + async def astream(self, _messages): + from langchain_core.messages import AIMessageChunk + + for line in self._lines: + yield AIMessageChunk(content=line + "\n") + + +async def _stream_graph(client, monkeypatch, conv_id: str, lines: list[str]) -> dict: + """跑一次 generate-stream,返回最后那条 final/error。""" + import app.api.copilot as copilot + + async def _model(*_a, **_k): + return _Streamer(lines), "mock-fast" + + monkeypatch.setattr(copilot, "get_chat_model", _model) + + async with client.stream("POST", "/api/copilot/generate-stream", json={ + "instruction": "哪六个?哪五个?", "intent": "answer", "conversation_id": conv_id, + }) as r: + last = {} + async for line in r.aiter_lines(): + if line.startswith("data: "): + last = json.loads(line[6:]) + return last + + +async def test_a_follow_up_that_only_sends_edits_still_produces_a_graph( + client, monkeypatch, +) -> None: + """真踩过的那条:f5db9809 的第二轮。 + + 上下文注入是对的——模型的说明写着"在上一轮已接好 Shop 数据源的 agent + 节点上更新提示词",它完全看懂了。但它照着改图的协议只发了 update_node, + 而这条路径的累加器从空图起步,于是那些操作全落空,用户收到的是 + "图校验未通过:图是空的,先拖一个节点进来"——他只是问了句追问。 + """ + conv_id = await _seeded(client) + final = await _stream_graph(client, monkeypatch, conv_id, [ + '{"op":"plan","summary":"在上一轮的 agent 上改提示词"}', + '{"op":"update_node","id":"shop_agent","config":{"prompt":"列出具体名单"}}', + '{"op":"done","explanation":"沿用上一轮的图,只改了提示词"}', + ]) + + assert final.get("op") == "final", final + ids = [n["id"] for n in final["graph"]["nodes"]] + assert ids, "重放到上一轮那张图上之后不该还是空的" + assert "shop_agent" in ids + # 改动要真的落上去,不是原样把旧图还回来 + agent = next(n for n in final["graph"]["nodes"] if n["id"] == "shop_agent") + assert agent["data"]["config"]["prompt"] == "列出具体名单" + + +async def test_a_full_rebuild_does_not_drag_the_old_nodes_along( + client, monkeypatch, +) -> None: + """模型照约定完整重发时,兜底不能画蛇添足。 + + 否则上一轮的 shop_agent 会和新节点一起留在图里,变成一个没人连的孤儿。 + """ + conv_id = await _seeded(client) + final = await _stream_graph(client, monkeypatch, conv_id, [ + '{"op":"plan","summary":"重新搭"}', + '{"op":"add_node","node":{"id":"in","type":"input","label":"入口","config":{}}}', + '{"op":"add_node","node":{"id":"fresh_agent","type":"agent","label":"查","config":{}}}', + '{"op":"add_edge","edge":{"source":"in","target":"fresh_agent"}}', + '{"op":"add_node","node":{"id":"out","type":"output","label":"成果","config":{}}}', + '{"op":"add_edge","edge":{"source":"fresh_agent","target":"out"}}', + '{"op":"done","explanation":"新图"}', + ]) + + assert final.get("op") == "final", final + ids = {n["id"] for n in final["graph"]["nodes"]} + assert ids == {"in", "fresh_agent", "out"} + assert "shop_agent" not in ids + + +async def test_the_first_turn_has_nothing_to_fall_back_on(client, monkeypatch) -> None: + """没有上一轮时,空图就是空图——不能凭空变出一张来掩盖模型什么都没给。""" + conv = await _conversation(client) + final = await _stream_graph(client, monkeypatch, conv["id"], [ + '{"op":"plan","summary":"想想"}', + '{"op":"done","explanation":"什么都没搭"}', + ]) + assert final["op"] == "final" + assert final["graph"]["nodes"] == [] + assert any("空" in str(i.get("message", "")) for i in final["issues"]), final["issues"] diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1a47db5f..6b86f49a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,6 +1,7 @@ import type { - Approval, GraphSpec, KbDocument, MemoryItem, Provider, Run, RunEvent, - Skill, ToolInfo, ValidationIssue, VarIssue, Variable, Workflow, + Approval, Conversation, ConversationDetail, ConversationTurn, GraphSpec, + KbDocument, MemoryItem, Provider, Run, RunEvent, Skill, ToolInfo, + ValidationIssue, VarIssue, Variable, Workflow, } from '../types' const BASE = '/api' @@ -206,9 +207,29 @@ export const api = { }, artifact: (id: string) => get<{ id: string; content: any }>(`/artifacts/${id}`), + // ---- 会话 ---- + conversations: { + list: (kind: 'chat' | 'canvas' = 'chat', workflowId?: string) => + get( + `/conversations?kind=${kind}` + (workflowId ? `&workflow_id=${workflowId}` : '')), + create: (body: { kind?: 'chat' | 'canvas'; workflow_id?: string; title?: string }) => + post('/conversations', body), + get: (id: string) => get(`/conversations/${id}`), + update: (id: string, body: { title?: string; archived?: boolean }) => + patch(`/conversations/${id}`, body), + remove: (id: string) => del(`/conversations/${id}`), + startTurn: (id: string, question: string) => + post(`/conversations/${id}/turns`, { question }), + patchTurn: (id: string, turnId: string, body: Partial) => + patch(`/conversations/${id}/turns/${turnId}`, body), + }, + // ---- Copilot ---- copilot: { - generate: (body: { instruction: string; base_graph?: GraphSpec | null }) => + generate: (body: { + instruction: string; base_graph?: GraphSpec | null + conversation_id?: string | null + }) => post<{ graph: GraphSpec; explanation: string; issues: ValidationIssue[] }>( '/copilot/generate', body), explain: (graph: GraphSpec) => post<{ explanation: string }>('/copilot/explain', { graph }), @@ -236,6 +257,8 @@ export function streamCopilot( model?: string | null /** answer = 用户在问问题(问数据页),build = 用户在描述流程(画布)*/ intent?: 'build' | 'answer' + /** 属于哪次对话。带上它,这一轮才知道前面聊过什么 */ + conversation_id?: string | null }, onOp: (op: any) => void, onEnd: (error?: string) => void, diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx index 6f6a51eb..34cf8699 100644 --- a/frontend/src/pages/ChatPage.tsx +++ b/frontend/src/pages/ChatPage.tsx @@ -1,12 +1,14 @@ import { useEffect, useMemo, useState } from 'react' -import { Database, MessageSquare, Send, Square, Trash2 } from 'lucide-react' +import { Database, ListTree, MessageSquare, Send, Square } from 'lucide-react' import { api } from '../api/client' import { ApprovalCard } from '../run/RunPanel' import { AssistantStream, StreamEmpty, type StreamTurn } from '../run/AssistantStream' import { decodeCopilot, decodeRun } from '../run/decode' import { useCatalog } from '../store/catalog' import { useChat, type ChatTurn } from '../store/chat' +import { useConversations } from '../store/conversations' import { useStudio } from '../store/studio' +import { ConversationList } from './ConversationList' import { useToast } from '../components/ui' /** @@ -23,41 +25,56 @@ import { useToast } from '../components/ui' * dense 一个开关。两边各写一套的话,同一次运行在两个页面会讲出不同的故事。 */ export function ChatPage() { - const { turns, busy, ask, stop, clear } = useChat() + const { busy, ask, stop, load, loadSteps, turnsOf } = useChat() + const currentId = useConversations((s) => s.currentId) + const create = useConversations((s) => s.create) + const turns = useChat((s) => (currentId ? s.byConversation[currentId] : undefined)) const [draft, setDraft] = useState('') const sources = useDataSources() const setGraph = useStudio((s) => s.setGraph) const toast = useToast() - const send = (text?: string) => { + // 切到哪个会话就把哪个会话的内容取回来。内存里已经有的不会回源—— + // 那一份带着这次跑出来的完整步骤,回源拿到的只有问题和答案 + useEffect(() => { if (currentId) void load(currentId) }, [currentId, load]) + + const send = async (text?: string) => { const q = (text ?? draft).trim() if (!q || busy) return - ask(q) + // 没有会话就先开一个。第一次进来的人不该先被要求"新建对话" + const id = currentId ?? (await create()) + ask(id, q) setDraft('') } - const streamTurns = useMemo(() => turns.map(toStreamTurn), [turns]) + const streamTurns = useMemo(() => (turns ?? []).map(toStreamTurn), [turns]) return ( -
+
+ +
问数据 说需求,它自己接数据源、建流程、跑完给结论 - - {!!turns.length && ( - - )}
} - approvalsFor={(t) => } + approvalsFor={(t) => ( + x.id === t.id)?.restored + ? () => void loadSteps(currentId, t.id) + : undefined + } + /> + )} onOpenGraph={(graph) => { setGraph(graph) toast('已放到画布,切到「编排」继续改', 'ok') @@ -77,15 +94,16 @@ export function ChatPage() { onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => { // Enter 发送,Shift+Enter 换行——对话框的通用约定 - if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void send() } }} /> {busy ? ( - ) : ( - @@ -94,6 +112,7 @@ export function ChatPage() {
} /> +
) } @@ -126,18 +145,39 @@ function toStreamTurn(turn: ChatTurn): StreamTurn { } } -function Approvals({ turnId, runId }: { turnId: string; runId?: string }) { +/** + * 挂在一轮下面的两样东西:待办的审批,和历史轮次的「看执行过程」。 + * + * 从库里恢复的轮次没有事件流——一次会话几十轮,把每轮的事件都预加载回来 + * 是几十个请求换一堆没人看的步骤。所以默认只显示问题和答案,想看过程再取。 + */ +function Approvals({ turnId, runId, onShowSteps }: { + turnId: string + runId?: string + onShowSteps?: () => void +}) { + const conversationId = useConversations((s) => s.currentId) const approvals = useCatalog((s) => s.approvals) const reattach = useChat((s) => s.reattach) const pending = approvals.filter((a) => a.run_id === runId && a.status === 'pending') - if (!runId || !pending.length) return null + if (!runId) return null return ( -
- {pending.map((a) => ( - reattach(turnId)} /> - ))} -
+ <> + {onShowSteps && ( + + )} + {!!pending.length && ( +
+ {pending.map((a) => ( + { if (conversationId) void reattach(conversationId, turnId) }} /> + ))} +
+ )} + ) } @@ -145,7 +185,7 @@ function Approvals({ turnId, runId }: { turnId: string; runId?: string }) { function StarterHints({ sources, onPick }: { sources: any[] - onPick: (q: string) => void + onPick: (q: string) => void | Promise }) { if (!sources.length) { return ( @@ -176,7 +216,7 @@ function StarterHints({ sources, onPick }: { {suggestions.map((q) => ( ))} diff --git a/frontend/src/pages/ConversationList.tsx b/frontend/src/pages/ConversationList.tsx new file mode 100644 index 00000000..4b1fd102 --- /dev/null +++ b/frontend/src/pages/ConversationList.tsx @@ -0,0 +1,136 @@ +import { useEffect, useRef, useState } from 'react' +import { Check, MessageSquarePlus, PanelLeftClose, PanelLeftOpen, Pencil, Trash2, X } from 'lucide-react' +import clsx from 'clsx' +import { useChat } from '../store/chat' +import { useConversations } from '../store/conversations' + +/** + * 左侧会话列表。 + * + * 之前对话只活在内存里,刷新页面就没了——用户那三次提问在界面上再也找不回来, + * 只剩 runs 表里三条互不相干的记录。这一栏是它们重新变成"一次对话"的入口。 + * + * 只在问数据页出现。画布右栏的 Copilot 也有自己的会话,但那是依附某张图的 + * "改图指令",和这里的"问题"不是一回事,混进同一个列表只会让两种都更难找。 + */ +export function ConversationList() { + const { list, currentId, loading, load, create, select, rename, remove } = useConversations() + const forget = useChat((s) => s.forget) + const busy = useChat((s) => s.busy) + const [collapsed, setCollapsed] = useState(false) + const [editing, setEditing] = useState(null) + + useEffect(() => { void load() }, [load]) + + // 这里**不**自动建空会话。试过,代价是每次进页面都留下一条"新对话": + // create 是异步的,StrictMode 把 effect 跑两遍,两次都看到空列表于是各建 + // 一条,列表很快被空壳淹掉。真正需要会话的时刻是用户开口那一下, + // ChatPage 的 send 会在那时按需建——在此之前空列表配空态提示就是对的。 + + if (collapsed) { + return ( +
+ + +
+ ) + } + + return ( + + ) +} + +function RenameField({ initial, onSubmit, onCancel }: { + initial: string + onSubmit: (title: string) => void + onCancel: () => void +}) { + const [text, setText] = useState(initial) + const ref = useRef(null) + useEffect(() => { ref.current?.select() }, []) + + return ( +
+ setText(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') onSubmit(text) + if (e.key === 'Escape') onCancel() + }} + autoFocus + /> + + +
+ ) +} diff --git a/frontend/src/store/chat.ts b/frontend/src/store/chat.ts index 02e40278..abb5be47 100644 --- a/frontend/src/store/chat.ts +++ b/frontend/src/store/chat.ts @@ -1,6 +1,8 @@ import { create } from 'zustand' import { api, streamCopilot, streamRun } from '../api/client' import type { CopilotOp } from '../run/decode' +import { decodeRun } from '../run/decode' +import { useConversations } from './conversations' import type { GraphSpec, Run, RunEvent } from '../types' /** @@ -16,6 +18,10 @@ import type { GraphSpec, Run, RunEvent } from '../types' * 这里**只存原始流**(Copilot 的操作、运行的事件),不在 store 里翻译。 * 翻译归 run/decode.ts,全站一份;store 里再译一遍的结果是同一次运行在 * 对话页和画布上讲出两个不同的故事,而用户没法判断哪个是真的。 + * + * 轮次按会话分桶存放。之前是单个 turns 数组,于是"对话"只存在于内存里: + * 刷新页面就全没了,而每轮又各自从零建图、互不知情。现在每一轮同时写进 + * 后端的 conversation_turns,刷新能恢复,下一轮也拿得到上一轮的上下文。 */ export type Phase = @@ -29,6 +35,8 @@ export type Phase = export interface ChatTurn { id: string + /** 后端 conversation_turns 里的 id。回填靠它 */ + serverId: string | null question: string phase: Phase /** 一句话进度,给人看"现在到哪了" */ @@ -53,18 +61,29 @@ export interface ChatTurn { /** 这一轮自己的取消句柄。放 turn 上而不是 store 上:store 单值会被 * 下一轮 ask 覆盖,上一轮的 WebSocket 就再也关不掉了 */ cancel: (() => void) | null + /** 从库里恢复的历史轮次。它没有事件流,步骤要点开才去取 */ + restored?: boolean + /** 已经补取过事件(或正在取),别重复拉 */ + stepsLoaded?: boolean } interface ChatState { - turns: ChatTurn[] + /** 按会话分桶。切走再切回来要能看到原来的步骤,所以内存里有就不回源 */ + byConversation: Record busy: boolean cancel: (() => void) | null + loading: boolean - ask: (question: string) => void - stop: () => void - clear: () => void + turnsOf: (conversationId: string | null) => ChatTurn[] + load: (conversationId: string) => Promise + ask: (conversationId: string, question: string) => void + stop: (conversationId: string) => void /** 审批完成后重新接上运行 */ - reattach: (turnId: string) => Promise + reattach: (conversationId: string, turnId: string) => Promise + /** 历史轮次点开"执行过程"时才去取事件 */ + loadSteps: (conversationId: string, turnId: string) => Promise + /** 会话被删了,把内存里那一桶也丢掉 */ + forget: (conversationId: string) => void } let seq = 0 @@ -80,35 +99,136 @@ const PHASE_TEXT: Record = { error: '出错了', } +const EMPTY: ChatTurn[] = [] + type Patch = (fn: (t: ChatTurn) => Partial) => void +/** + * 成果 dict 压成一句话,存进 conversation_turns.answer。 + * + * 它有两个去处:刷新页面后重建界面,以及下一轮拼进 Copilot 的上下文。 + * 后者决定了这里不能只存个 "[object Object]"——模型要靠它知道上一轮 + * 答了什么,才接得住"再按月份拆一下"。 + */ +function answerText(output: Record | null): string { + if (!output) return '' + const parts: string[] = [] + for (const [key, value] of Object.entries(output)) { + if (key.startsWith('_') || value == null || value === '') continue + parts.push(typeof value === 'string' ? value : JSON.stringify(value)) + } + return parts.join('\n').slice(0, 4000) +} + export const useChat = create((set, get) => ({ - turns: [], + byConversation: {}, busy: false, cancel: null, + loading: false, - ask: (question) => { - const id = newId() - const patch: Patch = (fn) => - set((s) => ({ - turns: s.turns.map((t) => (t.id === id ? { ...t, ...fn(t) } : t)), + turnsOf: (conversationId) => + (conversationId && get().byConversation[conversationId]) || EMPTY, + + forget: (conversationId) => + set((s) => { + get().byConversation[conversationId]?.forEach((t) => t.cancel?.()) + const { [conversationId]: _gone, ...rest } = s.byConversation + return { byConversation: rest } + }), + + load: async (conversationId) => { + // 内存里有就用内存里的:那一份带着这次会话跑出来的完整步骤, + // 回源拿到的只有问题和答案。切走再切回来不该丢掉过程 + if (get().byConversation[conversationId]) return + set({ loading: true }) + try { + const detail = await api.conversations.get(conversationId) + const turns: ChatTurn[] = detail.turns.map((t) => ({ + id: t.id, + serverId: t.id, + question: t.question, + phase: t.status === 'error' ? 'error' : t.status === 'running' ? 'error' : 'done', + status: t.status === 'error' ? PHASE_TEXT.error : PHASE_TEXT.done, + thinking: '', + ops: [], + events: [], + graph: (t.graph as GraphSpec) ?? null, + explanation: t.explanation ?? '', + // 只留 id:usage / run_class 这些恢复时用不上,真要看详情那一栏 + // 自己会去取。给个空壳比不给强——「看执行过程」全靠它找到那次运行 + run: t.run_id ? ({ id: t.run_id } as Run) : null, + output: t.answer ? { answer: t.answer } : null, + // running 说明上次是被强杀/关标签页断在半路的。标成出错而不是留一个 + // 永远转圈的行——它不会再动了,转圈只是在骗人 + error: t.status === 'running' ? '上次没有跑完' : (t.error ?? ''), + startedAt: Date.parse(t.created_at ?? '') || Date.now(), + lastSeq: 0, + cancel: null, + restored: true, + stepsLoaded: false, })) + set((s) => ({ + byConversation: { ...s.byConversation, [conversationId]: turns }, + loading: false, + })) + } catch { + set({ loading: false }) + } + }, + + loadSteps: async (conversationId, turnId) => { + const turn = get().byConversation[conversationId]?.find((t) => t.id === turnId) + if (!turn || turn.stepsLoaded) return + const runId = turn.run?.id + if (!runId) return + const patch = patcher(set, conversationId, turnId) + patch(() => ({ stepsLoaded: true })) + try { + const events = await api.runs.events(runId) + patch(() => ({ events })) + } catch { + patch(() => ({ stepsLoaded: false })) // 取失败就还能再点一次 + } + }, + + ask: (conversationId, question) => { + const id = newId() + const patch = patcher(set, conversationId, id) set((s) => ({ busy: true, - turns: [ - ...s.turns, - { - id, question, phase: 'planning', status: PHASE_TEXT.planning, - thinking: '', ops: [], events: [], graph: null, explanation: '', - run: null, output: null, error: '', startedAt: Date.now(), - lastSeq: 0, cancel: null, - }, - ], + byConversation: { + ...s.byConversation, + [conversationId]: [ + ...(s.byConversation[conversationId] ?? []), + { + id, serverId: null, question, phase: 'planning', status: PHASE_TEXT.planning, + thinking: '', ops: [], events: [], graph: null, explanation: '', + run: null, output: null, error: '', startedAt: Date.now(), + lastSeq: 0, cancel: null, + }, + ], + }, })) + useConversations.getState().touch(conversationId, question) + + // 先开一轮,拿到 serverId 再开始建图。顺序不能反:Copilot 那边要读 + // 这次会话的历史,而"这一轮"必须已经是 running 状态才不会被算进自己的上下文 + const started = api.conversations.startTurn(conversationId, question) + .then((turn) => { patch(() => ({ serverId: turn.id })); return turn.id }) + .catch(() => null) + + const save = async (body: Record) => { + const serverId = await started + if (!serverId) return + try { + await api.conversations.patchTurn(conversationId, serverId, body) + } catch { /* 存不下不该影响正在进行的对话 */ } + } + const cancelCopilot = streamCopilot( - { instruction: question, base_graph: null, intent: 'answer' }, + { instruction: question, base_graph: null, intent: 'answer', conversation_id: conversationId }, (op) => { // 先原样收下,翻译交给 decodeCopilot。thinking 合并在写入时做: // 一次生成几百上千条 delta,逐条存下来光数组就比图大一个量级 @@ -150,12 +270,14 @@ export const useChat = create((set, get) => ({ graph, phase: 'running', status: PHASE_TEXT.running, explanation: op.explanation ?? '', })) - void launch(id, graph, question, patch, set) + void save({ graph, explanation: op.explanation ?? '' }) + void launch(conversationId, id, graph, question, patch, set, save) break } case 'error': patch(() => ({ phase: 'error', error: op.message ?? '生成失败' })) set({ busy: false, cancel: null }) + void save({ status: 'error', error: op.message ?? '生成失败' }) break } }, @@ -163,47 +285,70 @@ export const useChat = create((set, get) => ({ if (!error) return patch((t) => (t.phase === 'error' ? {} : { phase: 'error', error })) set({ busy: false, cancel: null }) + void save({ status: 'error', error }) }, ) patch(() => ({ cancel: cancelCopilot })) set({ cancel: cancelCopilot }) }, - stop: () => { + stop: (conversationId) => { // 只停最后一轮。把所有非终态 turn 一律标成"已取消"会误杀正等人工介入的 // 历史轮次——那些是等着用户回来处理的,不是卡住的。 - const turns = get().turns + const turns = get().byConversation[conversationId] ?? [] const last = turns[turns.length - 1] last?.cancel?.() get().cancel?.() + if (last?.serverId && !['done', 'error', 'waiting'].includes(last.phase)) { + void api.conversations + .patchTurn(conversationId, last.serverId, { status: 'error', error: '已取消' }) + .catch(() => undefined) + } set((s) => ({ busy: false, cancel: null, - turns: s.turns.map((t) => - t.id === last?.id && !['done', 'error', 'waiting'].includes(t.phase) - ? { ...t, phase: 'error', error: '已取消', cancel: null } - : t, - ), + byConversation: { + ...s.byConversation, + [conversationId]: turns.map((t) => + t.id === last?.id && !['done', 'error', 'waiting'].includes(t.phase) + ? { ...t, phase: 'error' as Phase, error: '已取消', cancel: null } + : t, + ), + }, })) }, - clear: () => { - get().cancel?.() - get().turns.forEach((t) => t.cancel?.()) // 每轮各有各的订阅,逐个关 - set({ turns: [], busy: false, cancel: null }) - }, - - reattach: async (turnId) => { - const turn = get().turns.find((t) => t.id === turnId) + reattach: async (conversationId, turnId) => { + const turn = get().byConversation[conversationId]?.find((t) => t.id === turnId) if (!turn?.run) return - const patch: Patch = (fn) => - set((s) => ({ turns: s.turns.map((t) => (t.id === turnId ? { ...t, ...fn(t) } : t)) })) + const patch = patcher(set, conversationId, turnId) + const save = async (body: Record) => { + if (!turn.serverId) return + try { + await api.conversations.patchTurn(conversationId, turn.serverId, body) + } catch { /* 同上 */ } + } patch(() => ({ phase: 'running', status: PHASE_TEXT.running })) set({ busy: true }) - watch(turn.run.id, turnId, patch, set, turn.lastSeq) + watch(turn.run.id, patch, set, save, turn.lastSeq) }, })) +/** 定点更新某个会话里的某一轮。认的是 turn id,所以切走看别的会话也不受影响 */ +function patcher(set: any, conversationId: string, turnId: string): Patch { + return (fn) => + set((s: ChatState) => { + const turns = s.byConversation[conversationId] + if (!turns) return s + return { + byConversation: { + ...s.byConversation, + [conversationId]: turns.map((t) => (t.id === turnId ? { ...t, ...fn(t) } : t)), + }, + } + }) +} + /** * 用户的问题填进这张图的入口字段。 * @@ -213,13 +358,18 @@ export const useChat = create((set, get) => ({ * 错误里提的那个字段名他从来没见过——那是生成的图里的东西。 * * 之前"能跑"纯属运气:Copilot 那一次恰好没声明字段。 + * + * history 是另一件事:图跑起来的时候,模型得知道"它们"指的是上一轮那批数据。 + * Copilot 侧的上下文只影响图长什么样,管不到这个。 */ -function fillInput(graph: GraphSpec, question: string): Record { +export function fillInput( + graph: GraphSpec, question: string, history = '', +): Record { const entry = (graph.nodes ?? []).find((n: any) => n.type === 'input') const fields: any[] = (entry?.data?.config?.fields ?? []) as any[] - if (!fields.length) return { question } // 没声明字段也带上,下游可能用 input.question + if (!fields.length) return { question, history } // 没声明字段也带上,下游可能用 input.question - const payload: Record = {} + const payload: Record = { history } for (const f of fields) { if (f?.name) payload[f.name] = f.default ?? '' } @@ -232,17 +382,34 @@ function fillInput(graph: GraphSpec, question: string): Record { return payload } +/** 把这次会话之前几轮压成纯文本,塞进 input.history */ +function historyOf(conversationId: string, upToTurnId: string): string { + const turns = useChat.getState().byConversation[conversationId] ?? [] + const lines: string[] = [] + for (const t of turns) { + if (t.id === upToTurnId) break + const answer = answerText(t.output) || t.explanation + if (t.question && answer) lines.push(`问:${t.question}\n答:${answer.slice(0, 600)}`) + } + return lines.slice(-6).join('\n\n') +} + /** 图建好了就直接跑——用户要的是答案,不是一张图。 */ async function launch( - turnId: string, graph: GraphSpec, question: string, patch: Patch, set: any, + conversationId: string, turnId: string, graph: GraphSpec, question: string, + patch: Patch, set: any, save: (body: Record) => Promise, ) { try { - const run = await api.runs.start({ graph, input: fillInput(graph, question) }) + const run = await api.runs.start({ + graph, input: fillInput(graph, question, historyOf(conversationId, turnId)), + }) patch(() => ({ run })) - watch(run.id, turnId, patch, set) + void save({ run_id: run.id }) + watch(run.id, patch, set, save) } catch (e: any) { patch(() => ({ phase: 'error', error: e?.message ?? '启动失败' })) set({ busy: false, cancel: null }) + void save({ status: 'error', error: e?.message ?? '启动失败' }) } } @@ -252,7 +419,10 @@ async function launch( * 只做两件事:把事件原样存进这一轮,以及更新那几个驱动 UI 状态机的相位。 * "查了哪张表、跑了什么 SQL"不在这里翻译——decodeRun 会从同一批事件里读出来。 */ -function watch(runId: string, turnId: string, patch: Patch, set: any, after = 0) { +function watch( + runId: string, patch: Patch, set: any, + save: (body: Record) => Promise, after = 0, +) { const stop = streamRun(runId, (event: RunEvent) => { const d: any = event.data ?? {} patch((t) => ({ @@ -280,6 +450,7 @@ function watch(runId: string, turnId: string, patch: Patch, set: any, after = 0) case 'run.failed': patch(() => ({ phase: 'error', error: String(d.error ?? '运行失败') })) set({ busy: false, cancel: null }) + void save({ status: 'error', error: String(d.error ?? '运行失败') }) break case 'run.finished': // 成果在事件里就有,不必再取一次 run;但 run 对象带着 usage 和 @@ -288,6 +459,7 @@ function watch(runId: string, turnId: string, patch: Patch, set: any, after = 0) phase: 'done', status: PHASE_TEXT.done, output: d.output ?? null, })) set({ busy: false, cancel: null }) + void save({ status: 'done', answer: answerText(d.output ?? null) }) void api.runs.get(runId) .then((run) => patch(() => ({ run }))) .catch(() => undefined) @@ -299,3 +471,5 @@ function watch(runId: string, turnId: string, patch: Patch, set: any, after = 0) patch(() => ({ cancel: stop })) set({ cancel: stop }) } + +export { decodeRun } diff --git a/frontend/src/store/conversations.ts b/frontend/src/store/conversations.ts new file mode 100644 index 00000000..a2f22a0a --- /dev/null +++ b/frontend/src/store/conversations.ts @@ -0,0 +1,114 @@ +import { create } from 'zustand' +import { api } from '../api/client' +import type { Conversation } from '../types' + +/** + * 会话列表的状态。 + * + * 只管"有哪些对话、当前在看哪个"。对话里的内容归 chat store —— 两者分开是 + * 因为列表要常驻(切换时不能闪),而内容是按会话缓存的一大坨东西。 + * + * 当前会话 id 存进 localStorage:刷新后回到刚才那个对话,而不是甩回空白页。 + * 这是"对话持久化"里用户最先感知到的一半——另一半(内容本身)在 chat store。 + */ + +const LAST_KEY = 'agentlab.lastConversation' + +interface ConversationState { + list: Conversation[] + currentId: string | null + loading: boolean + + load: () => Promise + create: () => Promise + select: (id: string | null) => void + rename: (id: string, title: string) => Promise + remove: (id: string) => Promise + /** 有人在这个会话里说话了,把它顶到列表最前并刷新副标题 */ + touch: (id: string, lastQuestion?: string) => void +} + +export const useConversations = create((set, get) => ({ + list: [], + currentId: localStorage.getItem(LAST_KEY), + loading: false, + + load: async () => { + set({ loading: true }) + try { + const list = await api.conversations.list('chat') + // 记着的那个可能已经被删了(另一个标签页里删的)。指着一个不存在的 + // 会话会让页面停在空白,这时候退回最近一条,而不是让人对着空屏幕 + const current = get().currentId + const alive = current && list.some((c) => c.id === current) + const next = alive ? current : (list[0]?.id ?? null) + if (next !== current) { + if (next) localStorage.setItem(LAST_KEY, next) + else localStorage.removeItem(LAST_KEY) + } + set({ list, currentId: next, loading: false }) + } catch { + set({ loading: false }) + } + }, + + create: async () => { + // 已经有一条空会话就直接用它。连点几次"新对话"不该留下一串一模一样的 + // 空壳——用户的意思是"我要从头开始说",不是"我要三个空对话" + const blank = get().list.find((c) => c.turn_count === 0) + if (blank) { + get().select(blank.id) + return blank.id + } + const created = await api.conversations.create({ kind: 'chat' }) + localStorage.setItem(LAST_KEY, created.id) + set((s) => ({ list: [created, ...s.list], currentId: created.id })) + return created.id + }, + + select: (id) => { + if (id) localStorage.setItem(LAST_KEY, id) + else localStorage.removeItem(LAST_KEY) + set({ currentId: id }) + }, + + rename: async (id, title) => { + const clean = title.trim() + if (!clean) return + // 先改本地再发请求:改名是个不该等一次往返的动作 + set((s) => ({ list: s.list.map((c) => (c.id === id ? { ...c, title: clean } : c)) })) + try { + await api.conversations.update(id, { title: clean }) + } catch { + void get().load() // 没改成就回源,别让界面上留着一个假名字 + } + }, + + remove: async (id) => { + await api.conversations.remove(id) + set((s) => { + const list = s.list.filter((c) => c.id !== id) + const currentId = s.currentId === id ? (list[0]?.id ?? null) : s.currentId + if (currentId) localStorage.setItem(LAST_KEY, currentId) + else localStorage.removeItem(LAST_KEY) + return { list, currentId } + }) + }, + + touch: (id, lastQuestion) => { + set((s) => { + const hit = s.list.find((c) => c.id === id) + if (!hit) return s + const updated: Conversation = { + ...hit, + last_question: lastQuestion ?? hit.last_question, + // 第一句话会成为标题,本地先顶上,省得列表里那条还写着"新对话" + title: hit.title === '新对话' && lastQuestion + ? (lastQuestion.length > 24 ? lastQuestion.slice(0, 24) + '…' : lastQuestion) + : hit.title, + turn_count: hit.turn_count + (lastQuestion ? 1 : 0), + } + return { list: [updated, ...s.list.filter((c) => c.id !== id)] } + }) + }, +})) diff --git a/frontend/src/store/studio.ts b/frontend/src/store/studio.ts index 77acb98e..5be5fec5 100644 --- a/frontend/src/store/studio.ts +++ b/frontend/src/store/studio.ts @@ -86,6 +86,44 @@ export interface CopilotTurn { // 无上限地攒着只会把内存和滚动条都撑坏。 const COPILOT_TURN_LIMIT = 10 +/** + * 找出(或建出)这张图的 Copilot 会话。 + * + * 画布的对话依附工作流:打开这张图就接着上次聊。之前连说两次"再加一个节点", + * 第二次并不知道第一次说了什么——base_graph 带的是图的**结果**,带不出 + * "你刚才要我干什么",所以模型经常把上一条的意图又做一遍或者做反。 + * + * 不进左侧列表:那边列的是"问题",这里是"改图指令",两种东西混在一个列表里 + * 只会让两种都更难找。 + * + * 没保存过的草稿(workflow 为空)没有会话——它还没有一个能挂历史的身份。 + */ +async function ensureCanvasConversation( + workflowId: string, set: (partial: Partial) => void, +): Promise { + try { + const existing = await api.conversations.list('canvas', workflowId) + const id = existing[0]?.id + ?? (await api.conversations.create({ kind: 'canvas', workflow_id: workflowId })).id + set({ copilotConversationId: id }) + return id + } catch { + return null // 记不下来也不该挡着人改图 + } +} + +/** 把画布上这一轮记进会话,下一条指令才接得上 */ +function recordCanvasTurn( + conversationId: string | null, + instruction: string, + patch: { graph?: any; explanation?: string; status?: 'done' | 'error'; error?: string }, +): void { + if (!conversationId) return + void api.conversations.startTurn(conversationId, instruction) + .then((turn) => api.conversations.patchTurn(conversationId, turn.id, patch)) + .catch(() => undefined) +} + interface StudioState { workflow: Workflow | null nodes: FlowNode[] @@ -105,6 +143,9 @@ interface StudioState { streaming: boolean unsubscribe: (() => void) | null + /** 这张图的 Copilot 会话 id。一张图一条,用来让连续几条改图指令互相知情 */ + copilotConversationId: string | null + copilot: { active: boolean lastOp: string @@ -220,6 +261,7 @@ export const useStudio = create((set, get) => ({ activeEdges: [], streaming: false, unsubscribe: null, + copilotConversationId: null, copilot: { active: false, lastOp: '', explanation: '', error: '', model: '', phase: '', thinking: '', elapsedMs: 0, lastInstruction: '', lastUseBase: false }, copilotNew: [], @@ -234,7 +276,11 @@ export const useStudio = create((set, get) => ({ run: null, events: [], runtime: {}, activeEdges: [], streaming: false, unsubscribe: null, // 换了一张图,之前那些"我让它改成这样"就不再指向眼前这张图了 copilotTurns: [], + // 先清空再去解析:留着上一张图的会话 id,这中间的任何一条指令 + // 都会带着别的图的上下文发出去 + copilotConversationId: null, }) + void ensureCanvasConversation(workflow.id, set) void get().validate() }, @@ -470,6 +516,7 @@ export const useStudio = create((set, get) => ({ instruction, base_graph: useBase && state.nodes.length ? toGraph(state.nodes, state.edges) : null, model: model ?? undefined, + conversation_id: state.copilotConversationId, }, (op) => { const s = get() @@ -576,6 +623,9 @@ export const useStudio = create((set, get) => ({ thinking: '', elapsedMs: 0, error: '', explanation: op.explanation ?? get().copilot.explanation } }) settle({ phase: 'done', explanation: op.explanation ?? get().copilot.explanation }) + recordCanvasTurn(get().copilotConversationId, instruction, { + graph: op.graph, explanation: op.explanation ?? '', status: 'done', + }) void get().validate() setTimeout(() => set({ copilotNew: [] }), 6000) break @@ -585,6 +635,9 @@ export const useStudio = create((set, get) => ({ set({ copilot: { ...get().copilot, active: false, lastOp: '', phase: '', thinking: '', explanation: '', error: op.message ?? '生成失败' } }) settle({ phase: 'error', error: op.message ?? '生成失败' }) + recordCanvasTurn(get().copilotConversationId, instruction, { + status: 'error', error: op.message ?? '生成失败', + }) break } }, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d80ae42e..754f1eb6 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -183,3 +183,40 @@ export interface VarIssue { node_id?: string | null path: string } + +/** + * 一次对话。 + * + * kind 分两种:chat 是问数据页(一轮 = 建图 → 跑图 → 出答案,进左侧列表), + * canvas 是画布右栏的 Copilot(依附某张图,一轮 = 一次改图,不进列表)。 + */ +export interface Conversation { + id: string + title: string + kind: 'chat' | 'canvas' + workflow_id?: string | null + archived: boolean + created_at?: string + last_active_at?: string + turn_count: number + /** 列表里的副标题,让人一眼认出是哪次聊天 */ + last_question: string +} + +export interface ConversationDetail extends Conversation { + turns: ConversationTurn[] +} + +export interface ConversationTurn { + id: string + seq: number + question: string + answer: string + explanation: string + graph?: GraphSpec | null + /** 可能为空:建图阶段就失败时压根没有 run,但这一轮仍然发生过 */ + run_id?: string | null + status: 'running' | 'done' | 'error' + error: string + created_at?: string +} diff --git a/scripts/check-ui.mjs b/scripts/check-ui.mjs index d2a93da0..3488bfcc 100644 --- a/scripts/check-ui.mjs +++ b/scripts/check-ui.mjs @@ -8,6 +8,7 @@ import { chromium } from '../frontend/node_modules/playwright-core/index.mjs' const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273' +const API = process.env.AGENTLAB_API ?? 'http://localhost:8000/api' const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' const SHOTS = process.env.SHOT_DIR ?? '' @@ -199,6 +200,38 @@ console.log('\n=== 问数据 ===') await page.close() } +console.log('\n=== 会话 ===') +{ + // 对话以前只活在内存里,刷新就没了。这一组守的是它真的落了库: + // 列表能列出来、切过去内容跟着变、刷新还在。 + // + // 最后一项守的是一个真踩过的坑:进页面时自动建一条空会话,而 create 是 + // 异步的、StrictMode 又把 effect 跑两遍,于是每次访问都留下两三条"新对话"。 + const before = await (await fetch(`${API}/conversations`)).json() + + const { page, errors } = await visit('/chat') + check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | ')) + check('有"新对话"按钮', await page.getByRole('button', { name: /新对话/ }).first().isVisible()) + + const seeded = before.find((c) => c.turn_count > 0) + if (seeded) { + await page.locator('aside button[title]').filter({ hasText: seeded.title }).first().click() + await page.waitForTimeout(700) + check('切过去能看到那次问的话', (await page.locator('main').innerText()).includes(seeded.title)) + await page.reload({ waitUntil: 'networkidle' }) + await page.waitForTimeout(900) + check('刷新后还在', (await page.locator('main').innerText()).includes(seeded.title)) + } else { + check('(跳过:库里还没有带轮次的会话)', true) + } + + const after = await (await fetch(`${API}/conversations`)).json() + check('逛一圈没有凭空多出空会话', after.length <= before.length, + `${before.length} → ${after.length}`) + await shot(page, 'conversations') + await page.close() +} + console.log('\n=== 三处讲的是同一个故事 ===') { // 同一次运行,运行页详情和 preview 里的 dense 渲染应该给出同一批步骤。