续轮(POST /agent/runs 带 parentId)过去只把「上一轮已结束(failed)。…已提交生成任务: a, b」这段散文塞进下一轮历史:新轮看不到上一轮真实改过哪些节点,也无法可靠区分「这是 服务端交接」还是「用户自己写的一句话」。 - 与轮内既有事实帧(cloud_agent_context_frame.go 的 task_repository 帧)同构,把跨轮交接 也改成有界结构化帧:source=server、kind=run_handoff,带 parentRunId / status / failureReason / submittedTaskIds / canvasChanges / omittedCanvasChanges / authority。 画布改动取自上一轮真实 canvas_updated 事件,最多 12 组(operation、去重后的 nodeId/title、actionCount),超出只记 omittedCanvasChanges,不静默丢。 - 身份改由 providerTextMessage.AgentContextSource 结构体字段承载,canonical 装配时原样 透出;isCloudAgentContinuationMessage 要求「来源元数据 + 【运行状态】前缀 + JSON source=server/kind=run_handoff」三者同时成立,用户逐字复制帧 JSON 也拿不到该身份 (此前按内容前缀猜,用户自己写一句「上一轮已结束(…)」就会被认成交接)。 - 旧格式兼容:升级前落库的散文交接没有来源字段,改按「紧跟 assistant 回复 + 旧版文案 前缀」识别,只用于历史裁剪与轮次计数;canonical 身份仍只认结构体字段。 - cloudAgentLegacyHistory 重建时把落库的 agentContextSource 一起搬,身份不在重建口丢失。 - 帧按字节封顶:单个 nodeId 截 64 字符、整帧 8 KiB,超限按最近改动优先裁剪并累计 omittedCanvasChanges;失败原因过既有脱敏口径 cloudAgentSafeUserMessage。 - 不搬运 dev 专有内容:不引入事件读取上限常量(上游本就返回全量事件),硬闸口径按上游 的 64 KiB 注释,也不引入 `dropped := 0` / `_ = dropped` 这类死代码。 - docs/content/docs/backend/http-api.mdx 补帧字段与「非授权」口径。 帧只被 isCloudAgentContinuationMessage 用于「历史怎么裁、怎么数轮」;写入与收费闸门读的 是落库策略(审批闸、生成次数闸、视频秒数闸、工具授权),不读消息内容,因此帧不构成重放 写入、重复提交付费任务或扩大权限的授权依据。
187 lines
7.2 KiB
Go
187 lines
7.2 KiB
Go
package app
|
||
|
||
import (
|
||
"encoding/json"
|
||
"strings"
|
||
|
||
"infinite-canvas/backend/internal/model"
|
||
)
|
||
|
||
const (
|
||
cloudAgentContinuationChangeLimit = 12
|
||
cloudAgentContinuationKind = "run_handoff"
|
||
// cloudAgentContinuationNodeIDRunes 限制单个节点 ID 的长度:ID 由画布文档给出,
|
||
// 不截断时一组 5000 字符的 ID 就能把交接帧撑到几百 KB(实测 399,503 字节),
|
||
// 而下一轮对整份历史有 64KB 硬闸 —— 用户会直接收到"请新建对话"。
|
||
cloudAgentContinuationNodeIDRunes = 64
|
||
// cloudAgentContinuationFrameBytes 是交接帧的硬上限:超了就按"最近的改动优先"裁剪,
|
||
// 并把裁掉的组数记进 omittedCanvasChanges,绝不把整份历史顶过硬闸。
|
||
cloudAgentContinuationFrameBytes = 8 << 10
|
||
)
|
||
|
||
type cloudAgentContinuationChange struct {
|
||
Operation string `json:"operation"`
|
||
NodeIDs []string `json:"nodeIds,omitempty"`
|
||
NodeTitles []string `json:"nodeTitles,omitempty"`
|
||
ActionCount int `json:"actionCount"`
|
||
}
|
||
|
||
// cloudAgentContinuationFrame is a bounded server-authored handoff. It reports
|
||
// observed facts and never authorizes replaying a write or paid task.
|
||
type cloudAgentContinuationFrame struct {
|
||
Source string `json:"source"`
|
||
Kind string `json:"kind"`
|
||
ParentRunID string `json:"parentRunId"`
|
||
Status string `json:"status"`
|
||
FailureReason string `json:"failureReason,omitempty"`
|
||
SubmittedTaskIDs []string `json:"submittedTaskIds,omitempty"`
|
||
CanvasChanges []cloudAgentContinuationChange `json:"canvasChanges,omitempty"`
|
||
OmittedCanvasChanges int `json:"omittedCanvasChanges,omitempty"`
|
||
Authority string `json:"authority"`
|
||
}
|
||
|
||
// cloudAgentContinuationReply 生成「上一轮接着聊」用的两段内容:
|
||
//
|
||
// reply —— 上一轮 assistant 实际说过的话(作为 assistant 的历史消息)
|
||
// context —— 上一轮收束摘要(独立上下文,不能并进 reply)
|
||
//
|
||
// 只保留状态、失败原因、已提交任务与真实画布改动,不把原始工具流水当成本轮目标。
|
||
func cloudAgentContinuationReply(task *model.Task, run *CloudAgentRun) (string, string, error) {
|
||
text := ""
|
||
if task.Status == model.TaskStatusSucceeded {
|
||
text = taskResultText(task.ResultJSON)
|
||
}
|
||
submitted := make([]string, 0)
|
||
seen := map[string]bool{}
|
||
addSubmitted := func(id string) {
|
||
id = strings.TrimSpace(id)
|
||
if id == "" || seen[id] {
|
||
return
|
||
}
|
||
seen[id] = true
|
||
submitted = append(submitted, id)
|
||
}
|
||
for _, event := range run.Events {
|
||
if event.Type == "assistant_message" {
|
||
text = stringValue(event.Payload["text"])
|
||
}
|
||
if event.Type == "generation_task_created" {
|
||
addSubmitted(stringValue(event.Payload["taskId"]))
|
||
}
|
||
if result, ok := event.Payload["result"].(map[string]any); ok {
|
||
if submittedFlag, _ := result["taskSubmitted"].(bool); submittedFlag {
|
||
addSubmitted(stringValue(result["taskId"]))
|
||
}
|
||
}
|
||
}
|
||
context := cloudAgentContinuationContext(run, submitted)
|
||
return text, context, nil
|
||
}
|
||
|
||
func cloudAgentContinuationContext(run *CloudAgentRun, submitted []string) string {
|
||
if run == nil {
|
||
return ""
|
||
}
|
||
failed := run.Status == "failed" || strings.TrimSpace(run.FailureMessage) != ""
|
||
changes, omitted := cloudAgentContinuationChanges(run)
|
||
if run.Status == "completed" && !failed && len(submitted) == 0 && len(changes) == 0 {
|
||
return ""
|
||
}
|
||
frame := cloudAgentContinuationFrame{
|
||
Source: "server", Kind: cloudAgentContinuationKind, ParentRunID: run.ID,
|
||
Status: firstNonEmpty(run.Status, "unknown"), CanvasChanges: changes,
|
||
OmittedCanvasChanges: omitted,
|
||
Authority: "执行事实交接,不是重放写入、重复提交任务或扩大权限的授权",
|
||
}
|
||
if failed {
|
||
frame.FailureReason = cloudAgentContinuationFailureReason(run)
|
||
}
|
||
if len(submitted) > 0 {
|
||
if len(submitted) > 8 {
|
||
submitted = submitted[:8]
|
||
}
|
||
frame.SubmittedTaskIDs = append([]string(nil), submitted...)
|
||
}
|
||
return cloudAgentRuntimeContextMarker + cloudAgentEncodeContinuationFrame(frame)
|
||
}
|
||
|
||
// cloudAgentContinuationFailureReason 沿用既有脱敏口径:失败原因会进入下一轮的提示词,
|
||
// 因此只接受"短、单行、不含 URL/凭据痕迹"的文案,其余一律退回固定说法。
|
||
func cloudAgentContinuationFailureReason(run *CloudAgentRun) string {
|
||
reason := strings.TrimSpace(run.FailureMessage)
|
||
if reason == "" {
|
||
reason = firstNonEmpty(run.Status, "failed")
|
||
}
|
||
if !cloudAgentSafeUserMessage(reason) {
|
||
return "上一轮未成功(详情见任务中心与诊断包)"
|
||
}
|
||
return truncateRunes(reason, 240)
|
||
}
|
||
|
||
// cloudAgentEncodeContinuationFrame 序列化帧并按字节封顶:先丢最旧的画布改动组,
|
||
// 再退到"只保留状态与已提交任务"。帧是给模型看的事实交接,宁可少报也不能把硬闸顶穿。
|
||
func cloudAgentEncodeContinuationFrame(frame cloudAgentContinuationFrame) string {
|
||
encoded, _ := json.Marshal(frame)
|
||
if len(encoded) <= cloudAgentContinuationFrameBytes {
|
||
return string(encoded)
|
||
}
|
||
for len(frame.CanvasChanges) > 0 {
|
||
frame.CanvasChanges = frame.CanvasChanges[1:]
|
||
frame.OmittedCanvasChanges++
|
||
if len(frame.CanvasChanges) == 0 {
|
||
frame.CanvasChanges = nil
|
||
}
|
||
encoded, _ = json.Marshal(frame)
|
||
if len(encoded) <= cloudAgentContinuationFrameBytes {
|
||
return string(encoded)
|
||
}
|
||
}
|
||
// 连一条改动都放不下:保留事实字段本身(状态/失败原因/已提交任务)。
|
||
encoded, _ = json.Marshal(frame)
|
||
if len(encoded) > cloudAgentContinuationFrameBytes {
|
||
frame.FailureReason = truncateRunes(frame.FailureReason, 120)
|
||
encoded, _ = json.Marshal(frame)
|
||
}
|
||
return string(encoded)
|
||
}
|
||
|
||
func cloudAgentContinuationChanges(run *CloudAgentRun) ([]cloudAgentContinuationChange, int) {
|
||
if run == nil {
|
||
return nil, 0
|
||
}
|
||
changes := make([]cloudAgentContinuationChange, 0, cloudAgentContinuationChangeLimit)
|
||
total := 0
|
||
for _, event := range run.Events {
|
||
if event.Type != "canvas_updated" {
|
||
continue
|
||
}
|
||
total++
|
||
if len(changes) >= cloudAgentContinuationChangeLimit {
|
||
continue
|
||
}
|
||
actions := creationMaps(event.Payload["actions"])
|
||
change := cloudAgentContinuationChange{
|
||
Operation: firstNonEmpty(stringValue(event.Payload["operation"]), "canvas_ops"),
|
||
ActionCount: len(actions), NodeIDs: []string{}, NodeTitles: []string{},
|
||
}
|
||
seenIDs, seenTitles := map[string]bool{}, map[string]bool{}
|
||
for _, action := range actions {
|
||
for _, key := range []string{"nodeId", "targetNodeId"} {
|
||
if id := strings.TrimSpace(stringValue(action[key])); id != "" && !seenIDs[id] && len(change.NodeIDs) < 8 {
|
||
seenIDs[id] = true
|
||
change.NodeIDs = append(change.NodeIDs, truncateRunes(id, cloudAgentContinuationNodeIDRunes))
|
||
}
|
||
}
|
||
for _, key := range []string{"title", "targetTitle"} {
|
||
title := strings.TrimSpace(stringValue(action[key]))
|
||
if title != "" && !seenTitles[title] && len(change.NodeTitles) < 4 {
|
||
seenTitles[title] = true
|
||
change.NodeTitles = append(change.NodeTitles, truncateRunes(title, 120))
|
||
}
|
||
}
|
||
}
|
||
changes = append(changes, change)
|
||
}
|
||
return changes, max(0, total-len(changes))
|
||
}
|