chore(sync): 上游同步 - 合并 upstream/main 至 ee6b3446,按新 module 路径收敛冲突

This commit is contained in:
2026-10-10 09:31:17 +08:00
1310 changed files with 47075 additions and 7234 deletions

View File

@@ -11,7 +11,7 @@ import (
"strings"
"time"
agentruntime "infinite-canvas/backend/internal/agent/runtime"
agentruntime "yingce/backend/internal/agent/runtime"
)
type Status struct {

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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) {

View File

@@ -10,7 +10,7 @@ import (
"time"
"gorm.io/gorm"
"infinite-canvas/backend/internal/model"
"yingce/backend/internal/model"
)
func (s *Service) PublicSystemChannels() ([]PublicModelChannel, error) {

View File

@@ -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"
)

View File

@@ -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

View File

@@ -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"
)

View File

@@ -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"

View File

@@ -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"
)

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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"

View File

@@ -3,7 +3,7 @@ package app
import (
"testing"
"infinite-canvas/backend/internal/model"
"yingce/backend/internal/model"
)
func TestAllVideoProtocolsQuoteReserveAndSettleWithoutProviderUsage(t *testing.T) {

View File

@@ -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

View File

@@ -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) {

View File

@@ -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]++

View File

@@ -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) {

View File

@@ -1,6 +1,6 @@
package app
import "infinite-canvas/backend/internal/model"
import "yingce/backend/internal/model"
type AnalyticsFinance struct {
SettledOrders int `json:"settledOrders"`

View File

@@ -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"

View File

@@ -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()

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"
)

View File

@@ -4,7 +4,7 @@ import (
"encoding/json"
"testing"
"infinite-canvas/backend/internal/model"
"yingce/backend/internal/model"
)
func TestAppearanceButtonFillRoundTripAndValidation(t *testing.T) {

View File

@@ -11,7 +11,7 @@ import (
"strings"
"unicode"
"infinite-canvas/backend/internal/model"
"yingce/backend/internal/model"
)
const Live2DMaxBytes int64 = 128 << 20

View File

@@ -8,7 +8,7 @@ import (
"os"
"testing"
"infinite-canvas/backend/internal/model"
"yingce/backend/internal/model"
)
func live2DTestFiles(t *testing.T) map[string][]byte {

View File

@@ -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"

View File

@@ -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 {

View File

@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"infinite-canvas/backend/internal/model"
"yingce/backend/internal/model"
)
func updateTestValue() UpdateAnnouncement {

View File

@@ -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"
)

View File

@@ -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

View File

@@ -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 (

View File

@@ -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) {

View File

@@ -8,7 +8,7 @@ import (
"sort"
"strings"
"infinite-canvas/backend/internal/model"
"yingce/backend/internal/model"
)
type ChannelModelsRequest struct {

View File

@@ -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

View File

@@ -4,7 +4,7 @@ import (
"strings"
"testing"
"infinite-canvas/backend/internal/model"
"yingce/backend/internal/model"
)
func TestChannelModelLabelSaveAndCatalogPreserveChannelIdentity(t *testing.T) {

View File

@@ -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 {

View File

@@ -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"
)

View File

@@ -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) {

View File

@@ -4,7 +4,7 @@ import (
"strings"
"testing"
"infinite-canvas/backend/internal/model"
"yingce/backend/internal/model"
)
func TestChannelModelTagsValidation(t *testing.T) {

View File

@@ -3,7 +3,7 @@ package app
import (
"testing"
"infinite-canvas/backend/internal/model"
"yingce/backend/internal/model"
)
// 模型级上游键重命名后,任务请求必须使用新键;价格档中与旧值相同的上游键属于

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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 {

View File

@@ -1,8 +1,8 @@
package app
import (
"infinite-canvas/backend/internal/model"
"testing"
"yingce/backend/internal/model"
)
func TestVisualChannelOrderAtomicSnapshotAndPermissions(t *testing.T) {

View File

@@ -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)
}

View File

@@ -3,7 +3,7 @@ package app
import (
"time"
"infinite-canvas/backend/internal/model"
"yingce/backend/internal/model"
)
func (req ChannelRequest) presentationOnly() bool {

View File

@@ -7,7 +7,7 @@ import (
"testing"
"time"
"infinite-canvas/backend/internal/model"
"yingce/backend/internal/model"
)
func TestChannelPresentationUsesModelLabelsAndPublicOrder(t *testing.T) {

View File

@@ -4,7 +4,7 @@ import (
"encoding/json"
"testing"
"infinite-canvas/backend/internal/prompts"
"yingce/backend/internal/prompts"
)
func TestChapterAssetsPromptContract(t *testing.T) {

View File

@@ -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"

View File

@@ -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.

View File

@@ -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 {

View 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("错误码标签缺失")
}
}

View File

@@ -6,7 +6,7 @@ import (
"strings"
"testing"
"infinite-canvas/backend/internal/model"
"yingce/backend/internal/model"
)
func TestCloudAgentMediaReadHashSurvivesMoveBeforeDraft(t *testing.T) {

View File

@@ -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"
)
// 停机时审批后的媒体等待会被取消。此时不能继续回写结果、也不能恢复运行时:

View File

@@ -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 (

View File

@@ -6,7 +6,7 @@ import (
"strings"
"testing"
"infinite-canvas/backend/internal/model"
"yingce/backend/internal/model"
)
func TestCloudAgentBatchTableReferenceLimit(t *testing.T) {

View 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")
}
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 (

View File

@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"infinite-canvas/backend/internal/model"
"yingce/backend/internal/model"
)
func TestCloudAgentCreatesCharacterCardFromCanvasMedia(t *testing.T) {

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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 是"压缩历史"这次独立模型调用的任务操作名。

View File

@@ -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 用真实保存路径持久化运行状态(消息、事件与检查点同事务),

View File

@@ -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.

View File

@@ -4,7 +4,7 @@ import (
"encoding/json"
"strings"
"infinite-canvas/backend/internal/model"
"yingce/backend/internal/model"
)
const (

View File

@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"infinite-canvas/backend/internal/model"
"yingce/backend/internal/model"
)
func TestCloudAgentContinuationReplySplitsFactsFromAssistantText(t *testing.T) {

View File

@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"infinite-canvas/backend/internal/model"
"yingce/backend/internal/model"
)
// 自研(2026-09-15):部署改了策略或能力集后,旧轮次的 policy 快照与当前二进制不一致,

View File

@@ -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{}}

View File

@@ -6,7 +6,7 @@ import (
"strings"
"testing"
"infinite-canvas/backend/internal/model"
"yingce/backend/internal/model"
)
func TestCloudAgentCanvasArgumentFeedbackIdentifiesFailingOperation(t *testing.T) {

View File

@@ -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

View File

@@ -3,7 +3,7 @@ package app
import (
"testing"
"infinite-canvas/backend/internal/model"
"yingce/backend/internal/model"
)
func TestCloudAgentEmptyModelOutputDetection(t *testing.T) {

View File

@@ -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 (

View File

@@ -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"
)

View File

@@ -1,8 +1,8 @@
package app
import (
"infinite-canvas/backend/internal/canvas/contract"
"strings"
"yingce/backend/internal/canvas/contract"
)
// cloudAgentGenerationSpec 将 Agent 参数和已解析素材转换为画布生成合同。

View File

@@ -6,7 +6,7 @@ import (
"strings"
"testing"
"infinite-canvas/backend/internal/model"
"yingce/backend/internal/model"
)
func TestCloudAgentIdentityUsesSavedNameAcrossConversationTurns(t *testing.T) {

View File

@@ -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):运行中插话。

View File

@@ -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 插话进入对话的正文格式:前缀是给模型看的结构

View File

@@ -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 桥)。

View File

@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"infinite-canvas/backend/internal/model"
"yingce/backend/internal/model"
)
// agentInterjectionState 取回并解码某个运行的运行态。

View 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)
}
}

View File

@@ -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 的节点整理:模型只决定"整理哪些节点 / 怎么归类",几何一律由服务端算。

View File

@@ -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 造一张用于整理测试的画布:文本 / 图片 / 视频三类节点挤在同一个角落。

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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.

View File

@@ -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