Files
AgentLab/backend/app/api/conversations.py
T
ThearchyHelios dcadc0c48b 对话有上下文了,左边也能选会话
三条 run(d983c4e6 / 14103db5 / 08c3de1c)在用户看来是一次对话,系统里却
毫无关联。查下来不是哪里传漏了,是整层缺失:

- 每轮 base_graph=None 从零建图,Copilot 不知道上一轮问过什么
- 每轮 thread_id = run.id,三条独立的 checkpoint 线程
- 前端 turns 只活在 zustand 里,刷新页面就全没了——那三轮当时在界面上
  已经找不回来,只剩 runs 表里三条互不相干的记录
- 后端 13 张表里没有会话这个概念

加两张表(conversations / conversation_turns)。失败的轮次也记:只记成功的,
历史就成了一份美化过的记录,而人回来最想搞清楚的恰恰是上次卡在哪。

Copilot 的上下文带三样:前几轮问答、上一轮那张图(走 _slim 去掉坐标噪音)。
只给问答文字不够——"再按月份拆一下"会让模型重新设计一张图,实测经常接到另
一张表上,答案对不上前一轮。运行时另外注入 {{ input.history }},管的是指代:
用户问"它们平均多少",图里的 agent 得知道"它们"是上一轮那 12 家店。

左侧会话栏只在问数据页出现,可折叠、可改名删除,当前会话存 localStorage。
轮次按会话分桶缓存——切走再切回来要看得到原来的步骤,回源只拿得到问答。
历史轮次的执行过程不预加载,点「看执行过程」才取事件、走现成的 decodeRun。
画布右栏的 Copilot 依附工作流各有一条会话,连说两次"再加一个节点"能接上了
(base_graph 带的是图的结果,带不出"你刚才要我干什么"),但不进左侧列表。

过程中修掉的两个自己引入的坑:

- 第一版在列表为空时自动建一条会话。create 是异步的、StrictMode 把 effect
  跑两遍,于是每次进页面都留下两三条"新对话"。改成用户开口那一刻按需建
- 加了"在上一轮的图上改"这条指令之后,模型照改图协议只发 update_node,
  而问数据页这条路径的累加器从空图起步,操作全部落空,用户收到的是
  "图校验未通过:图是空的,先拖一个节点进来"(会话 f5db9809 的第二轮,
  而它的 explanation 证明上下文其实完全读懂了)。指令改成沿用 id 和 config
  但完整重发,另加一道兜底:空图且上一轮有图时把操作重放到那张图上

check-ui 多一节「会话」:切换、刷新还在、逛一圈不多出空会话。
2026-09-18 17:58:04 +08:00

347 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""会话:把一串问答串成一次对话。
在此之前"对话"只是前端内存里的一个数组——刷新页面就没了,而每一轮又各自
从零建图,所以"上一轮问过什么"在系统里无处可查。这个模块把它落到库里,
同时服务两件事:给用户看的历史列表,和给模型看的上下文(`_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)