云端 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 会落进额度类目掩盖真因), 归为"会话里的工具调用与结果不匹配"。 - 文档补该能力口径与工具全集条目;前端零改动。
116 lines
4.5 KiB
Go
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
|
|
}
|