Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_anchor.go
AetherNo2332 080e4b901e feat(cloud-agent): 看图工具 canvas_inspect_image 与同批多图工具结果配对
云端 Agent 过去只能凭标题或提示词猜画面:上游既有的 image_text_detect / image_annotation_render
是"组织 OCR 请求"和"渲染标注图当参考"两种不同能力,模型自己从不看像素。本 PR 新增只读工具
canvas_inspect_image,把画面本身交给模型。

- 交付走与生成任务参考图同一套短时签名链接(providerResourceURL),Agent 上下文只增长一个
  URL,不内联 base64:一张 500 KB 的内联图片会同时顶穿请求体硬限与压缩阈值。
- 能力门控:仅当本轮渠道模型声明 text.references.maxImages > 0 时暴露该工具;VisionEnabled 在
  建 run 时按渠道模型合同定格并持久化(工具授权每步从落库状态重建,客户端传入值被忽略)。
- 上下文治理:图片保留最近 3 个工具轮次,同一张图一轮内最多附图 2 次(之后只回执文字,确需
  重看传 refresh=true),超出保留轮次换成逐图文字占位符并落 context_images_pruned 事件。
- 视觉锚点:模型看过后写进正文的一句观察记入 ReferenceAssets[].VisualNote 并跨轮继承,下一轮
  不必重复看图,也不再对这张图声称"没有视觉识别证据"。
- 同批多图合并成一条 user 消息:图片只能挂 user 消息(tool 角色只接受字符串),逐张 append 会让
  历史退化成 tool → user(图) → tool → user(图),被上游以"tool_calls 之后缺少对应 tool 消息"
  拒绝。改为先缓冲到 PendingImageInspections,等整批 tool 结果入历史后合并成一条;两个 flush 点
  共用同一个幂等助手,ask_user 提前结束路径单独 flush。缓冲必须进检查点:一次推进只执行一个
  调用,整批跨越多次转移,每次转移都从 StateJSON 重新解码。
- 附带把上述 400 从"渠道余额不足"归类里拆出来(原文含 insufficient 会落进额度类目掩盖真因),
  归为"会话里的工具调用与结果不匹配"。
- 文档补该能力口径与工具全集条目;前端零改动。
2026-09-22 01:32:07 +08:00

116 lines
4.5 KiB
Go

package app
import (
"strings"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
)
// The anchor records the current request and observable reference candidates.
// It does not infer creative permissions or carry an old goal into a new turn.
type cloudAgentCreativeAnchor struct {
Version int `json:"version"`
UserPrompt string `json:"userPrompt"`
ReferenceNodeIDs []string `json:"referenceNodeIds,omitempty"`
ReferenceAssets []cloudAgentReferenceAnchor `json:"referenceAssets,omitempty"`
}
type cloudAgentReferenceAnchor struct {
NodeID string `json:"nodeId"`
Type string `json:"type"`
Title string `json:"title,omitempty"`
Prompt string `json:"prompt,omitempty"`
AssetTags []string `json:"assetTags,omitempty"`
ReferenceReady bool `json:"referenceReady"`
VisualIdentity string `json:"visualIdentity"`
RequiresVisualInspection bool `json:"requiresVisualInspection"`
// VisualNote 是模型看过这张图之后自己写下的一句观察。锚点会跨轮继承,
// 因此下一轮不必重复看图也能拿到文字观察(推理内容不会回灌上下文)。
VisualNote string `json:"visualNote,omitempty"`
Width any `json:"width,omitempty"`
Height any `json:"height,omitempty"`
}
// cloudAgentCreativeAnchorForCanvas 按当前画布重建候选素材锚点。
// inherited 是上一轮的锚点:只继承"视觉事实"——已经看过的画面和模型自己写下的观察;
// 用户目标、权限和旧计划都不继承(用户消息才是本轮目标,候选素材按当前画布重建,
// 避免把过期素材带进新轮)。
func cloudAgentCreativeAnchorForCanvas(repo *repository.Repository, userID string, canvas *model.CanvasProject, prompt string, inherited *cloudAgentCreativeAnchor) (cloudAgentCreativeAnchor, error) {
anchor := cloudAgentCreativeAnchor{Version: 2, UserPrompt: prompt}
inspected := map[string]cloudAgentReferenceAnchor{}
if inherited != nil {
for _, asset := range inherited.ReferenceAssets {
if asset.VisualIdentity == "inspected" {
inspected[asset.NodeID] = asset
}
}
}
doc, err := creationDocument(canvas.PayloadJSON)
if err != nil {
return cloudAgentCreativeAnchor{}, BadAuthRequest("服务端画布内容无法解析,请先重新同步")
}
nodes := creationMaps(doc["nodes"])
byID := make(map[string]map[string]any, len(nodes))
for _, node := range nodes {
if id := stringValue(node["id"]); id != "" {
byID[id] = node
}
}
ids := make([]string, 0, 16)
for _, node := range nodes {
descriptor, known := cloudAgentNodeCapabilityForType(stringValue(node["type"]))
if known && descriptor.Connection.CanReference {
ids = append(ids, stringValue(node["id"]))
}
if len(ids) == 16 {
break
}
}
for _, id := range ids {
if id == "" || byID[id] == nil {
continue
}
node := byID[id]
descriptor, known := cloudAgentNodeCapabilityForType(stringValue(node["type"]))
if !known || !descriptor.Connection.CanReference {
continue
}
meta, _ := node["metadata"].(map[string]any)
item := cloudAgentReferenceAnchor{
NodeID: stringValue(node["id"]), Type: stringValue(node["type"]),
Title: truncateRunes(stringValue(node["title"]), 300),
Prompt: truncateRunes(firstNonEmpty(stringValue(meta["prompt"]), stringValue(meta["composerContent"])), 1000),
VisualIdentity: "unknown", RequiresVisualInspection: true,
}
if tags, ok := meta["assetTags"].([]any); ok {
for _, tag := range tags {
if text := strings.TrimSpace(stringValue(tag)); text != "" {
item.AssetTags = append(item.AssetTags, truncateRunes(text, 120))
}
}
}
if repo != nil {
ref, _, refErr := cloudAgentReference(repo, userID, node)
if refErr == nil {
item.ReferenceReady = true
item.Width, item.Height = ref["width"], ref["height"]
}
}
if viewed, ok := inspected[item.NodeID]; ok {
// 这一轮之前已经看过画面:直接继承模型自己写下的观察,不重复看图。
item.VisualIdentity = "inspected"
item.RequiresVisualInspection = false
item.VisualNote = viewed.VisualNote
}
anchor.ReferenceNodeIDs = append(anchor.ReferenceNodeIDs, item.NodeID)
anchor.ReferenceAssets = append(anchor.ReferenceAssets, item)
if len(anchor.ReferenceAssets) == 16 {
break
}
}
return anchor, nil
}