* refactor(playground): streamline chat request state - extract conversation actions from the page component to keep message flow logic reusable. - unify streaming and non-streaming generation state, including abort support for non-stream requests. - simplify message rendering and payload construction while localizing Playground prompts. * fix(playground): validate persisted chat state - wrap saved Playground state with a storage version while still reading legacy values. - validate config, parameter toggles, and messages before restoring them from localStorage. - cap stored chat history to the latest messages to avoid oversized or stale state. * refactor(playground): centralize message content access - route chat rendering, copy actions, and error display through shared message helpers. - reuse the current-version update helper for non-streaming assistant responses. - keep message version details behind utility functions to reduce future model churn. * refactor(playground): split storage schemas - move Playground storage validation schemas into a dedicated module. - keep storage read and write logic focused on migration, trimming, and persistence. - preserve the existing storage envelope and validation behavior. * refactor(playground): extract options loading hook - move model and group queries into a dedicated hook so the page component stays focused on layout wiring. - preserve existing fallback selection and error toast behavior while reusing the hook through the playground barrel export. * refactor(playground): extract prompt suggestions - move static prompt suggestion rendering into a focused component so the input stays centered on compose controls. - preserve translated suggestion submission behavior while isolating icon metadata from the input form. * refactor(playground): extract input tools - move attachment and search controls into a dedicated component so the prompt input stays focused on compose state. - keep existing development toast behavior and disabled handling while centralizing tool metadata. * refactor(playground): extract input controls - move model, group, send, and stop controls into a focused component so the input only manages compose state. - preserve existing disabled states and generation button behavior while isolating control rendering. * refactor(playground): extract message content display - move sources, reasoning, loading, error, and response rendering into a dedicated message content component. - keep the chat list focused on message iteration, edit state, and action wiring without changing display behavior. * refactor(playground): extract message editor - move inline message editing controls into a dedicated editor component so the chat list stays focused on rendering flow. - preserve save, save-and-submit, cancel, and disabled-state behavior for edited messages. * refactor(playground): extract stream error parsing - move SSE error payload parsing into a reusable stream utility so the request hook stays focused on lifecycle handling. - preserve existing error message, error code, and fallback behavior for raw or empty stream errors. * refactor(playground): extract request error parsing - move non-stream request error extraction into a shared utility so the chat handler stays focused on request flow. - preserve the existing response message, error code, and fallback priority for failed chat completions. * refactor(playground): extract streaming chunk updates - move reasoning and content chunk application into a message utility so the chat handler only wires stream events. - preserve error-state skipping, reasoning accumulation, and content streaming behavior for assistant messages. * refactor(playground): extract message reasoning parser - move think tag parsing into a dedicated playground message utility. - export the parser through the shared playground lib barrel for consistent imports. * refactor(playground): extract message streaming utilities - move stream chunk application and message finalization into a dedicated utility. - keep stored message sanitization with the streaming lifecycle helpers. * refactor(playground): extract message update utilities - move assistant message update helpers into a focused playground utility. - keep error-state message updates separate from core message construction helpers. * refactor(playground): extract completion choice handling - move non-streaming choice application into the message streaming utilities. - keep the chat handler focused on request orchestration and message updates. * refactor(playground): centralize assistant completion state - add a helper for finalizing assistant messages with complete status. - reuse the helper in stream completion and stop-generation paths. * refactor(playground): extract stream message parsing - move SSE delta parsing into a shared stream utility. - keep the stream request hook focused on lifecycle handling and update dispatch. * refactor(playground): extract stream ready state checks - move SSE ready-state status handling into stream utilities. - keep weak source status typing outside the stream request hook. * refactor(playground): extract conversation message helpers - move send, regenerate, and edit message list construction into focused utilities. - keep the conversation hook focused on edit state and update dispatch. * refactor(playground): extract state initialization helpers - move playground initial state loading into focused utility helpers. - centralize message state updater resolution outside the React state hook. * refactor(playground): extract option fallback helpers - move model and group fallback selection into focused playground utilities. - keep the options hook focused on query results, toasts, and config updates. * refactor(playground): extract message action helpers - move message action state derivation into focused utilities. - keep the action component focused on guarded handlers and rendering. * refactor(playground): extract input control state - move submit, stop, and selector state derivation into a pure helper. - keep input controls focused on rendering model selectors and action buttons. * refactor(playground): extract message content state - move source, reasoning, loader, and body visibility checks into a pure helper. - use a discriminated state shape so rendered reasoning content stays type-safe. * refactor(playground): extract message editor state - move save eligibility and submit visibility checks into a pure helper. - keep the editor component focused on textarea and button rendering. * refactor(playground): extract message error state - move error kind, fallback content, and admin visibility checks into a pure helper. - centralize the model pricing settings path used by the error action. * refactor(playground): extract chat render state - move editing content lookup and per-message render flags into conversation helpers. - keep the chat component focused on mapping messages to editor and content views. * refactor(playground): extract suggestion display state - move suggestion class selection into a pure helper. - keep the suggestions component focused on translation and rendering. * refactor(playground): extract assistant message state checks - move final and pending assistant status checks into streaming utilities. - keep the chat handler focused on request lifecycle updates. * refactor(playground): extract input tool state - move attachment action metadata and development notices into input tool utilities. - keep the input tools component focused on menu and button rendering. * refactor(playground): extract stream protocol checks - move SSE done-message and closed-ready-state checks into stream utilities. - keep the stream request hook focused on event handling flow. * refactor(playground): extract message removal helper - move delete-message filtering into conversation message utilities. - keep the conversation hook focused on action orchestration. * refactor(playground): extract option error messages - move option load error message selection into playground option utilities - keep the options hook focused on query effects and fallback updates * refactor(playground): extract input submit text helper - move prompt submit text validation into input control utilities - let the input component submit only when a concrete text value is available * refactor(playground): centralize error message checks - add a shared helper for identifying error messages - remove direct status string checks from message content rendering * refactor(playground): extract message content display checks - move loader and content visibility decisions into local helper functions - keep message content state assembly focused on composing render state * refactor(playground): replace raw message role checks - use shared message role constants in conversation edit handling - avoid raw assistant role literals when validating API messages * refactor(playground): extract non-stream response handling - move chat completion response choice handling into message streaming utilities - keep the chat handler focused on request lifecycle and error routing * refactor(playground): centralize stream cleanup - reuse one stream cleanup path for completion, errors, startup failures, and manual stops - preserve the current-source guard when closing SSE streams * refactor(playground): extract pending assistant check - centralize pending assistant message detection in streaming utilities - reuse the helper when sanitizing stored playground messages * perf(playground): improve mobile input controls - split mobile input controls into selector and action rows - keep the desktop input footer compact while reducing mobile control crowding * perf(playground): add starter empty state - show starter prompts in the empty playground chat area - wire empty-state prompt selection into the existing send flow - add localized copy for the new empty state * perf(playground): improve mobile message actions - collapse mobile message actions into a touch-friendly dropdown menu - keep the desktop hover action strip unchanged for pointer workflows - share one action list between desktop buttons and the mobile menu * perf(playground): add error recovery actions - show retry, edit, and delete actions inside error message alerts - route edit recovery to the previous user prompt when available - keep recovery controls touch-friendly on mobile layouts * perf(playground): refine message editing experience - present message edits in a focused bordered editor panel - add unsaved-change state, reset, and cancel confirmation flows - improve mobile touch targets and keyboard shortcuts for editing * perf(playground): improve markdown code blocks - render fenced markdown code with syntax highlighting, line numbers, and fallback plain text - add copy, download, and collapse controls for playground AI responses - tighten code block layout and theme token styles for responsive markdown rendering * fix(playground): constrain markdown code block height - collapse long playground code blocks after a short preview instead of waiting for very large snippets - cap expanded code blocks so long responses scroll inside the code block - keep generic code block usage unconstrained unless a caller opts in * feat(playground): add chat history clearing - add a toolbar action that is enabled only when saved playground messages exist. - confirm destructive clears before removing browser-stored conversation state. - add localized strings for the action, dialog, and completion toast. * perf(playground): improve chat markdown rendering - refine assistant and user message surfaces so chat content matches the app UI. - normalize markdown typography, tables, images, lists, blockquotes, and details rendering. - add indentation cues for collapsible reasoning and source sections. * style: format code block component * style: format playground frontend files * feat(playground): render markdown with stream parser - replace Streamdown with stream-markdown-parser for project-owned markdown rendering and styling. - split response rendering into focused block, inline, table, alert, details, and footnote modules. - pass message final state into response parsing so streaming content can be parsed incrementally. * fix(playground): localize reasoning and chat feedback - translate reasoning status, message actions, playground errors, and response renderer fallbacks across supported locales. - keep reasoning duration numeric and tighten the collapsible layout to prevent trigger jitter. - register dynamic keys so i18n sync keeps runtime labels covered. * refactor(playground): group files by functional area - move chat, input, and message components into focused subdirectories to make the UI structure easier to scan. - split playground helpers into input, message, streaming, storage, options, state, and suggestions modules. - update barrel exports and imports so existing feature entry points continue to work. * fix(playground): prevent history replay from freezing page - defer saved conversation loading so route entry no longer blocks on localStorage parsing and markdown rendering. - limit initial history rendering and skip expensive markdown parsing for oversized responses. - normalize corrupted streaming snapshots and cumulative chunks to keep saved playground history bounded. - add message timing metadata and layout alignment groundwork without introducing live timers. * feat(playground): allow regenerating from user messages - show regenerate actions on user messages with saved content. - truncate following conversation state before starting a fresh assistant response. * feat(playground): add raw response source view - add a per-message source toggle for assistant responses. - render raw response content with the existing code block viewer. - localize the new source and preview action labels. * feat(playground): render code with unified editor - replace Shiki HTML rendering with a read-only CodeMirror view for code blocks and raw responses. - reuse the same CodeMirror frame for message editing so source and edit modes stay visually aligned. - add lightweight CodeMirror dependencies while keeping language support scoped to Markdown. * perf(playground): streamline chat input controls - combine model and group selection into one compact picker for faster context switching. - switch playground action buttons to icon-first controls with tooltips to reduce toolbar width. - refresh input footer styling and submit states so active and destructive actions are clearer. - bump dompurify lockfile entry to keep the frontend dependency current. * fix(playground): filter models by selected group - query user models by the selected playground group instead of reusing the cross-group model union. - clear unavailable model selections and block sending when the active group has no models. - align model selector and error action controls with the existing playground interaction style. * perf(playground): remove input suggestion chips - remove the prompt suggestion row below the playground input to reduce visual noise. - delete the now-unused suggestion component and display helper. * perf(playground): stabilize reasoning trigger layout - use fixed icon slots around the reasoning label so the left content stays still when toggling. - limit the open state animation to the chevron rotation for a smoother collapse interaction. * perf(playground): smooth reasoning expansion - use the collapsible panel height animation for vertical reasoning reveals. - sync inner content opacity and position with the panel state.
287 lines
9.1 KiB
Go
287 lines
9.1 KiB
Go
package controller
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/constant"
|
|
"github.com/QuantumNous/new-api/dto"
|
|
"github.com/QuantumNous/new-api/model"
|
|
"github.com/QuantumNous/new-api/setting/config"
|
|
"github.com/QuantumNous/new-api/setting/operation_setting"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/glebarez/sqlite"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type listModelsResponse struct {
|
|
Success bool `json:"success"`
|
|
Data []dto.OpenAIModels `json:"data"`
|
|
Object string `json:"object"`
|
|
}
|
|
|
|
type userModelsResponse struct {
|
|
Success bool `json:"success"`
|
|
Data []string `json:"data"`
|
|
}
|
|
|
|
func setupModelListControllerTestDB(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
|
|
initModelListColumnNames(t)
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite)
|
|
common.RedisEnabled = false
|
|
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
require.NoError(t, err)
|
|
model.DB = db
|
|
model.LOG_DB = db
|
|
|
|
require.NoError(t, db.AutoMigrate(&model.User{}, &model.Channel{}, &model.Ability{}, &model.Model{}, &model.Vendor{}))
|
|
|
|
t.Cleanup(func() {
|
|
sqlDB, err := db.DB()
|
|
if err == nil {
|
|
_ = sqlDB.Close()
|
|
}
|
|
})
|
|
|
|
return db
|
|
}
|
|
|
|
func initModelListColumnNames(t *testing.T) {
|
|
t.Helper()
|
|
|
|
originalIsMasterNode := common.IsMasterNode
|
|
originalSQLitePath := common.SQLitePath
|
|
originalMainDatabaseType := common.MainDatabaseType()
|
|
originalLogDatabaseType := common.LogDatabaseType()
|
|
originalSQLDSN, hadSQLDSN := os.LookupEnv("SQL_DSN")
|
|
defer func() {
|
|
common.IsMasterNode = originalIsMasterNode
|
|
common.SQLitePath = originalSQLitePath
|
|
common.SetDatabaseTypes(originalMainDatabaseType, originalLogDatabaseType)
|
|
if hadSQLDSN {
|
|
require.NoError(t, os.Setenv("SQL_DSN", originalSQLDSN))
|
|
} else {
|
|
require.NoError(t, os.Unsetenv("SQL_DSN"))
|
|
}
|
|
}()
|
|
|
|
common.IsMasterNode = false
|
|
common.SQLitePath = fmt.Sprintf("file:%s_init?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
|
common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite)
|
|
require.NoError(t, os.Setenv("SQL_DSN", "local"))
|
|
|
|
require.NoError(t, model.InitDB())
|
|
if model.DB != nil {
|
|
sqlDB, err := model.DB.DB()
|
|
if err == nil {
|
|
_ = sqlDB.Close()
|
|
}
|
|
}
|
|
}
|
|
|
|
func withTieredBillingConfig(t *testing.T, modes map[string]string, exprs map[string]string) {
|
|
t.Helper()
|
|
|
|
saved := map[string]string{}
|
|
require.NoError(t, config.GlobalConfig.SaveToDB(func(key, value string) error {
|
|
if strings.HasPrefix(key, "billing_setting.") {
|
|
saved[key] = value
|
|
}
|
|
return nil
|
|
}))
|
|
t.Cleanup(func() {
|
|
require.NoError(t, config.GlobalConfig.LoadFromDB(saved))
|
|
model.InvalidatePricingCache()
|
|
})
|
|
|
|
modeBytes, err := common.Marshal(modes)
|
|
require.NoError(t, err)
|
|
exprBytes, err := common.Marshal(exprs)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{
|
|
"billing_setting.billing_mode": string(modeBytes),
|
|
"billing_setting.billing_expr": string(exprBytes),
|
|
}))
|
|
model.InvalidatePricingCache()
|
|
}
|
|
|
|
func withSelfUseModeDisabled(t *testing.T) {
|
|
t.Helper()
|
|
|
|
original := operation_setting.SelfUseModeEnabled
|
|
operation_setting.SelfUseModeEnabled = false
|
|
t.Cleanup(func() {
|
|
operation_setting.SelfUseModeEnabled = original
|
|
})
|
|
}
|
|
|
|
func decodeListModelsResponse(t *testing.T, recorder *httptest.ResponseRecorder) map[string]struct{} {
|
|
t.Helper()
|
|
|
|
require.Equal(t, http.StatusOK, recorder.Code)
|
|
var payload listModelsResponse
|
|
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &payload))
|
|
require.True(t, payload.Success)
|
|
require.Equal(t, "list", payload.Object)
|
|
|
|
ids := make(map[string]struct{}, len(payload.Data))
|
|
for _, item := range payload.Data {
|
|
ids[item.Id] = struct{}{}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func pricingByModelName(pricings []model.Pricing) map[string]model.Pricing {
|
|
byName := make(map[string]model.Pricing, len(pricings))
|
|
for _, pricing := range pricings {
|
|
byName[pricing.ModelName] = pricing
|
|
}
|
|
return byName
|
|
}
|
|
|
|
func decodeUserModelsResponse(t *testing.T, recorder *httptest.ResponseRecorder) []string {
|
|
t.Helper()
|
|
|
|
require.Equal(t, http.StatusOK, recorder.Code)
|
|
var payload userModelsResponse
|
|
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &payload))
|
|
require.True(t, payload.Success)
|
|
return payload.Data
|
|
}
|
|
|
|
func TestGetUserModelsFiltersByRequestedGroup(t *testing.T) {
|
|
db := setupModelListControllerTestDB(t)
|
|
require.NoError(t, db.Create(&model.User{
|
|
Id: 1002,
|
|
Username: "playground-model-user",
|
|
Password: "password",
|
|
Group: "default",
|
|
Status: common.UserStatusEnabled,
|
|
}).Error)
|
|
require.NoError(t, db.Create(&[]model.Ability{
|
|
{Group: "default", Model: "zz-default-only-model", ChannelId: 1, Enabled: true},
|
|
{Group: "default", Model: "zz-disabled-model", ChannelId: 1, Enabled: false},
|
|
}).Error)
|
|
|
|
defaultRecorder := httptest.NewRecorder()
|
|
defaultContext, _ := gin.CreateTestContext(defaultRecorder)
|
|
defaultContext.Request = httptest.NewRequest(http.MethodGet, "/api/user/models?group=default", nil)
|
|
defaultContext.Set("id", 1002)
|
|
|
|
GetUserModels(defaultContext)
|
|
|
|
defaultModels := decodeUserModelsResponse(t, defaultRecorder)
|
|
require.ElementsMatch(t, []string{"zz-default-only-model"}, defaultModels)
|
|
|
|
vipRecorder := httptest.NewRecorder()
|
|
vipContext, _ := gin.CreateTestContext(vipRecorder)
|
|
vipContext.Request = httptest.NewRequest(http.MethodGet, "/api/user/models?group=vip", nil)
|
|
vipContext.Set("id", 1002)
|
|
|
|
GetUserModels(vipContext)
|
|
|
|
require.Empty(t, decodeUserModelsResponse(t, vipRecorder))
|
|
}
|
|
|
|
func TestListModelsIncludesTieredBillingModel(t *testing.T) {
|
|
withSelfUseModeDisabled(t)
|
|
withTieredBillingConfig(t, map[string]string{
|
|
"zz-tiered-visible-model": "tiered_expr",
|
|
"zz-tiered-empty-expr-model": "tiered_expr",
|
|
"zz-tiered-missing-expr-model": "tiered_expr",
|
|
}, map[string]string{
|
|
"zz-tiered-visible-model": `tier("base", p * 1 + c * 2)`,
|
|
"zz-tiered-empty-expr-model": " ",
|
|
})
|
|
|
|
db := setupModelListControllerTestDB(t)
|
|
require.NoError(t, db.Create(&model.User{
|
|
Id: 1001,
|
|
Username: "model-list-user",
|
|
Password: "password",
|
|
Group: "default",
|
|
Status: common.UserStatusEnabled,
|
|
}).Error)
|
|
require.NoError(t, db.Create(&[]model.Ability{
|
|
{Group: "default", Model: "zz-tiered-visible-model", ChannelId: 1, Enabled: true},
|
|
{Group: "default", Model: "zz-tiered-empty-expr-model", ChannelId: 1, Enabled: true},
|
|
{Group: "default", Model: "zz-tiered-missing-expr-model", ChannelId: 1, Enabled: true},
|
|
{Group: "default", Model: "zz-unpriced-model", ChannelId: 1, Enabled: true},
|
|
}).Error)
|
|
|
|
recorder := httptest.NewRecorder()
|
|
ctx, _ := gin.CreateTestContext(recorder)
|
|
ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
ctx.Set("id", 1001)
|
|
|
|
ListModels(ctx, constant.ChannelTypeOpenAI)
|
|
|
|
ids := decodeListModelsResponse(t, recorder)
|
|
require.Contains(t, ids, "zz-tiered-visible-model")
|
|
require.NotContains(t, ids, "zz-tiered-empty-expr-model")
|
|
require.NotContains(t, ids, "zz-tiered-missing-expr-model")
|
|
require.NotContains(t, ids, "zz-unpriced-model")
|
|
|
|
pricingByName := pricingByModelName(model.GetPricing())
|
|
visiblePricing, ok := pricingByName["zz-tiered-visible-model"]
|
|
require.True(t, ok)
|
|
require.Equal(t, "tiered_expr", visiblePricing.BillingMode)
|
|
require.NotEmpty(t, visiblePricing.BillingExpr)
|
|
|
|
emptyExprPricing, ok := pricingByName["zz-tiered-empty-expr-model"]
|
|
require.True(t, ok)
|
|
require.Empty(t, emptyExprPricing.BillingMode)
|
|
require.Empty(t, emptyExprPricing.BillingExpr)
|
|
|
|
missingExprPricing, ok := pricingByName["zz-tiered-missing-expr-model"]
|
|
require.True(t, ok)
|
|
require.Empty(t, missingExprPricing.BillingMode)
|
|
require.Empty(t, missingExprPricing.BillingExpr)
|
|
}
|
|
|
|
func TestListModelsTokenLimitIncludesTieredBillingModel(t *testing.T) {
|
|
withSelfUseModeDisabled(t)
|
|
withTieredBillingConfig(t, map[string]string{
|
|
"zz-token-tiered-visible-model": "tiered_expr",
|
|
"zz-token-tiered-empty-expr-model": "tiered_expr",
|
|
"zz-token-tiered-missing-expr-model": "tiered_expr",
|
|
}, map[string]string{
|
|
"zz-token-tiered-visible-model": `tier("base", p * 1 + c * 2)`,
|
|
"zz-token-tiered-empty-expr-model": "",
|
|
})
|
|
setupModelListControllerTestDB(t)
|
|
|
|
recorder := httptest.NewRecorder()
|
|
ctx, _ := gin.CreateTestContext(recorder)
|
|
ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
|
|
common.SetContextKey(ctx, constant.ContextKeyTokenModelLimitEnabled, true)
|
|
common.SetContextKey(ctx, constant.ContextKeyTokenModelLimit, map[string]bool{
|
|
"zz-token-tiered-visible-model": true,
|
|
"zz-token-tiered-empty-expr-model": true,
|
|
"zz-token-tiered-missing-expr-model": true,
|
|
"zz-token-unpriced-model": true,
|
|
})
|
|
|
|
ListModels(ctx, constant.ChannelTypeOpenAI)
|
|
|
|
ids := decodeListModelsResponse(t, recorder)
|
|
require.Contains(t, ids, "zz-token-tiered-visible-model")
|
|
require.NotContains(t, ids, "zz-token-tiered-empty-expr-model")
|
|
require.NotContains(t, ids, "zz-token-tiered-missing-expr-model")
|
|
require.NotContains(t, ids, "zz-token-unpriced-model")
|
|
}
|