chore(sync): 上游同步 - 合并 upstream/main 至 ee6b3446,按新 module 路径收敛冲突
This commit is contained in:
@@ -11,7 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentruntime "infinite-canvas/backend/internal/agent/runtime"
|
||||
agentruntime "yingce/backend/internal/agent/runtime"
|
||||
)
|
||||
|
||||
type Status struct {
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentruntime "infinite-canvas/backend/internal/agent/runtime"
|
||||
agentruntime "yingce/backend/internal/agent/runtime"
|
||||
)
|
||||
|
||||
func TestRemoteRunCallsBridgeAndRejectsBadToken(t *testing.T) {
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/kernel"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/kernel"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
type CreateAdminUserRequest struct {
|
||||
@@ -93,6 +93,7 @@ type ChannelRequest struct {
|
||||
Models []string `json:"models"`
|
||||
Headers []OutboundHeader `json:"headers"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
ProxyURL string `json:"proxyUrl"`
|
||||
}
|
||||
|
||||
type PublicModelChannel struct {
|
||||
@@ -106,6 +107,7 @@ type PublicModelChannel struct {
|
||||
APIKey string `json:"apiKey"`
|
||||
APIFormat string `json:"apiFormat"`
|
||||
ConcurrencyLimit int `json:"concurrencyLimit"`
|
||||
ProxyURL string `json:"proxyUrl"`
|
||||
Models []string `json:"models"`
|
||||
ModelCosts []PublicChannelModelPrice `json:"modelCosts"`
|
||||
Headers []OutboundHeader `json:"headers,omitempty"`
|
||||
@@ -187,6 +189,11 @@ func (s *Service) channelFromRequest(req ChannelRequest, channel model.ModelChan
|
||||
}
|
||||
channel.ModelsJSON = string(modelsJSON)
|
||||
channel.HeadersJSON = headersJSON
|
||||
proxyURL := strings.TrimSpace(req.ProxyURL)
|
||||
if _, err := ValidateProxyURL(proxyURL); err != nil {
|
||||
return channel, err
|
||||
}
|
||||
channel.ProxyURL = proxyURL
|
||||
if req.Enabled != nil {
|
||||
channel.Enabled = *req.Enabled
|
||||
}
|
||||
@@ -206,6 +213,9 @@ func mergeChannelRequest(req ChannelRequest, channel model.ModelChannel) Channel
|
||||
if req.Headers == nil {
|
||||
req.Headers, _ = ParseOutboundHeadersJSON(channel.HeadersJSON)
|
||||
}
|
||||
if strings.TrimSpace(req.ProxyURL) == "" {
|
||||
req.ProxyURL = channel.ProxyURL
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
@@ -232,6 +242,7 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model
|
||||
}
|
||||
apiKey := ""
|
||||
baseURL := channel.BaseURL
|
||||
proxyURL := ""
|
||||
var headers []OutboundHeader
|
||||
if channel.Scope == model.ChannelScopeSystem {
|
||||
if !admin {
|
||||
@@ -244,6 +255,9 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model
|
||||
} else if admin {
|
||||
apiKey = channel.APIKey
|
||||
}
|
||||
if admin {
|
||||
proxyURL = channel.ProxyURL
|
||||
}
|
||||
return PublicModelChannel{
|
||||
ID: channel.ID,
|
||||
UserID: channel.UserID,
|
||||
@@ -255,6 +269,7 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model
|
||||
APIKey: apiKey,
|
||||
APIFormat: channel.APIFormat,
|
||||
ConcurrencyLimit: channel.ConcurrencyLimit,
|
||||
ProxyURL: proxyURL,
|
||||
Models: models,
|
||||
ModelCosts: modelCosts,
|
||||
Headers: headers,
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
func (s *Service) LogAPICall(log model.ApiCallLog) error {
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
type AdminUserDetail struct {
|
||||
|
||||
@@ -3,8 +3,8 @@ package app
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/driver/sqlite"
|
||||
|
||||
@@ -7,11 +7,12 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/outbound/outboundtest"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/outbound/outboundtest"
|
||||
"yingce/backend/internal/repository"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
@@ -189,16 +190,40 @@ func TestChannelFromRequestStoresAndClearsHeaders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicChannelOnlyReturnsSystemHeadersToAdmin(t *testing.T) {
|
||||
channel := model.ModelChannel{ID: "system-1", Scope: model.ChannelScopeSystem, BaseURL: "https://example.com/v1", HeadersJSON: `[{"name":"X-Gateway-Tenant","value":"tenant-a"}]`}
|
||||
func TestChannelFromRequestValidatesProxyURL(t *testing.T) {
|
||||
limit := 4
|
||||
base := model.ModelChannel{}
|
||||
channel, err := channelFromRequest(ChannelRequest{Name: "Proxy", BaseURL: "https://93.184.216.34/v1", ProxyURL: "socks5://user:secret@127.0.0.1:1080", ConcurrencyLimit: &limit}, base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if channel.ProxyURL != "socks5://user:secret@127.0.0.1:1080" {
|
||||
t.Fatalf("proxy URL = %q", channel.ProxyURL)
|
||||
}
|
||||
for _, proxyURL := range []string{"ftp://127.0.0.1:1080", "socks5://", "socks5://127.0.0.1:1080#fragment", "http://" + strings.Repeat("a", 506)} {
|
||||
_, err := channelFromRequest(ChannelRequest{Name: "Proxy", BaseURL: "https://93.184.216.34/v1", ProxyURL: proxyURL, ConcurrencyLimit: &limit}, base)
|
||||
if err == nil {
|
||||
t.Fatalf("invalid proxy URL %q was accepted", proxyURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicChannelOnlyReturnsSystemHeadersAndProxyToAdmin(t *testing.T) {
|
||||
channel := model.ModelChannel{ID: "system-1", Scope: model.ChannelScopeSystem, BaseURL: "https://example.com/v1", HeadersJSON: `[{"name":"X-Gateway-Tenant","value":"tenant-a"}]`, ProxyURL: "socks5://user:secret@127.0.0.1:1080"}
|
||||
adminView := publicChannel(channel, true, nil)
|
||||
if len(adminView.Headers) != 1 || adminView.Headers[0].Name != "X-Gateway-Tenant" {
|
||||
t.Fatalf("admin headers = %#v", adminView.Headers)
|
||||
}
|
||||
if adminView.ProxyURL != channel.ProxyURL {
|
||||
t.Fatalf("admin proxy URL = %q", adminView.ProxyURL)
|
||||
}
|
||||
userView := publicChannel(channel, false, nil)
|
||||
if len(userView.Headers) != 0 {
|
||||
t.Fatalf("user headers = %#v", userView.Headers)
|
||||
}
|
||||
if userView.ProxyURL != "" {
|
||||
t.Fatalf("user proxy URL = %q", userView.ProxyURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelFromRequestRejectsInvalidConcurrencyLimit(t *testing.T) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
func (s *Service) PublicSystemChannels() ([]PublicModelChannel, error) {
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/assets"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/assets"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
const maxAdminResourceDeleteCount = 100
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/database"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/database"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -10,12 +10,12 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentruntime "infinite-canvas/backend/internal/agent/runtime"
|
||||
"infinite-canvas/backend/internal/agent/yingceagent"
|
||||
"infinite-canvas/backend/internal/buildinfo"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/platform"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
agentruntime "yingce/backend/internal/agent/runtime"
|
||||
"yingce/backend/internal/agent/yingceagent"
|
||||
"yingce/backend/internal/buildinfo"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/platform"
|
||||
"yingce/backend/internal/repository"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/database"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/platform"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/database"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/platform"
|
||||
"yingce/backend/internal/repository"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/driver/sqlite"
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/hostupdate"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/hostupdate"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
type UpdateManager interface {
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
func (s *Service) RequireAdmin(user *model.User) error {
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/database"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/database"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -3,7 +3,7 @@ package app
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestAllVideoProtocolsQuoteReserveAndSettleWithoutProviderUsage(t *testing.T) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
type AnalyticsQuery struct {
|
||||
@@ -28,24 +28,27 @@ type AnalyticsOverview struct {
|
||||
}
|
||||
|
||||
type AnalyticsKPI struct {
|
||||
ActiveUsers int `json:"activeUsers"`
|
||||
DAU int `json:"dau"`
|
||||
WAU int `json:"wau"`
|
||||
MAU int `json:"mau"`
|
||||
GenerationTasks int `json:"generationTasks"`
|
||||
UpstreamRequests int `json:"upstreamRequests"`
|
||||
SuccessRate float64 `json:"successRate"`
|
||||
P95DurationMs int64 `json:"p95DurationMs"`
|
||||
CurrentQueuedTasks int64 `json:"currentQueuedTasks"`
|
||||
Finance AnalyticsFinance `json:"finance"`
|
||||
ActiveUsers int `json:"activeUsers"`
|
||||
DAU int `json:"dau"`
|
||||
WAU int `json:"wau"`
|
||||
MAU int `json:"mau"`
|
||||
GenerationTasks int `json:"generationTasks"`
|
||||
UpstreamRequests int `json:"upstreamRequests"`
|
||||
SuccessRate float64 `json:"successRate"`
|
||||
P95DurationMs int64 `json:"p95DurationMs"`
|
||||
CurrentQueuedTasks int64 `json:"currentQueuedTasks"`
|
||||
Finance AnalyticsFinance `json:"finance"`
|
||||
TotalBalanceMicrocredits int64 `json:"totalBalanceMicrocredits"`
|
||||
TotalConsumedMicrocredits int64 `json:"totalConsumedMicrocredits"`
|
||||
}
|
||||
|
||||
type AnalyticsTrendPoint struct {
|
||||
Day string `json:"day"`
|
||||
Tasks int `json:"tasks"`
|
||||
Requests int `json:"requests"`
|
||||
ActiveUsers int `json:"activeUsers"`
|
||||
RequestSuccessRate float64 `json:"requestSuccessRate"`
|
||||
Day string `json:"day"`
|
||||
Tasks int `json:"tasks"`
|
||||
Requests int `json:"requests"`
|
||||
ActiveUsers int `json:"activeUsers"`
|
||||
RequestSuccessRate float64 `json:"requestSuccessRate"`
|
||||
ConsumedMicrocredits int64 `json:"consumedMicrocredits"`
|
||||
}
|
||||
|
||||
type AnalyticsModelRow struct {
|
||||
@@ -163,11 +166,21 @@ func (s *Service) AdminAnalytics(actor *model.User, query AnalyticsQuery) (*Anal
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalBalance, err := s.repo.TotalCreditBalance()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
queued, err := s.repo.CurrentQueuedTaskCount()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := buildAnalyticsOverview(filter, tasks, rollingTasks, rollingLogs, logs, activities, users)
|
||||
result.KPI.TotalBalanceMicrocredits = totalBalance
|
||||
for _, log := range logs {
|
||||
if log.BillingAmount > 0 {
|
||||
result.KPI.TotalConsumedMicrocredits += log.BillingAmount
|
||||
}
|
||||
}
|
||||
records, err := s.analyticsFinancialRecords(logs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/assets"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/assets"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
func (s *Service) AdminAPICallLogs(actor *model.User, query APICallLogQuery) (*APICallLogPage, error) {
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
func normalizeAnalyticsFilter(query AnalyticsQuery) repository.AnalyticsFilter {
|
||||
@@ -117,6 +117,9 @@ func buildAnalyticsTrend(filter repository.AnalyticsFilter, tasks []model.Task,
|
||||
key := log.CreatedAt.UTC().Format("2006-01-02")
|
||||
if point := points[key]; point != nil {
|
||||
point.Requests++
|
||||
if log.BillingAmount > 0 {
|
||||
point.ConsumedMicrocredits += log.BillingAmount
|
||||
}
|
||||
requestTotals[key]++
|
||||
if log.Status == model.ApiCallStatusSucceeded {
|
||||
requestSuccess[key]++
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
func (s *Service) estimateCallCost(log *model.ApiCallLog) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package app
|
||||
|
||||
import "infinite-canvas/backend/internal/model"
|
||||
import "yingce/backend/internal/model"
|
||||
|
||||
type AnalyticsFinance struct {
|
||||
SettledOrders int `json:"settledOrders"`
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"infinite-canvas/backend/internal/assets"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/assets"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -520,6 +520,7 @@ type CreateBannerAnnouncementRequest struct {
|
||||
NoticeType string `json:"noticeType"`
|
||||
Link string `json:"link"`
|
||||
Status string `json:"status"` // "active" | "disabled"
|
||||
SortOrder int `json:"sortOrder"`
|
||||
StartsAt *time.Time `json:"startsAt"`
|
||||
EndsAt *time.Time `json:"endsAt"`
|
||||
}
|
||||
@@ -763,6 +764,7 @@ func (s *Service) CreateBannerAnnouncement(actor *model.User, req CreateBannerAn
|
||||
NoticeType: noticeType,
|
||||
Link: link,
|
||||
Status: status,
|
||||
SortOrder: req.SortOrder,
|
||||
StartsAt: req.StartsAt,
|
||||
EndsAt: req.EndsAt,
|
||||
CreatedBy: actor.ID,
|
||||
@@ -810,6 +812,7 @@ func (s *Service) UpdateBannerAnnouncement(actor *model.User, id string, req Upd
|
||||
banner.NoticeType = noticeType
|
||||
banner.Link = link
|
||||
banner.Status = status
|
||||
banner.SortOrder = req.SortOrder
|
||||
banner.StartsAt = req.StartsAt
|
||||
banner.EndsAt = req.EndsAt
|
||||
banner.UpdatedAt = time.Now()
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/database"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/database"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -3,8 +3,8 @@ package app
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -16,8 +16,8 @@ import (
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"infinite-canvas/backend/internal/assets"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/assets"
|
||||
"yingce/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestAppearanceButtonFillRoundTripAndValidation(t *testing.T) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
const Live2DMaxBytes int64 = 128 << 20
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
func live2DTestFiles(t *testing.T) map[string][]byte {
|
||||
|
||||
@@ -14,9 +14,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/database"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/database"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/assets"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/assets"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
type UpdateAnnouncement struct {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
func updateTestValue() UpdateAnnouncement {
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/auth"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/auth"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
const SessionCookieName = auth.SessionCookieName
|
||||
|
||||
@@ -6,10 +6,10 @@ import (
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/assets"
|
||||
"infinite-canvas/backend/internal/canvas"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/assets"
|
||||
"yingce/backend/internal/canvas"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
type (
|
||||
|
||||
@@ -2,12 +2,12 @@ package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestCanvasHistoryProtectsMediaAndDeletionWorker(t *testing.T) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
type ChannelModelsRequest struct {
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"infinite-canvas/backend/internal/provider"
|
||||
"infinite-canvas/backend/internal/provider/bailian"
|
||||
"yingce/backend/internal/provider"
|
||||
"yingce/backend/internal/provider/bailian"
|
||||
)
|
||||
|
||||
var pluginsInitOnce sync.Once
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestChannelModelLabelSaveAndCatalogPreserveChannelIdentity(t *testing.T) {
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
type ChannelModelRepriceRequest struct {
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ package app
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
func normalizeChannelModelTags(input []model.ChannelModelTag) ([]model.ChannelModelTag, error) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestChannelModelTagsValidation(t *testing.T) {
|
||||
|
||||
@@ -3,7 +3,7 @@ package app
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
// 模型级上游键重命名后,任务请求必须使用新键;价格档中与旧值相同的上游键属于
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/protocol"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/protocol"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
type ChannelModelRequest struct {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
func (s *Service) FetchAdminChannelModels(ctx context.Context, actor *model.User, channelID string) (*AdminChannelModelFetchResult, error) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
func validateChannelModelTierCapabilities(tiers []model.ChannelModelPriceTier, rawCapabilityConfig string, capability string) error {
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
func mustEncodeModelCapabilityConfig(t *testing.T, config *ModelCapabilityConfig) string {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User, channelID string, req ChannelModelRequest) (*AdminChannelModelTestResult, error) {
|
||||
@@ -112,7 +112,7 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User,
|
||||
defer cancel()
|
||||
testCtx = context.WithValue(testCtx, providerAnalyticsKey{}, providerAnalyticsContext{
|
||||
Service: s, Billing: s.taskBilling(), UserID: actor.ID, ChannelID: channel.ID, Capability: capability,
|
||||
Operation: "admin_model_test", Model: modelKey, VideoSeconds: videoSecondsValue,
|
||||
Operation: "admin_model_test", Model: modelKey, VideoSeconds: videoSecondsValue, ProxyURL: channel.ProxyURL,
|
||||
})
|
||||
testCtx = withProtocolRegistry(testCtx, s.protocolRegistry())
|
||||
startedAt := time.Now()
|
||||
|
||||
@@ -2,9 +2,9 @@ package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"net/http"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
type ChannelOrderItem struct {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"testing"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestVisualChannelOrderAtomicSnapshotAndPermissions(t *testing.T) {
|
||||
|
||||
@@ -12,6 +12,8 @@ func (s *Service) ValidateChannelOutboundURL(rawURL string) (*url.URL, error) {
|
||||
return ValidateOutboundURL(rawURL)
|
||||
}
|
||||
|
||||
func (s *Service) OutboundHTTPClientForChannel(timeout time.Duration, _ *url.URL) *http.Client {
|
||||
return OutboundHTTPClient(timeout)
|
||||
// OutboundHTTPClientForChannel 返回渠道专用的出站 HTTP 客户端。
|
||||
// proxyURL 非空时使用渠道级代理(socks5/socks5h/http/https),否则回退到全局客户端。
|
||||
func (s *Service) OutboundHTTPClientForChannel(timeout time.Duration, _ *url.URL, proxyURL string) *http.Client {
|
||||
return OutboundHTTPClientWithProxy(timeout, proxyURL)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package app
|
||||
import (
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
func (req ChannelRequest) presentationOnly() bool {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestChannelPresentationUsesModelLabelsAndPublicOrder(t *testing.T) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/prompts"
|
||||
"yingce/backend/internal/prompts"
|
||||
)
|
||||
|
||||
func TestChapterAssetsPromptContract(t *testing.T) {
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"infinite-canvas/backend/internal/kernel"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/kernel"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
const cloudAgentOperation = "cloud_agent"
|
||||
|
||||
@@ -3,8 +3,8 @@ package app
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
// The anchor records the current request and observable reference candidates.
|
||||
|
||||
@@ -4,15 +4,16 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"infinite-canvas/backend/internal/canvas/capability"
|
||||
"infinite-canvas/backend/internal/canvas/layout"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/canvas/capability"
|
||||
"yingce/backend/internal/canvas/layout"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
// cloudAgentApprovalPreview is server-authored explanatory data. It never
|
||||
@@ -166,6 +167,11 @@ func applyCloudAgentCanvasPlan(doc map[string]any, ops []agentCanvasOp) ([]cloud
|
||||
return nil, cloudAgentFieldError(fmt.Sprintf("ops[%d].toNodeId", opIndex), "invalid_value", "连线不能指向自身;请选择另一个目标节点")
|
||||
}
|
||||
if err := validateCloudAgentConnectionWithHandles(nodes, op.FromNodeID, op.ToNodeID, op.FromHandleID, op.ToHandleID, edges); err != nil {
|
||||
// 保留端点校验指出的具体字段,让模型只修正错误 handle,而不是放弃镜头绑定。
|
||||
var fieldErr *cloudAgentFieldArgumentError
|
||||
if errors.As(err, &fieldErr) {
|
||||
return nil, cloudAgentFieldError(fmt.Sprintf("ops[%d].%s", opIndex, fieldErr.Field), fieldErr.Issue, cloudAgentSafeToolError(err))
|
||||
}
|
||||
return nil, cloudAgentFieldError(fmt.Sprintf("ops[%d]", opIndex), "invalid_connection", cloudAgentSafeToolError(err))
|
||||
}
|
||||
for _, edge := range edges {
|
||||
|
||||
184
backend/internal/app/cloud_agent_approval_resume_test.go
Normal file
184
backend/internal/app/cloud_agent_approval_resume_test.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 回归(线上评测 run ag7317ce5c seq 28→31):审批等待期间模型历史停在「等待审批」占位
|
||||
// 结果上,批准后执行器已写入画布,但恢复提示只有一句"已执行一次"——模型看不到结果里的
|
||||
// 新 snapshotHash,只能重发同参数调用,撞上自己第一次执行改变的快照(state_conflict)。
|
||||
// 恢复提示必须携带真实执行结果摘要;同批排在审批之后被中止的调用也要一并说明。
|
||||
func TestCloudAgentApprovalResumePromptCarriesReceipt(t *testing.T) {
|
||||
receipt := `{"nodeId":"script-smart-speaker-mom","snapshotHash":"b935f1b7","rows":4}`
|
||||
state := &cloudAgentRuntime{}
|
||||
state.Canonical.Messages = []map[string]any{
|
||||
{"role": "user", "content": "补第 2 镜"},
|
||||
{"role": "assistant", "content": "", "tool_calls": []interface{}{
|
||||
map[string]interface{}{"id": "call_00", "type": "function", "function": map[string]interface{}{"name": "canvas_edit_storyboard", "arguments": "{}"}},
|
||||
}},
|
||||
{"role": "tool", "tool_call_id": "call_00", "content": "操作正在等待用户审批。"},
|
||||
{"role": "tool", "tool_call_id": "call_00", "content": receipt},
|
||||
}
|
||||
|
||||
prompt := cloudAgentApprovalResumePrompt(state)
|
||||
if !strings.Contains(prompt, "b935f1b7") {
|
||||
t.Fatalf("恢复提示应携带执行结果里的最新 snapshotHash:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "不要重复调用") {
|
||||
t.Fatal("恢复提示必须明确禁止重发已执行的操作")
|
||||
}
|
||||
// 同批还有未执行的调用:CallIndex < len(Calls)。
|
||||
state.Calls = []cloudAgentCall{{ID: "call_00"}, {ID: "call_01"}}
|
||||
state.CallIndex = 0
|
||||
prompt = cloudAgentApprovalResumePrompt(state)
|
||||
if !strings.Contains(prompt, "其余调用未被执行") {
|
||||
t.Fatalf("同批剩余调用应在恢复提示中说明:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
// 尾部结果是失败(含 error 字段)或没有工具结果时,回退到不携带结果的通用提示,
|
||||
// 让模型从 canonical 历史里自己的工具结果获取事实。
|
||||
func TestCloudAgentApprovalResumePromptFallsBackWithoutReceipt(t *testing.T) {
|
||||
failed := &cloudAgentRuntime{}
|
||||
failed.Canonical.Messages = []map[string]any{
|
||||
{"role": "tool", "tool_call_id": "call_00", "content": `{"error":"上游拒绝","errorClass":"upstream_failure"}`},
|
||||
}
|
||||
if prompt := cloudAgentApprovalResumePrompt(failed); strings.Contains(prompt, "上游拒绝") {
|
||||
t.Fatalf("失败结果不应作为成功回执进入提示:\n%s", prompt)
|
||||
} else if !strings.Contains(prompt, "业务执行器已执行一次") {
|
||||
t.Fatalf("无回执时应回退通用提示:\n%s", prompt)
|
||||
}
|
||||
|
||||
empty := &cloudAgentRuntime{}
|
||||
if prompt := cloudAgentApprovalResumePrompt(empty); !strings.Contains(prompt, "业务执行器已执行一次") {
|
||||
t.Fatalf("空历史应回退通用提示:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
// 回执超长时截断,避免恢复提示把大结果(如整页画布读取)原样塞回上下文。
|
||||
func TestCloudAgentLastToolReceiptTruncates(t *testing.T) {
|
||||
big := map[string]any{"rows": strings.Repeat("镜", 3000)}
|
||||
raw, err := json.Marshal(big)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := &cloudAgentRuntime{}
|
||||
state.Canonical.Messages = []map[string]any{
|
||||
{"role": "tool", "tool_call_id": "call_00", "content": string(raw)},
|
||||
}
|
||||
receipt, ok := cloudAgentLastToolReceipt(state)
|
||||
if !ok {
|
||||
t.Fatal("应能取到尾部工具结果")
|
||||
}
|
||||
if got := len([]rune(receipt)); got > 1700 {
|
||||
t.Fatalf("超长回执应被截断:got %d runes", got)
|
||||
}
|
||||
if !strings.Contains(receipt, "已截断") {
|
||||
t.Fatal("截断回执应说明完整状态需用读取工具获取")
|
||||
}
|
||||
}
|
||||
|
||||
// 回归(评审 P2):结果 JSON 的字段顺序不保证 snapshotHash 排在前面(Go 序列化 map
|
||||
// 按字典序,preview/rows 会排在 snapshotHash 之前)。朴素前缀截断会把模型继续写入
|
||||
// 所需的最新快照一起截掉,恢复提示反而引导模型带着旧快照撞锁。摘要必须按字段组装:
|
||||
// 长描述字段截断,关键标识(nodeId/snapshotHash)无条件保留。
|
||||
func TestCloudAgentLastToolReceiptKeepsSnapshotBehindLargeFields(t *testing.T) {
|
||||
// 构造字典序在 snapshotHash 之前、且总体超过截断预算的大字段。
|
||||
result := map[string]any{
|
||||
"nodeId": "script-smart-speaker-mom",
|
||||
"preview": strings.Repeat("镜", 2000),
|
||||
"rows": strings.Repeat("分镜行内容", 900),
|
||||
"snapshotHash": "b935f1b7b18d4a85b2b9253a73dcff1f787f6b0bc1c440e46fde7fe3fa77b11f",
|
||||
"applied": true,
|
||||
}
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if idx := strings.Index(string(raw), `"snapshotHash"`); idx < 1600 {
|
||||
t.Fatalf("测试前提不成立:snapshotHash 应排在 1600 字符之后,实际在 %d", idx)
|
||||
}
|
||||
state := &cloudAgentRuntime{}
|
||||
state.Canonical.Messages = []map[string]any{
|
||||
{"role": "tool", "tool_call_id": "call_00", "content": string(raw)},
|
||||
}
|
||||
receipt, ok := cloudAgentLastToolReceipt(state)
|
||||
if !ok {
|
||||
t.Fatal("应能取到尾部工具结果")
|
||||
}
|
||||
for _, key := range []string{"b935f1b7b18d4a85", "script-smart-speaker-mom", `"applied":true`} {
|
||||
if !strings.Contains(receipt, key) {
|
||||
t.Fatalf("回执摘要必须保留关键标识 %s:\n%s", key, receipt)
|
||||
}
|
||||
}
|
||||
if got := len([]rune(receipt)); got > 1700 {
|
||||
t.Fatalf("摘要应控制在预算内:got %d runes", got)
|
||||
}
|
||||
// 预算不够时长字段要显式登记被丢弃,模型知道去哪补全。
|
||||
if !strings.Contains(receipt, "已截断") {
|
||||
t.Fatal("被截断的长字段应说明完整内容需用读取工具获取")
|
||||
}
|
||||
// 摘要仍是合法 JSON,模型可结构化解析。
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(receipt), &decoded); err != nil {
|
||||
t.Fatalf("摘要应是合法 JSON:%v", err)
|
||||
}
|
||||
if decoded["snapshotHash"] != result["snapshotHash"] {
|
||||
t.Fatal("快照字段值不应被改动")
|
||||
}
|
||||
}
|
||||
|
||||
// 回归(评审 P2 二轮):重发指引必须按对象区分。分镜等工具的 snapshotHash 是节点级的:
|
||||
// 同批第一个调用只改变自己目标对象的快照——同对象的剩余调用要用回执最新快照更新参数;
|
||||
// 其他对象的调用原快照仍有效,按原参数重发即可。指引若不限定"同一对象",模型把 A 的
|
||||
// 快照填进 B 的调用反而必然撞锁。
|
||||
func TestUnansweredCallPlaceholderAdvisesSnapshotUpdate(t *testing.T) {
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(cloudAgentPiUnansweredCallText), &decoded); err != nil {
|
||||
t.Fatalf("占位文本应是合法 JSON:%v", err)
|
||||
}
|
||||
message, _ := decoded["error"].(string)
|
||||
if strings.Contains(message, "原样重新调用") {
|
||||
t.Fatal("占位指引不应笼统要求原样重发(同批写同一对象会撞锁)")
|
||||
}
|
||||
for _, needle := range []string{"同一对象", "最新 snapshotHash", "按原参数重发", "重新读取"} {
|
||||
if !strings.Contains(message, needle) {
|
||||
t.Fatalf("占位指引应包含 %q:\n%s", needle, message)
|
||||
}
|
||||
}
|
||||
// 指引的三条分支必须互斥可判:同对象→用回执快照;跨对象/无回执→原参数;被拒→重读。
|
||||
if !strings.Contains(message, "目标对象不同或没有回执时,按原参数重发") {
|
||||
t.Fatal("跨对象分支必须明确指示按原参数重发")
|
||||
}
|
||||
}
|
||||
|
||||
// 占位补齐的「未执行」结果必须是结构化错误(errorClass=approval_deferred),
|
||||
// 与真实工具失败同形态,模型与界面都能按码识别这是审批暂停的副作用。
|
||||
func TestUnansweredCallPlaceholderIsStructuredError(t *testing.T) {
|
||||
messages := []map[string]any{
|
||||
{"role": "assistant", "content": []interface{}{
|
||||
map[string]interface{}{"type": "toolCall", "id": "call_a", "name": "generate_media", "arguments": map[string]interface{}{}},
|
||||
}},
|
||||
}
|
||||
repaired := repairRuntimeUnansweredCalls(messages)
|
||||
if len(repaired) != 2 {
|
||||
t.Fatalf("应补齐 1 条占位结果:got %d messages", len(repaired))
|
||||
}
|
||||
placeholder, _ := repaired[1]["content"].([]interface{})
|
||||
text, _ := placeholder[0].(map[string]interface{})["text"].(string)
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &decoded); err != nil {
|
||||
t.Fatalf("占位结果应是结构化 JSON:%v", err)
|
||||
}
|
||||
if decoded["errorClass"] != cloudAgentToolErrorApprovalDeferred {
|
||||
t.Fatalf("占位错误码应为 approval_deferred:got %v", decoded["errorClass"])
|
||||
}
|
||||
if decoded["error"] == nil {
|
||||
t.Fatal("占位结果应保留人可读的 error 说明")
|
||||
}
|
||||
if cloudAgentToolErrorLabel(cloudAgentToolErrorApprovalDeferred) != "审批暂停未执行" {
|
||||
t.Fatal("错误码标签缺失")
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestCloudAgentMediaReadHashSurvivesMoveBeforeDraft(t *testing.T) {
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
// 停机时审批后的媒体等待会被取消。此时不能继续回写结果、也不能恢复运行时:
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestCloudAgentBatchTableReferenceLimit(t *testing.T) {
|
||||
|
||||
155
backend/internal/app/cloud_agent_canvas_event_size_test.go
Normal file
155
backend/internal/app/cloud_agent_canvas_event_size_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"yingce/backend/internal/database"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
// TestAgentApprovalLargeCanvasEvent 验证完整审批路径,不只检查事件生成 helper。
|
||||
// 所有数据保存在临时数据库中,禁止启动 Pi 或调用收费模型。
|
||||
func TestAgentApprovalLargeCanvasEvent(t *testing.T) {
|
||||
for _, large := range []bool{false, true} {
|
||||
name := "small"
|
||||
if large {
|
||||
name = "large"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
db := agentPermissionsDB(t)
|
||||
if err := db.AutoMigrate(database.Models()...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &Service{repo: repository.New(db), dataDir: t.TempDir(), disablePiRuntime: true}
|
||||
if err := os.WriteFile(filepath.Join(s.dataDir, ".settings-key"), make([]byte, 32), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := "保留正文"
|
||||
if large {
|
||||
content = strings.Repeat("分镜正文", 18000)
|
||||
}
|
||||
doc := map[string]any{"nodes": []any{map[string]any{"id": "story", "type": "text", "title": "原标题", "content": content}}, "connections": []any{}}
|
||||
raw, err := json.Marshal(doc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile := mustEncodeModelCapabilityConfig(t, DefaultModelCapabilityConfigForModel("chat-completion", "text-test"))
|
||||
for _, row := range []any{
|
||||
&model.CanvasProject{ID: "agent-canvas", UserID: "user", PayloadJSON: string(raw)},
|
||||
&model.ModelChannel{ID: "channel", Scope: model.ChannelScopeSystem, Name: "测试", Enabled: true},
|
||||
&model.ChannelModel{ID: "cm", ChannelID: "channel", ModelKey: "text-test", Capability: "text", Protocol: model.ChannelInterfaceChatCompletion, CapabilityConfigJSON: profile, BillingMode: "fixed_request", UnitPriceMicrocredits: 100, PriceConfigured: true, Enabled: true},
|
||||
&model.ChannelModelPriceTier{ID: "tier", ChannelModelID: "cm", SelectorKey: "{}", SelectorJSON: "{}", BillingMode: "fixed_request", UnitPriceMicrocredits: 100, PriceConfigured: true, Enabled: true},
|
||||
&model.CreditAccount{UserID: "user", AvailableMicrocredits: 10000},
|
||||
} {
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
req := agentTestRequest()
|
||||
req.PermissionMode = "request_approval"
|
||||
root, err := s.CreateCloudAgentRun("user", req, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, state := agentInterjectionState(t, s, root.ID)
|
||||
call := cloudAgentCall{ID: "rename-story"}
|
||||
call.Function.Name = "canvas_apply_ops"
|
||||
args, err := json.Marshal(map[string]any{"snapshotHash": cloudAgentCanvasHash(doc), "ops": []any{map[string]any{"type": "update_node", "id": "story", "patch": map[string]any{"title": "新标题"}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call.Function.Arguments = string(args)
|
||||
state.Calls = []cloudAgentCall{call}
|
||||
state.CallIndex = 0
|
||||
run.Status = "running"
|
||||
approvalPauseSaveState(t, s, db, run, &state)
|
||||
run, state = agentInterjectionState(t, s, root.ID)
|
||||
if err := s.advanceCloudAgentTool(run, &state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, state = agentInterjectionState(t, s, root.ID)
|
||||
if state.Approval == nil {
|
||||
t.Fatal("missing approval")
|
||||
}
|
||||
approvalID := state.Approval.ID
|
||||
if err := s.DecideCloudAgentApproval("user", root.ID, approvalID, "approve", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, state = agentInterjectionState(t, s, root.ID)
|
||||
if state.Approval != nil || state.CallIndex != 1 {
|
||||
t.Fatal("approval did not complete")
|
||||
}
|
||||
payload := agentCanvasPatchForOperation(t, state, "canvas_apply_ops")
|
||||
if large {
|
||||
if payload["requiresRefresh"] != true || payload["canvasPatch"] != nil {
|
||||
t.Fatal("large event must request a snapshot refresh")
|
||||
}
|
||||
if len(creationMaps(payload["actions"])) != 1 || payload["preview"] == nil {
|
||||
t.Fatal("refresh notification lost its bounded operation summary")
|
||||
}
|
||||
} else if payload["canvasPatch"] == nil || payload["requiresRefresh"] == true {
|
||||
t.Fatal("small event must retain its delta")
|
||||
}
|
||||
for _, event := range state.Events {
|
||||
body, err := json.Marshal(event.Payload)
|
||||
if err != nil || len(body) > cloudAgentEventPayloadLimit {
|
||||
t.Fatal("event exceeded checkpoint limit")
|
||||
}
|
||||
}
|
||||
canvas, err := s.repo.CanvasProjectForUser("user", "agent-canvas")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := creationDocument(canvas.PayloadJSON)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
node := creationMaps(after["nodes"])[0]
|
||||
if node["title"] != "新标题" || node["content"] != content {
|
||||
t.Fatal("saved canvas lost its title or full content")
|
||||
}
|
||||
if canvas.Revision != 2 {
|
||||
t.Fatalf("unexpected canvas revision: %d", canvas.Revision)
|
||||
}
|
||||
// 审批重送必须复用已经完成的结果,不再写一次画布。
|
||||
if err := s.DecideCloudAgentApproval("user", root.ID, approvalID, "approve", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canvas, err = s.repo.CanvasProjectForUser("user", "agent-canvas")
|
||||
if err != nil || canvas.Revision != 2 {
|
||||
t.Fatal("duplicate approval changed the canvas")
|
||||
}
|
||||
var mutations int64
|
||||
if err := db.Model(&model.CloudAgentCanvasMutation{}).Where("run_id = ?", root.ID).Count(&mutations).Error; err != nil || mutations != 1 {
|
||||
t.Fatalf("unexpected mutation count: %d, error: %v", mutations, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentOversizedCanvasPreviewRequestsRefresh(t *testing.T) {
|
||||
db := agentPermissionsDB(t)
|
||||
repo := repository.New(db)
|
||||
canvas := &model.CanvasProject{ID: "canvas", UserID: "user", PayloadJSON: `{"nodes":[],"connections":[]}`}
|
||||
if err := db.Create(canvas).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := cloudAgentRuntime{}
|
||||
input := cloudAgentMutationInput{UserID: "user", CanvasID: canvas.ID, BeforeJSON: canvas.PayloadJSON, Operation: "canvas_apply_ops", Preview: &cloudAgentApprovalPreview{Description: strings.Repeat("摘要", cloudAgentEventPayloadLimit)}}
|
||||
if err := emitCloudAgentCanvasChange(repo, "run", &state, input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := agentCanvasPatchForOperation(t, state, input.Operation)
|
||||
if payload["requiresRefresh"] != true || payload["canvasPatch"] != nil || payload["preview"] != nil {
|
||||
t.Fatal("oversized summary must use a bounded refresh notification")
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil || len(raw) > cloudAgentEventPayloadLimit {
|
||||
t.Fatal("refresh notification exceeded the event limit")
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
// Persist the delta in the same transaction as the canvas and run checkpoint.
|
||||
@@ -85,6 +86,8 @@ func cloudAgentShrinkChange(before, after map[string]any) (map[string]any, map[s
|
||||
return shrunkBefore, shrunkAfter
|
||||
}
|
||||
|
||||
// emitCloudAgentCanvasChange 生成与画布写入同事务保存的事件。
|
||||
// 增量无法完整表达变更或超过事件上限时通知客户端重新读取,不截断真实画布内容。
|
||||
func emitCloudAgentCanvasChange(repo *repository.Repository, runID string, state *cloudAgentRuntime, input cloudAgentMutationInput) error {
|
||||
before, err := creationDocument(input.BeforeJSON)
|
||||
if err != nil {
|
||||
@@ -190,6 +193,24 @@ func emitCloudAgentCanvasChange(repo *repository.Repository, runID string, state
|
||||
if state.CallIndex >= 0 && state.CallIndex < len(state.Calls) {
|
||||
payload["callId"] = state.Calls[state.CallIndex].ID
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(raw) > cloudAgentEventPayloadLimit {
|
||||
// 修改标题或关联素材也可能携带整份分镜正文的 before/after,导致审批事务回滚。
|
||||
// 大增量沿用已有的快照刷新路径;画布、撤销记录和工具回执仍由原事务完整保存。
|
||||
delete(payload, "canvasPatch")
|
||||
payload["requiresRefresh"] = true
|
||||
raw, err = json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(raw) > cloudAgentEventPayloadLimit {
|
||||
// 摘要本身也超限时不截断字段,退回既有的完整刷新通知。
|
||||
payload = map[string]any{"canvasId": input.CanvasID, "operation": input.Operation, "requiresRefresh": true}
|
||||
}
|
||||
}
|
||||
state.event(runID, "canvas_updated", payload)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/prompts"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/prompts"
|
||||
)
|
||||
|
||||
func agentCanvasPatchForOperation(t *testing.T, state cloudAgentRuntime, operation string) map[string]any {
|
||||
|
||||
@@ -5,8 +5,9 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/canvas/capability"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/assets"
|
||||
"yingce/backend/internal/canvas/capability"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
type cloudAgentStructuredProjector func(value any, offset int, precise bool) (any, error)
|
||||
@@ -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 {
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
type cloudAgentCharacter struct {
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"infinite-canvas/backend/internal/canvas/layout"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/canvas/layout"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestCloudAgentCreatesCharacterCardFromCanvasMedia(t *testing.T) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
func seedCloudAgentCharacter(t *testing.T, db *gorm.DB, canvasID string) map[string]any {
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
func TestCloudAgentContextBudgetKeepsModelWindowAndOutputSeparate(t *testing.T) {
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/agentcontext"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/agentcontext"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
// cloudAgentContextCompactionOperation 是"压缩历史"这次独立模型调用的任务操作名。
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"infinite-canvas/backend/internal/agentcontext"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/agentcontext"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
// saveCloudAgentCompactionState 用真实保存路径持久化运行状态(消息、事件与检查点同事务),
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
// ContextFrame is a fresh projection of durable task facts, never authorization.
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestCloudAgentContinuationReplySplitsFactsFromAssistantText(t *testing.T) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
// 自研(2026-09-15):部署改了策略或能力集后,旧轮次的 policy 快照与当前二进制不一致,
|
||||
|
||||
@@ -3,12 +3,14 @@ package app
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/canvas/capability"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/canvas/capability"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestCloudAgentMixedCanvasReadsUnsupportedNodesWithoutGrantingCapabilities(t *testing.T) {
|
||||
@@ -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{}}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestCloudAgentCanvasArgumentFeedbackIdentifiesFailingOperation(t *testing.T) {
|
||||
|
||||
@@ -3,8 +3,8 @@ package app
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
const cloudAgentMaxEmptyOutputNudges = 2
|
||||
|
||||
@@ -3,7 +3,7 @@ package app
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestCloudAgentEmptyModelOutputDetection(t *testing.T) {
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"infinite-canvas/backend/internal/canvas/contract"
|
||||
"strings"
|
||||
"yingce/backend/internal/canvas/contract"
|
||||
)
|
||||
|
||||
// cloudAgentGenerationSpec 将 Agent 参数和已解析素材转换为画布生成合同。
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestCloudAgentIdentityUsesSavedNameAcrossConversationTurns(t *testing.T) {
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
// 自研(2026-09-17):运行中插话。
|
||||
|
||||
@@ -25,8 +25,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
// cloudAgentInterjectionContent 插话进入对话的正文格式:前缀是给模型看的结构
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
// interjectionPiRoot 建立 Pi 模式的运行(root 任务不占活动任务位,插话送达发生在 /model 桥)。
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
// agentInterjectionState 取回并解码某个运行的运行态。
|
||||
|
||||
248
backend/internal/app/cloud_agent_invalid_arguments_test.go
Normal file
248
backend/internal/app/cloud_agent_invalid_arguments_test.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
// 回归(线上 run ag3580aca,2026-10-07):用户发整份剧本,模型把大纲塞进 canvas_apply_ops
|
||||
// 参数超过 32000 字节,validateCloudAgentCalls 把整批调用判成致命协议错误,run 以
|
||||
// 「Agent 执行中断」失败。参数超限是模型输出问题,应走纠偏:无效调用回结构化错误让模型
|
||||
// 拆分重试,有效调用继续执行;连续多批无效才硬失败。
|
||||
func oversizedCall(id, name string) cloudAgentCall {
|
||||
args, _ := json.Marshal(map[string]any{"ops": []any{map[string]any{"type": "add_node", "id": "big", "content": strings.Repeat("章节大纲内容", 4000)}}})
|
||||
if len(args) <= cloudAgentToolArgumentsByteLimit {
|
||||
panic("测试前提不成立:参数应超过字节上限")
|
||||
}
|
||||
return cloudAgentCall{ID: id, Function: struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}{Name: name, Arguments: string(args)}}
|
||||
}
|
||||
|
||||
func validCall(id, name string) cloudAgentCall {
|
||||
return cloudAgentCall{ID: id, Function: struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}{Name: name, Arguments: `{"nodeId":"n1"}`}}
|
||||
}
|
||||
|
||||
func TestCloudAgentBatchInvalidCalls(t *testing.T) {
|
||||
big := oversizedCall("call_big", "canvas_apply_ops")
|
||||
ok := validCall("call_ok", "canvas_get_state")
|
||||
|
||||
invalid, structural := cloudAgentBatchInvalidCalls([]cloudAgentCall{big, ok})
|
||||
if len(invalid) != 1 {
|
||||
t.Fatalf("应只有超限调用无效:got %v", invalid)
|
||||
}
|
||||
if structural {
|
||||
t.Fatal("参数超限不是结构类问题,不应整步拒绝")
|
||||
}
|
||||
if !strings.Contains(invalid[0], "32000") {
|
||||
t.Fatalf("超限原因应说明字节上限:%s", invalid[0])
|
||||
}
|
||||
|
||||
// 非法 JSON、ID 重复各自可判;重复 ID 的第二个调用即使参数有效也不放行,
|
||||
// 且重复 ID 属结构类问题——按 ID 配对纠偏有歧义,必须整步拒绝。
|
||||
broken := cloudAgentCall{ID: "call_broken", Function: struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}{Name: "canvas_apply_ops", Arguments: `{"ops":`}}
|
||||
dup := validCall("call_ok", "canvas_get_state")
|
||||
invalid, structural = cloudAgentBatchInvalidCalls([]cloudAgentCall{broken, ok, dup})
|
||||
if len(invalid) != 2 {
|
||||
t.Fatalf("非法 JSON 与重复 ID 都应判无效:got %v", invalid)
|
||||
}
|
||||
if !structural {
|
||||
t.Fatal("重复 ID 应标记为结构类问题")
|
||||
}
|
||||
if _, argumentProblem := cloudAgentInvalidCallReason(broken, map[string]bool{}); !argumentProblem {
|
||||
t.Fatal("非法 JSON 应归类为参数问题")
|
||||
}
|
||||
}
|
||||
|
||||
// 回归(评审):Pi 恢复后重放超限调用时,/tool 桥接的参数校验发生在回执回放之前,
|
||||
// 模型只能拿到一句泛化的 invalid Agent tool arguments,拿不到纠偏提交预写的
|
||||
// 「拆分重试」结构化回执——Pi 会话与服务端 canonical 各记一份不同结果,下一轮模型
|
||||
// 请求按历史长度二选一。校验失败时必须先回放已保存回执,两边保持一致。
|
||||
func TestCloudAgentPiToolReplaysSavedReceiptForInvalidArguments(t *testing.T) {
|
||||
s, db, _ := agentMediaFixture(t)
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
}
|
||||
req := agentTestRequest()
|
||||
root, err := s.CreateCloudAgentRun("user", req, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, err := s.repo.CloudAgent("user", root.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, err := cloudAgentDecode(run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
big := oversizedCall("call_big", "canvas_apply_ops")
|
||||
receipt := `{"error":"该工具调用未通过参数校验:参数超过 32000 字节上限。","errorClass":"invalid_model_output","requiredAction":"fix_arguments"}`
|
||||
state.Canonical.Messages = append(state.Canonical.Messages,
|
||||
map[string]any{"role": "assistant", "content": "落大纲", "tool_calls": []cloudAgentCall{big}},
|
||||
map[string]any{"role": "tool", "tool_call_id": "call_big", "content": receipt},
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
payload := map[string]json.RawMessage{
|
||||
"callId": json.RawMessage(`"call_big"`),
|
||||
"name": json.RawMessage(`"canvas_apply_ops"`),
|
||||
"arguments": json.RawMessage(big.Function.Arguments),
|
||||
}
|
||||
got, toolErr := s.cloudAgentPiTool(t.Context(), "user", run.ID, payload)
|
||||
if toolErr != nil {
|
||||
t.Fatalf("已保存回执的无效调用应回放回执而不是报校验错误:%v", toolErr)
|
||||
}
|
||||
result, _ := got.(map[string]any)
|
||||
if result == nil {
|
||||
t.Fatalf("应返回工具结果形态:%v", got)
|
||||
}
|
||||
if content, _ := result["content"].(string); !strings.Contains(content, "32000") || !strings.Contains(content, "fix_arguments") {
|
||||
t.Fatalf("回放结果应是预写的结构化回执:%v", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinishCloudAgentPiModelStepWithInvalidCalls(t *testing.T) {
|
||||
s, db, _ := agentMediaFixture(t)
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
}
|
||||
req := agentTestRequest()
|
||||
root, err := s.CreateCloudAgentRun("user", req, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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.ActiveTaskID = "model-task-1"
|
||||
state.TaskIDs = append(state.TaskIDs, "model-task-1")
|
||||
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)
|
||||
}
|
||||
|
||||
big := oversizedCall("call_big", "canvas_apply_ops")
|
||||
ok := validCall("call_ok", "canvas_get_state")
|
||||
invalid, structural := cloudAgentBatchInvalidCalls([]cloudAgentCall{big, ok})
|
||||
if structural {
|
||||
t.Fatal("测试前提不成立:超限应属参数类问题")
|
||||
}
|
||||
if len(invalid) != 1 {
|
||||
t.Fatalf("测试前提不成立:应恰好一个无效调用,got %v", invalid)
|
||||
}
|
||||
|
||||
if err := s.finishCloudAgentPiModelStepWithInvalidCalls("user", run.ID, "model-task-1", "我先落大纲", "推理文本", []cloudAgentCall{big, ok}, invalid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
latest, err := s.repo.CloudAgent("user", run.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fresh, err := cloudAgentDecode(latest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fresh.ActiveTaskID != "" {
|
||||
t.Fatal("纠偏提交后应释放活动任务")
|
||||
}
|
||||
if fresh.InvalidArgumentSteps != 1 {
|
||||
t.Fatalf("参数纠偏计数应为 1:got %d", fresh.InvalidArgumentSteps)
|
||||
}
|
||||
if len(fresh.Calls) != 1 || fresh.Calls[0].ID != "call_ok" {
|
||||
t.Fatalf("执行批次应只含有效调用:%+v", fresh.Calls)
|
||||
}
|
||||
if fresh.CallIndex != 0 {
|
||||
t.Fatal("执行游标应回到 0")
|
||||
}
|
||||
|
||||
// canonical:assistant 保留全部调用,无效调用紧跟结构化错误结果(配对完整)。
|
||||
assistantCalls := 0
|
||||
receipt := ""
|
||||
for _, message := range fresh.Canonical.Messages {
|
||||
if stringField(message, "role") == "assistant" {
|
||||
if calls, ok := message["tool_calls"].([]any); ok {
|
||||
assistantCalls = len(calls)
|
||||
}
|
||||
}
|
||||
if stringField(message, "role") == "tool" && stringField(message, "tool_call_id") == "call_big" {
|
||||
receipt = stringField(message, "content")
|
||||
}
|
||||
}
|
||||
if assistantCalls != 2 {
|
||||
t.Fatalf("assistant 应保留全部 2 个调用:got %d", assistantCalls)
|
||||
}
|
||||
if receipt == "" {
|
||||
t.Fatal("无效调用必须有错误回执")
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(receipt), &decoded); err != nil {
|
||||
t.Fatalf("回执应是结构化 JSON:%v", err)
|
||||
}
|
||||
if decoded["errorClass"] != cloudAgentToolErrorInvalidModelOutput || decoded["requiredAction"] != "fix_arguments" {
|
||||
t.Fatalf("回执应按模型输出问题分类并指引修正参数:%v", decoded)
|
||||
}
|
||||
if !strings.Contains(stringField(decoded, "error"), "拆分") {
|
||||
t.Fatalf("超限回执应指引拆分重试:%v", decoded["error"])
|
||||
}
|
||||
|
||||
// 事件可诊断:拒绝的工具体与尝试次数入日志(事件 JSON 顶层带 type 字段)。
|
||||
rejected := false
|
||||
for _, event := range latest.Journal {
|
||||
var payload map[string]any
|
||||
if json.Unmarshal([]byte(event.EventJSON), &payload) == nil && payload["type"] == "tool_arguments_rejected" {
|
||||
rejected = true
|
||||
}
|
||||
}
|
||||
if !rejected {
|
||||
t.Fatal("缺少 tool_arguments_rejected 事件")
|
||||
}
|
||||
|
||||
// 成功批次清零计数:普通提交一批全部有效的调用。
|
||||
state, err = cloudAgentDecode(latest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state.ActiveTaskID = "model-task-2"
|
||||
state.TaskIDs = append(state.TaskIDs, "model-task-2")
|
||||
if err = s.repo.MutateCloudAgent("user", run.ID, latest.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error {
|
||||
return cloudAgentSave(current, &state)
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next := validCall("call_next", "canvas_get_state")
|
||||
if err := s.finishCloudAgentPiModelStep("user", run.ID, "model-task-2", "", "", []cloudAgentCall{next}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if latest, err = s.repo.CloudAgent("user", run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fresh, err = cloudAgentDecode(latest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fresh.InvalidArgumentSteps != 0 {
|
||||
t.Fatalf("全部有效的批次应清零纠偏计数:got %d", fresh.InvalidArgumentSteps)
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/canvas/layout"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/canvas/layout"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
// 云端 Agent 的节点整理:模型只决定"整理哪些节点 / 怎么归类",几何一律由服务端算。
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/canvas/layout"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"yingce/backend/internal/canvas/layout"
|
||||
"yingce/backend/internal/model"
|
||||
)
|
||||
|
||||
// layoutFixture 造一张用于整理测试的画布:文本 / 图片 / 视频三类节点挤在同一个角落。
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"infinite-canvas/backend/internal/kernel"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/kernel"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -48,6 +48,7 @@ const (
|
||||
cloudAgentLessonSearchTokenMax = 8
|
||||
cloudAgentRememberLessonMaxPerRun = 3
|
||||
cloudAgentRememberLessonPendingPerUser = 50
|
||||
cloudAgentPendingLessonTTL = 30 * 24 * time.Hour
|
||||
cloudAgentRememberLessonApprovedMax = 300
|
||||
cloudAgentMemoryImportMax = 200
|
||||
)
|
||||
@@ -121,12 +122,16 @@ func cloudAgentRememberLesson(repo *repository.Repository, userID string, state
|
||||
if cloudAgentRememberLessonCount(state) >= cloudAgentRememberLessonMaxPerRun {
|
||||
return nil, BadAuthRequest(fmt.Sprintf("本轮最多记录 %d 条经验,请合并成更通用的一条", cloudAgentRememberLessonMaxPerRun))
|
||||
}
|
||||
now := time.Now()
|
||||
if _, err := repo.ExpirePendingAgentLessons(userID, now.Add(-cloudAgentPendingLessonTTL), now, cloudAgentRememberLessonPendingPerUser); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pending, err := repo.CountAgentLessonsByAuthor(userID, model.AgentLessonStatusPending)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pending >= cloudAgentRememberLessonPendingPerUser {
|
||||
return nil, BadAuthRequest("待你批准的记忆已经比较多,请先到「设置 → Agent 记忆」处理后再记新的")
|
||||
return nil, BadAuthRequest(fmt.Sprintf("当前有 %d 条待批准记忆,已达到上限 %d;请先到「设置 → Agent 记忆」批准或删除后再记新的", pending, cloudAgentRememberLessonPendingPerUser))
|
||||
}
|
||||
var args struct {
|
||||
Topic string `json:"topic"`
|
||||
@@ -170,7 +175,6 @@ func cloudAgentRememberLesson(repo *repository.Repository, userID string, state
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
entry := &model.AgentLesson{
|
||||
ID: newID(), Topic: topic, Situation: situation, Lesson: lesson, Source: source,
|
||||
Category: cloudAgentNormalizeLessonCategory(args.Category),
|
||||
@@ -214,13 +218,23 @@ func cloudAgentLessonEligibleSuccesses(state *cloudAgentRuntime) int {
|
||||
continue
|
||||
}
|
||||
name, _ := event.Payload["toolName"].(string)
|
||||
switch name {
|
||||
case "", "skills_load", "remember_lesson", "recall_lessons", "agent_profile_read", "plan_update", "ask_user":
|
||||
continue
|
||||
if cloudAgentLessonToolEligible(name) {
|
||||
count++
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count
|
||||
if count > state.LessonEligibleToolSuccesses {
|
||||
return count
|
||||
}
|
||||
return state.LessonEligibleToolSuccesses
|
||||
}
|
||||
|
||||
func cloudAgentLessonToolEligible(name string) bool {
|
||||
switch name {
|
||||
case "", "skills_load", "remember_lesson", "recall_lessons", "agent_profile_read", "plan_update", "ask_user":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func cloudAgentRememberLessonCount(state *cloudAgentRuntime) int {
|
||||
@@ -236,7 +250,10 @@ func cloudAgentRememberLessonCount(state *cloudAgentRuntime) int {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
if count > state.RememberLessonSuccesses {
|
||||
return count
|
||||
}
|
||||
return state.RememberLessonSuccesses
|
||||
}
|
||||
|
||||
func cloudAgentLessonFingerprint(situation, lesson, stepsJSON string) string {
|
||||
@@ -397,20 +414,34 @@ func (s *Service) cloudAgentLessonsBlock(userID, taskText string) string {
|
||||
}
|
||||
|
||||
func cloudAgentPickLessonIndex(lessons []model.AgentLesson, taskText string, limit int) ([]model.AgentLesson, int) {
|
||||
matched := make([]model.AgentLesson, 0)
|
||||
rest := make([]model.AgentLesson, 0, len(lessons))
|
||||
for _, lesson := range lessons {
|
||||
if cloudAgentLessonMatchesTask(lesson, taskText) {
|
||||
matched = append(matched, lesson)
|
||||
} else {
|
||||
rest = append(rest, lesson)
|
||||
}
|
||||
type rankedLesson struct {
|
||||
lesson model.AgentLesson
|
||||
score int
|
||||
}
|
||||
ranked := make([]rankedLesson, 0, len(lessons))
|
||||
for _, lesson := range lessons {
|
||||
ranked = append(ranked, rankedLesson{lesson: lesson, score: cloudAgentLessonTaskScore(lesson, taskText)})
|
||||
}
|
||||
sort.SliceStable(ranked, func(i, j int) bool {
|
||||
if ranked[i].score != ranked[j].score {
|
||||
return ranked[i].score > ranked[j].score
|
||||
}
|
||||
if ranked[i].lesson.Hits != ranked[j].lesson.Hits {
|
||||
return ranked[i].lesson.Hits > ranked[j].lesson.Hits
|
||||
}
|
||||
return ranked[i].lesson.UpdatedAt.After(ranked[j].lesson.UpdatedAt)
|
||||
})
|
||||
index := make([]model.AgentLesson, 0, len(ranked))
|
||||
matchedN := 0
|
||||
for _, item := range ranked {
|
||||
if item.score > 0 {
|
||||
matchedN++
|
||||
}
|
||||
index = append(index, item.lesson)
|
||||
}
|
||||
index := append(matched, rest...)
|
||||
if limit > 0 && len(index) > limit {
|
||||
index = index[:limit]
|
||||
}
|
||||
matchedN := len(matched)
|
||||
if matchedN > len(index) {
|
||||
matchedN = len(index)
|
||||
}
|
||||
@@ -476,20 +507,34 @@ func cloudAgentLessonsBlock(view cloudAgentLessonView) string {
|
||||
}
|
||||
|
||||
func cloudAgentLessonMatchesTask(lesson model.AgentLesson, taskText string) bool {
|
||||
return cloudAgentLessonTaskScore(lesson, taskText) > 0
|
||||
}
|
||||
|
||||
func cloudAgentLessonTaskScore(lesson model.AgentLesson, taskText string) int {
|
||||
haystack := cloudAgentLessonNormalize(taskText)
|
||||
if haystack == "" {
|
||||
return false
|
||||
return 0
|
||||
}
|
||||
score := 0
|
||||
for _, needle := range cloudAgentLessonNeedles(lesson) {
|
||||
if utf8.RuneCountInString(needle) >= 3 && strings.Contains(haystack, needle) {
|
||||
return true
|
||||
runes := []rune(needle)
|
||||
isHanPhrase := len(runes) >= 2 && unicode.Is(unicode.Han, runes[0]) && unicode.Is(unicode.Han, runes[1])
|
||||
if (!isHanPhrase && utf8.RuneCountInString(needle) < 3) || !strings.Contains(haystack, needle) {
|
||||
continue
|
||||
}
|
||||
weight := 1
|
||||
if strings.Contains(cloudAgentLessonNormalize(lesson.Topic), needle) {
|
||||
weight = 4
|
||||
} else if strings.Contains(cloudAgentLessonNormalize(lesson.Situation), needle) {
|
||||
weight = 2
|
||||
}
|
||||
score += weight
|
||||
}
|
||||
return false
|
||||
return score
|
||||
}
|
||||
|
||||
func cloudAgentLessonNeedles(lesson model.AgentLesson) []string {
|
||||
needles := make([]string, 0, 8)
|
||||
needles := make([]string, 0, 24)
|
||||
seen := map[string]bool{}
|
||||
push := func(value string) {
|
||||
value = cloudAgentLessonNormalize(value)
|
||||
@@ -499,6 +544,14 @@ func cloudAgentLessonNeedles(lesson model.AgentLesson) []string {
|
||||
seen[value] = true
|
||||
needles = append(needles, value)
|
||||
}
|
||||
pushCJKBigrams := func(value string) {
|
||||
runes := []rune(cloudAgentLessonNormalize(value))
|
||||
for index := 0; index+1 < len(runes); index++ {
|
||||
if unicode.Is(unicode.Han, runes[index]) && unicode.Is(unicode.Han, runes[index+1]) {
|
||||
push(string(runes[index : index+2]))
|
||||
}
|
||||
}
|
||||
}
|
||||
var steps []model.AgentLessonStep
|
||||
if json.Unmarshal([]byte(lesson.StepsJSON), &steps) == nil {
|
||||
for _, step := range steps {
|
||||
@@ -509,13 +562,16 @@ func cloudAgentLessonNeedles(lesson model.AgentLesson) []string {
|
||||
return r == '.' || r == '-' || r == '_' || r == ' '
|
||||
}) {
|
||||
push(chunk)
|
||||
pushCJKBigrams(chunk)
|
||||
}
|
||||
pushCJKBigrams(lesson.Situation)
|
||||
return needles
|
||||
}
|
||||
|
||||
func cloudAgentLessonGenericToken(token string) bool {
|
||||
switch token {
|
||||
case "video", "image", "canvas", "storyboard", "shot", "step", "task", "model", "with", "and", "the":
|
||||
case "video", "image", "canvas", "storyboard", "shot", "step", "task", "model", "with", "and", "the",
|
||||
"画布", "分镜", "镜头", "视频", "图片", "生成", "任务", "操作", "处理", "使用", "通过", "进行", "一个", "这个", "可以", "需要", "如果", "然后", "之后", "之前", "结果", "内容", "相关":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
func agentLessonTestRepo(t *testing.T) *repository.Repository {
|
||||
@@ -736,3 +736,110 @@ func TestAdminAgentLessonsFiltersByUser(t *testing.T) {
|
||||
t.Fatalf("不筛选时应看到全部:%d(%v)", len(all), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberLessonGateSurvivesEventWindowAndCheckpoint(t *testing.T) {
|
||||
state := &cloudAgentRuntime{LessonEligibleToolSuccesses: 1}
|
||||
for i := 0; i < repository.CloudAgentJournalWindow; i++ {
|
||||
state.Events = append(state.Events, CloudAgentEvent{Type: "message_delta"})
|
||||
}
|
||||
if got := cloudAgentLessonEligibleSuccesses(state); got != 1 {
|
||||
t.Fatalf("delta 窗口不得抹掉已记录的合格成功数:got %d", got)
|
||||
}
|
||||
|
||||
state.Events = nil
|
||||
run := &model.CloudAgentExecution{}
|
||||
if err := cloudAgentSave(run, state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restored, err := cloudAgentDecode(run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if restored.LessonEligibleToolSuccesses != 1 || cloudAgentLessonEligibleSuccesses(&restored) != 1 {
|
||||
t.Fatalf("checkpoint 恢复后写入门禁必须保持:%+v", restored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingAgentLessonsExpireWithoutTouchingApproved(t *testing.T) {
|
||||
repo := agentLessonTestRepo(t)
|
||||
now := time.Now()
|
||||
old := now.Add(-cloudAgentPendingLessonTTL - time.Hour)
|
||||
entries := []*model.AgentLesson{
|
||||
{ID: newID(), Topic: "old-pending", Status: model.AgentLessonStatusPending, AuthorUserID: "user-a", CreatedAt: old, UpdatedAt: old},
|
||||
{ID: newID(), Topic: "fresh-pending", Status: model.AgentLessonStatusPending, AuthorUserID: "user-a", CreatedAt: now, UpdatedAt: now},
|
||||
{ID: newID(), Topic: "old-approved", Status: model.AgentLessonStatusApproved, AuthorUserID: "user-a", CreatedAt: old, UpdatedAt: old},
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if err := repo.Create(entry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
changed, err := repo.ExpirePendingAgentLessons("", now.Add(-cloudAgentPendingLessonTTL), now, 500)
|
||||
if err != nil || changed != 1 {
|
||||
t.Fatalf("应只过期一条旧 pending:changed=%d err=%v", changed, err)
|
||||
}
|
||||
for _, want := range []struct{ id, status string }{
|
||||
{entries[0].ID, model.AgentLessonStatusRejected},
|
||||
{entries[1].ID, model.AgentLessonStatusPending},
|
||||
{entries[2].ID, model.AgentLessonStatusApproved},
|
||||
} {
|
||||
got, findErr := repo.AgentLessonForUser("user-a", want.id)
|
||||
if findErr != nil || got.Status != want.status {
|
||||
t.Fatalf("记忆 %s 状态不符:got=%+v err=%v", want.id, got, findErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberLessonExpiresOldPendingBeforeApplyingLimit(t *testing.T) {
|
||||
repo := agentLessonTestRepo(t)
|
||||
now := time.Now()
|
||||
old := now.Add(-cloudAgentPendingLessonTTL - time.Hour)
|
||||
for index := 0; index < cloudAgentRememberLessonPendingPerUser; index++ {
|
||||
created := now
|
||||
if index == 0 {
|
||||
created = old
|
||||
}
|
||||
entry := &model.AgentLesson{ID: newID(), Topic: "pending", Status: model.AgentLessonStatusPending, AuthorUserID: "user-a", CreatedAt: created, UpdatedAt: created}
|
||||
if err := repo.Create(entry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
state := &cloudAgentRuntime{
|
||||
Request: CloudAgentRequest{PermissionMode: "auto"},
|
||||
LessonEligibleToolSuccesses: 1,
|
||||
}
|
||||
call := rememberLessonCall(`{"topic":"canvas.snapshot-hash","category":"canvas","situation":"写画布时报快照过期","lesson":"用返回的新 snapshotHash"}`)
|
||||
if _, err := cloudAgentRememberLesson(repo, "user-a", state, call); err != nil {
|
||||
t.Fatalf("过期一条后应可新建待审记忆:%v", err)
|
||||
}
|
||||
pending, err := repo.CountAgentLessonsByAuthor("user-a", model.AgentLessonStatusPending)
|
||||
if err != nil || pending != cloudAgentRememberLessonPendingPerUser {
|
||||
t.Fatalf("新写入后待审数应回到上限:%d (%v)", pending, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberLessonPendingLimitErrorShowsCountAndSettings(t *testing.T) {
|
||||
repo := agentLessonTestRepo(t)
|
||||
now := time.Now()
|
||||
for index := 0; index < cloudAgentRememberLessonPendingPerUser; index++ {
|
||||
entry := &model.AgentLesson{ID: newID(), Topic: "pending", Status: model.AgentLessonStatusPending, AuthorUserID: "user-a", CreatedAt: now, UpdatedAt: now}
|
||||
if err := repo.Create(entry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
state := &cloudAgentRuntime{Request: CloudAgentRequest{PermissionMode: "auto"}, LessonEligibleToolSuccesses: 1}
|
||||
_, err := cloudAgentRememberLesson(repo, "user-a", state, rememberLessonCall(`{"topic":"canvas.snapshot-hash","category":"canvas","situation":"写画布时报快照过期","lesson":"用返回的新 snapshotHash"}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "50") || !strings.Contains(err.Error(), "设置 → Agent 记忆") {
|
||||
t.Fatalf("拒绝信息应包含待审数量和处理入口:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLessonIndexRanksTaskRelevanceBeforeHitsAndMatchesTwoHanRunes(t *testing.T) {
|
||||
now := time.Now()
|
||||
unrelated := model.AgentLesson{ID: "unrelated", Topic: "video.duration", Situation: "竖屏视频生成时长", Hits: 9000, UpdatedAt: now}
|
||||
relevant := model.AgentLesson{ID: "relevant", Topic: "对象绑定", Situation: "预演对象绑定角色时", Hits: 0, UpdatedAt: now.Add(-time.Hour)}
|
||||
index, matched := cloudAgentPickLessonIndex([]model.AgentLesson{unrelated, relevant}, "请处理对象绑定", 20)
|
||||
if matched != 1 || len(index) != 2 || index[0].ID != "relevant" {
|
||||
t.Fatalf("任务相关记忆应先于高频无关记忆,且两字短语应命中:matched=%d index=%+v", matched, index)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
// The Agent uses the same public catalog as the composer, never a second routing policy.
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"yingce/backend/internal/model"
|
||||
"yingce/backend/internal/repository"
|
||||
)
|
||||
|
||||
// Only user-editable image options cross the approval boundary. Targets,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user