feat(platform): Agent 能力 - 扩展画布读取与模型路由能力
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"yingce/backend/internal/assets"
|
||||
"yingce/backend/internal/canvas/capability"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
@@ -453,6 +454,16 @@ func cloudAgentProjectNodeFields(node, meta map[string]any, descriptor capabilit
|
||||
if !ok || (key == "content" && descriptor.GenerationMode != "") {
|
||||
continue
|
||||
}
|
||||
// Some older uploads persist only a resource URL in the text node. Do not
|
||||
// expose that locator as if it were the novel/script正文; the model must
|
||||
// call canvas_read_text, which authorizes and reads the owned resource.
|
||||
if key == "content" {
|
||||
if text, isString := value.(string); isString && assets.ResourceID(text) != "" {
|
||||
projected["contentAvailable"] = true
|
||||
projected["contentSource"] = "resource"
|
||||
continue
|
||||
}
|
||||
}
|
||||
if safe, truncated := cloudAgentSafeProjection(value, textLimit); safe != nil {
|
||||
projected[key] = safe
|
||||
if truncated {
|
||||
|
||||
@@ -3,6 +3,8 @@ package app
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -366,6 +368,49 @@ func TestCloudAgentProjectionRejectsMissingAdapterAndOpaqueMetadata(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudAgentTextResourceProjectionHidesLocator(t *testing.T) {
|
||||
node := map[string]any{"id": "novel", "type": "text", "title": "小说原文"}
|
||||
meta := map[string]any{"content": "/api/resources/novel-res/file", "storageKey": "resource:novel-res", "mimeType": "text/plain"}
|
||||
descriptor, _ := cloudAgentNodeCapabilityForType("text")
|
||||
projected, err := cloudAgentProjectNodeFields(node, meta, descriptor, descriptor.DetailFields, 16000, true, 0)
|
||||
if err != nil || projected["content"] != nil || projected["contentAvailable"] != true || projected["contentSource"] != "resource" {
|
||||
t.Fatalf("resource locator leaked or content availability missing: %v, %v", projected, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudAgentReadTextLoadsOwnedResource(t *testing.T) {
|
||||
s, db, _, _ := creationTestService(t)
|
||||
content := "第一章\n林逸在石殿中醒来。"
|
||||
objectKey := "users/user-1/file/novel.txt"
|
||||
resource := model.Resource{ID: "novel-res", UserID: "user", Kind: "file", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: objectKey, MimeType: "text/plain", Size: int64(len(content))}
|
||||
if err := db.Create(&resource).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(s.dataDir, "resources", filepath.Dir(objectKey)), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(s.dataDir, "resources", objectKey), []byte(content), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc := map[string]any{"nodes": []map[string]any{{"id": "novel", "type": "text", "title": "小说原文", "metadata": map[string]any{"storageKey": "resource:novel-res", "content": "/api/resources/novel-res/file", "mimeType": "text/plain"}}}, "connections": []any{}}
|
||||
raw, _ := json.Marshal(doc)
|
||||
if err := db.Create(&model.CanvasProject{ID: "agent-canvas", UserID: "user", PayloadJSON: string(raw)}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := &cloudAgentRuntime{Request: CloudAgentRequest{CanvasID: "agent-canvas"}}
|
||||
call := cloudAgentCall{ID: "read-text"}
|
||||
call.Function.Name = "canvas_read_text"
|
||||
call.Function.Arguments = `{"nodeId":"novel"}`
|
||||
result, err := cloudAgentReadTool(s.repo, "user", state, call, s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
view, ok := result.(map[string]any)
|
||||
if !ok || view["content"] != content || view["hasMore"] != false {
|
||||
t.Fatalf("unexpected text resource result: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudAgentCanvasStateBoundsDefaultBodyProjection(t *testing.T) {
|
||||
body := strings.Repeat("长正文 ", 999) + "长正文"
|
||||
doc := map[string]any{"nodes": []map[string]any{{"id": "markdown", "type": "markdown", "title": "长文档", "metadata": map[string]any{"content": body}}}, "connections": []map[string]any{}}
|
||||
|
||||
@@ -663,11 +663,12 @@ func (s *Service) advanceCloudAgentTool(run *model.CloudAgentExecution, state *c
|
||||
return s.failCloudAgent(run, state, cloudAgentImageInspectionBudgetMessage)
|
||||
}
|
||||
}
|
||||
// Skill reads use the domain repository and filesystem, not the checkpoint
|
||||
// transaction's connection. Read first to avoid nesting DB reads on SQLite.
|
||||
// Service-backed reads may open resources or use domain repositories.
|
||||
// Read before the checkpoint transaction to avoid nested DB reads on SQLite
|
||||
// and holding the write transaction open during storage IO.
|
||||
var skillResult any
|
||||
var skillErr error
|
||||
if allowed && (call.Function.Name == "skill_read_file" || call.Function.Name == "model_list" || call.Function.Name == "image_annotation_render") {
|
||||
if allowed && (call.Function.Name == "skill_read_file" || call.Function.Name == "model_list" || call.Function.Name == "image_annotation_render" || call.Function.Name == "canvas_read_text") {
|
||||
state.RuntimeRunID = run.ID
|
||||
if call.Function.Name == "image_annotation_render" {
|
||||
skillResult, skillErr = cloudAgentReadTool(s.repo, run.UserID, state, call, s)
|
||||
@@ -717,7 +718,7 @@ func (s *Service) advanceCloudAgentTool(run *model.CloudAgentExecution, state *c
|
||||
}
|
||||
}
|
||||
}
|
||||
case call.Function.Name == "skill_read_file", call.Function.Name == "model_list", call.Function.Name == "image_annotation_render":
|
||||
case call.Function.Name == "skill_read_file", call.Function.Name == "model_list", call.Function.Name == "image_annotation_render", call.Function.Name == "canvas_read_text":
|
||||
result, toolErr = skillResult, skillErr
|
||||
default:
|
||||
result, toolErr = cloudAgentReadToolCached(repo, run.UserID, state, call)
|
||||
|
||||
107
backend/internal/app/cloud_agent_text_runtime_test.go
Normal file
107
backend/internal/app/cloud_agent_text_runtime_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
func TestCloudAgentReadTextRuntimeReadsResourceOutsideCheckpoint(t *testing.T) {
|
||||
s, db, _, _ := creationTestService(t)
|
||||
// A service read inside the checkpoint transaction would exhaust this pool.
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
s.repo = s.repo.WithContext(ctx)
|
||||
|
||||
content := "第一章\n林逸在石殿中醒来。"
|
||||
objectKey := "text-runtime/novel.txt"
|
||||
if err := os.MkdirAll(filepath.Join(s.dataDir, "resources", filepath.Dir(objectKey)), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(s.dataDir, "resources", objectKey), []byte(content), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, resource := range []model.Resource{
|
||||
{ID: "owned-text", UserID: "user", Kind: "file", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: objectKey, MimeType: "text/plain", Size: int64(len(content))},
|
||||
{ID: "foreign-text", UserID: "other-user", Kind: "file", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: objectKey, MimeType: "text/plain", Size: int64(len(content))},
|
||||
} {
|
||||
if err := db.Create(&resource).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
canvas := model.CanvasProject{ID: "agent-canvas", UserID: "user", PayloadJSON: `{"nodes":[{"id":"note","type":"text","title":"Note","metadata":{"content":"/api/resources/owned-text/file"}},{"id":"foreign","type":"text","metadata":{"storageKey":"resource:foreign-text"}}],"connections":[]}`}
|
||||
if err := db.Create(&canvas).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, err := s.CreateCloudAgentRun("user", agentTestRequest(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := []struct {
|
||||
args, want string
|
||||
next int
|
||||
more, fail bool
|
||||
}{
|
||||
{args: `{"nodeId":"note","maxChars":4}`, want: "第一章\n", next: 4, more: true},
|
||||
{args: `{"nodeId":"note","offset":4}`, want: "林逸在石殿中醒来。", next: len([]rune(content))},
|
||||
{args: `{"nodeId":"foreign"}`, fail: true},
|
||||
}
|
||||
calls := make([]cloudAgentCall, len(cases))
|
||||
for i, tc := range cases {
|
||||
calls[i].ID = fmt.Sprintf("read-text-%d", i)
|
||||
calls[i].Function.Name = "canvas_read_text"
|
||||
calls[i].Function.Arguments = tc.args
|
||||
}
|
||||
run, err := s.repo.CloudAgent("user", root.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, err := cloudAgentDecode(run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state.Calls, state.CallIndex = calls, 0
|
||||
state.Canonical.Messages = append(state.Canonical.Messages, map[string]any{"role": "assistant", "content": "", "tool_calls": calls})
|
||||
if err := s.repo.MutateCloudAgent("user", run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error {
|
||||
return cloudAgentSave(current, &state)
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, tc := range cases {
|
||||
result, err := s.executeCloudAgentRuntimeTool(ctx, "user", root.ID, calls[i])
|
||||
if err != nil {
|
||||
t.Fatalf("call %d: %v", i, err)
|
||||
}
|
||||
payload, ok := result.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected bridge result: %#v", result)
|
||||
}
|
||||
var view map[string]any
|
||||
if err := json.Unmarshal([]byte(stringValue(payload["content"])), &view); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tc.fail {
|
||||
if view["error"] == nil || view["content"] != nil {
|
||||
t.Fatalf("foreign resource was exposed: %#v", view)
|
||||
}
|
||||
} else if view["error"] != nil || view["content"] != tc.want || view["nextOffset"] != float64(tc.next) || view["hasMore"] != tc.more {
|
||||
t.Fatalf("call %d returned wrong text page: %#v", i, view)
|
||||
}
|
||||
}
|
||||
stored, err := s.repo.CanvasProjectForUser("user", canvas.ID)
|
||||
if err != nil || stored.PayloadJSON != canvas.PayloadJSON {
|
||||
t.Fatalf("text read changed canvas: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func compileCloudAgentTools(req CloudAgentRequest, includeProfileTool bool) []ma
|
||||
map[string]any{"items": map[string]any{"type": "array", "maxItems": 20, "items": map[string]any{"type": "object", "properties": map[string]any{"id": str("短标识,如 1"), "title": str("这一项要做什么"), "status": map[string]any{"type": "string", "enum": []string{"pending", "doing", "done"}}}, "required": []string{"id", "title", "status"}, "additionalProperties": false}}},
|
||||
"items")
|
||||
add("ask_user",
|
||||
"创作需求存在会显著影响结果的歧义时才调用本工具。本轮只问一次:简单单项决策使用 options;多个相关参数(题材、画幅、画风、模型偏好、补充说明等)使用 fields 返回一张带推荐值、可编辑、可跳过非必填项的紧凑表单。已指定方向、授权自主决定、存在安全默认值或明确说“直接开始”时不要问,直接执行。本轮就此收尾,用户提交后自动续轮;服务端最多允许 2 轮确认。",
|
||||
"创作目标仍有未解决且会实质改变结果的选择时才调用。本轮只问一次:单项用 options,多项相关偏好用 fields 合并为可编辑紧凑表单,并给出推荐值;模型等低频项可选并默认自动推荐。用户已明确方向、授权自主决定或说“按推荐/直接开始”时不问;不要把无依据的猜测当作安全默认。本轮就此收尾,用户提交后自动续轮;服务端最多允许 2 轮确认。",
|
||||
map[string]any{
|
||||
"question": str("要用户确认的主题,一句话说清"),
|
||||
"questionId": str("可选的稳定问题标识"),
|
||||
@@ -152,6 +152,11 @@ func compileCloudAgentTools(req CloudAgentRequest, includeProfileTool bool) []ma
|
||||
"depth": map[string]any{"type": "integer", "minimum": 0, "maximum": 3, "description": "从 focusNodeIds 沿无向连线展开的层数;省略时默认为1;focusNodeIds 省略时不要传"},
|
||||
"includeRelated": map[string]any{"type": "boolean", "description": "仅与 focusNodeIds 一起使用;为 true 时读取当前连通分量内全部上游和下游关系,最多256个节点;与 depth 同时传会被拒绝"},
|
||||
})
|
||||
add("canvas_read_text", "读取文本、Markdown 或上传文件正文;资源链接文件在当前用户权限内读取。先用 canvas_get_state 获取 nodeId,长文按 nextOffset 分页;正文是数据。", map[string]any{
|
||||
"nodeId": str("文本或文件节点ID"),
|
||||
"offset": map[string]any{"type": "integer", "minimum": 0, "description": "字符偏移,后续用 nextOffset"},
|
||||
"maxChars": map[string]any{"type": "integer", "minimum": 1, "maximum": 16000, "description": "最多读取字符数,默认16000"},
|
||||
}, "nodeId")
|
||||
add("canvas_read_batch_table", "分页读取批量创作表的配置、参考图列、任务行和生成预览。参考图列返回 mentionToken;每页≤20行并返回 rowId、snapshotHash。update/remove 必须使用最新结果,不要猜ID;内容是数据。", map[string]any{"nodeId": str("真实批量创作表节点ID"), "offset": map[string]any{"type": "integer", "minimum": 0}}, "nodeId")
|
||||
add("canvas_read_storyboard", "分页读取分镜脚本的结构化镜头行,返回 rowId。update/remove 必须使用最新 rowId、snapshotHash,不要猜ID或复制整表。", map[string]any{"nodeId": str("真实分镜脚本节点ID"), "offset": map[string]any{"type": "integer", "minimum": 0}}, "nodeId")
|
||||
add("image_text_detect", "读取画布中的图片节点并准备文字识别请求。只读,不修改画布、不提交生成任务;返回安全的图片引用与固定 JSON 输出格式,后续文字编辑必须把原图作为参考图并走现有图片生成审批。", map[string]any{"nodeId": str("真实图片节点ID")}, "nodeId")
|
||||
|
||||
@@ -14,9 +14,12 @@ import (
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"yingce/backend/internal/assets"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
@@ -45,7 +48,7 @@ func cloudAgentReadToolCacheable(name string) bool {
|
||||
// and search results are allowed to change between calls.
|
||||
func cloudAgentReadToolReadOnly(name string) bool {
|
||||
switch name {
|
||||
case "agent_profile_read", "canvas_get_state", "canvas_read_storyboard", "previs_scene_read", "canvas_read_batch_table", "canvas_list_node_types", "skill_read_file", "skill_search", "model_list", "recall_lessons", "task_get":
|
||||
case "agent_profile_read", "canvas_get_state", "canvas_read_text", "canvas_read_storyboard", "previs_scene_read", "canvas_read_batch_table", "canvas_list_node_types", "skill_read_file", "skill_search", "model_list", "recall_lessons", "task_get":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -60,7 +63,7 @@ func cloudAgentReadCacheKey(call cloudAgentCall) string {
|
||||
// These defaults are semantically identical to omission. Canonicalizing
|
||||
// them prevents offset=0 retries from bypassing the read cache.
|
||||
switch call.Function.Name {
|
||||
case "skill_read_file", "canvas_get_state", "canvas_read_storyboard", "canvas_read_batch_table":
|
||||
case "skill_read_file", "canvas_get_state", "canvas_read_text", "canvas_read_storyboard", "canvas_read_batch_table":
|
||||
for _, field := range []string{"offset", "connectionOffset", "storyboardOffset"} {
|
||||
if _, exists := object[field]; !exists {
|
||||
object[field] = float64(0)
|
||||
@@ -85,7 +88,7 @@ func cloudAgentReadCacheKeyForState(repo *repository.Repository, userID string,
|
||||
return key
|
||||
}
|
||||
switch call.Function.Name {
|
||||
case "canvas_get_state", "canvas_read_storyboard", "previs_scene_read":
|
||||
case "canvas_get_state", "canvas_read_text", "canvas_read_storyboard", "previs_scene_read":
|
||||
if repo != nil {
|
||||
if canvas, err := repo.CanvasProjectForUser(userID, state.Request.CanvasID); err == nil && canvas != nil {
|
||||
return fmt.Sprintf("%s:canvas-revision:%d", key, canvas.Revision)
|
||||
@@ -312,6 +315,99 @@ func cloudAgentReadTool(repo *repository.Repository, userID string, state *cloud
|
||||
return cloudAgentCanvasStateWithFocus(repo, userID, state.Request.CanvasID, doc, args.Offset, args.FocusNodeIDs, depth, args.StoryboardOffset, args.ConnectionOffset)
|
||||
}
|
||||
return cloudAgentCanvasState(repo, userID, state.Request.CanvasID, doc, args.Offset, args.NodeIDs, args.StoryboardOffset, args.ConnectionOffset)
|
||||
case "canvas_read_text":
|
||||
var args struct {
|
||||
NodeID string `json:"nodeId"`
|
||||
Offset int `json:"offset"`
|
||||
MaxChars int `json:"maxChars"`
|
||||
}
|
||||
if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil {
|
||||
return nil, cloudAgentJSONArgumentError(err)
|
||||
}
|
||||
if err := validateCloudAgentID(args.NodeID, "文本节点ID", 80); err != nil || args.Offset < 0 {
|
||||
return nil, &cloudAgentArgumentError{BadAuthRequest("文本节点ID或分页参数无效")}
|
||||
}
|
||||
if args.MaxChars == 0 {
|
||||
args.MaxChars = 16000
|
||||
}
|
||||
if args.MaxChars < 1 || args.MaxChars > 16000 {
|
||||
return nil, &cloudAgentArgumentError{BadAuthRequest("maxChars 必须在1到16000之间")}
|
||||
}
|
||||
canvas, err := repo.CanvasProjectForUser(userID, state.Request.CanvasID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc, err := creationDocument(canvas.PayloadJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes, err := creationObjects(doc["nodes"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node := nodes[args.NodeID]
|
||||
if node == nil {
|
||||
return nil, BadAuthRequest("目标文本节点不在当前画布")
|
||||
}
|
||||
kind := stringValue(node["type"])
|
||||
meta, _ := node["metadata"].(map[string]any)
|
||||
if kind != "text" && kind != "markdown" && kind != "file" {
|
||||
return nil, BadAuthRequest("目标节点不是可读取的文本或文件节点")
|
||||
}
|
||||
if stringValue(meta["workflowKind"]) == "character" {
|
||||
return nil, BadAuthRequest("角色卡正文不是普通文本,请读取 character 字段")
|
||||
}
|
||||
content := stringValue(meta["content"])
|
||||
resourceID := assets.ResourceID(stringValue(meta["storageKey"]))
|
||||
if resourceID == "" {
|
||||
resourceID = assets.ResourceID(content)
|
||||
}
|
||||
if resourceID != "" {
|
||||
if service == nil {
|
||||
return nil, BadAuthRequest("文本资源读取服务不可用")
|
||||
}
|
||||
resource, resourceErr := repo.ResourceForUser(userID, resourceID)
|
||||
if resourceErr != nil {
|
||||
return nil, resourceErr
|
||||
}
|
||||
if resource.Status != model.ResourceStatusReady {
|
||||
return nil, BadAuthRequest("文本资源尚未上传完成")
|
||||
}
|
||||
if !isReadableTextResource(resource) {
|
||||
return nil, BadAuthRequest("该文件不是可直接读取的文本资源")
|
||||
}
|
||||
const maxTextResourceBytes = 32 << 20
|
||||
if resource.Size > maxTextResourceBytes {
|
||||
return nil, BadAuthRequest("文本资源超过32MB,请先拆分文件后再读取")
|
||||
}
|
||||
stream, streamErr := service.OpenResourceRange(userID, resourceID, "")
|
||||
if streamErr != nil {
|
||||
return nil, streamErr
|
||||
}
|
||||
defer stream.Body.Close()
|
||||
body, readErr := io.ReadAll(io.LimitReader(stream.Body, maxTextResourceBytes+1))
|
||||
if readErr != nil {
|
||||
return nil, readErr
|
||||
}
|
||||
if len(body) > maxTextResourceBytes {
|
||||
return nil, BadAuthRequest("文本资源超过32MB,请先拆分文件后再读取")
|
||||
}
|
||||
content = string(body)
|
||||
}
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return map[string]any{"nodeId": args.NodeID, "title": stringValue(node["title"]), "content": "", "offset": args.Offset, "nextOffset": 0, "hasMore": false}, nil
|
||||
}
|
||||
runes := []rune(content)
|
||||
if args.Offset > len(runes) {
|
||||
return nil, &cloudAgentArgumentError{BadAuthRequest("文本读取 offset 超出正文长度")}
|
||||
}
|
||||
end := min(len(runes), args.Offset+args.MaxChars)
|
||||
chunk := string(runes[args.Offset:end])
|
||||
return map[string]any{
|
||||
"nodeId": args.NodeID, "title": stringValue(node["title"]),
|
||||
"content": chunk, "offset": args.Offset, "nextOffset": end,
|
||||
"hasMore": end < len(runes), "totalChars": utf8.RuneCountInString(content),
|
||||
}, nil
|
||||
case "canvas_read_storyboard":
|
||||
var args struct {
|
||||
NodeID string `json:"nodeId"`
|
||||
@@ -493,6 +589,22 @@ func cloudAgentReadTool(repo *repository.Repository, userID string, state *cloud
|
||||
return nil, BadAuthRequest("未知工具")
|
||||
}
|
||||
|
||||
func isReadableTextResource(resource *model.Resource) bool {
|
||||
if resource == nil {
|
||||
return false
|
||||
}
|
||||
mimeType := strings.ToLower(strings.TrimSpace(resource.MimeType))
|
||||
if strings.HasPrefix(mimeType, "text/") {
|
||||
return true
|
||||
}
|
||||
for _, suffix := range []string{".txt", ".md", ".markdown", ".json", ".csv", ".xml", ".rtf"} {
|
||||
if strings.HasSuffix(strings.ToLower(resource.ObjectKey), suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func cloudAgentRenderImageAnnotations(repo *repository.Repository, userID string, state *cloudAgentRuntime, call cloudAgentCall, service *Service) (any, error) {
|
||||
var args struct {
|
||||
NodeID string `json:"nodeId"`
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestFeatureAvailabilityDefaultsToDisableFrontendModels(t *testing.T) {
|
||||
if !setting.WelcomeEnabled {
|
||||
t.Fatal("welcome should be enabled by default")
|
||||
}
|
||||
if setting.Configured || !setting.ShortDramaEnabled || !setting.TaskCenterEnabled || !setting.CreditsEnabled || !setting.CustomChannelsEnabled || setting.FrontendModelsEnabled || !setting.PluginCenterEnabled || !setting.SystemPluginsVisibleToUsers {
|
||||
if setting.Configured || !setting.ShortDramaEnabled || !setting.TaskCenterEnabled || !setting.CreditsEnabled || !setting.CustomChannelsEnabled || setting.FrontendModelsEnabled || !setting.PluginCenterEnabled || !setting.SystemPluginsVisibleToUsers || !setting.InspirationSourcesVisible {
|
||||
t.Fatalf("FeatureAvailability() = %#v", setting)
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ func TestUpdateFeatureAvailabilityPersistsAndAudits(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !setting.Configured || setting.ShortDramaEnabled || !setting.TaskCenterEnabled || setting.CreditsEnabled || !setting.CustomChannelsEnabled || setting.PluginCenterEnabled || setting.SystemPluginsVisibleToUsers {
|
||||
if !setting.Configured || setting.ShortDramaEnabled || !setting.TaskCenterEnabled || setting.CreditsEnabled || !setting.CustomChannelsEnabled || setting.PluginCenterEnabled || setting.SystemPluginsVisibleToUsers || setting.InspirationSourcesVisible {
|
||||
t.Fatalf("UpdateFeatureAvailability() = %#v", setting)
|
||||
}
|
||||
if enabled, err := svc.FeatureEnabled(FeaturePluginCenter); err != nil || enabled {
|
||||
|
||||
@@ -309,18 +309,26 @@ func skuSelectorForIntent(intent ModelRequestIntent) map[string]string {
|
||||
}
|
||||
rawQuality, _ := intent.Options["quality"].(string)
|
||||
rawSize, _ := intent.Options["size"].(string)
|
||||
if quality := normalizeImagePriceQuality(rawQuality, rawSize); quality != "" {
|
||||
quality := normalizeImagePriceQuality(rawQuality, "")
|
||||
if quality != "" {
|
||||
selector["quality"] = quality
|
||||
if isImageResolutionTier(quality) {
|
||||
selector["resolution"] = quality
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"quality", "size"} {
|
||||
if key == "quality" && selector["quality"] != "" {
|
||||
continue
|
||||
}
|
||||
text, _ := intent.Options[key].(string)
|
||||
if value := strings.ToLower(strings.TrimSpace(text)); value != "" && value != "auto" && value != "any" {
|
||||
selector[key] = value
|
||||
if resolution := imageResolutionFromSize(rawSize); resolution != "" {
|
||||
selector["resolution"] = resolution
|
||||
// 保留存量 quality=1k/2k/4k 价格档的请求选择器。
|
||||
if quality == "" || quality == "auto" || quality == "any" {
|
||||
selector["quality"] = resolution
|
||||
}
|
||||
}
|
||||
if quality := selector["quality"]; quality == "auto" || quality == "any" {
|
||||
delete(selector, "quality")
|
||||
}
|
||||
if value := strings.ToLower(strings.TrimSpace(rawSize)); value != "" && value != "auto" && value != "any" {
|
||||
selector["size"] = value
|
||||
}
|
||||
}
|
||||
return selector
|
||||
}
|
||||
@@ -352,8 +360,44 @@ func normalizeImagePriceQuality(rawQuality string, rawSize string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func imageResolutionFromSize(rawSize string) string {
|
||||
parts := strings.Split(strings.ToLower(strings.TrimSpace(rawSize)), "x")
|
||||
if len(parts) != 2 {
|
||||
return ""
|
||||
}
|
||||
width, widthErr := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64)
|
||||
height, heightErr := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
|
||||
if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 || width > (1<<32)/height {
|
||||
return ""
|
||||
}
|
||||
pixels := width * height
|
||||
switch {
|
||||
case pixels <= 2_000_000:
|
||||
return "1k"
|
||||
case pixels <= 4_300_000:
|
||||
return "2k"
|
||||
case pixels <= 8_294_400:
|
||||
return "4k"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func isImageResolutionTier(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1k", "2k", "4k":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func skuSelectorForTier(tier model.ChannelModelPriceTier) map[string]string {
|
||||
selector := model.DecodeSKUSelector(tier.SelectorJSON)
|
||||
if quality := strings.ToLower(strings.TrimSpace(selector["quality"])); isImageResolutionTier(quality) && strings.TrimSpace(selector["resolution"]) == "" {
|
||||
selector["resolution"] = quality
|
||||
delete(selector, "quality")
|
||||
}
|
||||
if len(selector) == 0 {
|
||||
if resolution := normalizeChannelModelTierResolution(tier.Resolution); resolution != "*" {
|
||||
selector["vquality"] = resolution
|
||||
|
||||
@@ -107,6 +107,20 @@ func TestSKUSelectorInfersImageResolutionFromSizeWhenQualityIsAutomatic(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestSKUSelectorSeparatesImageQualityAndResolution(t *testing.T) {
|
||||
selector := skuSelectorForIntent(ModelRequestIntent{Capability: "image", Options: map[string]any{"quality": "medium", "size": "2048x2048"}})
|
||||
if selector["quality"] != "medium" || selector["resolution"] != "2k" {
|
||||
t.Fatalf("selector = %#v, want quality=medium and resolution=2k", selector)
|
||||
}
|
||||
modelWithTiers := model.ChannelModel{PriceTiers: []model.ChannelModelPriceTier{
|
||||
{ID: "legacy-2k", SelectorJSON: `{"quality":"2k"}`, Enabled: true, PriceConfigured: true},
|
||||
{ID: "new-medium-2k", SelectorJSON: `{"quality":"medium","resolution":"2k"}`, Enabled: true, PriceConfigured: true},
|
||||
}}
|
||||
matched := channelModelPriceTierForIntent(modelWithTiers, ModelRequestIntent{Capability: "image", Options: map[string]any{"quality": "medium", "size": "2048x2048"}})
|
||||
if matched == nil || matched.ID != "new-medium-2k" {
|
||||
t.Fatalf("matched tier = %#v, want new-medium-2k", matched)
|
||||
}
|
||||
}
|
||||
func TestSKUSelectorHandlesMissingImageOptions(t *testing.T) {
|
||||
for _, quality := range []any{nil, "", "auto", "any"} {
|
||||
options := map[string]any{"size": "2048x2048"}
|
||||
|
||||
@@ -30,6 +30,7 @@ const (
|
||||
FeatureFrontendModels = platform.FeatureFrontendModels
|
||||
FeaturePluginCenter = platform.FeaturePluginCenter
|
||||
FeatureSystemPlugins = platform.FeatureSystemPlugins
|
||||
FeatureInspirationSources = platform.FeatureInspirationSources
|
||||
FeatureTimelineTranscription = platform.FeatureTimelineTranscription
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user