Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4dd68bad52 | |||
| 0f043ae404 | |||
| 75c05bb4b8 | |||
| 81d3dc08e5 | |||
| 5681c92b3f | |||
| 6e5a359110 | |||
| 77d3157592 | |||
| 39e05118ff | |||
| 9e59ffc3d8 | |||
| abad0d3cc0 | |||
| 7aaa533265 | |||
| 7791b78429 | |||
| cb5c0453f5 | |||
| 4d20e053cb | |||
| 0ff9c35e62 | |||
| 0bbcaa8999 | |||
| 1e9ff8a0de | |||
| 9a2e60dff2 | |||
| b596de739d | |||
| 45d54c1613 | |||
| 086044650d | |||
| 0c7aceb831 | |||
| b2e25b7df2 | |||
| 230a3592f8 | |||
| afb470e405 | |||
| 1588027084 | |||
| 38bf2d8daa | |||
| e8c836d705 | |||
| e79cee1e9e | |||
| 63ead2bf7f | |||
| 5b86ce0d70 | |||
| 74985fa877 | |||
| 1d32037364 | |||
| dc245ae764 | |||
| f8add4ca49 | |||
| 65f8afe922 | |||
| 5bc4c74813 | |||
| 30025aeba3 | |||
| c91ba0c4eb | |||
| f223db9330 | |||
| 9e283ab10b | |||
| a8b7c92e5f | |||
| 6b6c9904ac | |||
| 1011934987 | |||
| bc8110ce36 | |||
| ad224ecf5b | |||
| a64f26d1d2 | |||
| 3360882642 | |||
| b37b6d80b3 | |||
| 3d850d38b6 | |||
| 349d5429ca | |||
| 465c5edab9 | |||
| ff06067a18 | |||
| 51ca897cf4 | |||
| 1288028181 | |||
| 2a528d46cb | |||
| 583da45296 | |||
| b302be30e3 | |||
| 88437a1869 | |||
| b08febaa3c | |||
| 92a0959448 | |||
| 49bc3a1175 | |||
| 0354c38bef | |||
| ebbe315533 | |||
| fddf54ccc5 |
@@ -33,16 +33,18 @@ jobs:
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web/default
|
||||
bun install
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd default
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
- name: Build Frontend (classic)
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web/classic
|
||||
bun install
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd classic
|
||||
VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
- name: Set up Go
|
||||
@@ -91,16 +93,18 @@ jobs:
|
||||
CI: ""
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
run: |
|
||||
cd web/default
|
||||
bun install
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd default
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
- name: Build Frontend (classic)
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web/classic
|
||||
bun install
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd classic
|
||||
VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
- name: Set up Go
|
||||
@@ -146,16 +150,18 @@ jobs:
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web/default
|
||||
bun install
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd default
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
- name: Build Frontend (classic)
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web/classic
|
||||
bun install
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd classic
|
||||
VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
- name: Set up Go
|
||||
|
||||
@@ -35,3 +35,4 @@ data/
|
||||
.test
|
||||
token_estimator_test.go
|
||||
skills-lock.json
|
||||
.playwright-mcp
|
||||
|
||||
+18
-16
@@ -1,22 +1,24 @@
|
||||
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder
|
||||
|
||||
WORKDIR /build
|
||||
COPY web/default/package.json .
|
||||
COPY web/default/bun.lock .
|
||||
RUN bun install
|
||||
COPY ./web/default .
|
||||
COPY ./VERSION .
|
||||
RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
|
||||
WORKDIR /build/web
|
||||
COPY web/package.json web/bun.lock ./
|
||||
COPY web/default/package.json ./default/package.json
|
||||
COPY web/classic/package.json ./classic/package.json
|
||||
RUN bun install --frozen-lockfile
|
||||
COPY ./web/default ./default
|
||||
COPY ./VERSION /build/VERSION
|
||||
RUN cd default && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
|
||||
|
||||
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder-classic
|
||||
|
||||
WORKDIR /build
|
||||
COPY web/classic/package.json .
|
||||
COPY web/classic/bun.lock .
|
||||
RUN bun install
|
||||
COPY ./web/classic .
|
||||
COPY ./VERSION .
|
||||
RUN VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
|
||||
WORKDIR /build/web
|
||||
COPY web/package.json web/bun.lock ./
|
||||
COPY web/default/package.json ./default/package.json
|
||||
COPY web/classic/package.json ./classic/package.json
|
||||
RUN bun install --frozen-lockfile
|
||||
COPY ./web/classic ./classic
|
||||
COPY ./VERSION /build/VERSION
|
||||
RUN cd classic && VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
|
||||
|
||||
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2
|
||||
ENV GO111MODULE=on CGO_ENABLED=0
|
||||
@@ -32,8 +34,8 @@ ADD go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
COPY --from=builder /build/dist ./web/default/dist
|
||||
COPY --from=builder-classic /build/dist ./web/classic/dist
|
||||
COPY --from=builder /build/web/default/dist ./web/default/dist
|
||||
COPY --from=builder-classic /build/web/classic/dist ./web/classic/dist
|
||||
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
|
||||
|
||||
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
|
||||
|
||||
@@ -37,7 +37,7 @@ func checkWriter(writer io.Writer) stringWriter {
|
||||
// W3C Working Draft 29 October 2009
|
||||
// http://www.w3.org/TR/2009/WD-eventsource-20091029/
|
||||
|
||||
var contentType = []string{"text/event-stream"}
|
||||
var writeContentType = []string{"text/event-stream"}
|
||||
var noCache = []string{"no-cache"}
|
||||
|
||||
var fieldReplacer = strings.NewReplacer(
|
||||
@@ -79,7 +79,7 @@ func (r CustomEvent) WriteContentType(w http.ResponseWriter) {
|
||||
r.Mutex.Lock()
|
||||
defer r.Mutex.Unlock()
|
||||
header := w.Header()
|
||||
header["Content-Type"] = contentType
|
||||
header["Content-Type"] = writeContentType
|
||||
|
||||
if _, exist := header["Cache-Control"]; !exist {
|
||||
header["Cache-Control"] = noCache
|
||||
|
||||
+19
-1
@@ -110,11 +110,29 @@ func UnmarshalBodyReusable(c *gin.Context, v any) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contentType := c.Request.Header.Get("Content-Type")
|
||||
|
||||
// disk-backed JSON: stream-decode directly from the file to avoid
|
||||
// materializing the entire payload back into a transient []byte
|
||||
// (diskStorage.Bytes() would ReadFull the whole file into the heap).
|
||||
if storage.IsDisk() && strings.HasPrefix(contentType, "application/json") {
|
||||
if _, seekErr := storage.Seek(0, io.SeekStart); seekErr != nil {
|
||||
return seekErr
|
||||
}
|
||||
if err := DecodeJson(storage, v); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, seekErr := storage.Seek(0, io.SeekStart); seekErr != nil {
|
||||
return seekErr
|
||||
}
|
||||
c.Request.Body = io.NopCloser(storage)
|
||||
return nil
|
||||
}
|
||||
|
||||
requestBody, err := storage.Bytes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contentType := c.Request.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(contentType, "application/json") {
|
||||
err = Unmarshal(requestBody, v)
|
||||
} else if strings.Contains(contentType, gin.MIMEPOSTForm) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package common
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -20,6 +21,16 @@ var (
|
||||
maskApiKeyPattern = regexp.MustCompile(`(['"]?)api_key:([^\s'"]+)(['"]?)`)
|
||||
)
|
||||
|
||||
const LocalLogContentLimit = 2048
|
||||
|
||||
// LocalLogPreview limits log-only content unless debug logging is enabled.
|
||||
func LocalLogPreview(content string) string {
|
||||
if DebugEnabled || len(content) <= LocalLogContentLimit {
|
||||
return content
|
||||
}
|
||||
return fmt.Sprintf("%s... [truncated, original_length=%d, limit=%d]", content[:LocalLogContentLimit], len(content), LocalLogContentLimit)
|
||||
}
|
||||
|
||||
func GetStringIfEmpty(str string, defaultValue string) string {
|
||||
if str == "" {
|
||||
return defaultValue
|
||||
|
||||
@@ -57,7 +57,24 @@ func normalizeChannelTestEndpoint(channel *model.Channel, modelName, endpointTyp
|
||||
return normalized
|
||||
}
|
||||
|
||||
func testChannel(channel *model.Channel, testModel string, endpointType string, isStream bool) testResult {
|
||||
func resolveChannelTestUserID(c *gin.Context) (int, error) {
|
||||
if c != nil {
|
||||
if userID := c.GetInt("id"); userID > 0 {
|
||||
return userID, nil
|
||||
}
|
||||
}
|
||||
|
||||
var rootUser model.User
|
||||
if err := model.DB.Select("id").Where("role = ?", common.RoleRootUser).First(&rootUser).Error; err != nil {
|
||||
return 0, fmt.Errorf("failed to resolve channel test user: %w", err)
|
||||
}
|
||||
if rootUser.Id == 0 {
|
||||
return 0, errors.New("failed to resolve channel test user")
|
||||
}
|
||||
return rootUser.Id, nil
|
||||
}
|
||||
|
||||
func testChannel(channel *model.Channel, testUserID int, testModel string, endpointType string, isStream bool) testResult {
|
||||
tik := time.Now()
|
||||
var unsupportedTestChannelTypes = []int{
|
||||
constant.ChannelTypeMidjourney,
|
||||
@@ -143,7 +160,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
|
||||
Header: make(http.Header),
|
||||
}
|
||||
|
||||
cache, err := model.GetUserCache(1)
|
||||
cache, err := model.GetUserCache(testUserID)
|
||||
if err != nil {
|
||||
return testResult{
|
||||
localErr: err,
|
||||
@@ -151,13 +168,13 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
|
||||
}
|
||||
}
|
||||
cache.WriteContext(c)
|
||||
c.Set("id", 1)
|
||||
c.Set("id", testUserID)
|
||||
|
||||
//c.Request.Header.Set("Authorization", "Bearer "+channel.Key)
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Set("channel", channel.Type)
|
||||
c.Set("base_url", channel.GetBaseURL())
|
||||
group, _ := model.GetUserGroup(1, false)
|
||||
group, _ := model.GetUserGroup(testUserID, false)
|
||||
c.Set("group", group)
|
||||
|
||||
newAPIError := middleware.SetupContextForSelectedChannel(c, channel, testModel)
|
||||
@@ -484,7 +501,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
|
||||
milliseconds := tok.Sub(tik).Milliseconds()
|
||||
consumedTime := float64(milliseconds) / 1000.0
|
||||
other := buildTestLogOther(c, info, priceData, usage, tieredResult)
|
||||
model.RecordConsumeLog(c, 1, model.RecordConsumeLogParams{
|
||||
model.RecordConsumeLog(c, testUserID, model.RecordConsumeLogParams{
|
||||
ChannelId: channel.Id,
|
||||
PromptTokens: usage.PromptTokens,
|
||||
CompletionTokens: usage.CompletionTokens,
|
||||
@@ -834,8 +851,13 @@ func TestChannel(c *gin.Context) {
|
||||
testModel := c.Query("model")
|
||||
endpointType := c.Query("endpoint_type")
|
||||
isStream, _ := strconv.ParseBool(c.Query("stream"))
|
||||
testUserID, err := resolveChannelTestUserID(c)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
tik := time.Now()
|
||||
result := testChannel(channel, testModel, endpointType, isStream)
|
||||
result := testChannel(channel, testUserID, testModel, endpointType, isStream)
|
||||
if result.localErr != nil {
|
||||
resp := gin.H{
|
||||
"success": false,
|
||||
@@ -872,6 +894,10 @@ var testAllChannelsLock sync.Mutex
|
||||
var testAllChannelsRunning bool = false
|
||||
|
||||
func testAllChannels(notify bool) error {
|
||||
testUserID, err := resolveChannelTestUserID(nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
testAllChannelsLock.Lock()
|
||||
if testAllChannelsRunning {
|
||||
@@ -902,7 +928,7 @@ func testAllChannels(notify bool) error {
|
||||
}
|
||||
isChannelEnabled := channel.Status == common.ChannelStatusEnabled
|
||||
tik := time.Now()
|
||||
result := testChannel(channel, "", "", shouldUseStreamForAutomaticChannelTest(channel))
|
||||
result := testChannel(channel, testUserID, "", "", shouldUseStreamForAutomaticChannelTest(channel))
|
||||
tok := time.Now()
|
||||
milliseconds := tok.Sub(tik).Milliseconds()
|
||||
|
||||
|
||||
@@ -1218,7 +1218,7 @@ func CopyChannel(c *gin.Context) {
|
||||
}
|
||||
|
||||
// insert
|
||||
if err := model.BatchInsertChannels([]model.Channel{clone}); err != nil {
|
||||
if err := clone.Insert(); err != nil {
|
||||
common.SysError("failed to clone channel: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "复制渠道失败,请稍后重试"})
|
||||
return
|
||||
|
||||
@@ -69,3 +69,14 @@ func TestBuildTestLogOtherInjectsTieredInfo(t *testing.T) {
|
||||
require.Equal(t, "base", other["matched_tier"])
|
||||
require.NotEmpty(t, other["expr_b64"])
|
||||
}
|
||||
|
||||
func TestResolveChannelTestUserIDUsesRequestUser(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Set("id", 2)
|
||||
|
||||
userID, err := resolveChannelTestUserID(ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, userID)
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ func GetStatus(c *gin.Context) {
|
||||
"demo_site_enabled": operation_setting.DemoSiteEnabled,
|
||||
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
|
||||
"register_enabled": common.RegisterEnabled,
|
||||
"password_login_enabled": common.PasswordLoginEnabled,
|
||||
"password_register_enabled": common.PasswordRegisterEnabled,
|
||||
"default_use_auto_group": setting.DefaultUseAutoGroup,
|
||||
|
||||
|
||||
+2
-2
@@ -88,7 +88,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
|
||||
|
||||
defer func() {
|
||||
if newAPIError != nil {
|
||||
logger.LogError(c, fmt.Sprintf("relay error: %s", newAPIError.Error()))
|
||||
logger.LogError(c, fmt.Sprintf("relay error: %s", common.LocalLogPreview(newAPIError.Error())))
|
||||
newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
|
||||
switch relayFormat {
|
||||
case types.RelayFormatOpenAIRealtime:
|
||||
@@ -354,7 +354,7 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
|
||||
}
|
||||
|
||||
func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) {
|
||||
logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error()))
|
||||
logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, common.LocalLogPreview(err.Error())))
|
||||
// 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况
|
||||
// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
|
||||
if service.ShouldDisableChannel(err) && channelError.AutoBan {
|
||||
|
||||
@@ -22,6 +22,10 @@ type BillingPreferenceRequest struct {
|
||||
BillingPreference string `json:"billing_preference"`
|
||||
}
|
||||
|
||||
type SubscriptionBalancePayRequest struct {
|
||||
PlanId int `json:"plan_id"`
|
||||
}
|
||||
|
||||
// ---- User APIs ----
|
||||
|
||||
func GetSubscriptionPlans(c *gin.Context) {
|
||||
@@ -37,6 +41,7 @@ func GetSubscriptionPlans(c *gin.Context) {
|
||||
}
|
||||
result := make([]SubscriptionPlanDTO, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
p.NormalizeDefaults()
|
||||
result = append(result, SubscriptionPlanDTO{
|
||||
Plan: p,
|
||||
})
|
||||
@@ -92,6 +97,25 @@ func UpdateSubscriptionPreference(c *gin.Context) {
|
||||
common.ApiSuccess(c, gin.H{"billing_preference": pref})
|
||||
}
|
||||
|
||||
func SubscriptionRequestBalancePay(c *gin.Context) {
|
||||
if !requirePaymentCompliance(c) {
|
||||
return
|
||||
}
|
||||
|
||||
userId := c.GetInt("id")
|
||||
var req SubscriptionBalancePayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
if err := model.PurchaseSubscriptionWithBalance(userId, req.PlanId); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
// ---- Admin APIs ----
|
||||
|
||||
func AdminListSubscriptionPlans(c *gin.Context) {
|
||||
@@ -102,6 +126,7 @@ func AdminListSubscriptionPlans(c *gin.Context) {
|
||||
}
|
||||
result := make([]SubscriptionPlanDTO, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
p.NormalizeDefaults()
|
||||
result = append(result, SubscriptionPlanDTO{
|
||||
Plan: p,
|
||||
})
|
||||
@@ -140,6 +165,9 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
|
||||
req.Plan.Currency = "USD"
|
||||
}
|
||||
req.Plan.Currency = "USD"
|
||||
if req.Plan.AllowBalancePay == nil {
|
||||
req.Plan.AllowBalancePay = common.GetPointer(true)
|
||||
}
|
||||
if req.Plan.DurationUnit == "" {
|
||||
req.Plan.DurationUnit = model.SubscriptionDurationMonth
|
||||
}
|
||||
@@ -256,6 +284,9 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
||||
"quota_reset_custom_seconds": req.Plan.QuotaResetCustomSeconds,
|
||||
"updated_at": common.GetTimestamp(),
|
||||
}
|
||||
if req.Plan.AllowBalancePay != nil {
|
||||
updateMap["allow_balance_pay"] = *req.Plan.AllowBalancePay
|
||||
}
|
||||
if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -103,8 +103,9 @@ func SubscriptionRequestWaffoPancakePay(c *gin.Context) {
|
||||
Amount: decimal.NewFromFloat(plan.PriceAmount).StringFixed(2),
|
||||
TaxCategory: "saas",
|
||||
},
|
||||
BuyerEmail: getWaffoPancakeBuyerEmail(user),
|
||||
ExpiresInSeconds: &expiresInSeconds,
|
||||
BuyerEmail: getWaffoPancakeBuyerEmail(user),
|
||||
ExpiresInSeconds: &expiresInSeconds,
|
||||
OrderMerchantExternalID: tradeNo,
|
||||
})
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅结账会话创建失败 user_id=%d plan_id=%d trade_no=%s error=%q", userId, plan.Id, tradeNo, err.Error()))
|
||||
|
||||
@@ -96,9 +96,6 @@ func getWaffoPancakeBuyerEmail(user *model.User) string {
|
||||
if user != nil && strings.TrimSpace(user.Email) != "" {
|
||||
return user.Email
|
||||
}
|
||||
if user != nil {
|
||||
return fmt.Sprintf("%d@new-api.local", user.Id)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -408,8 +405,9 @@ func RequestWaffoPancakePay(c *gin.Context) {
|
||||
Amount: formatWaffoPancakeAmount(payMoney),
|
||||
TaxCategory: "saas",
|
||||
},
|
||||
BuyerEmail: getWaffoPancakeBuyerEmail(user),
|
||||
ExpiresInSeconds: &expiresInSeconds,
|
||||
BuyerEmail: getWaffoPancakeBuyerEmail(user),
|
||||
ExpiresInSeconds: &expiresInSeconds,
|
||||
OrderMerchantExternalID: tradeNo,
|
||||
})
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 创建结账会话失败 user_id=%d trade_no=%s error=%q", id, tradeNo, err.Error()))
|
||||
@@ -485,9 +483,9 @@ func WaffoPancakeWebhook(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Subscription vs top-up dispatch by trade_no prefix (written at
|
||||
// session-creation time): WAFFO_PANCAKE_SUB- vs WAFFO_PANCAKE-.
|
||||
rawTradeNo := strings.TrimSpace(event.Data.OrderID)
|
||||
// Dispatch by trade_no prefix. OrderMerchantExternalID = our trade_no;
|
||||
// OrderID is Pancake's internal ORD_* (logs only).
|
||||
rawTradeNo := strings.TrimSpace(event.Data.OrderMerchantExternalID)
|
||||
isSubscription := strings.HasPrefix(rawTradeNo, "WAFFO_PANCAKE_SUB-")
|
||||
|
||||
if isSubscription {
|
||||
|
||||
+13
-1
@@ -251,8 +251,20 @@ func GetAllUsers(c *gin.Context) {
|
||||
func SearchUsers(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
group := c.Query("group")
|
||||
var role *int
|
||||
if roleStr := c.Query("role"); roleStr != "" {
|
||||
if parsed, err := strconv.Atoi(roleStr); err == nil {
|
||||
role = &parsed
|
||||
}
|
||||
}
|
||||
var status *int
|
||||
if statusStr := c.Query("status"); statusStr != "" {
|
||||
if parsed, err := strconv.Atoi(statusStr); err == nil {
|
||||
status = &parsed
|
||||
}
|
||||
}
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
users, total, err := model.SearchUsers(keyword, group, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
users, total, err := model.SearchUsers(keyword, group, role, status, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
|
||||
@@ -60,7 +60,7 @@ require (
|
||||
gorm.io/gorm v1.25.2
|
||||
)
|
||||
|
||||
require github.com/waffo-com/waffo-pancake-sdk-go v0.2.0
|
||||
require github.com/waffo-com/waffo-pancake-sdk-go v0.3.1
|
||||
|
||||
require (
|
||||
github.com/DmitriyVTitov/size v1.5.0 // indirect
|
||||
|
||||
@@ -312,6 +312,8 @@ github.com/waffo-com/waffo-pancake-sdk-go v0.1.1 h1:YOI7+3zTBlTB7Ou6+ZXnJV2JvW/a
|
||||
github.com/waffo-com/waffo-pancake-sdk-go v0.1.1/go.mod h1:5MBCGH/nqRRA5sHO/lQB/96r4BTAqy8QpWxn53m9htI=
|
||||
github.com/waffo-com/waffo-pancake-sdk-go v0.2.0 h1:cCSgccM66p7feTtgRqUUGT50tYQOhahsoPXavd+ib1U=
|
||||
github.com/waffo-com/waffo-pancake-sdk-go v0.2.0/go.mod h1:5MBCGH/nqRRA5sHO/lQB/96r4BTAqy8QpWxn53m9htI=
|
||||
github.com/waffo-com/waffo-pancake-sdk-go v0.3.1 h1:ngQSN/oVB35xTwFPLfg++bxPC+SptcF145Mb6c62YCc=
|
||||
github.com/waffo-com/waffo-pancake-sdk-go v0.3.1/go.mod h1:OB2MyFIQaefoPO0FV3J+yu9sDP8RVFQ+sbFsXqGuObc=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
FRONTEND_DIR = ./web/default
|
||||
FRONTEND_CLASSIC_DIR = ./web/classic
|
||||
BACKEND_DIR = .
|
||||
DEV_FRONTEND_DEFAULT_PORT ?= 5173
|
||||
DEV_FRONTEND_CLASSIC_PORT ?= 5174
|
||||
DEV_COMPOSE_FILE = docker-compose.dev.yml
|
||||
DEV_POSTGRES_SERVICE = postgres
|
||||
DEV_BACKEND_SERVICE = new-api
|
||||
@@ -14,11 +16,13 @@ all: build-all-frontends start-backend
|
||||
|
||||
build-frontend:
|
||||
@echo "Building default frontend..."
|
||||
@cd $(FRONTEND_DIR) && bun install && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
|
||||
@cd ./web && bun install --frozen-lockfile
|
||||
@cd $(FRONTEND_DIR) && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
|
||||
|
||||
build-frontend-classic:
|
||||
@echo "Building classic frontend..."
|
||||
@cd $(FRONTEND_CLASSIC_DIR) && bun install && VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
|
||||
@cd ./web && bun install --frozen-lockfile
|
||||
@cd $(FRONTEND_CLASSIC_DIR) && VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
|
||||
|
||||
build-all-frontends: build-frontend build-frontend-classic
|
||||
|
||||
@@ -35,12 +39,35 @@ dev-api-rebuild:
|
||||
@docker compose -f $(DEV_COMPOSE_FILE) up -d --build $(DEV_BACKEND_SERVICE)
|
||||
|
||||
dev-web:
|
||||
@echo "Starting frontend dev server..."
|
||||
@cd $(FRONTEND_DIR) && bun install && bun run dev
|
||||
@echo "Starting both frontend dev servers..."
|
||||
@echo "Default frontend: http://localhost:$(DEV_FRONTEND_DEFAULT_PORT)"
|
||||
@echo "Classic frontend: http://localhost:$(DEV_FRONTEND_CLASSIC_PORT)"
|
||||
@cd ./web && bun install
|
||||
@(cd $(FRONTEND_DIR) && bun run dev -- --host 0.0.0.0 --port $(DEV_FRONTEND_DEFAULT_PORT)) & \
|
||||
default_pid=$$!; \
|
||||
(cd $(FRONTEND_CLASSIC_DIR) && bun run dev -- --host 0.0.0.0 --port $(DEV_FRONTEND_CLASSIC_PORT)) & \
|
||||
classic_pid=$$!; \
|
||||
trap 'kill $$default_pid $$classic_pid 2>/dev/null; wait $$default_pid $$classic_pid 2>/dev/null; exit 130' INT TERM; \
|
||||
while kill -0 $$default_pid 2>/dev/null && kill -0 $$classic_pid 2>/dev/null; do \
|
||||
sleep 1; \
|
||||
done; \
|
||||
if ! kill -0 $$default_pid 2>/dev/null; then \
|
||||
wait $$default_pid; \
|
||||
status=$$?; \
|
||||
kill $$classic_pid 2>/dev/null; \
|
||||
wait $$classic_pid 2>/dev/null; \
|
||||
exit $$status; \
|
||||
fi; \
|
||||
wait $$classic_pid; \
|
||||
status=$$?; \
|
||||
kill $$default_pid 2>/dev/null; \
|
||||
wait $$default_pid 2>/dev/null; \
|
||||
exit $$status
|
||||
|
||||
dev-web-classic:
|
||||
@echo "Starting classic frontend dev server..."
|
||||
@cd $(FRONTEND_CLASSIC_DIR) && bun install && bun run dev
|
||||
@cd ./web && bun install
|
||||
@cd $(FRONTEND_CLASSIC_DIR) && bun run dev -- --host 0.0.0.0 --port $(DEV_FRONTEND_CLASSIC_PORT)
|
||||
|
||||
dev: dev-api dev-web
|
||||
|
||||
|
||||
+33
-2
@@ -643,13 +643,25 @@ func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int, reason
|
||||
if len(keys) == 0 {
|
||||
channel.Status = status
|
||||
} else {
|
||||
var keyIndex int
|
||||
keyIndex := -1
|
||||
for i, key := range keys {
|
||||
if key == usingKey {
|
||||
keyIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if keyIndex < 0 {
|
||||
if usingKey != "" {
|
||||
common.SysLog(fmt.Sprintf("failed to update multi-key status: channel_id=%d, using key not found", channel.Id))
|
||||
return
|
||||
}
|
||||
channel.Status = status
|
||||
info := channel.GetOtherInfo()
|
||||
info["status_reason"] = reason
|
||||
info["status_time"] = common.GetTimestamp()
|
||||
channel.SetOtherInfo(info)
|
||||
return
|
||||
}
|
||||
if channel.ChannelInfo.MultiKeyStatusList == nil {
|
||||
channel.ChannelInfo.MultiKeyStatusList = make(map[int]int)
|
||||
}
|
||||
@@ -666,16 +678,31 @@ func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int, reason
|
||||
channel.ChannelInfo.MultiKeyDisabledReason[keyIndex] = reason
|
||||
channel.ChannelInfo.MultiKeyDisabledTime[keyIndex] = common.GetTimestamp()
|
||||
}
|
||||
if len(channel.ChannelInfo.MultiKeyStatusList) >= channel.ChannelInfo.MultiKeySize {
|
||||
if !hasEnabledMultiKey(keys, channel.ChannelInfo.MultiKeyStatusList) {
|
||||
channel.Status = common.ChannelStatusAutoDisabled
|
||||
info := channel.GetOtherInfo()
|
||||
info["status_reason"] = "All keys are disabled"
|
||||
info["status_time"] = common.GetTimestamp()
|
||||
channel.SetOtherInfo(info)
|
||||
} else if status == common.ChannelStatusEnabled {
|
||||
channel.Status = common.ChannelStatusEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasEnabledMultiKey(keys []string, statusList map[int]int) bool {
|
||||
for i := range keys {
|
||||
if statusList == nil {
|
||||
return true
|
||||
}
|
||||
status, ok := statusList[i]
|
||||
if !ok || status == common.ChannelStatusEnabled {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func UpdateChannelStatus(channelId int, usingKey string, status int, reason string) bool {
|
||||
if common.MemoryCacheEnabled {
|
||||
channelStatusLock.Lock()
|
||||
@@ -687,11 +714,15 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
|
||||
}
|
||||
if channelCache.ChannelInfo.IsMultiKey {
|
||||
// Use per-channel lock to prevent concurrent map read/write with GetNextEnabledKey
|
||||
beforeStatus := channelCache.Status
|
||||
pollingLock := GetChannelPollingLock(channelId)
|
||||
pollingLock.Lock()
|
||||
// 如果是多Key模式,更新缓存中的状态
|
||||
handlerMultiKeyUpdate(channelCache, usingKey, status, reason)
|
||||
pollingLock.Unlock()
|
||||
if beforeStatus != channelCache.Status {
|
||||
CacheUpdateChannelStatus(channelId, channelCache.Status)
|
||||
}
|
||||
//CacheUpdateChannel(channelCache)
|
||||
//return true
|
||||
} else {
|
||||
|
||||
+51
-40
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -16,25 +17,39 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func applyExplicitLogTextFilter(tx *gorm.DB, column string, value string) (*gorm.DB, error) {
|
||||
if value == "" {
|
||||
return tx, nil
|
||||
}
|
||||
if strings.Contains(value, "%") {
|
||||
pattern, err := sanitizeLikePattern(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tx.Where(column+" LIKE ? ESCAPE '!'", pattern), nil
|
||||
}
|
||||
return tx.Where(column+" = ?", value), nil
|
||||
}
|
||||
|
||||
type Log struct {
|
||||
Id int `json:"id" gorm:"index:idx_created_at_id,priority:1;index:idx_user_id_id,priority:2"`
|
||||
UserId int `json:"user_id" gorm:"index;index:idx_user_id_id,priority:1"`
|
||||
CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:2;index:idx_created_at_type"`
|
||||
Type int `json:"type" gorm:"index:idx_created_at_type"`
|
||||
Content string `json:"content"`
|
||||
Username string `json:"username" gorm:"index;index:index_username_model_name,priority:2;default:''"`
|
||||
TokenName string `json:"token_name" gorm:"index;default:''"`
|
||||
ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"`
|
||||
Quota int `json:"quota" gorm:"default:0"`
|
||||
PromptTokens int `json:"prompt_tokens" gorm:"default:0"`
|
||||
CompletionTokens int `json:"completion_tokens" gorm:"default:0"`
|
||||
UseTime int `json:"use_time" gorm:"default:0"`
|
||||
IsStream bool `json:"is_stream"`
|
||||
ChannelId int `json:"channel" gorm:"index"`
|
||||
ChannelName string `json:"channel_name" gorm:"->"`
|
||||
TokenId int `json:"token_id" gorm:"default:0;index"`
|
||||
Group string `json:"group" gorm:"index"`
|
||||
Ip string `json:"ip" gorm:"index;default:''"`
|
||||
Id int `json:"id" gorm:"index:idx_created_at_id,priority:2;index:idx_user_id_id,priority:2"`
|
||||
UserId int `json:"user_id" gorm:"index;index:idx_user_id_id,priority:1"`
|
||||
CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:1;index:idx_created_at_type"`
|
||||
Type int `json:"type" gorm:"index:idx_created_at_type"`
|
||||
Content string `json:"content"`
|
||||
Username string `json:"username" gorm:"index;index:index_username_model_name,priority:2;default:''"`
|
||||
TokenName string `json:"token_name" gorm:"index;default:''"`
|
||||
ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"`
|
||||
Quota int `json:"quota" gorm:"default:0"`
|
||||
PromptTokens int `json:"prompt_tokens" gorm:"default:0"`
|
||||
CompletionTokens int `json:"completion_tokens" gorm:"default:0"`
|
||||
UseTime int `json:"use_time" gorm:"default:0"`
|
||||
IsStream bool `json:"is_stream"`
|
||||
ChannelId int `json:"channel" gorm:"index"`
|
||||
ChannelName string `json:"channel_name" gorm:"->"`
|
||||
TokenId int `json:"token_id" gorm:"default:0;index"`
|
||||
Group string `json:"group" gorm:"index"`
|
||||
Ip string `json:"ip" gorm:"index;default:''"`
|
||||
RequestId string `json:"request_id,omitempty" gorm:"type:varchar(64);index:idx_logs_request_id;default:''"`
|
||||
UpstreamRequestId string `json:"upstream_request_id,omitempty" gorm:"type:varchar(128);index:idx_logs_upstream_request_id;default:''"`
|
||||
Other string `json:"other"`
|
||||
@@ -145,7 +160,7 @@ func RecordTopupLog(userId int, content string, callerIp string, paymentMethod s
|
||||
|
||||
func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string, tokenName string, content string, tokenId int, useTimeSeconds int,
|
||||
isStream bool, group string, other map[string]interface{}) {
|
||||
logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, content))
|
||||
logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, common.LocalLogPreview(content)))
|
||||
username := c.GetString("username")
|
||||
requestId := c.GetString(common.RequestIdKey)
|
||||
upstreamRequestId := c.GetString(common.UpstreamRequestIdKey)
|
||||
@@ -308,11 +323,11 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
|
||||
tx = LOG_DB.Where("logs.type = ?", logType)
|
||||
}
|
||||
|
||||
if modelName != "" {
|
||||
tx = tx.Where("logs.model_name like ?", modelName)
|
||||
if tx, err = applyExplicitLogTextFilter(tx, "logs.model_name", modelName); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if username != "" {
|
||||
tx = tx.Where("logs.username = ?", username)
|
||||
if tx, err = applyExplicitLogTextFilter(tx, "logs.username", username); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if tokenName != "" {
|
||||
tx = tx.Where("logs.token_name = ?", tokenName)
|
||||
@@ -339,7 +354,7 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
err = tx.Order("logs.id desc").Limit(num).Offset(startIdx).Find(&logs).Error
|
||||
err = tx.Order("logs.created_at desc, logs.id desc").Limit(num).Offset(startIdx).Find(&logs).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -397,12 +412,8 @@ func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int
|
||||
tx = LOG_DB.Where("logs.user_id = ? and logs.type = ?", userId, logType)
|
||||
}
|
||||
|
||||
if modelName != "" {
|
||||
modelNamePattern, err := sanitizeLikePattern(modelName)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
tx = tx.Where("logs.model_name LIKE ? ESCAPE '!'", modelNamePattern)
|
||||
if tx, err = applyExplicitLogTextFilter(tx, "logs.model_name", modelName); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if tokenName != "" {
|
||||
tx = tx.Where("logs.token_name = ?", tokenName)
|
||||
@@ -449,9 +460,11 @@ func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelNa
|
||||
// 为rpm和tpm创建单独的查询
|
||||
rpmTpmQuery := LOG_DB.Table("logs").Select("count(*) rpm, sum(prompt_tokens) + sum(completion_tokens) tpm")
|
||||
|
||||
if username != "" {
|
||||
tx = tx.Where("username = ?", username)
|
||||
rpmTpmQuery = rpmTpmQuery.Where("username = ?", username)
|
||||
if tx, err = applyExplicitLogTextFilter(tx, "username", username); err != nil {
|
||||
return stat, err
|
||||
}
|
||||
if rpmTpmQuery, err = applyExplicitLogTextFilter(rpmTpmQuery, "username", username); err != nil {
|
||||
return stat, err
|
||||
}
|
||||
if tokenName != "" {
|
||||
tx = tx.Where("token_name = ?", tokenName)
|
||||
@@ -463,13 +476,11 @@ func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelNa
|
||||
if endTimestamp != 0 {
|
||||
tx = tx.Where("created_at <= ?", endTimestamp)
|
||||
}
|
||||
if modelName != "" {
|
||||
modelNamePattern, err := sanitizeLikePattern(modelName)
|
||||
if err != nil {
|
||||
return stat, err
|
||||
}
|
||||
tx = tx.Where("model_name LIKE ? ESCAPE '!'", modelNamePattern)
|
||||
rpmTpmQuery = rpmTpmQuery.Where("model_name LIKE ? ESCAPE '!'", modelNamePattern)
|
||||
if tx, err = applyExplicitLogTextFilter(tx, "model_name", modelName); err != nil {
|
||||
return stat, err
|
||||
}
|
||||
if rpmTpmQuery, err = applyExplicitLogTextFilter(rpmTpmQuery, "model_name", modelName); err != nil {
|
||||
return stat, err
|
||||
}
|
||||
if channel != 0 {
|
||||
tx = tx.Where("channel_id = ?", channel)
|
||||
|
||||
@@ -397,6 +397,7 @@ func ensureSubscriptionPlanTableSQLite() error {
|
||||
` + "`custom_seconds`" + ` bigint NOT NULL DEFAULT 0,
|
||||
` + "`enabled`" + ` numeric DEFAULT 1,
|
||||
` + "`sort_order`" + ` integer DEFAULT 0,
|
||||
` + "`allow_balance_pay`" + ` numeric DEFAULT 1,
|
||||
` + "`stripe_price_id`" + ` varchar(128) DEFAULT '',
|
||||
` + "`creem_product_id`" + ` varchar(128) DEFAULT '',
|
||||
` + "`waffo_pancake_product_id`" + ` varchar(128) DEFAULT '',
|
||||
@@ -431,6 +432,7 @@ PRIMARY KEY (` + "`id`" + `)
|
||||
{Name: "custom_seconds", DDL: "`custom_seconds` bigint NOT NULL DEFAULT 0"},
|
||||
{Name: "enabled", DDL: "`enabled` numeric DEFAULT 1"},
|
||||
{Name: "sort_order", DDL: "`sort_order` integer DEFAULT 0"},
|
||||
{Name: "allow_balance_pay", DDL: "`allow_balance_pay` numeric DEFAULT 1"},
|
||||
{Name: "stripe_price_id", DDL: "`stripe_price_id` varchar(128) DEFAULT ''"},
|
||||
{Name: "creem_product_id", DDL: "`creem_product_id` varchar(128) DEFAULT ''"},
|
||||
{Name: "waffo_pancake_product_id", DDL: "`waffo_pancake_product_id` varchar(128) DEFAULT ''"},
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/pkg/cachex"
|
||||
"github.com/samber/hot"
|
||||
"github.com/shopspring/decimal"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -159,6 +160,8 @@ type SubscriptionPlan struct {
|
||||
Enabled bool `json:"enabled" gorm:"default:true"`
|
||||
SortOrder int `json:"sort_order" gorm:"type:int;default:0"`
|
||||
|
||||
AllowBalancePay *bool `json:"allow_balance_pay" gorm:"default:true"`
|
||||
|
||||
StripePriceId string `json:"stripe_price_id" gorm:"type:varchar(128);default:''"`
|
||||
CreemProductId string `json:"creem_product_id" gorm:"type:varchar(128);default:''"`
|
||||
WaffoPancakeProductId string `json:"waffo_pancake_product_id" gorm:"type:varchar(128);default:''"`
|
||||
@@ -192,6 +195,12 @@ func (p *SubscriptionPlan) BeforeUpdate(tx *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *SubscriptionPlan) NormalizeDefaults() {
|
||||
if p.AllowBalancePay == nil {
|
||||
p.AllowBalancePay = common.GetPointer(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Subscription order (payment -> webhook -> create UserSubscription)
|
||||
type SubscriptionOrder struct {
|
||||
Id int `json:"id"`
|
||||
@@ -359,6 +368,7 @@ func getSubscriptionPlanByIdTx(tx *gorm.DB, id int) (*SubscriptionPlan, error) {
|
||||
key := subscriptionPlanCacheKey(id)
|
||||
if key != "" {
|
||||
if cached, found, err := getSubscriptionPlanCache().Get(key); err == nil && found {
|
||||
cached.NormalizeDefaults()
|
||||
return &cached, nil
|
||||
}
|
||||
}
|
||||
@@ -370,6 +380,7 @@ func getSubscriptionPlanByIdTx(tx *gorm.DB, id int) (*SubscriptionPlan, error) {
|
||||
if err := query.Where("id = ?", id).First(&plan).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plan.NormalizeDefaults()
|
||||
_ = getSubscriptionPlanCache().SetWithTTL(key, plan, subscriptionPlanCacheTTL())
|
||||
return &plan, nil
|
||||
}
|
||||
@@ -665,6 +676,109 @@ func AdminBindSubscription(userId int, planId int, sourceNote string) (string, e
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func calcSubscriptionBalanceQuota(priceAmount float64) (int, error) {
|
||||
if priceAmount <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if common.QuotaPerUnit <= 0 {
|
||||
return 0, errors.New("额度单位配置错误")
|
||||
}
|
||||
quota := decimal.NewFromFloat(priceAmount).
|
||||
Mul(decimal.NewFromFloat(common.QuotaPerUnit)).
|
||||
Ceil().
|
||||
IntPart()
|
||||
return int(quota), nil
|
||||
}
|
||||
|
||||
// PurchaseSubscriptionWithBalance creates a subscription by deducting the user's wallet quota.
|
||||
func PurchaseSubscriptionWithBalance(userId int, planId int) error {
|
||||
if userId <= 0 || planId <= 0 {
|
||||
return errors.New("invalid userId or planId")
|
||||
}
|
||||
|
||||
var logPlanTitle string
|
||||
var logMoney float64
|
||||
var chargedQuota int
|
||||
var upgradeGroup string
|
||||
err := DB.Transaction(func(tx *gorm.DB) error {
|
||||
plan, err := getSubscriptionPlanByIdTx(tx, planId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !plan.Enabled {
|
||||
return errors.New("套餐未启用")
|
||||
}
|
||||
if plan.PriceAmount < 0 {
|
||||
return errors.New("套餐价格不能为负数")
|
||||
}
|
||||
if plan.AllowBalancePay != nil && !*plan.AllowBalancePay {
|
||||
return errors.New("该套餐不允许使用余额兑换")
|
||||
}
|
||||
|
||||
requiredQuota, err := calcSubscriptionBalanceQuota(plan.PriceAmount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var user User
|
||||
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where("id = ?", userId).First(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if requiredQuota > 0 && user.Quota < requiredQuota {
|
||||
return errors.New("余额不足")
|
||||
}
|
||||
if requiredQuota > 0 {
|
||||
if err := tx.Model(&User{}).Where("id = ?", userId).
|
||||
Update("quota", gorm.Expr("quota - ?", requiredQuota)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := CreateUserSubscriptionFromPlanTx(tx, userId, plan, PaymentMethodBalance); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := common.GetTimestamp()
|
||||
tradeNo := fmt.Sprintf("SUBBALUSR%dNO%s%d", userId, common.GetRandomString(6), time.Now().UnixNano())
|
||||
order := &SubscriptionOrder{
|
||||
UserId: userId,
|
||||
PlanId: plan.Id,
|
||||
Money: plan.PriceAmount,
|
||||
TradeNo: tradeNo,
|
||||
PaymentMethod: PaymentMethodBalance,
|
||||
PaymentProvider: PaymentProviderBalance,
|
||||
Status: common.TopUpStatusSuccess,
|
||||
CreateTime: now,
|
||||
CompleteTime: now,
|
||||
ProviderPayload: fmt.Sprintf("charged_quota=%d", requiredQuota),
|
||||
}
|
||||
if err := tx.Create(order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logPlanTitle = plan.Title
|
||||
logMoney = plan.PriceAmount
|
||||
chargedQuota = requiredQuota
|
||||
upgradeGroup = strings.TrimSpace(plan.UpgradeGroup)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if chargedQuota > 0 {
|
||||
if err := cacheDecrUserQuota(userId, int64(chargedQuota)); err != nil {
|
||||
common.SysLog("failed to decrease user quota cache after subscription balance purchase: " + err.Error())
|
||||
}
|
||||
}
|
||||
if upgradeGroup != "" {
|
||||
_ = UpdateUserGroupCache(userId, upgradeGroup)
|
||||
}
|
||||
msg := fmt.Sprintf("使用余额购买订阅成功,套餐: %s,支付金额: %.2f,扣除额度: %d", logPlanTitle, logMoney, chargedQuota)
|
||||
RecordLog(userId, LogTypeTopup, msg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllActiveUserSubscriptions returns all active subscriptions for a user.
|
||||
func GetAllActiveUserSubscriptions(userId int) ([]SubscriptionSummary, error) {
|
||||
if userId <= 0 {
|
||||
|
||||
@@ -29,6 +29,7 @@ const (
|
||||
PaymentMethodCreem = "creem"
|
||||
PaymentMethodWaffo = "waffo"
|
||||
PaymentMethodWaffoPancake = "waffo_pancake"
|
||||
PaymentMethodBalance = "balance"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -37,6 +38,7 @@ const (
|
||||
PaymentProviderCreem = "creem"
|
||||
PaymentProviderWaffo = "waffo"
|
||||
PaymentProviderWaffoPancake = "waffo_pancake"
|
||||
PaymentProviderBalance = "balance"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
+31
-17
@@ -225,7 +225,7 @@ func GetAllUsers(pageInfo *common.PageInfo) (users []*User, total int64, err err
|
||||
return users, total, nil
|
||||
}
|
||||
|
||||
func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User, int64, error) {
|
||||
func SearchUsers(keyword string, group string, role *int, status *int, startIdx int, num int) ([]*User, int64, error) {
|
||||
var users []*User
|
||||
var total int64
|
||||
var err error
|
||||
@@ -246,28 +246,25 @@ func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User,
|
||||
|
||||
// 构建搜索条件
|
||||
likeCondition := "username LIKE ? OR email LIKE ? OR display_name LIKE ?"
|
||||
likeArgs := []interface{}{"%" + keyword + "%", "%" + keyword + "%", "%" + keyword + "%"}
|
||||
|
||||
// 尝试将关键字转换为整数ID
|
||||
keywordInt, err := strconv.Atoi(keyword)
|
||||
if err == nil {
|
||||
// 如果是数字,同时搜索ID和其他字段
|
||||
likeCondition = "id = ? OR " + likeCondition
|
||||
if group != "" {
|
||||
query = query.Where("("+likeCondition+") AND "+commonGroupCol+" = ?",
|
||||
keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group)
|
||||
} else {
|
||||
query = query.Where(likeCondition,
|
||||
keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
|
||||
}
|
||||
} else {
|
||||
// 非数字关键字,只搜索字符串字段
|
||||
if group != "" {
|
||||
query = query.Where("("+likeCondition+") AND "+commonGroupCol+" = ?",
|
||||
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group)
|
||||
} else {
|
||||
query = query.Where(likeCondition,
|
||||
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
|
||||
}
|
||||
likeArgs = append([]interface{}{keywordInt}, likeArgs...)
|
||||
}
|
||||
|
||||
query = query.Where("("+likeCondition+")", likeArgs...)
|
||||
if group != "" {
|
||||
query = query.Where(commonGroupCol+" = ?", group)
|
||||
}
|
||||
if role != nil {
|
||||
query = query.Where("role = ?", *role)
|
||||
}
|
||||
if status != nil {
|
||||
query = query.Where("status = ?", *status)
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
@@ -987,6 +984,23 @@ func updateUserUsedQuotaAndRequestCount(id int, quota int, count int) {
|
||||
//}
|
||||
}
|
||||
|
||||
func updateUserQuotaUsedQuotaAndRequestCount(id int, quota int, usedQuota int, requestCount int) {
|
||||
if quota == 0 && usedQuota == 0 && requestCount == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
err := DB.Model(&User{}).Where("id = ?", id).Updates(
|
||||
map[string]interface{}{
|
||||
"quota": gorm.Expr("quota + ?", quota),
|
||||
"used_quota": gorm.Expr("used_quota + ?", usedQuota),
|
||||
"request_count": gorm.Expr("request_count + ?", requestCount),
|
||||
},
|
||||
).Error
|
||||
if err != nil {
|
||||
common.SysLog("failed to batch update user quota, used quota and request count: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func updateUserUsedQuota(id int, quota int) {
|
||||
err := DB.Model(&User{}).Where("id = ?", id).Updates(
|
||||
map[string]interface{}{
|
||||
|
||||
+26
-11
@@ -67,33 +67,48 @@ func batchUpdate() {
|
||||
}
|
||||
|
||||
common.SysLog("batch update started")
|
||||
stores := make([]map[int]int, BatchUpdateTypeCount)
|
||||
for i := 0; i < BatchUpdateTypeCount; i++ {
|
||||
batchUpdateLocks[i].Lock()
|
||||
store := batchUpdateStores[i]
|
||||
stores[i] = batchUpdateStores[i]
|
||||
batchUpdateStores[i] = make(map[int]int)
|
||||
batchUpdateLocks[i].Unlock()
|
||||
// TODO: maybe we can combine updates with same key?
|
||||
}
|
||||
|
||||
for i, store := range stores {
|
||||
if i == BatchUpdateTypeUserQuota || i == BatchUpdateTypeUsedQuota || i == BatchUpdateTypeRequestCount {
|
||||
continue
|
||||
}
|
||||
for key, value := range store {
|
||||
switch i {
|
||||
case BatchUpdateTypeUserQuota:
|
||||
err := increaseUserQuota(key, value)
|
||||
if err != nil {
|
||||
common.SysLog("failed to batch update user quota: " + err.Error())
|
||||
}
|
||||
case BatchUpdateTypeTokenQuota:
|
||||
err := increaseTokenQuota(key, value)
|
||||
if err != nil {
|
||||
common.SysLog("failed to batch update token quota: " + err.Error())
|
||||
}
|
||||
case BatchUpdateTypeUsedQuota:
|
||||
updateUserUsedQuota(key, value)
|
||||
case BatchUpdateTypeRequestCount:
|
||||
updateUserRequestCount(key, value)
|
||||
case BatchUpdateTypeChannelUsedQuota:
|
||||
updateChannelUsedQuota(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userQuotaStore := stores[BatchUpdateTypeUserQuota]
|
||||
usedQuotaStore := stores[BatchUpdateTypeUsedQuota]
|
||||
requestCountStore := stores[BatchUpdateTypeRequestCount]
|
||||
|
||||
userIDs := make(map[int]struct{}, len(userQuotaStore)+len(usedQuotaStore)+len(requestCountStore))
|
||||
for key := range userQuotaStore {
|
||||
userIDs[key] = struct{}{}
|
||||
}
|
||||
for key := range usedQuotaStore {
|
||||
userIDs[key] = struct{}{}
|
||||
}
|
||||
for key := range requestCountStore {
|
||||
userIDs[key] = struct{}{}
|
||||
}
|
||||
for key := range userIDs {
|
||||
updateUserQuotaUsedQuotaAndRequestCount(key, userQuotaStore[key], usedQuotaStore[key], requestCountStore[key])
|
||||
}
|
||||
common.SysLog("batch update finished")
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,23 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// applyUpstreamContentLength populates req.ContentLength when the upstream
|
||||
// body is wrapped in a BodyStorage (see relay/common/outbound_body.go).
|
||||
//
|
||||
// net/http.NewRequest only auto-detects ContentLength for *bytes.Reader,
|
||||
// *bytes.Buffer and *strings.Reader. When the body is a type-erased io.Reader
|
||||
// (which is the case for ReaderOnly(BodyStorage)), the Content-Length header
|
||||
// would otherwise be omitted, forcing chunked transfer encoding and breaking
|
||||
// some upstreams that require an explicit Content-Length.
|
||||
func applyUpstreamContentLength(req *http.Request, info *common.RelayInfo) {
|
||||
if info == nil {
|
||||
return
|
||||
}
|
||||
if info.UpstreamRequestBodySize > 0 && req.ContentLength <= 0 {
|
||||
req.ContentLength = info.UpstreamRequestBodySize
|
||||
}
|
||||
}
|
||||
|
||||
func SetupApiRequestHeader(info *common.RelayInfo, c *gin.Context, req *http.Header) {
|
||||
if info.RelayMode == constant.RelayModeAudioTranscription || info.RelayMode == constant.RelayModeAudioTranslation {
|
||||
// multipart/form-data
|
||||
@@ -297,6 +314,7 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new request failed: %w", err)
|
||||
}
|
||||
applyUpstreamContentLength(req, info)
|
||||
headers := req.Header
|
||||
err = a.SetupRequestHeader(c, &headers, info)
|
||||
if err != nil {
|
||||
@@ -326,6 +344,7 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new request failed: %w", err)
|
||||
}
|
||||
applyUpstreamContentLength(req, info)
|
||||
// set form data
|
||||
req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
|
||||
headers := req.Header
|
||||
@@ -522,6 +541,7 @@ func DoTaskApiRequest(a TaskAdaptor, c *gin.Context, info *common.RelayInfo, req
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new request failed: %w", err)
|
||||
}
|
||||
applyUpstreamContentLength(req, info)
|
||||
req.GetBody = func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(requestBody), nil
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ var awsModelIDMap = map[string]string{
|
||||
"claude-opus-4-5-20251101": "anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
"claude-opus-4-6": "anthropic.claude-opus-4-6-v1",
|
||||
"claude-opus-4-7": "anthropic.claude-opus-4-7",
|
||||
"claude-opus-4-8": "anthropic.claude-opus-4-8",
|
||||
// Nova models
|
||||
"nova-micro-v1:0": "amazon.nova-micro-v1:0",
|
||||
"nova-lite-v1:0": "amazon.nova-lite-v1:0",
|
||||
@@ -97,6 +98,11 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{
|
||||
"ap": true,
|
||||
"eu": true,
|
||||
},
|
||||
"anthropic.claude-opus-4-8": {
|
||||
"us": true,
|
||||
"ap": true,
|
||||
"eu": true,
|
||||
},
|
||||
"anthropic.claude-haiku-4-5-20251001-v1:0": {
|
||||
"us": true,
|
||||
"ap": true,
|
||||
|
||||
@@ -33,6 +33,13 @@ var ModelList = []string{
|
||||
"claude-opus-4-7-medium",
|
||||
"claude-opus-4-7-low",
|
||||
"claude-opus-4-7-thinking",
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-4-8-max",
|
||||
"claude-opus-4-8-xhigh",
|
||||
"claude-opus-4-8-high",
|
||||
"claude-opus-4-8-medium",
|
||||
"claude-opus-4-8-low",
|
||||
"claude-opus-4-8-thinking",
|
||||
}
|
||||
|
||||
var ChannelName = "claude"
|
||||
|
||||
@@ -154,14 +154,17 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
|
||||
}
|
||||
|
||||
if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(textRequest.Model); ok && effortLevel != "" &&
|
||||
(strings.HasPrefix(textRequest.Model, "claude-opus-4-6") || strings.HasPrefix(textRequest.Model, "claude-opus-4-7")) {
|
||||
(strings.HasPrefix(textRequest.Model, "claude-opus-4-6") ||
|
||||
strings.HasPrefix(textRequest.Model, "claude-opus-4-7") ||
|
||||
strings.HasPrefix(textRequest.Model, "claude-opus-4-8")) {
|
||||
claudeRequest.Model = baseModel
|
||||
claudeRequest.Thinking = &dto.Thinking{
|
||||
Type: "adaptive",
|
||||
}
|
||||
claudeRequest.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
|
||||
if strings.HasPrefix(baseModel, "claude-opus-4-7") {
|
||||
// Opus 4.7 rejects non-default temperature/top_p/top_k with 400
|
||||
if strings.HasPrefix(baseModel, "claude-opus-4-7") ||
|
||||
strings.HasPrefix(baseModel, "claude-opus-4-8") {
|
||||
// Opus 4.7/4.8 reject non-default temperature/top_p/top_k with 400
|
||||
// and defaults display to "omitted"; restore the 4.6 visible summary.
|
||||
claudeRequest.Thinking.Display = "summarized"
|
||||
claudeRequest.Temperature = nil
|
||||
@@ -175,8 +178,9 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
|
||||
strings.HasSuffix(textRequest.Model, "-thinking") {
|
||||
|
||||
trimmedModel := strings.TrimSuffix(textRequest.Model, "-thinking")
|
||||
if strings.HasPrefix(trimmedModel, "claude-opus-4-7") {
|
||||
// Opus 4.7 rejects thinking.type="enabled"; use adaptive at high effort.
|
||||
if strings.HasPrefix(trimmedModel, "claude-opus-4-7") ||
|
||||
strings.HasPrefix(trimmedModel, "claude-opus-4-8") {
|
||||
// Opus 4.7/4.8 reject thinking.type="enabled"; use adaptive at high effort.
|
||||
claudeRequest.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"}
|
||||
claudeRequest.OutputConfig = json.RawMessage(`{"effort":"high"}`)
|
||||
claudeRequest.Temperature = nil
|
||||
@@ -442,10 +446,7 @@ func StreamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.ChatCo
|
||||
tools := make([]dto.ToolCallResponse, 0)
|
||||
fcIdx := 0
|
||||
if claudeResponse.Index != nil {
|
||||
fcIdx = *claudeResponse.Index - 1
|
||||
if fcIdx < 0 {
|
||||
fcIdx = 0
|
||||
}
|
||||
fcIdx = *claudeResponse.Index
|
||||
}
|
||||
var choice dto.ChatCompletionsStreamResponseChoice
|
||||
if claudeResponse.Type == "message_start" {
|
||||
|
||||
@@ -9,6 +9,10 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func commonPointer[T any](value T) *T {
|
||||
return &value
|
||||
}
|
||||
|
||||
func TestFormatClaudeResponseInfo_MessageStart(t *testing.T) {
|
||||
claudeInfo := &ClaudeResponseInfo{
|
||||
Usage: &dto.Usage{},
|
||||
@@ -310,6 +314,58 @@ func TestRequestOpenAI2ClaudeMessage_IgnoresUnsupportedFileContent(t *testing.T)
|
||||
require.Equal(t, "see attachment", *content[0].Text)
|
||||
}
|
||||
|
||||
func TestRequestOpenAI2ClaudeMessage_ClaudeOpus48HighUsesAdaptiveThinking(t *testing.T) {
|
||||
request := dto.GeneralOpenAIRequest{
|
||||
Model: "claude-opus-4-8-high",
|
||||
Temperature: commonPointer(0.7),
|
||||
TopP: commonPointer(0.9),
|
||||
TopK: commonPointer(40),
|
||||
Messages: []dto.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: "hello",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "claude-opus-4-8", claudeRequest.Model)
|
||||
require.NotNil(t, claudeRequest.Thinking)
|
||||
require.Equal(t, "adaptive", claudeRequest.Thinking.Type)
|
||||
require.Equal(t, "summarized", claudeRequest.Thinking.Display)
|
||||
require.JSONEq(t, `{"effort":"high"}`, string(claudeRequest.OutputConfig))
|
||||
require.Nil(t, claudeRequest.Temperature)
|
||||
require.Nil(t, claudeRequest.TopP)
|
||||
require.Nil(t, claudeRequest.TopK)
|
||||
}
|
||||
|
||||
func TestRequestOpenAI2ClaudeMessage_ClaudeOpus48ThinkingUsesAdaptiveHighEffort(t *testing.T) {
|
||||
request := dto.GeneralOpenAIRequest{
|
||||
Model: "claude-opus-4-8-thinking",
|
||||
Temperature: commonPointer(0.7),
|
||||
TopP: commonPointer(0.9),
|
||||
TopK: commonPointer(40),
|
||||
Messages: []dto.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: "hello",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "claude-opus-4-8", claudeRequest.Model)
|
||||
require.NotNil(t, claudeRequest.Thinking)
|
||||
require.Equal(t, "adaptive", claudeRequest.Thinking.Type)
|
||||
require.Equal(t, "summarized", claudeRequest.Thinking.Display)
|
||||
require.JSONEq(t, `{"effort":"high"}`, string(claudeRequest.OutputConfig))
|
||||
require.Nil(t, claudeRequest.Temperature)
|
||||
require.Nil(t, claudeRequest.TopP)
|
||||
require.Nil(t, claudeRequest.TopK)
|
||||
}
|
||||
|
||||
func TestRequestOpenAI2ClaudeMessage_SupportsPDFFileContent(t *testing.T) {
|
||||
request := dto.GeneralOpenAIRequest{
|
||||
Model: "claude-3-5-sonnet",
|
||||
|
||||
@@ -1079,17 +1079,47 @@ func responseGeminiChat2OpenAI(c *gin.Context, response *dto.GeminiChatResponse)
|
||||
FinishReason: constant.FinishReasonStop,
|
||||
}
|
||||
if len(candidate.Content.Parts) > 0 {
|
||||
var texts []string
|
||||
// 使用 strings.Builder 直接累积最终 content,避免:
|
||||
// 1) 每张 inline image 生成一次中间 "" 字符串
|
||||
// 2) 末尾 strings.Join 再分配一份等大缓冲
|
||||
// Gemini 图片返回时 InlineData.Data 可能是数 MB 的 base64,
|
||||
// 上述两份临时分配在高并发下会显著放大堆驻留。
|
||||
var content strings.Builder
|
||||
var inlineGrow int
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.InlineData != nil {
|
||||
inlineGrow += len(part.InlineData.MimeType) + len(part.InlineData.Data) + 32
|
||||
}
|
||||
}
|
||||
if inlineGrow > 0 {
|
||||
content.Grow(inlineGrow)
|
||||
}
|
||||
appended := 0
|
||||
writeSep := func() {
|
||||
if appended > 0 {
|
||||
content.WriteByte('\n')
|
||||
}
|
||||
appended++
|
||||
}
|
||||
var toolCalls []dto.ToolCallResponse
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.InlineData != nil {
|
||||
// 媒体内容
|
||||
if strings.HasPrefix(part.InlineData.MimeType, "image") {
|
||||
imgText := ""
|
||||
texts = append(texts, imgText)
|
||||
writeSep()
|
||||
content.WriteString("
|
||||
content.WriteString(part.InlineData.MimeType)
|
||||
content.WriteString(";base64,")
|
||||
content.WriteString(part.InlineData.Data)
|
||||
content.WriteByte(')')
|
||||
} else {
|
||||
// 其他媒体类型,直接显示链接
|
||||
texts = append(texts, fmt.Sprintf("[media](data:%s;base64,%s)", part.InlineData.MimeType, part.InlineData.Data))
|
||||
writeSep()
|
||||
content.WriteString("[media](data:")
|
||||
content.WriteString(part.InlineData.MimeType)
|
||||
content.WriteString(";base64,")
|
||||
content.WriteString(part.InlineData.Data)
|
||||
content.WriteByte(')')
|
||||
}
|
||||
} else if part.FunctionCall != nil {
|
||||
choice.FinishReason = constant.FinishReasonToolCalls
|
||||
@@ -1100,13 +1130,22 @@ func responseGeminiChat2OpenAI(c *gin.Context, response *dto.GeminiChatResponse)
|
||||
choice.Message.ReasoningContent = &part.Text
|
||||
} else {
|
||||
if part.ExecutableCode != nil {
|
||||
texts = append(texts, "```"+part.ExecutableCode.Language+"\n"+part.ExecutableCode.Code+"\n```")
|
||||
writeSep()
|
||||
content.WriteString("```")
|
||||
content.WriteString(part.ExecutableCode.Language)
|
||||
content.WriteByte('\n')
|
||||
content.WriteString(part.ExecutableCode.Code)
|
||||
content.WriteString("\n```")
|
||||
} else if part.CodeExecutionResult != nil {
|
||||
texts = append(texts, "```output\n"+part.CodeExecutionResult.Output+"\n```")
|
||||
writeSep()
|
||||
content.WriteString("```output\n")
|
||||
content.WriteString(part.CodeExecutionResult.Output)
|
||||
content.WriteString("\n```")
|
||||
} else {
|
||||
// 过滤掉空行
|
||||
if part.Text != "\n" {
|
||||
texts = append(texts, part.Text)
|
||||
writeSep()
|
||||
content.WriteString(part.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1115,7 +1154,7 @@ func responseGeminiChat2OpenAI(c *gin.Context, response *dto.GeminiChatResponse)
|
||||
choice.Message.SetToolCalls(toolCalls)
|
||||
isToolCall = true
|
||||
}
|
||||
choice.Message.SetStringContent(strings.Join(texts, "\n"))
|
||||
choice.Message.SetStringContent(content.String())
|
||||
|
||||
}
|
||||
if candidate.FinishReason != nil {
|
||||
@@ -1169,7 +1208,25 @@ func streamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*d
|
||||
//Role: "assistant",
|
||||
},
|
||||
}
|
||||
var texts []string
|
||||
// 使用 strings.Builder 直接累积 delta content,避免每张 image / 每个
|
||||
// 文本片段都先 `+` 拼出一份临时 string,再 strings.Join 再拷贝一遍。
|
||||
var content strings.Builder
|
||||
var inlineGrow int
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.InlineData != nil {
|
||||
inlineGrow += len(part.InlineData.MimeType) + len(part.InlineData.Data) + 32
|
||||
}
|
||||
}
|
||||
if inlineGrow > 0 {
|
||||
content.Grow(inlineGrow)
|
||||
}
|
||||
appended := 0
|
||||
writeSep := func() {
|
||||
if appended > 0 {
|
||||
content.WriteByte('\n')
|
||||
}
|
||||
appended++
|
||||
}
|
||||
isTools := false
|
||||
isThought := false
|
||||
if candidate.FinishReason != nil {
|
||||
@@ -1207,8 +1264,12 @@ func streamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*d
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.InlineData != nil {
|
||||
if strings.HasPrefix(part.InlineData.MimeType, "image") {
|
||||
imgText := ""
|
||||
texts = append(texts, imgText)
|
||||
writeSep()
|
||||
content.WriteString("
|
||||
content.WriteString(part.InlineData.MimeType)
|
||||
content.WriteString(";base64,")
|
||||
content.WriteString(part.InlineData.Data)
|
||||
content.WriteByte(')')
|
||||
}
|
||||
} else if part.FunctionCall != nil {
|
||||
isTools = true
|
||||
@@ -1219,23 +1280,33 @@ func streamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*d
|
||||
|
||||
} else if part.Thought {
|
||||
isThought = true
|
||||
texts = append(texts, part.Text)
|
||||
writeSep()
|
||||
content.WriteString(part.Text)
|
||||
} else {
|
||||
if part.ExecutableCode != nil {
|
||||
texts = append(texts, "```"+part.ExecutableCode.Language+"\n"+part.ExecutableCode.Code+"\n```\n")
|
||||
writeSep()
|
||||
content.WriteString("```")
|
||||
content.WriteString(part.ExecutableCode.Language)
|
||||
content.WriteByte('\n')
|
||||
content.WriteString(part.ExecutableCode.Code)
|
||||
content.WriteString("\n```\n")
|
||||
} else if part.CodeExecutionResult != nil {
|
||||
texts = append(texts, "```output\n"+part.CodeExecutionResult.Output+"\n```\n")
|
||||
writeSep()
|
||||
content.WriteString("```output\n")
|
||||
content.WriteString(part.CodeExecutionResult.Output)
|
||||
content.WriteString("\n```\n")
|
||||
} else {
|
||||
if part.Text != "\n" {
|
||||
texts = append(texts, part.Text)
|
||||
writeSep()
|
||||
content.WriteString(part.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if isThought {
|
||||
choice.Delta.SetReasoningContent(strings.Join(texts, "\n"))
|
||||
choice.Delta.SetReasoningContent(content.String())
|
||||
} else {
|
||||
choice.Delta.SetContentString(strings.Join(texts, "\n"))
|
||||
choice.Delta.SetContentString(content.String())
|
||||
}
|
||||
if isTools {
|
||||
choice.FinishReason = &constant.FinishReasonToolCalls
|
||||
@@ -1339,6 +1410,14 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
|
||||
response.Id = id
|
||||
response.Created = createAt
|
||||
response.Model = info.UpstreamModelName
|
||||
if response.IsToolCall() {
|
||||
finishReason = constant.FinishReasonToolCalls
|
||||
if info.RelayFormat == types.RelayFormatClaude {
|
||||
for choiceIdx := range response.Choices {
|
||||
response.Choices[choiceIdx].FinishReason = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
for choiceIdx := range response.Choices {
|
||||
choiceKey := response.Choices[choiceIdx].Index
|
||||
for toolIdx := range response.Choices[choiceIdx].Delta.ToolCalls {
|
||||
@@ -1399,7 +1478,9 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
|
||||
logger.LogError(c, err.Error())
|
||||
}
|
||||
if isStop {
|
||||
_ = handleStream(c, info, helper.GenerateStopResponse(id, createAt, info.UpstreamModelName, finishReason))
|
||||
if info.RelayFormat != types.RelayFormatClaude {
|
||||
_ = handleStream(c, info, helper.GenerateStopResponse(id, createAt, info.UpstreamModelName, finishReason))
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
@@ -1409,6 +1490,10 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
|
||||
}
|
||||
|
||||
response := helper.GenerateFinalUsageResponse(id, createAt, info.UpstreamModelName, *usage)
|
||||
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo != nil && !info.ClaudeConvertInfo.Done {
|
||||
response = helper.GenerateStopResponse(id, createAt, info.UpstreamModelName, finishReason)
|
||||
response.Usage = usage
|
||||
}
|
||||
handleErr := handleFinalStream(c, info, response)
|
||||
if handleErr != nil {
|
||||
common.SysLog("send final response failed: " + handleErr.Error())
|
||||
|
||||
@@ -45,6 +45,7 @@ var claudeModelMap = map[string]string{
|
||||
"claude-opus-4-5-20251101": "claude-opus-4-5@20251101",
|
||||
"claude-opus-4-6": "claude-opus-4-6",
|
||||
"claude-opus-4-7": "claude-opus-4-7",
|
||||
"claude-opus-4-8": "claude-opus-4-8",
|
||||
}
|
||||
|
||||
const anthropicVersion = "vertex-2023-10-16"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -125,7 +124,14 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad
|
||||
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
var requestBody io.Reader = bytes.NewBuffer(jsonData)
|
||||
body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData)
|
||||
if err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
defer closer.Close()
|
||||
jsonData = nil
|
||||
info.UpstreamRequestBodySize = size
|
||||
var requestBody io.Reader = body
|
||||
|
||||
var httpResp *http.Response
|
||||
resp, err := adaptor.DoRequest(c, info, requestBody)
|
||||
|
||||
+17
-7
@@ -1,7 +1,6 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -54,14 +53,17 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
}
|
||||
|
||||
if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(request.Model); ok && effortLevel != "" &&
|
||||
(strings.HasPrefix(request.Model, "claude-opus-4-6") || strings.HasPrefix(request.Model, "claude-opus-4-7")) {
|
||||
(strings.HasPrefix(request.Model, "claude-opus-4-6") ||
|
||||
strings.HasPrefix(request.Model, "claude-opus-4-7") ||
|
||||
strings.HasPrefix(request.Model, "claude-opus-4-8")) {
|
||||
request.Model = baseModel
|
||||
request.Thinking = &dto.Thinking{
|
||||
Type: "adaptive",
|
||||
}
|
||||
request.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
|
||||
if strings.HasPrefix(request.Model, "claude-opus-4-7") {
|
||||
// Opus 4.7 rejects non-default temperature/top_p/top_k with 400
|
||||
if strings.HasPrefix(request.Model, "claude-opus-4-7") ||
|
||||
strings.HasPrefix(request.Model, "claude-opus-4-8") {
|
||||
// Opus 4.7/4.8 reject non-default temperature/top_p/top_k with 400
|
||||
// and defaults display to "omitted"; restore the 4.6 visible summary.
|
||||
request.Thinking.Display = "summarized"
|
||||
request.Temperature = nil
|
||||
@@ -75,8 +77,9 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
strings.HasSuffix(request.Model, "-thinking") {
|
||||
if request.Thinking == nil {
|
||||
baseModel := strings.TrimSuffix(request.Model, "-thinking")
|
||||
if strings.HasPrefix(baseModel, "claude-opus-4-7") {
|
||||
// Opus 4.7 rejects thinking.type="enabled"; use adaptive at high effort.
|
||||
if strings.HasPrefix(baseModel, "claude-opus-4-7") ||
|
||||
strings.HasPrefix(baseModel, "claude-opus-4-8") {
|
||||
// Opus 4.7/4.8 reject thinking.type="enabled"; use adaptive at high effort.
|
||||
request.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"}
|
||||
request.OutputConfig = json.RawMessage(`{"effort":"high"}`)
|
||||
request.Temperature = nil
|
||||
@@ -179,7 +182,14 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
}
|
||||
|
||||
logger.LogDebug(c, "requestBody: %s", jsonData)
|
||||
requestBody = bytes.NewBuffer(jsonData)
|
||||
body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
defer closer.Close()
|
||||
jsonData = nil
|
||||
info.UpstreamRequestBodySize = size
|
||||
requestBody = body
|
||||
}
|
||||
|
||||
statusCodeMappingStr := c.GetString("status_code_mapping")
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
)
|
||||
|
||||
// NewOutboundJSONBody wraps the already-marshaled upstream request body into a
|
||||
// BodyStorage. When disk cache is enabled and the payload exceeds the configured
|
||||
// threshold, the data is written to a temp file and the original []byte can be
|
||||
// GC'd, significantly reducing the heap residency while waiting for the
|
||||
// upstream provider to respond (the dominant cost for large base64 payloads).
|
||||
//
|
||||
// In memory mode the underlying memoryStorage reuses the same backing array,
|
||||
// so this is equivalent to bytes.NewReader(data) in terms of memory usage.
|
||||
//
|
||||
// The caller MUST invoke closer.Close() once the upstream call has finished
|
||||
// (typically via defer) to release the disk file / memory accounting.
|
||||
//
|
||||
// The returned reader is wrapped with common.ReaderOnly to prevent the HTTP
|
||||
// transport from prematurely closing the underlying BodyStorage. The returned
|
||||
// size is meant to be propagated to http.Request.ContentLength because the
|
||||
// type-erased io.Reader prevents net/http from auto-detecting it.
|
||||
func NewOutboundJSONBody(data []byte) (body io.Reader, size int64, closer io.Closer, err error) {
|
||||
storage, err := common.CreateBodyStorage(data)
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
return common.ReaderOnly(storage), storage.Size(), storage, nil
|
||||
}
|
||||
+168
-133
@@ -153,9 +153,8 @@ func ApplyParamOverride(jsonData []byte, paramOverride map[string]interface{}, c
|
||||
}
|
||||
}
|
||||
|
||||
// 使用新方法
|
||||
result, err := applyOperations(string(workingJSON), operations, conditionContext)
|
||||
return []byte(result), err
|
||||
// 使用新方法(基于 []byte,避免整包 string 拷贝)
|
||||
return applyOperations(workingJSON, operations, conditionContext)
|
||||
}
|
||||
|
||||
// 直接使用旧方法
|
||||
@@ -510,13 +509,13 @@ func tryParseOperations(paramOverride map[string]interface{}) ([]ParamOperation,
|
||||
return operations, true
|
||||
}
|
||||
|
||||
func checkConditions(jsonStr, contextJSON string, conditions []ConditionOperation, logic string) (bool, error) {
|
||||
func checkConditions(data []byte, contextJSON string, conditions []ConditionOperation, logic string) (bool, error) {
|
||||
if len(conditions) == 0 {
|
||||
return true, nil // 没有条件,直接通过
|
||||
}
|
||||
results := make([]bool, len(conditions))
|
||||
for i, condition := range conditions {
|
||||
result, err := checkSingleCondition(jsonStr, contextJSON, condition)
|
||||
result, err := checkSingleCondition(data, contextJSON, condition)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -529,10 +528,10 @@ func checkConditions(jsonStr, contextJSON string, conditions []ConditionOperatio
|
||||
return lo.SomeBy(results, func(item bool) bool { return item }), nil
|
||||
}
|
||||
|
||||
func checkSingleCondition(jsonStr, contextJSON string, condition ConditionOperation) (bool, error) {
|
||||
func checkSingleCondition(data []byte, contextJSON string, condition ConditionOperation) (bool, error) {
|
||||
// 处理负数索引
|
||||
path := processNegativeIndex(jsonStr, condition.Path)
|
||||
value := gjson.Get(jsonStr, path)
|
||||
path := processNegativeIndex(data, condition.Path)
|
||||
value := gjson.GetBytes(data, path)
|
||||
if !value.Exists() && contextJSON != "" {
|
||||
value = gjson.Get(contextJSON, condition.Path)
|
||||
}
|
||||
@@ -561,7 +560,7 @@ func checkSingleCondition(jsonStr, contextJSON string, condition ConditionOperat
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func processNegativeIndex(jsonStr string, path string) string {
|
||||
func processNegativeIndex(data []byte, path string) string {
|
||||
matches := negativeIndexRegexp.FindAllStringSubmatch(path, -1)
|
||||
|
||||
if len(matches) == 0 {
|
||||
@@ -578,7 +577,7 @@ func processNegativeIndex(jsonStr string, path string) string {
|
||||
arrayPath = arrayPath[:len(arrayPath)-1]
|
||||
}
|
||||
|
||||
array := gjson.Get(jsonStr, arrayPath)
|
||||
array := gjson.GetBytes(data, arrayPath)
|
||||
if array.IsArray() {
|
||||
length := len(array.Array())
|
||||
actualIndex := length + index
|
||||
@@ -667,36 +666,76 @@ func compareNumeric(jsonValue, targetValue gjson.Result, operator string) (bool,
|
||||
}
|
||||
}
|
||||
|
||||
// applyOperationsLegacy 原参数覆盖方法
|
||||
// applyOperationsLegacy 原参数覆盖方法。
|
||||
//
|
||||
// 旧实现把整个 jsonData unmarshal 成 map[string]interface{} 再 marshal 回来,
|
||||
// 对包含大 base64 字段(如 Gemini inlineData.data)的请求会放大数倍内存
|
||||
// (interface 装箱、map bucket、再次 marshal)。
|
||||
// 这里改成在 []byte 上直接调用 sjson.SetBytes,按顶层 key 逐个写入,
|
||||
// 不再把 payload 解码到 map[string]interface{}。
|
||||
//
|
||||
// 语义保持:每个 paramOverride 顶层 key 视为字面 key(不解析点号路径),
|
||||
// 与旧的 reqMap[key] = value 一致。包含 `.` `*` `?` `\` 的 key 会被转义,
|
||||
// 防止被 sjson 当作嵌套路径或通配符。
|
||||
func applyOperationsLegacy(jsonData []byte, paramOverride map[string]interface{}, auditRecorder *paramOverrideAuditRecorder) ([]byte, error) {
|
||||
reqMap := make(map[string]interface{})
|
||||
err := common.Unmarshal(jsonData, &reqMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if len(paramOverride) == 0 {
|
||||
return jsonData, nil
|
||||
}
|
||||
|
||||
result := jsonData
|
||||
for key, value := range paramOverride {
|
||||
reqMap[key] = value
|
||||
escaped := escapeSjsonLiteralKey(key)
|
||||
next, err := sjson.SetBytes(result, escaped, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = next
|
||||
auditRecorder.recordOperation("set", key, "", "", value)
|
||||
}
|
||||
|
||||
return common.Marshal(reqMap)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func applyOperations(jsonStr string, operations []ParamOperation, conditionContext map[string]interface{}) (string, error) {
|
||||
// escapeSjsonLiteralKey 把可能被 sjson 误判为路径或通配符的字符转义,
|
||||
// 用于把字面 key 安全地传给 sjson.SetBytes / sjson.DeleteBytes。
|
||||
func escapeSjsonLiteralKey(key string) string {
|
||||
if !strings.ContainsAny(key, ".*?\\") {
|
||||
return key
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.Grow(len(key) + 4)
|
||||
for i := 0; i < len(key); i++ {
|
||||
c := key[i]
|
||||
switch c {
|
||||
case '.', '*', '?', '\\':
|
||||
sb.WriteByte('\\')
|
||||
}
|
||||
sb.WriteByte(c)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// applyOperations 在 []byte 上原地应用所有 param override 操作。
|
||||
//
|
||||
// 旧实现走 string-based gjson/sjson,在 ApplyParamOverride 入口会做
|
||||
// string(jsonData) 与最终 []byte(result) 各一次整包拷贝,对大 base64
|
||||
// payload 来说每次重试都额外多花 2 倍 body 体积的临时内存。
|
||||
// 这里改成全程在 []byte 上工作,sjson.SetBytes / gjson.GetBytes 都是
|
||||
// 直接读写 []byte,每个操作只会产生一份新 buffer。
|
||||
func applyOperations(jsonData []byte, operations []ParamOperation, conditionContext map[string]interface{}) ([]byte, error) {
|
||||
context := ensureContextMap(conditionContext)
|
||||
auditRecorder := getParamOverrideAuditRecorder(context)
|
||||
contextJSON, err := marshalContextJSON(context)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal condition context: %v", err)
|
||||
return nil, fmt.Errorf("failed to marshal condition context: %v", err)
|
||||
}
|
||||
|
||||
result := jsonStr
|
||||
result := jsonData
|
||||
for _, op := range operations {
|
||||
// 检查条件是否满足
|
||||
ok, err := checkConditions(result, contextJSON, op.Conditions, op.Logic)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
continue // 条件不满足,跳过当前操作
|
||||
@@ -707,7 +746,7 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
|
||||
if isPathBasedOperation(op.Mode) {
|
||||
opPaths, err = resolveOperationPaths(result, opPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
if len(opPaths) == 0 {
|
||||
continue
|
||||
@@ -725,10 +764,10 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
|
||||
}
|
||||
case "set":
|
||||
for _, path := range opPaths {
|
||||
if op.KeepOrigin && gjson.Get(result, path).Exists() {
|
||||
if op.KeepOrigin && gjson.GetBytes(result, path).Exists() {
|
||||
continue
|
||||
}
|
||||
result, err = sjson.Set(result, path, op.Value)
|
||||
result, err = sjson.SetBytes(result, path, op.Value)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
@@ -743,7 +782,7 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
|
||||
}
|
||||
case "copy":
|
||||
if op.From == "" || op.To == "" {
|
||||
return "", fmt.Errorf("copy from/to is required")
|
||||
return nil, fmt.Errorf("copy from/to is required")
|
||||
}
|
||||
opFrom := processNegativeIndex(result, op.From)
|
||||
opTo := processNegativeIndex(result, op.To)
|
||||
@@ -843,9 +882,9 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
|
||||
auditRecorder.recordOperation("return_error", op.Path, "", "", op.Value)
|
||||
returnErr, parseErr := parseParamOverrideReturnError(op.Value)
|
||||
if parseErr != nil {
|
||||
return "", parseErr
|
||||
return nil, parseErr
|
||||
}
|
||||
return "", returnErr
|
||||
return nil, returnErr
|
||||
case "prune_objects":
|
||||
for _, path := range opPaths {
|
||||
result, err = pruneObjects(result, path, contextJSON, op.Value)
|
||||
@@ -902,7 +941,7 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
|
||||
case "pass_headers":
|
||||
headerNames, parseErr := parseHeaderPassThroughNames(op.Value)
|
||||
if parseErr != nil {
|
||||
return "", parseErr
|
||||
return nil, parseErr
|
||||
}
|
||||
for _, headerName := range headerNames {
|
||||
if err = copyHeaderInContext(context, headerName, headerName, op.KeepOrigin); err != nil {
|
||||
@@ -924,10 +963,10 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
|
||||
contextJSON, err = marshalContextJSON(context)
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("unknown operation: %s", op.Mode)
|
||||
return nil, fmt.Errorf("unknown operation: %s", op.Mode)
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("operation %s failed: %w", op.Mode, err)
|
||||
return nil, fmt.Errorf("operation %s failed: %w", op.Mode, err)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
@@ -1361,11 +1400,11 @@ func parseSyncTarget(spec string) (syncTarget, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func readSyncTargetValue(jsonStr string, context map[string]interface{}, target syncTarget) (interface{}, bool, error) {
|
||||
func readSyncTargetValue(data []byte, context map[string]interface{}, target syncTarget) (interface{}, bool, error) {
|
||||
switch target.kind {
|
||||
case "json":
|
||||
path := processNegativeIndex(jsonStr, target.key)
|
||||
value := gjson.Get(jsonStr, path)
|
||||
path := processNegativeIndex(data, target.key)
|
||||
value := gjson.GetBytes(data, path)
|
||||
if !value.Exists() || value.Type == gjson.Null {
|
||||
return nil, false, nil
|
||||
}
|
||||
@@ -1384,52 +1423,52 @@ func readSyncTargetValue(jsonStr string, context map[string]interface{}, target
|
||||
}
|
||||
}
|
||||
|
||||
func writeSyncTargetValue(jsonStr string, context map[string]interface{}, target syncTarget, value interface{}) (string, error) {
|
||||
func writeSyncTargetValue(data []byte, context map[string]interface{}, target syncTarget, value interface{}) ([]byte, error) {
|
||||
switch target.kind {
|
||||
case "json":
|
||||
path := processNegativeIndex(jsonStr, target.key)
|
||||
nextJSON, err := sjson.Set(jsonStr, path, value)
|
||||
path := processNegativeIndex(data, target.key)
|
||||
nextJSON, err := sjson.SetBytes(data, path, value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
return nextJSON, nil
|
||||
case "header":
|
||||
if err := setHeaderOverrideInContext(context, target.key, value, false); err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
return jsonStr, nil
|
||||
return data, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported sync_fields target kind: %s", target.kind)
|
||||
return nil, fmt.Errorf("unsupported sync_fields target kind: %s", target.kind)
|
||||
}
|
||||
}
|
||||
|
||||
func syncFieldsBetweenTargets(jsonStr string, context map[string]interface{}, fromSpec string, toSpec string) (string, error) {
|
||||
func syncFieldsBetweenTargets(data []byte, context map[string]interface{}, fromSpec string, toSpec string) ([]byte, error) {
|
||||
fromTarget, err := parseSyncTarget(fromSpec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
toTarget, err := parseSyncTarget(toSpec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fromValue, fromExists, err := readSyncTargetValue(jsonStr, context, fromTarget)
|
||||
fromValue, fromExists, err := readSyncTargetValue(data, context, fromTarget)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
toValue, toExists, err := readSyncTargetValue(jsonStr, context, toTarget)
|
||||
toValue, toExists, err := readSyncTargetValue(data, context, toTarget)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If one side exists and the other side is missing, sync the missing side.
|
||||
if fromExists && !toExists {
|
||||
return writeSyncTargetValue(jsonStr, context, toTarget, fromValue)
|
||||
return writeSyncTargetValue(data, context, toTarget, fromValue)
|
||||
}
|
||||
if toExists && !fromExists {
|
||||
return writeSyncTargetValue(jsonStr, context, fromTarget, toValue)
|
||||
return writeSyncTargetValue(data, context, fromTarget, toValue)
|
||||
}
|
||||
return jsonStr, nil
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func ensureMapKeyInContext(context map[string]interface{}, key string) map[string]interface{} {
|
||||
@@ -1503,24 +1542,24 @@ func syncRuntimeHeaderOverrideFromContext(info *RelayInfo, context map[string]in
|
||||
info.UseRuntimeHeadersOverride = true
|
||||
}
|
||||
|
||||
func moveValue(jsonStr, fromPath, toPath string) (string, error) {
|
||||
sourceValue := gjson.Get(jsonStr, fromPath)
|
||||
func moveValue(data []byte, fromPath, toPath string) ([]byte, error) {
|
||||
sourceValue := gjson.GetBytes(data, fromPath)
|
||||
if !sourceValue.Exists() {
|
||||
return jsonStr, fmt.Errorf("source path does not exist: %s", fromPath)
|
||||
return data, fmt.Errorf("source path does not exist: %s", fromPath)
|
||||
}
|
||||
result, err := sjson.Set(jsonStr, toPath, sourceValue.Value())
|
||||
result, err := sjson.SetBytes(data, toPath, sourceValue.Value())
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
return sjson.Delete(result, fromPath)
|
||||
return sjson.DeleteBytes(result, fromPath)
|
||||
}
|
||||
|
||||
func copyValue(jsonStr, fromPath, toPath string) (string, error) {
|
||||
sourceValue := gjson.Get(jsonStr, fromPath)
|
||||
func copyValue(data []byte, fromPath, toPath string) ([]byte, error) {
|
||||
sourceValue := gjson.GetBytes(data, fromPath)
|
||||
if !sourceValue.Exists() {
|
||||
return jsonStr, fmt.Errorf("source path does not exist: %s", fromPath)
|
||||
return data, fmt.Errorf("source path does not exist: %s", fromPath)
|
||||
}
|
||||
return sjson.Set(jsonStr, toPath, sourceValue.Value())
|
||||
return sjson.SetBytes(data, toPath, sourceValue.Value())
|
||||
}
|
||||
|
||||
func isPathBasedOperation(mode string) bool {
|
||||
@@ -1532,16 +1571,16 @@ func isPathBasedOperation(mode string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func resolveOperationPaths(jsonStr, path string) ([]string, error) {
|
||||
func resolveOperationPaths(data []byte, path string) ([]string, error) {
|
||||
if !strings.Contains(path, "*") {
|
||||
return []string{path}, nil
|
||||
}
|
||||
return expandWildcardPaths(jsonStr, path)
|
||||
return expandWildcardPaths(data, path)
|
||||
}
|
||||
|
||||
func expandWildcardPaths(jsonStr, path string) ([]string, error) {
|
||||
func expandWildcardPaths(data []byte, path string) ([]string, error) {
|
||||
var root interface{}
|
||||
if err := common.Unmarshal([]byte(jsonStr), &root); err != nil {
|
||||
if err := common.Unmarshal(data, &root); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1602,28 +1641,28 @@ func collectWildcardPaths(node interface{}, segments []string, prefix []string)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteValue(jsonStr, path string) (string, error) {
|
||||
func deleteValue(data []byte, path string) ([]byte, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return jsonStr, nil
|
||||
return data, nil
|
||||
}
|
||||
return sjson.Delete(jsonStr, path)
|
||||
return sjson.DeleteBytes(data, path)
|
||||
}
|
||||
|
||||
func modifyValue(jsonStr, path string, value interface{}, keepOrigin, isPrepend bool) (string, error) {
|
||||
current := gjson.Get(jsonStr, path)
|
||||
func modifyValue(data []byte, path string, value interface{}, keepOrigin, isPrepend bool) ([]byte, error) {
|
||||
current := gjson.GetBytes(data, path)
|
||||
switch {
|
||||
case current.IsArray():
|
||||
return modifyArray(jsonStr, path, value, isPrepend)
|
||||
return modifyArray(data, path, value, isPrepend)
|
||||
case current.Type == gjson.String:
|
||||
return modifyString(jsonStr, path, value, isPrepend)
|
||||
return modifyString(data, path, value, isPrepend)
|
||||
case current.Type == gjson.JSON:
|
||||
return mergeObjects(jsonStr, path, value, keepOrigin)
|
||||
return mergeObjects(data, path, value, keepOrigin)
|
||||
}
|
||||
return jsonStr, fmt.Errorf("operation not supported for type: %v", current.Type)
|
||||
return data, fmt.Errorf("operation not supported for type: %v", current.Type)
|
||||
}
|
||||
|
||||
func modifyArray(jsonStr, path string, value interface{}, isPrepend bool) (string, error) {
|
||||
current := gjson.Get(jsonStr, path)
|
||||
func modifyArray(data []byte, path string, value interface{}, isPrepend bool) ([]byte, error) {
|
||||
current := gjson.GetBytes(data, path)
|
||||
var newArray []interface{}
|
||||
// 添加新值
|
||||
addValue := func() {
|
||||
@@ -1647,11 +1686,11 @@ func modifyArray(jsonStr, path string, value interface{}, isPrepend bool) (strin
|
||||
addOriginal()
|
||||
addValue()
|
||||
}
|
||||
return sjson.Set(jsonStr, path, newArray)
|
||||
return sjson.SetBytes(data, path, newArray)
|
||||
}
|
||||
|
||||
func modifyString(jsonStr, path string, value interface{}, isPrepend bool) (string, error) {
|
||||
current := gjson.Get(jsonStr, path)
|
||||
func modifyString(data []byte, path string, value interface{}, isPrepend bool) ([]byte, error) {
|
||||
current := gjson.GetBytes(data, path)
|
||||
valueStr := fmt.Sprintf("%v", value)
|
||||
var newStr string
|
||||
if isPrepend {
|
||||
@@ -1659,17 +1698,17 @@ func modifyString(jsonStr, path string, value interface{}, isPrepend bool) (stri
|
||||
} else {
|
||||
newStr = current.String() + valueStr
|
||||
}
|
||||
return sjson.Set(jsonStr, path, newStr)
|
||||
return sjson.SetBytes(data, path, newStr)
|
||||
}
|
||||
|
||||
func trimStringValue(jsonStr, path string, value interface{}, isPrefix bool) (string, error) {
|
||||
current := gjson.Get(jsonStr, path)
|
||||
func trimStringValue(data []byte, path string, value interface{}, isPrefix bool) ([]byte, error) {
|
||||
current := gjson.GetBytes(data, path)
|
||||
if current.Type != gjson.String {
|
||||
return jsonStr, fmt.Errorf("operation not supported for type: %v", current.Type)
|
||||
return data, fmt.Errorf("operation not supported for type: %v", current.Type)
|
||||
}
|
||||
|
||||
if value == nil {
|
||||
return jsonStr, fmt.Errorf("trim value is required")
|
||||
return data, fmt.Errorf("trim value is required")
|
||||
}
|
||||
valueStr := fmt.Sprintf("%v", value)
|
||||
|
||||
@@ -1679,69 +1718,69 @@ func trimStringValue(jsonStr, path string, value interface{}, isPrefix bool) (st
|
||||
} else {
|
||||
newStr = strings.TrimSuffix(current.String(), valueStr)
|
||||
}
|
||||
return sjson.Set(jsonStr, path, newStr)
|
||||
return sjson.SetBytes(data, path, newStr)
|
||||
}
|
||||
|
||||
func ensureStringAffix(jsonStr, path string, value interface{}, isPrefix bool) (string, error) {
|
||||
current := gjson.Get(jsonStr, path)
|
||||
func ensureStringAffix(data []byte, path string, value interface{}, isPrefix bool) ([]byte, error) {
|
||||
current := gjson.GetBytes(data, path)
|
||||
if current.Type != gjson.String {
|
||||
return jsonStr, fmt.Errorf("operation not supported for type: %v", current.Type)
|
||||
return data, fmt.Errorf("operation not supported for type: %v", current.Type)
|
||||
}
|
||||
|
||||
if value == nil {
|
||||
return jsonStr, fmt.Errorf("ensure value is required")
|
||||
return data, fmt.Errorf("ensure value is required")
|
||||
}
|
||||
valueStr := fmt.Sprintf("%v", value)
|
||||
if valueStr == "" {
|
||||
return jsonStr, fmt.Errorf("ensure value is required")
|
||||
return data, fmt.Errorf("ensure value is required")
|
||||
}
|
||||
|
||||
currentStr := current.String()
|
||||
if isPrefix {
|
||||
if strings.HasPrefix(currentStr, valueStr) {
|
||||
return jsonStr, nil
|
||||
return data, nil
|
||||
}
|
||||
return sjson.Set(jsonStr, path, valueStr+currentStr)
|
||||
return sjson.SetBytes(data, path, valueStr+currentStr)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(currentStr, valueStr) {
|
||||
return jsonStr, nil
|
||||
return data, nil
|
||||
}
|
||||
return sjson.Set(jsonStr, path, currentStr+valueStr)
|
||||
return sjson.SetBytes(data, path, currentStr+valueStr)
|
||||
}
|
||||
|
||||
func transformStringValue(jsonStr, path string, transform func(string) string) (string, error) {
|
||||
current := gjson.Get(jsonStr, path)
|
||||
func transformStringValue(data []byte, path string, transform func(string) string) ([]byte, error) {
|
||||
current := gjson.GetBytes(data, path)
|
||||
if current.Type != gjson.String {
|
||||
return jsonStr, fmt.Errorf("operation not supported for type: %v", current.Type)
|
||||
return data, fmt.Errorf("operation not supported for type: %v", current.Type)
|
||||
}
|
||||
return sjson.Set(jsonStr, path, transform(current.String()))
|
||||
return sjson.SetBytes(data, path, transform(current.String()))
|
||||
}
|
||||
|
||||
func replaceStringValue(jsonStr, path, from, to string) (string, error) {
|
||||
current := gjson.Get(jsonStr, path)
|
||||
func replaceStringValue(data []byte, path, from, to string) ([]byte, error) {
|
||||
current := gjson.GetBytes(data, path)
|
||||
if current.Type != gjson.String {
|
||||
return jsonStr, fmt.Errorf("operation not supported for type: %v", current.Type)
|
||||
return data, fmt.Errorf("operation not supported for type: %v", current.Type)
|
||||
}
|
||||
if from == "" {
|
||||
return jsonStr, fmt.Errorf("replace from is required")
|
||||
return data, fmt.Errorf("replace from is required")
|
||||
}
|
||||
return sjson.Set(jsonStr, path, strings.ReplaceAll(current.String(), from, to))
|
||||
return sjson.SetBytes(data, path, strings.ReplaceAll(current.String(), from, to))
|
||||
}
|
||||
|
||||
func regexReplaceStringValue(jsonStr, path, pattern, replacement string) (string, error) {
|
||||
current := gjson.Get(jsonStr, path)
|
||||
func regexReplaceStringValue(data []byte, path, pattern, replacement string) ([]byte, error) {
|
||||
current := gjson.GetBytes(data, path)
|
||||
if current.Type != gjson.String {
|
||||
return jsonStr, fmt.Errorf("operation not supported for type: %v", current.Type)
|
||||
return data, fmt.Errorf("operation not supported for type: %v", current.Type)
|
||||
}
|
||||
if pattern == "" {
|
||||
return jsonStr, fmt.Errorf("regex pattern is required")
|
||||
return data, fmt.Errorf("regex pattern is required")
|
||||
}
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return jsonStr, err
|
||||
return data, err
|
||||
}
|
||||
return sjson.Set(jsonStr, path, re.ReplaceAllString(current.String(), replacement))
|
||||
return sjson.SetBytes(data, path, re.ReplaceAllString(current.String(), replacement))
|
||||
}
|
||||
|
||||
type pruneObjectsOptions struct {
|
||||
@@ -1750,37 +1789,33 @@ type pruneObjectsOptions struct {
|
||||
recursive bool
|
||||
}
|
||||
|
||||
func pruneObjects(jsonStr, path, contextJSON string, value interface{}) (string, error) {
|
||||
func pruneObjects(data []byte, path, contextJSON string, value interface{}) ([]byte, error) {
|
||||
options, err := parsePruneObjectsOptions(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
var root interface{}
|
||||
if err := common.Unmarshal([]byte(jsonStr), &root); err != nil {
|
||||
return "", err
|
||||
if err := common.Unmarshal(data, &root); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cleaned, _, err := pruneObjectsNode(root, options, contextJSON, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
cleanedBytes, err := common.Marshal(cleaned)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(cleanedBytes), nil
|
||||
return common.Marshal(cleaned)
|
||||
}
|
||||
|
||||
target := gjson.Get(jsonStr, path)
|
||||
target := gjson.GetBytes(data, path)
|
||||
if !target.Exists() {
|
||||
return jsonStr, nil
|
||||
return data, nil
|
||||
}
|
||||
|
||||
var targetNode interface{}
|
||||
if target.Type == gjson.JSON {
|
||||
if err := common.Unmarshal([]byte(target.Raw), &targetNode); err != nil {
|
||||
return "", err
|
||||
if err := common.UnmarshalJsonStr(target.Raw, &targetNode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
targetNode = target.Value()
|
||||
@@ -1788,13 +1823,13 @@ func pruneObjects(jsonStr, path, contextJSON string, value interface{}) (string,
|
||||
|
||||
cleaned, _, err := pruneObjectsNode(targetNode, options, contextJSON, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
cleanedBytes, err := common.Marshal(cleaned)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
return sjson.SetRaw(jsonStr, path, string(cleanedBytes))
|
||||
return sjson.SetRawBytes(data, path, cleanedBytes)
|
||||
}
|
||||
|
||||
func parsePruneObjectsOptions(value interface{}) (pruneObjectsOptions, error) {
|
||||
@@ -1970,16 +2005,16 @@ func shouldPruneObject(node map[string]interface{}, options pruneObjectsOptions,
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return checkConditions(string(nodeBytes), contextJSON, options.conditions, options.logic)
|
||||
return checkConditions(nodeBytes, contextJSON, options.conditions, options.logic)
|
||||
}
|
||||
|
||||
func mergeObjects(jsonStr, path string, value interface{}, keepOrigin bool) (string, error) {
|
||||
current := gjson.Get(jsonStr, path)
|
||||
func mergeObjects(data []byte, path string, value interface{}, keepOrigin bool) ([]byte, error) {
|
||||
current := gjson.GetBytes(data, path)
|
||||
var currentMap, newMap map[string]interface{}
|
||||
|
||||
// 解析当前值
|
||||
if err := common.Unmarshal([]byte(current.Raw), ¤tMap); err != nil {
|
||||
return "", err
|
||||
// 解析当前值(current.Raw 是 data 的子串,避免再分配一份)
|
||||
if err := common.UnmarshalJsonStr(current.Raw, ¤tMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 解析新值
|
||||
switch v := value.(type) {
|
||||
@@ -1988,7 +2023,7 @@ func mergeObjects(jsonStr, path string, value interface{}, keepOrigin bool) (str
|
||||
default:
|
||||
jsonBytes, _ := common.Marshal(v)
|
||||
if err := common.Unmarshal(jsonBytes, &newMap); err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// 合并
|
||||
@@ -2001,7 +2036,7 @@ func mergeObjects(jsonStr, path string, value interface{}, keepOrigin bool) (str
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
return sjson.Set(jsonStr, path, result)
|
||||
return sjson.SetBytes(data, path, result)
|
||||
}
|
||||
|
||||
// BuildParamOverrideContext 提供 ApplyParamOverride 可用的上下文信息。
|
||||
|
||||
@@ -154,6 +154,13 @@ type RelayInfo struct {
|
||||
UseRuntimeHeadersOverride bool
|
||||
ParamOverrideAudit []string
|
||||
|
||||
// UpstreamRequestBodySize is the byte size of the marshaled upstream request
|
||||
// body. It is set when the body is wrapped in a BodyStorage (see
|
||||
// relay/common/outbound_body.go), so that DoApiRequest can populate
|
||||
// http.Request.ContentLength manually (net/http only auto-detects it for
|
||||
// *bytes.Reader/Buffer/strings.Reader). 0 means "let net/http decide".
|
||||
UpstreamRequestBodySize int64
|
||||
|
||||
PriceData types.PriceData
|
||||
|
||||
// TieredBillingSnapshot is a frozen snapshot of tiered billing rules
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -176,7 +175,14 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
|
||||
|
||||
logger.LogDebug(c, "text request body: %s", jsonData)
|
||||
|
||||
requestBody = bytes.NewBuffer(jsonData)
|
||||
body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
defer closer.Close()
|
||||
jsonData = nil
|
||||
info.UpstreamRequestBodySize = size
|
||||
requestBody = body
|
||||
}
|
||||
|
||||
var httpResp *http.Response
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -59,7 +58,14 @@ func EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
|
||||
}
|
||||
|
||||
logger.LogDebug(c, "converted embedding request body: %s", jsonData)
|
||||
var requestBody io.Reader = bytes.NewBuffer(jsonData)
|
||||
body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
defer closer.Close()
|
||||
jsonData = nil
|
||||
info.UpstreamRequestBodySize = size
|
||||
var requestBody io.Reader = body
|
||||
statusCodeMappingStr := c.GetString("status_code_mapping")
|
||||
resp, err := adaptor.DoRequest(c, info, requestBody)
|
||||
if err != nil {
|
||||
|
||||
+16
-3
@@ -1,7 +1,6 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -165,7 +164,14 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
|
||||
logger.LogDebug(c, "Gemini request body: %s", jsonData)
|
||||
|
||||
requestBody = bytes.NewReader(jsonData)
|
||||
body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
defer closer.Close()
|
||||
jsonData = nil
|
||||
info.UpstreamRequestBodySize = size
|
||||
requestBody = body
|
||||
}
|
||||
|
||||
resp, err := adaptor.DoRequest(c, info, requestBody)
|
||||
@@ -263,7 +269,14 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo) (newAPI
|
||||
}
|
||||
}
|
||||
logger.LogDebug(c, "Gemini embedding request body: %s", jsonData)
|
||||
requestBody = bytes.NewReader(jsonData)
|
||||
body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
defer closer.Close()
|
||||
jsonData = nil
|
||||
info.UpstreamRequestBodySize = size
|
||||
requestBody = body
|
||||
|
||||
resp, err := adaptor.DoRequest(c, info, requestBody)
|
||||
if err != nil {
|
||||
|
||||
+11
-4
@@ -77,7 +77,14 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
|
||||
}
|
||||
|
||||
logger.LogDebug(c, "image request body: %s", jsonData)
|
||||
requestBody = bytes.NewBuffer(jsonData)
|
||||
body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
defer closer.Close()
|
||||
jsonData = nil
|
||||
info.UpstreamRequestBodySize = size
|
||||
requestBody = body
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,9 +140,9 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
|
||||
usage.(*dto.Usage).PromptTokens = 1
|
||||
}
|
||||
|
||||
quality := "standard"
|
||||
if request.Quality == "hd" {
|
||||
quality = "hd"
|
||||
quality := request.Quality
|
||||
if quality == "" {
|
||||
quality = "standard"
|
||||
}
|
||||
|
||||
var logContent []string
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -69,7 +68,14 @@ func RerankHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
}
|
||||
|
||||
logger.LogDebug(c, "Rerank request body: %s", jsonData)
|
||||
requestBody = bytes.NewBuffer(jsonData)
|
||||
body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
defer closer.Close()
|
||||
jsonData = nil
|
||||
info.UpstreamRequestBodySize = size
|
||||
requestBody = body
|
||||
}
|
||||
|
||||
resp, err := adaptor.DoRequest(c, info, requestBody)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -104,7 +103,14 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
|
||||
}
|
||||
|
||||
logger.LogDebug(c, "requestBody: %s", jsonData)
|
||||
requestBody = bytes.NewBuffer(jsonData)
|
||||
body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
defer closer.Close()
|
||||
jsonData = nil
|
||||
info.UpstreamRequestBodySize = size
|
||||
requestBody = body
|
||||
}
|
||||
|
||||
var httpResp *http.Response
|
||||
|
||||
@@ -153,6 +153,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
subscriptionRoute.GET("/plans", controller.GetSubscriptionPlans)
|
||||
subscriptionRoute.GET("/self", controller.GetSubscriptionSelf)
|
||||
subscriptionRoute.PUT("/self/preference", controller.UpdateSubscriptionPreference)
|
||||
subscriptionRoute.POST("/balance/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestBalancePay)
|
||||
subscriptionRoute.POST("/epay/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestEpay)
|
||||
subscriptionRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestStripePay)
|
||||
subscriptionRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestCreemPay)
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ func formatNotifyType(channelId int, status int) string {
|
||||
|
||||
// disable & notify
|
||||
func DisableChannel(channelError types.ChannelError, reason string) {
|
||||
common.SysLog(fmt.Sprintf("通道「%s」(#%d)发生错误,准备禁用,原因:%s", channelError.ChannelName, channelError.ChannelId, reason))
|
||||
common.SysLog(fmt.Sprintf("通道「%s」(#%d)发生错误,准备禁用,原因:%s", channelError.ChannelName, channelError.ChannelId, common.LocalLogPreview(reason)))
|
||||
|
||||
// 检查是否启用自动禁用功能
|
||||
if !channelError.AutoBan {
|
||||
|
||||
+5
-3
@@ -92,11 +92,13 @@ func RelayErrorHandler(ctx context.Context, resp *http.Response, showBodyWhenFai
|
||||
}
|
||||
CloseResponseBodyGracefully(resp)
|
||||
var errResponse dto.GeneralErrorResponse
|
||||
responseBodyText := string(responseBody)
|
||||
responseBodyPreview := common.LocalLogPreview(responseBodyText)
|
||||
buildErrWithBody := func(message string) error {
|
||||
if message == "" {
|
||||
return fmt.Errorf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody))
|
||||
return fmt.Errorf("bad response status code %d, body: %s", resp.StatusCode, responseBodyText)
|
||||
}
|
||||
return fmt.Errorf("bad response status code %d, message: %s, body: %s", resp.StatusCode, message, string(responseBody))
|
||||
return fmt.Errorf("bad response status code %d, message: %s, body: %s", resp.StatusCode, message, responseBodyText)
|
||||
}
|
||||
|
||||
err = common.Unmarshal(responseBody, &errResponse)
|
||||
@@ -104,7 +106,7 @@ func RelayErrorHandler(ctx context.Context, resp *http.Response, showBodyWhenFai
|
||||
if showBodyWhenFail {
|
||||
newApiErr.Err = buildErrWithBody("")
|
||||
} else {
|
||||
logger.LogError(ctx, fmt.Sprintf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody)))
|
||||
logger.LogError(ctx, fmt.Sprintf("bad response status code %d, body: %s", resp.StatusCode, responseBodyPreview))
|
||||
newApiErr.Err = fmt.Errorf("bad response status code %d", resp.StatusCode)
|
||||
}
|
||||
return
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -55,3 +63,99 @@ func TestResetStatusCode(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayErrorHandlerTruncatesInvalidJSONBodyInLog(t *testing.T) {
|
||||
withDebugEnabled(t, false)
|
||||
|
||||
body := strings.Repeat("b", common.LocalLogContentLimit+256)
|
||||
var logBuffer bytes.Buffer
|
||||
|
||||
common.LogWriterMu.Lock()
|
||||
oldWriter := gin.DefaultErrorWriter
|
||||
gin.DefaultErrorWriter = &logBuffer
|
||||
common.LogWriterMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
common.LogWriterMu.Lock()
|
||||
gin.DefaultErrorWriter = oldWriter
|
||||
common.LogWriterMu.Unlock()
|
||||
})
|
||||
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
|
||||
newAPIError := RelayErrorHandler(context.Background(), resp, false)
|
||||
|
||||
require.NotNil(t, newAPIError)
|
||||
require.Equal(t, "bad response status code 500", newAPIError.Error())
|
||||
require.Contains(t, logBuffer.String(), "[truncated")
|
||||
require.Contains(t, logBuffer.String(), fmt.Sprintf("original_length=%d", len(body)))
|
||||
require.NotContains(t, logBuffer.String(), strings.Repeat("b", common.LocalLogContentLimit+1))
|
||||
}
|
||||
|
||||
func TestRelayErrorHandlerKeepsStructuredErrorMessage(t *testing.T) {
|
||||
message := strings.Repeat("c", common.LocalLogContentLimit+256)
|
||||
body := `{"message":"` + message + `"}`
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
|
||||
newAPIError := RelayErrorHandler(context.Background(), resp, false)
|
||||
|
||||
require.NotNil(t, newAPIError)
|
||||
require.Equal(t, message, newAPIError.Error())
|
||||
}
|
||||
|
||||
func TestRelayErrorHandlerKeepsOpenAIErrorMessage(t *testing.T) {
|
||||
message := strings.Repeat("d", common.LocalLogContentLimit+256)
|
||||
body := `{"error":{"message":"` + message + `","type":"server_error","code":"server_error"}}`
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
|
||||
newAPIError := RelayErrorHandler(context.Background(), resp, false)
|
||||
|
||||
require.NotNil(t, newAPIError)
|
||||
require.Equal(t, message, newAPIError.Error())
|
||||
}
|
||||
|
||||
func TestRelayErrorHandlerKeepsInvalidJSONBodyInDebugLog(t *testing.T) {
|
||||
withDebugEnabled(t, true)
|
||||
|
||||
body := strings.Repeat("e", common.LocalLogContentLimit+256)
|
||||
var logBuffer bytes.Buffer
|
||||
|
||||
common.LogWriterMu.Lock()
|
||||
oldWriter := gin.DefaultErrorWriter
|
||||
gin.DefaultErrorWriter = &logBuffer
|
||||
common.LogWriterMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
common.LogWriterMu.Lock()
|
||||
gin.DefaultErrorWriter = oldWriter
|
||||
common.LogWriterMu.Unlock()
|
||||
})
|
||||
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
|
||||
newAPIError := RelayErrorHandler(context.Background(), resp, false)
|
||||
|
||||
require.NotNil(t, newAPIError)
|
||||
require.NotContains(t, logBuffer.String(), "[truncated")
|
||||
require.Contains(t, logBuffer.String(), body)
|
||||
}
|
||||
|
||||
func withDebugEnabled(t *testing.T, enabled bool) {
|
||||
t.Helper()
|
||||
|
||||
oldDebug := common.DebugEnabled
|
||||
common.DebugEnabled = enabled
|
||||
t.Cleanup(func() {
|
||||
common.DebugEnabled = oldDebug
|
||||
})
|
||||
}
|
||||
|
||||
+30
-19
@@ -17,14 +17,15 @@ type WaffoPancakePriceSnapshot struct {
|
||||
}
|
||||
|
||||
// WaffoPancakeCreateSessionParams is the input to CreateWaffoPancakeCheckoutSession.
|
||||
// BuyerIdentity (merchant-controlled, stable per user) is what survives the
|
||||
// buyer editing email at checkout — see WaffoPancakeBuyerIdentityFromUserID.
|
||||
// BuyerIdentity must be stable per user (see WaffoPancakeBuyerIdentityFromUserID).
|
||||
// OrderMerchantExternalID = our trade_no; Pancake echoes it back in webhooks.
|
||||
type WaffoPancakeCreateSessionParams struct {
|
||||
ProductID string
|
||||
BuyerIdentity string
|
||||
PriceSnapshot *WaffoPancakePriceSnapshot
|
||||
BuyerEmail string
|
||||
ExpiresInSeconds *int
|
||||
ProductID string
|
||||
BuyerIdentity string
|
||||
PriceSnapshot *WaffoPancakePriceSnapshot
|
||||
BuyerEmail string
|
||||
ExpiresInSeconds *int
|
||||
OrderMerchantExternalID string
|
||||
}
|
||||
|
||||
// WaffoPancakeCheckoutSession is the response of CreateWaffoPancakeCheckoutSession.
|
||||
@@ -52,7 +53,9 @@ type WaffoPancakeWebhookEvent struct {
|
||||
}
|
||||
|
||||
type WaffoPancakeWebhookData struct {
|
||||
// OrderID = Pancake ORD_* (logs); OrderMerchantExternalID = our trade_no (lookup).
|
||||
OrderID string
|
||||
OrderMerchantExternalID string
|
||||
BuyerEmail string
|
||||
Currency string
|
||||
Amount string
|
||||
@@ -100,6 +103,9 @@ func CreateWaffoPancakeCheckoutSession(ctx context.Context, params *WaffoPancake
|
||||
if strings.TrimSpace(params.BuyerIdentity) == "" {
|
||||
return nil, fmt.Errorf("missing buyer identity")
|
||||
}
|
||||
if strings.TrimSpace(params.OrderMerchantExternalID) == "" {
|
||||
return nil, fmt.Errorf("missing order merchant external id")
|
||||
}
|
||||
client, err := newWaffoPancakeClient()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build Waffo Pancake client: %w", err)
|
||||
@@ -107,10 +113,11 @@ func CreateWaffoPancakeCheckoutSession(ctx context.Context, params *WaffoPancake
|
||||
|
||||
sdkParams := pancake.AuthenticatedCheckoutParams{
|
||||
CreateCheckoutSessionParams: pancake.CreateCheckoutSessionParams{
|
||||
ProductID: params.ProductID,
|
||||
Currency: "USD",
|
||||
BuyerEmail: optionalString(params.BuyerEmail),
|
||||
ExpiresInSeconds: params.ExpiresInSeconds,
|
||||
ProductID: params.ProductID,
|
||||
Currency: "USD",
|
||||
BuyerEmail: optionalString(params.BuyerEmail),
|
||||
ExpiresInSeconds: params.ExpiresInSeconds,
|
||||
OrderMerchantExternalID: optionalString(params.OrderMerchantExternalID),
|
||||
},
|
||||
BuyerIdentity: params.BuyerIdentity,
|
||||
}
|
||||
@@ -163,6 +170,10 @@ func VerifyConfiguredWaffoPancakeWebhook(payload string, signatureHeader string)
|
||||
if evt.Data.MerchantProvidedBuyerIdentity != nil {
|
||||
identity = *evt.Data.MerchantProvidedBuyerIdentity
|
||||
}
|
||||
externalID := ""
|
||||
if evt.Data.OrderMerchantExternalID != nil {
|
||||
externalID = *evt.Data.OrderMerchantExternalID
|
||||
}
|
||||
return &WaffoPancakeWebhookEvent{
|
||||
ID: evt.ID,
|
||||
Timestamp: evt.Timestamp,
|
||||
@@ -172,6 +183,7 @@ func VerifyConfiguredWaffoPancakeWebhook(payload string, signatureHeader string)
|
||||
Mode: string(evt.Mode),
|
||||
Data: WaffoPancakeWebhookData{
|
||||
OrderID: evt.Data.OrderID,
|
||||
OrderMerchantExternalID: externalID,
|
||||
BuyerEmail: evt.Data.BuyerEmail,
|
||||
Currency: evt.Data.Currency,
|
||||
Amount: evt.Data.Amount,
|
||||
@@ -183,19 +195,18 @@ func VerifyConfiguredWaffoPancakeWebhook(payload string, signatureHeader string)
|
||||
}
|
||||
|
||||
// ResolveWaffoPancakeTradeNo maps a verified webhook event to a local TopUp
|
||||
// trade_no, rejecting any payload whose buyer identity doesn't match the one
|
||||
// we recorded at checkout — defence-in-depth on top of signature verification.
|
||||
// trade_no via OrderMerchantExternalID, and rejects buyer-identity mismatches.
|
||||
func ResolveWaffoPancakeTradeNo(event *WaffoPancakeWebhookEvent) (string, error) {
|
||||
if event == nil {
|
||||
return "", fmt.Errorf("missing webhook event")
|
||||
}
|
||||
tradeNo := strings.TrimSpace(event.Data.OrderID)
|
||||
tradeNo := strings.TrimSpace(event.Data.OrderMerchantExternalID)
|
||||
if tradeNo == "" {
|
||||
return "", fmt.Errorf("missing webhook orderId")
|
||||
return "", fmt.Errorf("missing webhook orderMerchantExternalId")
|
||||
}
|
||||
topUp := model.GetTopUpByTradeNo(tradeNo)
|
||||
if topUp == nil || topUp.PaymentProvider != model.PaymentProviderWaffoPancake {
|
||||
return "", fmt.Errorf("waffo pancake order not found for webhook orderId=%s", tradeNo)
|
||||
return "", fmt.Errorf("waffo pancake order not found for tradeNo=%s", tradeNo)
|
||||
}
|
||||
expectedIdentity := WaffoPancakeBuyerIdentityFromUserID(topUp.UserId)
|
||||
actualIdentity := strings.TrimSpace(event.Data.MerchantProvidedBuyerIdentity)
|
||||
@@ -216,13 +227,13 @@ func ResolveWaffoPancakeSubscriptionTradeNo(event *WaffoPancakeWebhookEvent) (st
|
||||
if event == nil {
|
||||
return "", fmt.Errorf("missing webhook event")
|
||||
}
|
||||
tradeNo := strings.TrimSpace(event.Data.OrderID)
|
||||
tradeNo := strings.TrimSpace(event.Data.OrderMerchantExternalID)
|
||||
if tradeNo == "" {
|
||||
return "", fmt.Errorf("missing webhook orderId")
|
||||
return "", fmt.Errorf("missing webhook orderMerchantExternalId")
|
||||
}
|
||||
order := model.GetSubscriptionOrderByTradeNo(tradeNo)
|
||||
if order == nil || order.PaymentProvider != model.PaymentProviderWaffoPancake {
|
||||
return "", fmt.Errorf("waffo pancake subscription order not found for webhook orderId=%s", tradeNo)
|
||||
return "", fmt.Errorf("waffo pancake subscription order not found for tradeNo=%s", tradeNo)
|
||||
}
|
||||
expectedIdentity := WaffoPancakeBuyerIdentityFromUserID(order.UserId)
|
||||
actualIdentity := strings.TrimSpace(event.Data.MerchantProvidedBuyerIdentity)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -40,24 +41,36 @@ func setupWaffoPancakeTestDB(t *testing.T) *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
func TestCreateWaffoPancakeCheckoutSession_RequiresOrderMerchantExternalID(t *testing.T) {
|
||||
session, err := CreateWaffoPancakeCheckoutSession(context.Background(), &WaffoPancakeCreateSessionParams{
|
||||
ProductID: "PROD_checkout_guard",
|
||||
BuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(1),
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
require.Nil(t, session)
|
||||
require.Contains(t, err.Error(), "missing order merchant external id")
|
||||
}
|
||||
|
||||
func TestResolveWaffoPancakeTradeNo_UsesWebhookOrderIDWhenLocalOrderExists(t *testing.T) {
|
||||
db := setupWaffoPancakeTestDB(t)
|
||||
|
||||
topUp := &model.TopUp{
|
||||
UserId: 1,
|
||||
Amount: 10,
|
||||
Money: 29,
|
||||
TradeNo: "ORD_5dXBtmF2HLlHfbPNm0Wcnz",
|
||||
UserId: 1,
|
||||
Amount: 10,
|
||||
Money: 29,
|
||||
TradeNo: "ORD_5dXBtmF2HLlHfbPNm0Wcnz",
|
||||
PaymentMethod: model.PaymentMethodWaffoPancake,
|
||||
PaymentProvider: model.PaymentProviderWaffoPancake,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
require.NoError(t, db.Create(topUp).Error)
|
||||
|
||||
tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{
|
||||
Data: WaffoPancakeWebhookData{
|
||||
OrderID: "ORD_5dXBtmF2HLlHfbPNm0Wcnz",
|
||||
OrderID: "ORD_internal_pancake_id",
|
||||
OrderMerchantExternalID: "ORD_5dXBtmF2HLlHfbPNm0Wcnz",
|
||||
MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(topUp.UserId),
|
||||
},
|
||||
})
|
||||
@@ -69,14 +82,14 @@ func TestResolveWaffoPancakeTradeNo_RejectsBuyerIdentityMismatch(t *testing.T) {
|
||||
db := setupWaffoPancakeTestDB(t)
|
||||
|
||||
topUp := &model.TopUp{
|
||||
UserId: 42,
|
||||
Amount: 10,
|
||||
Money: 29,
|
||||
TradeNo: "ORD_identity_mismatch_case",
|
||||
UserId: 42,
|
||||
Amount: 10,
|
||||
Money: 29,
|
||||
TradeNo: "ORD_identity_mismatch_case",
|
||||
PaymentMethod: model.PaymentMethodWaffoPancake,
|
||||
PaymentProvider: model.PaymentProviderWaffoPancake,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
require.NoError(t, db.Create(topUp).Error)
|
||||
|
||||
@@ -84,7 +97,8 @@ func TestResolveWaffoPancakeTradeNo_RejectsBuyerIdentityMismatch(t *testing.T) {
|
||||
// crossed-wires bug or a tampered payload. Either way: reject.
|
||||
tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{
|
||||
Data: WaffoPancakeWebhookData{
|
||||
OrderID: "ORD_identity_mismatch_case",
|
||||
OrderID: "ORD_internal_pancake_id",
|
||||
OrderMerchantExternalID: "ORD_identity_mismatch_case",
|
||||
MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(99), // wrong user
|
||||
},
|
||||
})
|
||||
@@ -97,14 +111,14 @@ func TestResolveWaffoPancakeTradeNo_RejectsMissingBuyerIdentity(t *testing.T) {
|
||||
db := setupWaffoPancakeTestDB(t)
|
||||
|
||||
topUp := &model.TopUp{
|
||||
UserId: 7,
|
||||
Amount: 10,
|
||||
Money: 29,
|
||||
TradeNo: "ORD_missing_identity",
|
||||
UserId: 7,
|
||||
Amount: 10,
|
||||
Money: 29,
|
||||
TradeNo: "ORD_missing_identity",
|
||||
PaymentMethod: model.PaymentMethodWaffoPancake,
|
||||
PaymentProvider: model.PaymentProviderWaffoPancake,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
require.NoError(t, db.Create(topUp).Error)
|
||||
|
||||
@@ -113,7 +127,8 @@ func TestResolveWaffoPancakeTradeNo_RejectsMissingBuyerIdentity(t *testing.T) {
|
||||
// reject so that we never credit anonymous orders to a specific user.
|
||||
tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{
|
||||
Data: WaffoPancakeWebhookData{
|
||||
OrderID: "ORD_missing_identity",
|
||||
OrderID: "ORD_internal_pancake_id",
|
||||
OrderMerchantExternalID: "ORD_missing_identity",
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
@@ -133,22 +148,23 @@ func TestResolveWaffoPancakeTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing.
|
||||
require.NoError(t, db.Create(user).Error)
|
||||
|
||||
topUp := &model.TopUp{
|
||||
UserId: user.Id,
|
||||
Amount: 10,
|
||||
Money: 29,
|
||||
TradeNo: "WAFFO_PANCAKE-42-123456-abc123",
|
||||
UserId: user.Id,
|
||||
Amount: 10,
|
||||
Money: 29,
|
||||
TradeNo: "WAFFO_PANCAKE-42-123456-abc123",
|
||||
PaymentMethod: model.PaymentMethodWaffoPancake,
|
||||
PaymentProvider: model.PaymentProviderWaffoPancake,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
require.NoError(t, db.Create(topUp).Error)
|
||||
|
||||
tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{
|
||||
Data: WaffoPancakeWebhookData{
|
||||
OrderID: "ORD_unknown",
|
||||
BuyerEmail: user.Email,
|
||||
Amount: "29.00",
|
||||
OrderID: "ORD_internal_pancake_id",
|
||||
OrderMerchantExternalID: "WAFFO_PANCAKE-unknown",
|
||||
BuyerEmail: user.Email,
|
||||
Amount: "29.00",
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
@@ -177,7 +193,8 @@ func TestResolveWaffoPancakeSubscriptionTradeNo_UsesWebhookOrderIDWhenLocalOrder
|
||||
|
||||
tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{
|
||||
Data: WaffoPancakeWebhookData{
|
||||
OrderID: "WAFFO_PANCAKE_SUB-1-1700000000-abc123",
|
||||
OrderID: "ORD_internal_pancake_id",
|
||||
OrderMerchantExternalID: "WAFFO_PANCAKE_SUB-1-1700000000-abc123",
|
||||
MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(order.UserId),
|
||||
},
|
||||
})
|
||||
@@ -202,7 +219,8 @@ func TestResolveWaffoPancakeSubscriptionTradeNo_RejectsBuyerIdentityMismatch(t *
|
||||
|
||||
tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{
|
||||
Data: WaffoPancakeWebhookData{
|
||||
OrderID: "WAFFO_PANCAKE_SUB-42-mismatch",
|
||||
OrderID: "ORD_internal_pancake_id",
|
||||
OrderMerchantExternalID: "WAFFO_PANCAKE_SUB-42-mismatch",
|
||||
MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(99), // wrong user
|
||||
},
|
||||
})
|
||||
@@ -228,7 +246,7 @@ func TestResolveWaffoPancakeSubscriptionTradeNo_RejectsMissingBuyerIdentity(t *t
|
||||
|
||||
tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{
|
||||
Data: WaffoPancakeWebhookData{
|
||||
OrderID: "WAFFO_PANCAKE_SUB-7-missing-identity",
|
||||
OrderMerchantExternalID: "WAFFO_PANCAKE_SUB-7-missing-identity",
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
@@ -253,7 +271,7 @@ func TestResolveWaffoPancakeSubscriptionTradeNo_FailsWhenWebhookOrderIDIsUnknown
|
||||
|
||||
tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{
|
||||
Data: WaffoPancakeWebhookData{
|
||||
OrderID: "WAFFO_PANCAKE_SUB-unknown",
|
||||
OrderMerchantExternalID: "WAFFO_PANCAKE_SUB-unknown",
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
@@ -71,6 +71,13 @@ var defaultCacheRatio = map[string]float64{
|
||||
"claude-opus-4-7-high": 0.1,
|
||||
"claude-opus-4-7-medium": 0.1,
|
||||
"claude-opus-4-7-low": 0.1,
|
||||
"claude-opus-4-8": 0.1,
|
||||
"claude-opus-4-8-thinking": 0.1,
|
||||
"claude-opus-4-8-max": 0.1,
|
||||
"claude-opus-4-8-xhigh": 0.1,
|
||||
"claude-opus-4-8-high": 0.1,
|
||||
"claude-opus-4-8-medium": 0.1,
|
||||
"claude-opus-4-8-low": 0.1,
|
||||
}
|
||||
|
||||
var defaultCreateCacheRatio = map[string]float64{
|
||||
@@ -106,6 +113,13 @@ var defaultCreateCacheRatio = map[string]float64{
|
||||
"claude-opus-4-7-high": 1.25,
|
||||
"claude-opus-4-7-medium": 1.25,
|
||||
"claude-opus-4-7-low": 1.25,
|
||||
"claude-opus-4-8": 1.25,
|
||||
"claude-opus-4-8-thinking": 1.25,
|
||||
"claude-opus-4-8-max": 1.25,
|
||||
"claude-opus-4-8-xhigh": 1.25,
|
||||
"claude-opus-4-8-high": 1.25,
|
||||
"claude-opus-4-8-medium": 1.25,
|
||||
"claude-opus-4-8-low": 1.25,
|
||||
}
|
||||
|
||||
//var defaultCreateCacheRatio = map[string]float64{}
|
||||
|
||||
@@ -152,6 +152,12 @@ var defaultModelRatio = map[string]float64{
|
||||
"claude-opus-4-7-high": 2.5,
|
||||
"claude-opus-4-7-medium": 2.5,
|
||||
"claude-opus-4-7-low": 2.5,
|
||||
"claude-opus-4-8": 2.5,
|
||||
"claude-opus-4-8-max": 2.5,
|
||||
"claude-opus-4-8-xhigh": 2.5,
|
||||
"claude-opus-4-8-high": 2.5,
|
||||
"claude-opus-4-8-medium": 2.5,
|
||||
"claude-opus-4-8-low": 2.5,
|
||||
"claude-3-opus-20240229": 7.5, // $15 / 1M tokens
|
||||
"claude-opus-4-20250514": 7.5,
|
||||
"claude-opus-4-1-20250805": 7.5,
|
||||
|
||||
Vendored
+1245
-526
File diff suppressed because it is too large
Load Diff
Vendored
-2379
File diff suppressed because it is too large
Load Diff
Vendored
-1
@@ -24,6 +24,5 @@
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Vendored
+21
-20
@@ -4,30 +4,32 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@douyinfe/semi-illustrations": "^2.69.1",
|
||||
"@douyinfe/semi-icons": "^2.63.1",
|
||||
"@douyinfe/semi-ui": "^2.69.1",
|
||||
"@lobehub/icons": "^2.0.0",
|
||||
"@lobehub/icons": "catalog:",
|
||||
"@visactor/react-vchart": "~1.8.8",
|
||||
"@visactor/vchart": "~1.8.8",
|
||||
"@visactor/vchart-semi-theme": "~1.8.8",
|
||||
"axios": "1.15.2",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.11",
|
||||
"axios": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"history": "^5.3.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"i18next": "^23.16.8",
|
||||
"i18next-browser-languagedetector": "^7.2.0",
|
||||
"katex": "^0.16.22",
|
||||
"lucide-react": "^0.511.0",
|
||||
"marked": "^4.1.1",
|
||||
"mermaid": "^11.6.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"qrcode.react": "catalog:",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-fireworks": "^1.0.4",
|
||||
"react-i18next": "^13.0.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-icons": "catalog:",
|
||||
"react-markdown": "catalog:",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-telegram-login": "^1.1.2",
|
||||
"react-toastify": "^9.0.8",
|
||||
@@ -35,20 +37,20 @@
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-gfm": "catalog:",
|
||||
"remark-math": "^6.0.0",
|
||||
"sse.js": "^2.6.0",
|
||||
"sse.js": "catalog:",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"use-debounce": "^10.0.4"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"dev": "rsbuild dev",
|
||||
"build": "rsbuild build",
|
||||
"lint": "prettier . --check",
|
||||
"lint:fix": "prettier . --write",
|
||||
"eslint": "bunx eslint \"**/*.{js,jsx}\" --cache",
|
||||
"eslint:fix": "bunx eslint \"**/*.{js,jsx}\" --fix --cache",
|
||||
"preview": "vite preview",
|
||||
"preview": "rsbuild preview",
|
||||
"i18n:extract": "bunx i18next-cli extract",
|
||||
"i18n:status": "bunx i18next-cli status",
|
||||
"i18n:sync": "bunx i18next-cli sync",
|
||||
@@ -73,20 +75,19 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6",
|
||||
"@rsbuild/core": "^2.0.7",
|
||||
"@rsbuild/plugin-react": "^2.0.0",
|
||||
"@so1ve/prettier-config": "^3.1.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"code-inspector-plugin": "^1.3.3",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-plugin-header": "^3.1.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"i18next-cli": "^1.10.3",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.0.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"prettier": "catalog:",
|
||||
"tailwindcss": "^3",
|
||||
"typescript": "4.4.2",
|
||||
"vite": "^5.2.0"
|
||||
"typescript": "4.4.2"
|
||||
},
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
|
||||
Vendored
+106
@@ -0,0 +1,106 @@
|
||||
import path from 'path'
|
||||
import { createRequire } from 'module'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { defineConfig, loadEnv } from '@rsbuild/core'
|
||||
import { pluginReact } from '@rsbuild/plugin-react'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const require = createRequire(import.meta.url)
|
||||
const semiUiDir = path.resolve(
|
||||
path.dirname(require.resolve('@douyinfe/semi-ui')),
|
||||
'../..',
|
||||
)
|
||||
|
||||
export default defineConfig(({ envMode }) => {
|
||||
const env = loadEnv({ mode: envMode, prefixes: ['VITE_'] })
|
||||
const clientServerUrl =
|
||||
process.env.VITE_REACT_APP_SERVER_URL ||
|
||||
env.rawPublicVars.VITE_REACT_APP_SERVER_URL ||
|
||||
''
|
||||
const proxyServerUrl =
|
||||
clientServerUrl ||
|
||||
'http://localhost:3000'
|
||||
const isProd = envMode === 'production'
|
||||
const devProxy = Object.fromEntries(
|
||||
(['/api', '/mj', '/pg'] as const).map((key) => [
|
||||
key,
|
||||
{ target: proxyServerUrl, changeOrigin: true },
|
||||
]),
|
||||
) as Record<string, { target: string; changeOrigin: boolean }>
|
||||
|
||||
return {
|
||||
plugins: [pluginReact()],
|
||||
source: {
|
||||
entry: {
|
||||
index: './src/index.jsx',
|
||||
},
|
||||
define: {
|
||||
'import.meta.env.VITE_REACT_APP_SERVER_URL': JSON.stringify(
|
||||
clientServerUrl,
|
||||
),
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@douyinfe/semi-ui/dist/css/semi.css': path.resolve(
|
||||
semiUiDir,
|
||||
'dist/css/semi.css',
|
||||
),
|
||||
},
|
||||
},
|
||||
html: {
|
||||
template: './index.html',
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
strictPort: true,
|
||||
proxy: devProxy,
|
||||
},
|
||||
output: {
|
||||
minify: isProd,
|
||||
target: 'web',
|
||||
distPath: {
|
||||
root: 'dist',
|
||||
},
|
||||
},
|
||||
performance: {
|
||||
removeConsole: isProd ? ['log'] : false,
|
||||
buildCache: {
|
||||
cacheDigest: [process.env.VITE_REACT_APP_VERSION],
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
rspack: {
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /src[\\/].*\.js$/,
|
||||
type: 'javascript/auto',
|
||||
use: [
|
||||
{
|
||||
loader: 'builtin:swc-loader',
|
||||
options: {
|
||||
jsc: {
|
||||
parser: {
|
||||
syntax: 'ecmascript',
|
||||
jsx: true,
|
||||
},
|
||||
transform: {
|
||||
react: {
|
||||
runtime: 'automatic',
|
||||
development: !isProd,
|
||||
refresh: !isProd,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
+1
-1
@@ -947,7 +947,7 @@ const LoginForm = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div
|
||||
className='blur-ball blur-ball-indigo'
|
||||
|
||||
@@ -104,7 +104,7 @@ const PasswordResetConfirm = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div
|
||||
className='blur-ball blur-ball-indigo'
|
||||
|
||||
+1
-1
@@ -104,7 +104,7 @@ const PasswordResetForm = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div
|
||||
className='blur-ball blur-ball-indigo'
|
||||
|
||||
+1
-1
@@ -770,7 +770,7 @@ const RegisterForm = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div
|
||||
className='blur-ball blur-ball-indigo'
|
||||
|
||||
@@ -141,7 +141,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
// 显示加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex justify-center items-center min-h-screen'>
|
||||
<div className='classic-page-fill flex justify-center items-center'>
|
||||
<Spin size='large' />
|
||||
</div>
|
||||
);
|
||||
@@ -150,7 +150,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
// 如果没有内容,显示空状态
|
||||
if (!content || content.trim() === '') {
|
||||
return (
|
||||
<div className='flex justify-center items-center min-h-screen bg-gray-50'>
|
||||
<div className='classic-page-fill flex justify-center items-center bg-gray-50'>
|
||||
<Empty
|
||||
title={t('管理员未设置' + title + '内容')}
|
||||
image={
|
||||
@@ -168,7 +168,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
// 如果是 URL,显示链接卡片
|
||||
if (isUrl(content)) {
|
||||
return (
|
||||
<div className='flex justify-center items-center min-h-screen bg-gray-50 p-4'>
|
||||
<div className='classic-page-fill flex justify-center items-center bg-gray-50 p-4'>
|
||||
<Card className='max-w-md w-full'>
|
||||
<div className='text-center'>
|
||||
<Title heading={4} className='mb-4'>
|
||||
@@ -196,7 +196,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
// 如果是 HTML 内容,直接渲染
|
||||
if (isHtmlContent(content)) {
|
||||
return (
|
||||
<div className='min-h-screen bg-gray-50'>
|
||||
<div className='classic-page-fill bg-gray-50'>
|
||||
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='bg-white rounded-lg shadow-sm p-8'>
|
||||
<Title heading={2} className='text-center mb-8'>
|
||||
@@ -214,7 +214,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
|
||||
// 其他内容统一使用 Markdown 渲染器
|
||||
return (
|
||||
<div className='min-h-screen bg-gray-50'>
|
||||
<div className='classic-page-fill bg-gray-50'>
|
||||
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='bg-white rounded-lg shadow-sm p-8'>
|
||||
<Title heading={2} className='text-center mb-8'>
|
||||
|
||||
+10
-5
@@ -71,6 +71,7 @@ const PageLayout = () => {
|
||||
|
||||
const isConsoleRoute = location.pathname.startsWith('/console');
|
||||
const showSider = isConsoleRoute && (!isMobile || drawerOpen);
|
||||
const isFixedLayout = isConsoleRoute || location.pathname === '/pricing';
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile && drawerOpen && collapsed) {
|
||||
@@ -146,11 +147,11 @@ const PageLayout = () => {
|
||||
|
||||
return (
|
||||
<Layout
|
||||
className='app-layout'
|
||||
className={`app-layout${isFixedLayout ? ' app-layout-fixed' : ''}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: isMobile ? 'visible' : 'hidden',
|
||||
overflow: isFixedLayout && !isMobile ? 'hidden' : 'visible',
|
||||
}}
|
||||
>
|
||||
<Header
|
||||
@@ -171,9 +172,10 @@ const PageLayout = () => {
|
||||
</Header>
|
||||
<Layout
|
||||
style={{
|
||||
overflow: isMobile ? 'visible' : 'auto',
|
||||
overflow: isFixedLayout && !isMobile ? 'auto' : 'visible',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: '1 1 auto',
|
||||
}}
|
||||
>
|
||||
{showSider && (
|
||||
@@ -206,15 +208,18 @@ const PageLayout = () => {
|
||||
flex: '1 1 auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<Content
|
||||
className={isFixedLayout ? undefined : 'public-page-content'}
|
||||
style={{
|
||||
flex: '1 0 auto',
|
||||
overflowY: isMobile ? 'visible' : 'hidden',
|
||||
flex: isFixedLayout ? '1 0 auto' : '1 1 auto',
|
||||
overflowY: isFixedLayout && !isMobile ? 'hidden' : 'visible',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
padding: shouldInnerPadding ? (isMobile ? '5px' : '24px') : '0',
|
||||
position: 'relative',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
|
||||
@@ -52,9 +52,6 @@ const PaymentSetting = () => {
|
||||
StripeMinTopUp: 1,
|
||||
StripePromotionCodesEnabled: false,
|
||||
|
||||
WaffoPancakeMerchantID: '',
|
||||
WaffoPancakePrivateKey: '',
|
||||
WaffoPancakeReturnURL: '',
|
||||
'payment_setting.compliance_confirmed': false,
|
||||
'payment_setting.compliance_terms_version': '',
|
||||
'payment_setting.compliance_confirmed_at': 0,
|
||||
@@ -165,11 +162,6 @@ const PaymentSetting = () => {
|
||||
case 'StripeMinTopUp':
|
||||
newInputs[item.key] = parseFloat(item.value);
|
||||
break;
|
||||
case 'WaffoPancakeMerchantID':
|
||||
case 'WaffoPancakePrivateKey':
|
||||
case 'WaffoPancakeReturnURL':
|
||||
newInputs[item.key] = item.value;
|
||||
break;
|
||||
default:
|
||||
if (item.key.endsWith('Enabled')) {
|
||||
newInputs[item.key] = toBoolean(item.value);
|
||||
|
||||
Vendored
+43
-9
@@ -17,12 +17,46 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
export * from './channel.constants';
|
||||
export * from './user.constants';
|
||||
export * from './toast.constants';
|
||||
export * from './common.constant';
|
||||
export * from './dashboard.constants';
|
||||
export * from './playground.constants';
|
||||
export * from './redemption.constants';
|
||||
export * from './channel-affinity-template.constants';
|
||||
export * from './billing.constants';
|
||||
export {
|
||||
CHANNEL_OPTIONS,
|
||||
MODEL_FETCHABLE_CHANNEL_TYPES,
|
||||
MODEL_TABLE_PAGE_SIZE,
|
||||
} from './channel.constants';
|
||||
export { userConstants } from './user.constants';
|
||||
export { toastConstants } from './toast.constants';
|
||||
export {
|
||||
ITEMS_PER_PAGE,
|
||||
DEFAULT_ENDPOINT,
|
||||
TABLE_COMPACT_MODES_KEY,
|
||||
API_ENDPOINTS,
|
||||
TASK_ACTION_GENERATE,
|
||||
TASK_ACTION_TEXT_GENERATE,
|
||||
TASK_ACTION_FIRST_TAIL_GENERATE,
|
||||
TASK_ACTION_REFERENCE_GENERATE,
|
||||
TASK_ACTION_REMIX_GENERATE,
|
||||
} from './common.constant';
|
||||
export {
|
||||
REDEMPTION_STATUS,
|
||||
REDEMPTION_STATUS_MAP,
|
||||
REDEMPTION_ACTIONS,
|
||||
} from './redemption.constants';
|
||||
export {
|
||||
CODEX_CLI_HEADER_PASSTHROUGH_HEADERS,
|
||||
CLAUDE_CLI_HEADER_PASSTHROUGH_HEADERS,
|
||||
CODEX_CLI_HEADER_PASSTHROUGH_TEMPLATE,
|
||||
CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE,
|
||||
CHANNEL_AFFINITY_RULE_TEMPLATES,
|
||||
cloneChannelAffinityTemplate,
|
||||
} from './channel-affinity-template.constants';
|
||||
export {
|
||||
BILLING_VARS,
|
||||
BILLING_VAR_KEYS,
|
||||
BILLING_PRICING_VARS,
|
||||
BILLING_EXTRA_VARS,
|
||||
BILLING_VAR_KEY_TO_FIELD,
|
||||
BILLING_VAR_FIELD_TO_LABEL,
|
||||
BILLING_VAR_FIELD_TO_SHORT_LABEL,
|
||||
BILLING_CACHE_VAR_MAP,
|
||||
BILLING_VAR_REGEX,
|
||||
BILLING_CONDITION_VARS,
|
||||
} from './billing.constants';
|
||||
|
||||
Vendored
+2
-2
@@ -94,7 +94,6 @@ import {
|
||||
SiGitlab,
|
||||
SiGoogle,
|
||||
SiKeycloak,
|
||||
SiLinkedin,
|
||||
SiNextcloud,
|
||||
SiNotion,
|
||||
SiOkta,
|
||||
@@ -106,6 +105,7 @@ import {
|
||||
SiWechat,
|
||||
SiX,
|
||||
} from 'react-icons/si';
|
||||
import { FaLinkedin } from 'react-icons/fa';
|
||||
|
||||
// 获取侧边栏Lucide图标组件
|
||||
export function getLucideIcon(key, selected = false) {
|
||||
@@ -509,7 +509,7 @@ const oauthProviderIconMap = {
|
||||
google: SiGoogle,
|
||||
discord: SiDiscord,
|
||||
facebook: SiFacebook,
|
||||
linkedin: SiLinkedin,
|
||||
linkedin: FaLinkedin,
|
||||
x: SiX,
|
||||
twitter: SiX,
|
||||
slack: SiSlack,
|
||||
|
||||
Vendored
+1
-1
@@ -123,7 +123,7 @@ export function showError(error) {
|
||||
console.error(error);
|
||||
if (error.message) {
|
||||
if (error.name === 'AxiosError') {
|
||||
switch (error.response.status) {
|
||||
switch (error.response?.status) {
|
||||
case 401:
|
||||
// 清除用户状态
|
||||
localStorage.removeItem('user');
|
||||
|
||||
Vendored
+28
-6
@@ -31,18 +31,40 @@ body {
|
||||
background-color: var(--semi-color-bg-0);
|
||||
}
|
||||
|
||||
/* 桌面端禁止 body 纵向滚动 - 防止 VChart tooltip 触发页面滚动条 */
|
||||
@media (min-width: 768px) {
|
||||
body {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.app-layout {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
.app-layout-fixed {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.public-page-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.classic-page-fill {
|
||||
flex: 1 0 auto;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.classic-home-page,
|
||||
.classic-home-default {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.classic-home-default {
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
.classic-home-hero {
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
.app-sider {
|
||||
height: calc(100vh - 64px);
|
||||
height: calc(100dvh - 64px);
|
||||
|
||||
Vendored
+2
@@ -1,3 +1,5 @@
|
||||
import '@douyinfe/semi-ui/react19-adapter';
|
||||
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
|
||||
+8
-3
@@ -133,9 +133,9 @@ const About = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<div className='classic-page-fill flex flex-col pt-[60px] px-2'>
|
||||
{aboutLoaded && about === '' ? (
|
||||
<div className='flex justify-center items-center h-screen p-8'>
|
||||
<div className='flex flex-1 justify-center items-center p-8'>
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationConstruction style={{ width: 150, height: 150 }} />
|
||||
@@ -156,7 +156,12 @@ const About = () => {
|
||||
{about.startsWith('https://') ? (
|
||||
<iframe
|
||||
src={about}
|
||||
style={{ width: '100%', height: '100vh', border: 'none' }}
|
||||
style={{
|
||||
width: '100%',
|
||||
flex: '1 1 auto',
|
||||
minHeight: 0,
|
||||
border: 'none',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ import { useTranslation } from 'react-i18next';
|
||||
const Forbidden = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className='flex justify-center items-center h-screen p-8'>
|
||||
<div className='classic-page-fill flex justify-center items-center p-8'>
|
||||
<Empty
|
||||
image={<IllustrationNoAccess style={{ width: 250, height: 250 }} />}
|
||||
darkModeImage={
|
||||
|
||||
Vendored
+6
-6
@@ -149,20 +149,20 @@ const Home = () => {
|
||||
}, [endpointItems.length]);
|
||||
|
||||
return (
|
||||
<div className='w-full overflow-x-hidden'>
|
||||
<div className='classic-page-fill classic-home-page w-full overflow-x-hidden'>
|
||||
<NoticeModal
|
||||
visible={noticeVisible}
|
||||
onClose={() => setNoticeVisible(false)}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
{homePageContentLoaded && homePageContent === '' ? (
|
||||
<div className='w-full overflow-x-hidden'>
|
||||
<div className='classic-home-default w-full overflow-x-hidden'>
|
||||
{/* Banner 部分 */}
|
||||
<div className='w-full border-b border-semi-color-border min-h-[500px] md:min-h-[600px] lg:min-h-[700px] relative overflow-x-hidden'>
|
||||
<div className='classic-home-hero w-full border-b border-semi-color-border relative overflow-x-hidden'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div className='blur-ball blur-ball-indigo' />
|
||||
<div className='blur-ball blur-ball-teal' />
|
||||
<div className='flex items-center justify-center h-full px-4 py-20 md:py-24 lg:py-32 mt-10'>
|
||||
<div className='flex items-center justify-center px-4 pt-24 pb-8'>
|
||||
{/* 居中内容区 */}
|
||||
<div className='flex flex-col items-center justify-center text-center max-w-4xl mx-auto'>
|
||||
<div className='flex flex-col items-center justify-center mb-6 md:mb-8'>
|
||||
@@ -335,11 +335,11 @@ const Home = () => {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='overflow-x-hidden w-full'>
|
||||
<div className='classic-page-fill overflow-x-hidden w-full'>
|
||||
{homePageContent.startsWith('https://') ? (
|
||||
<iframe
|
||||
src={homePageContent}
|
||||
className='w-full h-screen border-none'
|
||||
className='w-full h-full border-none'
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ import { useTranslation } from 'react-i18next';
|
||||
const NotFound = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className='flex justify-center items-center h-screen p-8'>
|
||||
<div className='classic-page-fill flex justify-center items-center p-8'>
|
||||
<Empty
|
||||
image={<IllustrationNotFound style={{ width: 250, height: 250 }} />}
|
||||
darkModeImage={
|
||||
|
||||
Vendored
-107
@@ -1,107 +0,0 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig, transformWithEsbuild } from 'vite';
|
||||
import pkg from '@douyinfe/vite-plugin-semi';
|
||||
import path from 'path';
|
||||
import { codeInspectorPlugin } from 'code-inspector-plugin';
|
||||
const { vitePluginSemi } = pkg;
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
codeInspectorPlugin({
|
||||
bundler: 'vite',
|
||||
}),
|
||||
{
|
||||
name: 'treat-js-files-as-jsx',
|
||||
async transform(code, id) {
|
||||
if (!/src\/.*\.js$/.test(id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use the exposed transform from vite, instead of directly
|
||||
// transforming with esbuild
|
||||
return transformWithEsbuild(code, id, {
|
||||
loader: 'jsx',
|
||||
jsx: 'automatic',
|
||||
});
|
||||
},
|
||||
},
|
||||
react(),
|
||||
vitePluginSemi({
|
||||
cssLayer: true,
|
||||
}),
|
||||
],
|
||||
optimizeDeps: {
|
||||
force: true,
|
||||
esbuildOptions: {
|
||||
loader: {
|
||||
'.js': 'jsx',
|
||||
'.json': 'json',
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'react-core': ['react', 'react-dom', 'react-router-dom'],
|
||||
'semi-ui': ['@douyinfe/semi-icons', '@douyinfe/semi-ui'],
|
||||
tools: ['axios', 'history', 'marked'],
|
||||
'react-components': [
|
||||
'react-dropzone',
|
||||
'react-fireworks',
|
||||
'react-telegram-login',
|
||||
'react-toastify',
|
||||
'react-turnstile',
|
||||
],
|
||||
i18n: [
|
||||
'i18next',
|
||||
'react-i18next',
|
||||
'i18next-browser-languagedetector',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/mj': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/pg': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Vendored
+69
-55
@@ -18,83 +18,97 @@
|
||||
"knip": "knip"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.1",
|
||||
"@base-ui/react": "^1.5.0",
|
||||
"@fontsource-variable/lora": "^5.2.8",
|
||||
"@fontsource-variable/public-sans": "^5.2.7",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@hugeicons/core-free-icons": "^4.1.1",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@hugeicons/core-free-icons": "^4.1.4",
|
||||
"@hugeicons/react": "^1.1.6",
|
||||
"@lobehub/icons": "^4.0.3",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@tanstack/react-query": "^5.95.2",
|
||||
"@tanstack/react-router": "^1.168.23",
|
||||
"@lobehub/icons": "catalog:",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@tanstack/react-query": "^5.100.14",
|
||||
"@tanstack/react-router": "^1.170.8",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"@visactor/react-vchart": "^2.0.13",
|
||||
"@visactor/vchart": "^2.0.13",
|
||||
"ai": "^6.0.27",
|
||||
"@tanstack/react-virtual": "^3.13.25",
|
||||
"@visactor/react-vchart": "^2.0.22",
|
||||
"@visactor/vchart": "^2.0.22",
|
||||
"ai": "^6.0.191",
|
||||
"auto-skeleton-react": "^1.0.5",
|
||||
"axios": "^1.13.6",
|
||||
"axios": "catalog:",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"clsx": "catalog:",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"i18next": "^25.7.4",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"date-fns": "^4.3.0",
|
||||
"dayjs": "catalog:",
|
||||
"i18next": "^26.2.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^1.7.0",
|
||||
"motion": "^12.38.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"lucide-react": "^1.16.0",
|
||||
"motion": "^12.40.0",
|
||||
"nanoid": "^5.1.11",
|
||||
"next-themes": "^0.4.6",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.4",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.71.0",
|
||||
"react-i18next": "^16.5.2",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.11.0",
|
||||
"qrcode.react": "catalog:",
|
||||
"react": "^19.2.6",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.76.1",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-icons": "catalog:",
|
||||
"react-markdown": "catalog:",
|
||||
"react-resizable-panels": "^4.11.2",
|
||||
"react-top-loading-bar": "^3.0.2",
|
||||
"recharts": "3.8.0",
|
||||
"recharts": "3.8.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^4.0.2",
|
||||
"remark-gfm": "catalog:",
|
||||
"shiki": "^4.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"sse.js": "^2.7.2",
|
||||
"streamdown": "^2.0.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"sse.js": "catalog:",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tokenlens": "^1.3.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
"use-stick-to-bottom": "^1.1.4",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.12"
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@rsbuild/core": "^2.0.1",
|
||||
"@rsbuild/core": "^2.0.7",
|
||||
"@rsbuild/plugin-react": "^2.0.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.95.2",
|
||||
"@tanstack/react-query-devtools": "^5.95.2",
|
||||
"@tanstack/react-router-devtools": "^1.166.13",
|
||||
"@tanstack/router-plugin": "^1.167.23",
|
||||
"@tanstack/eslint-plugin-query": "^5.100.14",
|
||||
"@tanstack/react-query-devtools": "^5.100.14",
|
||||
"@tanstack/react-router-devtools": "^1.167.0",
|
||||
"@tanstack/router-plugin": "^1.168.11",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"eslint": "^10.1.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"knip": "^6.0.6",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"shadcn": "^3.7.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.2"
|
||||
"globals": "^17.6.0",
|
||||
"knip": "^6.14.2",
|
||||
"prettier": "catalog:",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"shadcn": "^4.8.0",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.59.4"
|
||||
},
|
||||
"overrides": {
|
||||
"brace-expansion": "2.1.1",
|
||||
"dompurify": "3.4.5",
|
||||
"fast-uri": "3.1.2",
|
||||
"hono": "4.12.22",
|
||||
"ip-address": "10.2.0",
|
||||
"js-cookie": "3.0.7",
|
||||
"mermaid": "11.15.0",
|
||||
"minimist": "1.2.8",
|
||||
"postcss": "8.5.15",
|
||||
"qs": "6.15.2",
|
||||
"uuid": "14.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -65,6 +65,7 @@ export default defineConfig(({ envMode }) => {
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
strictPort: true,
|
||||
proxy: devProxy,
|
||||
},
|
||||
output: {
|
||||
|
||||
Vendored
+106
@@ -29,6 +29,90 @@ const OBFUSCATED_KEYS = [
|
||||
},
|
||||
]
|
||||
|
||||
const BRAND_AND_LITERAL_KEYS = new Set([
|
||||
'AI Proxy',
|
||||
'AIGC2D',
|
||||
'Alipay',
|
||||
'Anthropic',
|
||||
'API URL',
|
||||
'API2GPT',
|
||||
'AccessKey / SecretAccessKey',
|
||||
'AZURE_OPENAI_ENDPOINT *',
|
||||
'Baidu V2',
|
||||
'ChatGPT',
|
||||
'Claude',
|
||||
'Client ID',
|
||||
'Client Secret',
|
||||
'Cloudflare',
|
||||
'Cohere',
|
||||
'DeepSeek',
|
||||
'Discord',
|
||||
'DoubaoVideo',
|
||||
'FastGPT',
|
||||
'Gemini',
|
||||
'Gemini Image 4K',
|
||||
'GitHub',
|
||||
'Jimeng',
|
||||
'JustSong',
|
||||
'LingYiWanWu',
|
||||
'LinuxDO',
|
||||
'Midjourney',
|
||||
'MidjourneyPlus',
|
||||
'Midjourney-Proxy',
|
||||
'MiniMax',
|
||||
'Mistral',
|
||||
'MokaAI',
|
||||
'Moonshot',
|
||||
'New API',
|
||||
'New API <noreply@example.com>',
|
||||
'NewAPI',
|
||||
'OAuth Client Secret',
|
||||
'OhMyGPT',
|
||||
'Ollama',
|
||||
'One API',
|
||||
'OpenAI',
|
||||
'OpenAIMax',
|
||||
'OpenRouter',
|
||||
'Pancake',
|
||||
'Passkey',
|
||||
'Perplexity',
|
||||
'QuantumNous',
|
||||
'Quota:',
|
||||
'Replicate',
|
||||
'SiliconFlow',
|
||||
'Stripe',
|
||||
'Submodel',
|
||||
'SunoAPI',
|
||||
'Telegram',
|
||||
'Tencent',
|
||||
'TTFT P50',
|
||||
'TTFT P95',
|
||||
'TTFT P99',
|
||||
'Uptime Kuma',
|
||||
'Uptime Kuma URL',
|
||||
'Vertex AI',
|
||||
'VolcEngine',
|
||||
'Waffo Pancake Dashboard',
|
||||
'Waffo Pancake MoR',
|
||||
'WeChat',
|
||||
'WeChat Pay',
|
||||
'Webhook URL',
|
||||
'Webhook URL:',
|
||||
'Well-Known URL',
|
||||
'Worker URL',
|
||||
'Xinference',
|
||||
'Xunfei',
|
||||
'Zhipu V4',
|
||||
'"default": "us-central1", "claude-3-5-sonnet-20240620": "europe-west1"',
|
||||
'edit_this',
|
||||
'footer.columns.related.links.midjourney',
|
||||
'footer.columns.related.links.newApiKeyTool',
|
||||
'my-status',
|
||||
'new-api-key-tool',
|
||||
'price_xxx',
|
||||
'whsec_xxx',
|
||||
])
|
||||
|
||||
function isPlainObject(v) {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v)
|
||||
}
|
||||
@@ -97,6 +181,24 @@ function isLikelyUntranslated({ locale, baseValue, value }) {
|
||||
|
||||
// Skip short tokens / acronyms / ids
|
||||
const s = baseValue.trim()
|
||||
if (BRAND_AND_LITERAL_KEYS.has(s)) return false
|
||||
if (
|
||||
/^https?:\/\//.test(s) ||
|
||||
/^\/[\w/-]+/.test(s) ||
|
||||
/^[\w.-]+@[\w.-]+$/.test(s) ||
|
||||
/^smtp\./i.test(s) ||
|
||||
/^socks5:/i.test(s) ||
|
||||
/^org-/.test(s) ||
|
||||
/^gpt-/i.test(s) ||
|
||||
/^checkout\./.test(s) ||
|
||||
/^footer\./.test(s) ||
|
||||
/^[A-Z0-9_ *./:-]+$/.test(s) ||
|
||||
s.startsWith('{') ||
|
||||
s.startsWith('[') ||
|
||||
s.includes(' ')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (s.length < 6) return false
|
||||
if (!/[A-Za-z]{3,}/.test(s)) return false
|
||||
|
||||
@@ -187,6 +289,8 @@ async function main() {
|
||||
|
||||
if (Object.keys(extras).length > 0) {
|
||||
await fs.writeFile(path.join(extrasDir, `${locale}.extras.json`), stableStringify(extras), 'utf8')
|
||||
} else {
|
||||
await fs.rm(path.join(extrasDir, `${locale}.extras.json`), { force: true })
|
||||
}
|
||||
if (Object.keys(untranslated).length > 0) {
|
||||
await fs.writeFile(
|
||||
@@ -194,6 +298,8 @@ async function main() {
|
||||
stableStringify(untranslated),
|
||||
'utf8',
|
||||
)
|
||||
} else {
|
||||
await fs.rm(path.join(reportsDir, `${locale}.untranslated.json`), { force: true })
|
||||
}
|
||||
|
||||
// Rewrite locale file in base order (even for en to normalize formatting)
|
||||
|
||||
+4
-3
@@ -33,7 +33,7 @@ import {
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command'
|
||||
import { getNavGroupsForPath } from './layout/lib/workspace-registry'
|
||||
import { getNavGroupsForPath } from './layout/lib/sidebar-view-registry'
|
||||
import { ScrollArea } from './ui/scroll-area'
|
||||
|
||||
export function CommandMenu() {
|
||||
@@ -44,8 +44,9 @@ export function CommandMenu() {
|
||||
const { pathname } = useLocation()
|
||||
const sidebarData = useSidebarData()
|
||||
|
||||
// 根据当前路径从工作区注册表获取对应的侧边栏配置
|
||||
const navGroups = getNavGroupsForPath(pathname, t) || sidebarData.navGroups
|
||||
// Use the active nested sidebar view's nav groups when one matches
|
||||
// the current URL; otherwise fall back to the root navigation.
|
||||
const navGroups = getNavGroupsForPath(pathname, t) ?? sidebarData.navGroups
|
||||
|
||||
const runCommand = React.useCallback(
|
||||
(command: () => unknown) => {
|
||||
|
||||
+99
-6
@@ -34,6 +34,7 @@ import { IconThemeSystem } from '@/assets/custom/icon-theme-system'
|
||||
import {
|
||||
type ContentLayout,
|
||||
THEME_PRESETS,
|
||||
type ThemeFont,
|
||||
type ThemePreset,
|
||||
type ThemeRadius,
|
||||
type ThemeScale,
|
||||
@@ -53,6 +54,12 @@ import {
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet'
|
||||
import {
|
||||
sideDrawerContentClassName,
|
||||
sideDrawerFooterClassName,
|
||||
sideDrawerFormClassName,
|
||||
sideDrawerHeaderClassName,
|
||||
} from '@/components/drawer-layout'
|
||||
import { useSidebar } from './ui/sidebar'
|
||||
|
||||
const Item = RadioPrimitive.Root
|
||||
@@ -88,16 +95,17 @@ export function ConfigDrawer() {
|
||||
>
|
||||
<Palette className='size-[1.2rem]' aria-hidden='true' />
|
||||
</SheetTrigger>
|
||||
<SheetContent className='flex w-full flex-col sm:max-w-md'>
|
||||
<SheetHeader className='pb-0 text-start'>
|
||||
<SheetContent className={sideDrawerContentClassName('sm:max-w-md')}>
|
||||
<SheetHeader className={sideDrawerHeaderClassName()}>
|
||||
<SheetTitle>{t('Theme Settings')}</SheetTitle>
|
||||
<SheetDescription id='config-drawer-description'>
|
||||
{t('Adjust the appearance and layout to suit your preferences.')}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className='space-y-6 overflow-y-auto px-4'>
|
||||
<div className={sideDrawerFormClassName()}>
|
||||
<ThemeConfig />
|
||||
<PresetConfig />
|
||||
<FontConfig />
|
||||
<RadiusConfig />
|
||||
<ScaleConfig />
|
||||
<SidebarConfig />
|
||||
@@ -105,7 +113,7 @@ export function ConfigDrawer() {
|
||||
<ContentLayoutConfig />
|
||||
<DirConfig />
|
||||
</div>
|
||||
<SheetFooter className='gap-2'>
|
||||
<SheetFooter className={sideDrawerFooterClassName('grid-cols-1')}>
|
||||
<Button
|
||||
variant='destructive'
|
||||
onClick={handleReset}
|
||||
@@ -296,13 +304,97 @@ function PresetConfig() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Font options shown in the theme drawer.
|
||||
*
|
||||
* Each option renders a live "Aa" preview in the font it represents.
|
||||
* `Auto` deliberately leaves `fontFamily` undefined so the preview inherits
|
||||
* the currently active body font — that way the user sees what `Auto` will
|
||||
* actually look like for the active preset (Anthropic → serif glyphs,
|
||||
* everything else → sans glyphs) without us having to duplicate the
|
||||
* preset-default mapping in the UI.
|
||||
*/
|
||||
const FONT_OPTIONS: {
|
||||
value: ThemeFont
|
||||
label: string
|
||||
// CSS font-family applied to the "Aa" preview. `undefined` = inherit
|
||||
// from the current theme (used by the `default` option).
|
||||
preview?: string
|
||||
}[] = [
|
||||
{ value: 'default', label: 'Auto', preview: undefined },
|
||||
{ value: 'sans', label: 'Sans', preview: 'var(--font-sans)' },
|
||||
{ value: 'serif', label: 'Serif', preview: 'var(--font-serif)' },
|
||||
]
|
||||
|
||||
function FontConfig() {
|
||||
const { t } = useTranslation()
|
||||
const { defaults, customization, setFont } = useThemeCustomization()
|
||||
return (
|
||||
<div>
|
||||
<SectionTitle
|
||||
title={t('Font')}
|
||||
showReset={customization.font !== defaults.font}
|
||||
onReset={() => setFont(defaults.font)}
|
||||
/>
|
||||
<Radio
|
||||
value={customization.font}
|
||||
onValueChange={(v) => setFont(v as ThemeFont)}
|
||||
className='grid w-full grid-cols-3 gap-4'
|
||||
aria-label={t('Select body font')}
|
||||
>
|
||||
{FONT_OPTIONS.map((option) => (
|
||||
<Item
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className='group flex flex-col items-stretch outline-none'
|
||||
aria-label={
|
||||
option.value === 'default' ? t('System default') : option.label
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'ring-border relative h-12 rounded-md ring-[1px] transition',
|
||||
'group-data-checked:ring-primary group-data-checked:shadow-md',
|
||||
'group-focus-visible:ring-2',
|
||||
'group-hover:ring-primary/60'
|
||||
)}
|
||||
>
|
||||
<CircleCheck
|
||||
className={cn(
|
||||
'fill-primary absolute top-0 right-0 z-10 size-5 translate-x-1/2 -translate-y-1/2 stroke-white',
|
||||
'group-data-unchecked:hidden'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span
|
||||
aria-hidden='true'
|
||||
className='text-foreground absolute inset-0 flex items-center justify-center text-lg leading-none font-medium'
|
||||
style={
|
||||
option.preview
|
||||
? { fontFamily: option.preview }
|
||||
: // `font: inherit` defers to the active theme so the
|
||||
// "Auto" tile previews what the resolved font will be.
|
||||
{ font: 'inherit', fontSize: '1.125rem' }
|
||||
}
|
||||
>
|
||||
Aa
|
||||
</span>
|
||||
</div>
|
||||
<div className='mt-1.5 text-center text-xs'>{option.label}</div>
|
||||
</Item>
|
||||
))}
|
||||
</Radio>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RADIUS_OPTIONS: {
|
||||
value: ThemeRadius
|
||||
label: string
|
||||
// CSS border-radius value used to render the visual preview corner.
|
||||
preview: string
|
||||
}[] = [
|
||||
{ value: 'default', label: 'Auto', preview: '999px' },
|
||||
{ value: 'default', label: 'Auto', preview: '1rem' },
|
||||
{ value: 'none', label: '0', preview: '0' },
|
||||
{ value: 'sm', label: '0.3', preview: '0.3rem' },
|
||||
{ value: 'md', label: '0.5', preview: '0.5rem' },
|
||||
@@ -398,6 +490,7 @@ function ScaleConfig() {
|
||||
{ value: 'sm', label: t('Compact'), rows: 4, rowGap: '3px' },
|
||||
{ value: 'default', label: t('Default'), rows: 3, rowGap: '6px' },
|
||||
{ value: 'lg', label: t('Comfortable'), rows: 2, rowGap: '10px' },
|
||||
{ value: 'xl', label: t('Super Large'), rows: 1, rowGap: '14px' },
|
||||
]
|
||||
return (
|
||||
<div>
|
||||
@@ -409,7 +502,7 @@ function ScaleConfig() {
|
||||
<Radio
|
||||
value={customization.scale}
|
||||
onValueChange={(v) => setScale(v as ThemeScale)}
|
||||
className='grid w-full grid-cols-3 gap-4'
|
||||
className='grid w-full grid-cols-4 gap-3'
|
||||
aria-label={t('Select interface density')}
|
||||
>
|
||||
{scaleOptions.map((option) => (
|
||||
|
||||
@@ -107,10 +107,7 @@ export function DataTableFacetedFilter<TData, TValue>({
|
||||
</>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className='min-w-[200px] max-w-[360px] p-0'
|
||||
align='start'
|
||||
>
|
||||
<PopoverContent className='max-w-[360px] min-w-[200px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput placeholder={title} />
|
||||
<CommandList>
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { createElement, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export const sideDrawerContentClassName = (className?: string) =>
|
||||
cn(
|
||||
'bg-background text-foreground flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 shadow-none',
|
||||
className
|
||||
)
|
||||
|
||||
export const sideDrawerHeaderClassName = (className?: string) =>
|
||||
cn(
|
||||
'border-border/70 bg-background/95 border-b px-4 py-3 text-start backdrop-blur supports-[backdrop-filter]:bg-background/80 sm:px-6 sm:py-4',
|
||||
className
|
||||
)
|
||||
|
||||
export const sideDrawerFormClassName = (className?: string) =>
|
||||
cn(
|
||||
'flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6 sm:py-5',
|
||||
className
|
||||
)
|
||||
|
||||
export const sideDrawerFooterClassName = (className?: string) =>
|
||||
cn(
|
||||
'border-border/70 bg-background/95 grid grid-cols-2 gap-2 border-t px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-background/80 sm:flex sm:flex-row sm:justify-end sm:px-6 sm:py-4',
|
||||
className
|
||||
)
|
||||
|
||||
export const sideDrawerSectionClassName = (className?: string) =>
|
||||
cn(
|
||||
'border-border/60 flex flex-col gap-4 border-b pb-6 last:border-b-0 last:pb-0',
|
||||
className
|
||||
)
|
||||
|
||||
export const sideDrawerSwitchItemClassName = (className?: string) =>
|
||||
cn(
|
||||
'border-border/60 flex min-h-16 flex-row items-center justify-between gap-3 border-y py-3',
|
||||
className
|
||||
)
|
||||
|
||||
export function SideDrawerSection(props: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return createElement(
|
||||
'section',
|
||||
{ className: sideDrawerSectionClassName(props.className) },
|
||||
props.children
|
||||
)
|
||||
}
|
||||
|
||||
export function SideDrawerSectionHeader(props: {
|
||||
title: ReactNode
|
||||
description?: ReactNode
|
||||
icon?: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return createElement(
|
||||
'div',
|
||||
{ className: cn('flex items-start gap-3', props.className) },
|
||||
props.icon
|
||||
? createElement(
|
||||
'span',
|
||||
{
|
||||
className:
|
||||
'bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md',
|
||||
},
|
||||
props.icon
|
||||
)
|
||||
: null,
|
||||
createElement(
|
||||
'div',
|
||||
{ className: 'min-w-0 flex-1' },
|
||||
createElement(
|
||||
'h3',
|
||||
{ className: 'text-sm leading-none font-semibold tracking-tight' },
|
||||
props.title
|
||||
),
|
||||
props.description
|
||||
? createElement(
|
||||
'p',
|
||||
{ className: 'text-muted-foreground mt-1 text-xs leading-5' },
|
||||
props.description
|
||||
)
|
||||
: null
|
||||
)
|
||||
)
|
||||
}
|
||||
+4
-5
@@ -31,12 +31,12 @@ type GroupBadgeProps = Omit<
|
||||
|
||||
function getGroupRatioClassName(ratio: number): string {
|
||||
if (ratio > 1) {
|
||||
return 'border-warning/25 bg-warning/10 text-warning'
|
||||
return 'bg-warning/10 text-warning'
|
||||
}
|
||||
if (ratio < 1) {
|
||||
return 'border-info/25 bg-info/10 text-info'
|
||||
return 'bg-info/10 text-info'
|
||||
}
|
||||
return 'border-border bg-muted text-muted-foreground'
|
||||
return 'bg-muted text-muted-foreground'
|
||||
}
|
||||
|
||||
function getGroupLabel(params: {
|
||||
@@ -94,11 +94,10 @@ export function GroupBadge(props: GroupBadgeProps) {
|
||||
{badge}
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-[11px] leading-none tabular-nums',
|
||||
'inline-flex h-5 items-center rounded-full px-1.5 font-mono text-xs leading-none font-medium tabular-nums',
|
||||
getGroupRatioClassName(ratio)
|
||||
)}
|
||||
>
|
||||
<span className='size-1 rounded-full bg-current opacity-60' />
|
||||
<span>{ratio}x</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type KeyboardEvent,
|
||||
} from 'react'
|
||||
import { AlertCircle, Braces, CheckCircle2, Code2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
export type JsonCodeEditorProps = Omit<ComponentProps<'div'>, 'onChange'> & {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
heightClassName?: string
|
||||
}
|
||||
|
||||
export function JsonCodeEditor({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
heightClassName = 'h-56 min-h-56 max-h-56',
|
||||
className,
|
||||
id,
|
||||
'aria-describedby': ariaDescribedBy,
|
||||
'aria-invalid': ariaInvalid,
|
||||
...rootProps
|
||||
}: JsonCodeEditorProps) {
|
||||
const { t } = useTranslation()
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [scrollTop, setScrollTop] = useState(0)
|
||||
const lineNumbers = useMemo(() => {
|
||||
const count = Math.max(1, value.split('\n').length)
|
||||
return Array.from({ length: count }, (_, index) => index + 1)
|
||||
}, [value])
|
||||
const jsonStatus = useMemo(() => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return { valid: true, message: t('JSON') }
|
||||
try {
|
||||
JSON.parse(trimmed)
|
||||
return { valid: true, message: t('JSON') }
|
||||
} catch {
|
||||
return { valid: false, message: t('Invalid JSON') }
|
||||
}
|
||||
}, [value, t])
|
||||
|
||||
const formatJson = () => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return
|
||||
try {
|
||||
onChange(JSON.stringify(JSON.parse(trimmed), null, 2))
|
||||
} catch {
|
||||
// Keep invalid drafts untouched; validation feedback remains visible.
|
||||
}
|
||||
}
|
||||
|
||||
const updateValueWithSelection = (
|
||||
nextValue: string,
|
||||
selectionStart: number,
|
||||
selectionEnd = selectionStart
|
||||
) => {
|
||||
onChange(nextValue)
|
||||
window.requestAnimationFrame(() => {
|
||||
textareaRef.current?.setSelectionRange(selectionStart, selectionEnd)
|
||||
})
|
||||
}
|
||||
|
||||
const getLineIndent = (text: string, cursor: number) => {
|
||||
const lineStart = text.lastIndexOf('\n', cursor - 1) + 1
|
||||
return text.slice(lineStart, cursor).match(/^\s*/)?.[0] ?? ''
|
||||
}
|
||||
|
||||
const handleEditorKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const target = event.currentTarget
|
||||
const start = target.selectionStart
|
||||
const end = target.selectionEnd
|
||||
const selected = value.slice(start, end)
|
||||
const before = value.slice(0, start)
|
||||
const after = value.slice(end)
|
||||
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
|
||||
if (start !== end && selected.includes('\n')) {
|
||||
const selectionLineStart = value.lastIndexOf('\n', start - 1) + 1
|
||||
const selectedBlock = value.slice(selectionLineStart, end)
|
||||
const lines = selectedBlock.split('\n')
|
||||
const nextBlock = event.shiftKey
|
||||
? lines
|
||||
.map((line) =>
|
||||
line.startsWith(' ')
|
||||
? line.slice(2)
|
||||
: line.startsWith('\t')
|
||||
? line.slice(1)
|
||||
: line
|
||||
)
|
||||
.join('\n')
|
||||
: lines.map((line) => ` ${line}`).join('\n')
|
||||
const nextValue =
|
||||
value.slice(0, selectionLineStart) + nextBlock + value.slice(end)
|
||||
updateValueWithSelection(
|
||||
nextValue,
|
||||
selectionLineStart,
|
||||
selectionLineStart + nextBlock.length
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.shiftKey) {
|
||||
const lineStart = value.lastIndexOf('\n', start - 1) + 1
|
||||
const removable = value.slice(lineStart, lineStart + 2)
|
||||
if (removable === ' ') {
|
||||
updateValueWithSelection(
|
||||
value.slice(0, lineStart) + value.slice(lineStart + 2),
|
||||
Math.max(lineStart, start - 2),
|
||||
Math.max(lineStart, end - 2)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
updateValueWithSelection(`${before} ${after}`, start + 2)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
const indent = getLineIndent(value, start)
|
||||
const previousChar = before.trimEnd().at(-1)
|
||||
const nextChar = after.trimStart().at(0)
|
||||
const shouldNest = previousChar === '{' || previousChar === '['
|
||||
const shouldClose =
|
||||
(previousChar === '{' && nextChar === '}') ||
|
||||
(previousChar === '[' && nextChar === ']')
|
||||
|
||||
if (shouldNest && shouldClose) {
|
||||
const innerIndent = `${indent} `
|
||||
const insert = `\n${innerIndent}\n${indent}`
|
||||
updateValueWithSelection(
|
||||
`${before}${insert}${after}`,
|
||||
start + 1 + innerIndent.length
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const nextIndent = shouldNest ? `${indent} ` : indent
|
||||
const insert = `\n${nextIndent}`
|
||||
updateValueWithSelection(
|
||||
`${before}${insert}${after}`,
|
||||
start + insert.length
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const pairs: Record<string, string> = {
|
||||
'"': '"',
|
||||
'{': '}',
|
||||
'[': ']',
|
||||
}
|
||||
const closingChars = new Set(Object.values(pairs))
|
||||
|
||||
if (closingChars.has(event.key) && value[start] === event.key) {
|
||||
event.preventDefault()
|
||||
textareaRef.current?.setSelectionRange(start + 1, start + 1)
|
||||
return
|
||||
}
|
||||
|
||||
if (pairs[event.key]) {
|
||||
event.preventDefault()
|
||||
const close = pairs[event.key]
|
||||
const wrapped = `${event.key}${selected}${close}`
|
||||
updateValueWithSelection(
|
||||
`${before}${wrapped}${after}`,
|
||||
start + 1,
|
||||
start + 1 + selected.length
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Backspace' && start === end && start > 0) {
|
||||
const previousChar = value[start - 1]
|
||||
const nextChar = value[start]
|
||||
if (pairs[previousChar] === nextChar) {
|
||||
event.preventDefault()
|
||||
updateValueWithSelection(
|
||||
value.slice(0, start - 1) + value.slice(start + 1),
|
||||
start - 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-input bg-background focus-within:border-ring focus-within:ring-ring/50 overflow-hidden rounded-lg border transition-colors focus-within:ring-3',
|
||||
className
|
||||
)}
|
||||
{...rootProps}
|
||||
>
|
||||
<div className='bg-muted/30 flex h-8 items-center justify-between border-b px-2'>
|
||||
<div className='text-muted-foreground flex min-w-0 items-center gap-1.5 text-xs font-medium'>
|
||||
<Braces className='h-3.5 w-3.5' />
|
||||
<span>{t('JSON')}</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-xs',
|
||||
jsonStatus.valid ? 'text-emerald-600' : 'text-destructive'
|
||||
)}
|
||||
>
|
||||
{jsonStatus.valid ? (
|
||||
<CheckCircle2 className='h-3.5 w-3.5' />
|
||||
) : (
|
||||
<AlertCircle className='h-3.5 w-3.5' />
|
||||
)}
|
||||
{jsonStatus.message}
|
||||
</span>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={formatJson}
|
||||
disabled={disabled || !jsonStatus.valid || !value.trim()}
|
||||
>
|
||||
<Code2 className='mr-1 h-3.5 w-3.5' />
|
||||
{t('Format JSON')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn('relative flex overflow-hidden', heightClassName)}>
|
||||
<div className='bg-muted/20 text-muted-foreground/70 relative w-10 shrink-0 overflow-hidden border-r font-mono text-xs leading-5 select-none'>
|
||||
<div
|
||||
className='px-2 py-2 text-right'
|
||||
style={{ transform: `translateY(-${scrollTop}px)` }}
|
||||
>
|
||||
{lineNumbers.map((lineNumber) => (
|
||||
<div key={lineNumber}>{lineNumber}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
id={id}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
aria-invalid={ariaInvalid}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
|
||||
className={cn(
|
||||
'[field-sizing:fixed] resize-none overflow-auto rounded-none border-0 bg-transparent px-3 py-2 font-mono text-xs leading-5 shadow-none ring-0 outline-none focus-visible:ring-0',
|
||||
heightClassName
|
||||
)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+9
-18
@@ -20,8 +20,7 @@ import { useNotifications } from '@/hooks/use-notifications'
|
||||
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
|
||||
import { ConfigDrawer } from '@/components/config-drawer'
|
||||
import { LanguageSwitcher } from '@/components/language-switcher'
|
||||
import { NotificationButton } from '@/components/notification-button'
|
||||
import { NotificationDialog } from '@/components/notification-dialog'
|
||||
import { NotificationPopover } from '@/components/notification-popover'
|
||||
import { ProfileDropdown } from '@/components/profile-dropdown'
|
||||
import { Search } from '@/components/search'
|
||||
import { defaultTopNavLinks } from '../config/top-nav.config'
|
||||
@@ -128,9 +127,15 @@ export function AppHeader({
|
||||
)}
|
||||
{showSearch && <Search />}
|
||||
{showNotifications && (
|
||||
<NotificationButton
|
||||
<NotificationPopover
|
||||
open={notifications.popoverOpen}
|
||||
onOpenChange={notifications.setPopoverOpen}
|
||||
unreadCount={notifications.unreadCount}
|
||||
onClick={() => notifications.openDialog()}
|
||||
activeTab={notifications.activeTab}
|
||||
onTabChange={notifications.setActiveTab}
|
||||
notice={notifications.notice}
|
||||
announcements={notifications.announcements}
|
||||
loading={notifications.loading}
|
||||
/>
|
||||
)}
|
||||
<LanguageSwitcher />
|
||||
@@ -139,20 +144,6 @@ export function AppHeader({
|
||||
</div>
|
||||
)}
|
||||
</Header>
|
||||
|
||||
{/* Notification Dialog */}
|
||||
{showNotifications && (
|
||||
<NotificationDialog
|
||||
open={notifications.dialogOpen}
|
||||
onOpenChange={notifications.setDialogOpen}
|
||||
activeTab={notifications.activeTab}
|
||||
onTabChange={notifications.setActiveTab}
|
||||
notice={notifications.notice}
|
||||
announcements={notifications.announcements}
|
||||
loading={notifications.loading}
|
||||
onCloseToday={notifications.closeToday}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+39
-39
@@ -16,59 +16,59 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useMemo } from 'react'
|
||||
import { useLocation } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
|
||||
import { MOTION_TRANSITION, MOTION_VARIANTS } from '@/lib/motion'
|
||||
import { useLayout } from '@/context/layout-provider'
|
||||
import { useSidebarConfig } from '@/hooks/use-sidebar-config'
|
||||
import { useSidebarData } from '@/hooks/use-sidebar-data'
|
||||
import { useSidebarView } from '@/hooks/use-sidebar-view'
|
||||
import { Sidebar, SidebarContent, SidebarRail } from '@/components/ui/sidebar'
|
||||
import { getNavGroupsForPath } from '../lib/workspace-registry'
|
||||
import { NavGroup } from './nav-group'
|
||||
import { SidebarViewHeader } from './sidebar-view-header'
|
||||
|
||||
/**
|
||||
* Application sidebar component
|
||||
* Fetches corresponding navigation menu from workspace registry based on current path
|
||||
* Dynamically filters navigation items based on backend SidebarModulesAdmin configuration
|
||||
* Application sidebar.
|
||||
*
|
||||
* Automatically matches workspace configuration for current path through workspace registry system
|
||||
* Adding new workspaces only requires registration in workspace-registry.ts
|
||||
* Adopts the Vercel / Cloudflare "drill-in" pattern: the URL drives
|
||||
* which sidebar *view* is rendered. Clicking a top-level entry like
|
||||
* `System Settings` swaps the sidebar to a contextual workspace —
|
||||
* with a `← Back to Dashboard` affordance — instead of stacking the
|
||||
* sub-navigation inside the root tree.
|
||||
*
|
||||
* Architecture:
|
||||
* - View resolution + filtering: {@link useSidebarView}
|
||||
* - View registry: `layout/lib/sidebar-view-registry.ts`
|
||||
* - Per-view header: {@link SidebarViewHeader}
|
||||
*
|
||||
* Adding a new nested view only requires registering a {@link SidebarView}
|
||||
* in the registry; this component requires no changes.
|
||||
*/
|
||||
export function AppSidebar() {
|
||||
const { t } = useTranslation()
|
||||
const { collapsible, variant } = useLayout()
|
||||
const { pathname } = useLocation()
|
||||
const userRole = useAuthStore((state) => state.auth.user?.role)
|
||||
const sidebarData = useSidebarData()
|
||||
|
||||
// Get navigation group configuration corresponding to current path from workspace registry
|
||||
const allNavGroups = getNavGroupsForPath(pathname, t) || sidebarData.navGroups
|
||||
|
||||
// Filter sidebar navigation items based on backend configuration
|
||||
const configFilteredNavGroups = useSidebarConfig(allNavGroups)
|
||||
|
||||
// Filter navigation groups based on user role
|
||||
// Non-Admin users cannot see Admin navigation group
|
||||
const currentNavGroups = useMemo(() => {
|
||||
const isAdmin = userRole && userRole >= ROLE.ADMIN
|
||||
return configFilteredNavGroups.filter((group) => {
|
||||
if (group.id === 'admin') {
|
||||
return isAdmin
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [configFilteredNavGroups, userRole])
|
||||
const { key, view, navGroups } = useSidebarView()
|
||||
const shouldReduce = useReducedMotion()
|
||||
|
||||
return (
|
||||
<Sidebar collapsible={collapsible} variant={variant}>
|
||||
{view && <SidebarViewHeader view={view} />}
|
||||
|
||||
<SidebarContent className='py-2'>
|
||||
{currentNavGroups.map((props) => {
|
||||
const key = props.id || props.title
|
||||
return <NavGroup key={key} {...props} />
|
||||
})}
|
||||
<AnimatePresence mode='wait' initial={false}>
|
||||
<motion.div
|
||||
key={key}
|
||||
initial={
|
||||
shouldReduce ? false : MOTION_VARIANTS.sidebarSlide.initial
|
||||
}
|
||||
animate={MOTION_VARIANTS.sidebarSlide.animate}
|
||||
exit={shouldReduce ? undefined : MOTION_VARIANTS.sidebarSlide.exit}
|
||||
transition={MOTION_TRANSITION.fast}
|
||||
className='flex flex-col'
|
||||
>
|
||||
{navGroups.map((props) => (
|
||||
<NavGroup key={props.id || props.title} {...props} />
|
||||
))}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
|
||||
@@ -23,7 +23,6 @@ import { SearchProvider } from '@/context/search-provider'
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
|
||||
import { AnimatedOutlet } from '@/components/page-transition'
|
||||
import { SkipToMain } from '@/components/skip-to-main'
|
||||
import { WorkspaceProvider } from '../context/workspace-context'
|
||||
import { AppHeader } from './app-header'
|
||||
import { AppSidebar } from './app-sidebar'
|
||||
|
||||
@@ -37,24 +36,23 @@ export function AuthenticatedLayout(props: AuthenticatedLayoutProps) {
|
||||
return (
|
||||
<LayoutProvider>
|
||||
<SearchProvider>
|
||||
<WorkspaceProvider>
|
||||
<SidebarProvider defaultOpen={defaultOpen} className='flex-col'>
|
||||
<SkipToMain />
|
||||
<AppHeader />
|
||||
<div className='flex min-h-0 w-full flex-1'>
|
||||
<AppSidebar />
|
||||
<SidebarInset
|
||||
className={cn(
|
||||
'@container/content',
|
||||
'h-[calc(100svh-var(--app-header-height,0px))]',
|
||||
'peer-data-[variant=inset]:h-[calc(100svh-var(--app-header-height,0px)-(var(--spacing)*4))]'
|
||||
)}
|
||||
>
|
||||
{props.children ?? <AnimatedOutlet />}
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</WorkspaceProvider>
|
||||
<SidebarProvider defaultOpen={defaultOpen} className='flex-col'>
|
||||
<SkipToMain />
|
||||
<AppHeader />
|
||||
<div className='flex min-h-0 w-full flex-1'>
|
||||
<AppSidebar />
|
||||
<SidebarInset
|
||||
className={cn(
|
||||
'@container/content',
|
||||
'h-[calc(100svh-var(--app-header-height,0px))]',
|
||||
'min-h-0 overflow-hidden',
|
||||
'peer-data-[variant=inset]:h-[calc(100svh-var(--app-header-height,0px)-(var(--spacing)*4))]'
|
||||
)}
|
||||
>
|
||||
{props.children ?? <AnimatedOutlet />}
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</SearchProvider>
|
||||
</LayoutProvider>
|
||||
)
|
||||
|
||||
@@ -231,9 +231,9 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) {
|
||||
<DropdownMenuTrigger
|
||||
render={<SidebarMenuButton tooltip={item.title} />}
|
||||
>
|
||||
{item.icon && <item.icon className='h-4 w-4' />}
|
||||
<span>{item.title}</span>
|
||||
<ChevronRight className='ms-auto h-4 w-4 opacity-70' />
|
||||
{item.icon && <item.icon className='h-4 w-4 shrink-0' />}
|
||||
<span className='min-w-0 flex-1 truncate'>{item.title}</span>
|
||||
<ChevronRight className='ms-auto h-4 w-4 shrink-0 opacity-70' />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start'>
|
||||
{visiblePresets.map((preset) => (
|
||||
@@ -261,9 +261,9 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) {
|
||||
className='group/collapsible-trigger'
|
||||
render={<SidebarMenuButton />}
|
||||
>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[panel-open]/collapsible-trigger:rotate-90' />
|
||||
{item.icon && <item.icon className='shrink-0' />}
|
||||
<span className='min-w-0 flex-1 truncate'>{item.title}</span>
|
||||
<ChevronRight className='ms-auto size-4 shrink-0 transition-transform duration-200 group-data-[panel-open]/collapsible-trigger:rotate-90' />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className='CollapsibleContent'>
|
||||
<SidebarMenuSub>
|
||||
|
||||
+2
-2
@@ -20,8 +20,8 @@ import { Fragment, useMemo } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSystemConfig } from '@/hooks/use-system-config'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { useSystemConfig } from '@/hooks/use-system-config'
|
||||
|
||||
interface FooterLink {
|
||||
text: string
|
||||
@@ -235,7 +235,7 @@ export function Footer(props: FooterProps) {
|
||||
className='custom-footer text-muted-foreground min-w-0 text-center text-sm sm:text-left'
|
||||
dangerouslySetInnerHTML={{ __html: footerHtml }}
|
||||
/>
|
||||
<div className='border-border/60 flex w-full flex-wrap items-center justify-center gap-x-3 gap-y-1 border-t pt-4 text-muted-foreground/45 text-xs sm:w-auto sm:justify-end sm:border-t-0 sm:border-l sm:pt-0 sm:pl-5'>
|
||||
<div className='border-border/60 text-muted-foreground/45 flex w-full flex-wrap items-center justify-center gap-x-3 gap-y-1 border-t pt-4 text-xs sm:w-auto sm:justify-end sm:border-t-0 sm:border-l sm:pt-0 sm:pl-5'>
|
||||
<LegalLinks />
|
||||
<ProjectAttribution currentYear={currentYear} inline />
|
||||
</div>
|
||||
|
||||
+11
-11
@@ -112,7 +112,7 @@ export function NavGroup({ title, items }: NavGroupProps) {
|
||||
* Navigation badge component
|
||||
*/
|
||||
function NavBadge({ children }: { children: ReactNode }) {
|
||||
return <Badge className='px-1 py-0 text-xs'>{children}</Badge>
|
||||
return <Badge className='shrink-0 px-1 py-0 text-xs'>{children}</Badge>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,8 +127,8 @@ function SidebarMenuLink({ item, href }: { item: NavLink; href: string }) {
|
||||
tooltip={item.title}
|
||||
render={<Link to={item.url} onClick={() => setOpenMobile(false)} />}
|
||||
>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
{item.icon && <item.icon className='shrink-0' />}
|
||||
<span className='min-w-0 flex-1 truncate'>{item.title}</span>
|
||||
{item.badge && <NavBadge>{item.badge}</NavBadge>}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
@@ -170,10 +170,10 @@ function SidebarMenuCollapsible({
|
||||
className='group/collapsible-trigger'
|
||||
render={<SidebarMenuButton tooltip={item.title} />}
|
||||
>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
{item.icon && <item.icon className='shrink-0' />}
|
||||
<span className='min-w-0 flex-1 truncate'>{item.title}</span>
|
||||
{item.badge && <NavBadge>{item.badge}</NavBadge>}
|
||||
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[panel-open]/collapsible-trigger:rotate-90' />
|
||||
<ChevronRight className='ms-auto size-4 shrink-0 transition-transform duration-200 group-data-[panel-open]/collapsible-trigger:rotate-90' />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className='CollapsibleContent'>
|
||||
<SidebarMenuSub>
|
||||
@@ -185,8 +185,8 @@ function SidebarMenuCollapsible({
|
||||
<Link to={subItem.url} onClick={() => setOpenMobile(false)} />
|
||||
}
|
||||
>
|
||||
{subItem.icon && <subItem.icon />}
|
||||
<span>{subItem.title}</span>
|
||||
{subItem.icon && <subItem.icon className='shrink-0' />}
|
||||
<span className='min-w-0 flex-1 truncate'>{subItem.title}</span>
|
||||
{subItem.badge && <NavBadge>{subItem.badge}</NavBadge>}
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
@@ -219,10 +219,10 @@ function SidebarMenuCollapsedDropdown({
|
||||
/>
|
||||
}
|
||||
>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
{item.icon && <item.icon className='shrink-0' />}
|
||||
<span className='min-w-0 flex-1 truncate'>{item.title}</span>
|
||||
{item.badge && <NavBadge>{item.badge}</NavBadge>}
|
||||
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[popup-open]/dropdown-trigger:rotate-90' />
|
||||
<ChevronRight className='ms-auto size-4 shrink-0 transition-transform duration-200 group-data-[popup-open]/dropdown-trigger:rotate-90' />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side='right' align='start' sideOffset={4}>
|
||||
<DropdownMenuGroup>
|
||||
|
||||
@@ -35,8 +35,7 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { LanguageSwitcher } from '@/components/language-switcher'
|
||||
import { NotificationButton } from '@/components/notification-button'
|
||||
import { NotificationDialog } from '@/components/notification-dialog'
|
||||
import { NotificationPopover } from '@/components/notification-popover'
|
||||
import { ProfileDropdown } from '@/components/profile-dropdown'
|
||||
import { ThemeSwitch } from '@/components/theme-switch'
|
||||
import { defaultTopNavLinks } from '../config/top-nav.config'
|
||||
@@ -271,9 +270,15 @@ export function PublicHeader(props: PublicHeaderProps) {
|
||||
{showLanguageSwitcher && <LanguageSwitcher />}
|
||||
{showThemeSwitch && <ThemeSwitch />}
|
||||
{showNotifications && (
|
||||
<NotificationButton
|
||||
<NotificationPopover
|
||||
open={notifications.popoverOpen}
|
||||
onOpenChange={notifications.setPopoverOpen}
|
||||
unreadCount={notifications.unreadCount}
|
||||
onClick={() => notifications.openDialog()}
|
||||
activeTab={notifications.activeTab}
|
||||
onTabChange={notifications.setActiveTab}
|
||||
notice={notifications.notice}
|
||||
announcements={notifications.announcements}
|
||||
loading={notifications.loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -445,20 +450,6 @@ export function PublicHeader(props: PublicHeaderProps) {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Notification Dialog */}
|
||||
{showNotifications && (
|
||||
<NotificationDialog
|
||||
open={notifications.dialogOpen}
|
||||
onOpenChange={notifications.setDialogOpen}
|
||||
activeTab={notifications.activeTab}
|
||||
onTabChange={notifications.setActiveTab}
|
||||
notice={notifications.notice}
|
||||
announcements={notifications.announcements}
|
||||
loading={notifications.loading}
|
||||
onCloseToday={notifications.closeToday}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,11 +33,6 @@ function SectionPageLayoutTitle(_props: SlotProps) {
|
||||
}
|
||||
SectionPageLayoutTitle.displayName = 'SectionPageLayout.Title'
|
||||
|
||||
function SectionPageLayoutDescription(_props: SlotProps) {
|
||||
return null
|
||||
}
|
||||
SectionPageLayoutDescription.displayName = 'SectionPageLayout.Description'
|
||||
|
||||
function SectionPageLayoutActions(_props: SlotProps) {
|
||||
return null
|
||||
}
|
||||
@@ -87,13 +82,13 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
|
||||
<div className='mb-2 sm:mb-3'>{breadcrumb}</div>
|
||||
)}
|
||||
<div className='flex flex-wrap items-center justify-between gap-x-3 gap-y-2 sm:gap-x-4'>
|
||||
<div className='min-w-0'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h2 className='truncate text-base font-bold tracking-tight sm:text-lg'>
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
{actions != null && (
|
||||
<div className='flex shrink-0 flex-wrap items-center gap-2 sm:gap-x-4'>
|
||||
<div className='flex shrink-0 flex-wrap items-center justify-end gap-2 sm:gap-x-4'>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
@@ -114,7 +109,6 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
|
||||
}
|
||||
|
||||
SectionPageLayout.Title = SectionPageLayoutTitle
|
||||
SectionPageLayout.Description = SectionPageLayoutDescription
|
||||
SectionPageLayout.Actions = SectionPageLayoutActions
|
||||
SectionPageLayout.Content = SectionPageLayoutContent
|
||||
SectionPageLayout.Breadcrumb = SectionPageLayoutBreadcrumb
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar'
|
||||
import type { SidebarView } from '../types'
|
||||
|
||||
type SidebarViewHeaderProps = {
|
||||
view: SidebarView
|
||||
}
|
||||
|
||||
/**
|
||||
* Header for a nested sidebar view (Vercel / Cloudflare drill-in pattern).
|
||||
*
|
||||
* Renders only the back affordance — workspace context is conveyed by
|
||||
* the nav groups below, not a redundant title row.
|
||||
*/
|
||||
export function SidebarViewHeader(props: SidebarViewHeaderProps) {
|
||||
const { t } = useTranslation()
|
||||
const { setOpenMobile } = useSidebar()
|
||||
|
||||
return (
|
||||
<SidebarHeader className='border-sidebar-border border-b px-2 py-2'>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
tooltip={t(props.view.parent.label)}
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'gap-1.5 font-medium'
|
||||
)}
|
||||
render={
|
||||
<Link
|
||||
to={props.view.parent.to}
|
||||
onClick={() => setOpenMobile(false)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ChevronLeft className='size-4 shrink-0' />
|
||||
<span className='truncate'>{t(props.view.parent.label)}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
)
|
||||
}
|
||||
@@ -33,15 +33,16 @@ import { getModelsSectionNavItems } from '@/features/system-settings/models/sect
|
||||
import { getOperationsSectionNavItems } from '@/features/system-settings/operations/section-registry.tsx'
|
||||
import { getSecuritySectionNavItems } from '@/features/system-settings/security/section-registry.tsx'
|
||||
import { getSiteSectionNavItems } from '@/features/system-settings/site/section-registry.tsx'
|
||||
import { type NavGroup } from '../types'
|
||||
import type { NavGroup, SidebarView } from '../types'
|
||||
|
||||
/**
|
||||
* System settings sidebar configuration
|
||||
* Displayed when switching to "System Settings" workspace
|
||||
* Sidebar nav groups for the System Settings nested view.
|
||||
*
|
||||
* Kept as a single group because the workspace title in the sidebar
|
||||
* header already provides top-level context — the inner group label
|
||||
* scopes the items as "administration" actions.
|
||||
*/
|
||||
export const WORKSPACE_SYSTEM_SETTINGS_ID = 'system-settings'
|
||||
|
||||
export function getSystemSettingsNavGroups(t: TFunction): NavGroup[] {
|
||||
function getSystemSettingsNavGroups(t: TFunction): NavGroup[] {
|
||||
return [
|
||||
{
|
||||
id: 'system-administration',
|
||||
@@ -86,3 +87,20 @@ export function getSystemSettingsNavGroups(t: TFunction): NavGroup[] {
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Nested sidebar view for `/system-settings/*`.
|
||||
*
|
||||
* Activates the Vercel / Cloudflare-style drill-in sidebar:
|
||||
* the root navigation is replaced by the system administration
|
||||
* groups, with a "Back to Dashboard" affordance in the header.
|
||||
*/
|
||||
export const SYSTEM_SETTINGS_VIEW: SidebarView = {
|
||||
id: 'system-settings',
|
||||
pathPattern: /^\/system-settings(\/|$)/,
|
||||
parent: {
|
||||
to: '/dashboard/overview',
|
||||
label: 'Back to Dashboard',
|
||||
},
|
||||
getNavGroups: getSystemSettingsNavGroups,
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import * as React from 'react'
|
||||
import { type Workspace } from '../types'
|
||||
|
||||
type WorkspaceContextType = {
|
||||
activeWorkspace: Workspace | null
|
||||
setActiveWorkspace: (workspace: Workspace) => void
|
||||
}
|
||||
|
||||
const WorkspaceContext = React.createContext<WorkspaceContextType | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
/**
|
||||
* 工作区上下文 Provider
|
||||
* 管理当前激活的工作区状态,用于切换不同的侧边栏视图
|
||||
*/
|
||||
export function WorkspaceProvider({ children }: { children: React.ReactNode }) {
|
||||
const [activeWorkspace, setActiveWorkspace] =
|
||||
React.useState<Workspace | null>(null)
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({ activeWorkspace, setActiveWorkspace }),
|
||||
[activeWorkspace]
|
||||
)
|
||||
|
||||
return (
|
||||
<WorkspaceContext.Provider value={value}>
|
||||
{children}
|
||||
</WorkspaceContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用工作区上下文的 Hook
|
||||
* @throws 如果在 WorkspaceProvider 外部使用会抛出错误
|
||||
*/
|
||||
export function useWorkspace() {
|
||||
const context = React.useContext(WorkspaceContext)
|
||||
if (!context) {
|
||||
throw new Error('useWorkspace must be used within WorkspaceProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
+15
-22
@@ -17,10 +17,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
/**
|
||||
* Layout 组件统一导出
|
||||
* Public surface of the Layout module.
|
||||
*/
|
||||
|
||||
// 核心组件
|
||||
// Core components
|
||||
export { AppHeader } from './components/app-header'
|
||||
export { AppSidebar } from './components/app-sidebar'
|
||||
export { AuthenticatedLayout } from './components/authenticated-layout'
|
||||
@@ -34,41 +34,34 @@ export { Main } from './components/main'
|
||||
export { PageFooterPortal } from './components/page-footer'
|
||||
export { NavGroup } from './components/nav-group'
|
||||
export { SectionPageLayout } from './components/section-page-layout'
|
||||
export { SidebarViewHeader } from './components/sidebar-view-header'
|
||||
export { SystemBrand } from './components/system-brand'
|
||||
export { TopNav } from './components/top-nav'
|
||||
export { MobileDrawer } from './components/mobile-drawer'
|
||||
|
||||
// 上下文
|
||||
export { WorkspaceProvider, useWorkspace } from './context/workspace-context'
|
||||
|
||||
// 配置
|
||||
export {
|
||||
getSystemSettingsNavGroups,
|
||||
WORKSPACE_SYSTEM_SETTINGS_ID,
|
||||
} from './config/system-settings.config'
|
||||
// Configuration
|
||||
export { SYSTEM_SETTINGS_VIEW } from './config/system-settings.config'
|
||||
export { defaultTopNavLinks } from './config/top-nav.config'
|
||||
|
||||
// 常量
|
||||
// Constants
|
||||
export { MOBILE_DRAWER_ANIMATION, MOBILE_DRAWER_CONFIG } from './constants'
|
||||
|
||||
// 工具函数 - 工作区注册表
|
||||
// Sidebar view registry
|
||||
export {
|
||||
getWorkspaceByPath,
|
||||
getNavGroupsForPath,
|
||||
isInWorkspace,
|
||||
getAllWorkspaces,
|
||||
WORKSPACE_IDS,
|
||||
} from './lib/workspace-registry'
|
||||
resolveSidebarView,
|
||||
} from './lib/sidebar-view-registry'
|
||||
|
||||
// 类型导出(使用 type-only 导出避免与组件冲突)
|
||||
// Type exports (type-only to avoid conflicts with components above)
|
||||
export type {
|
||||
Workspace,
|
||||
NavLink,
|
||||
NavCollapsible,
|
||||
NavItem,
|
||||
NavGroup as NavGroupType,
|
||||
NavItem,
|
||||
NavLink,
|
||||
ResolvedSidebarView,
|
||||
SidebarData,
|
||||
SidebarView,
|
||||
SidebarViewParent,
|
||||
TopNavLink,
|
||||
} from './types'
|
||||
export type { WorkspaceConfig, WorkspaceId } from './lib/workspace-registry'
|
||||
export type { SectionPageLayoutProps } from './components/section-page-layout'
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { type TFunction } from 'i18next'
|
||||
import { SYSTEM_SETTINGS_VIEW } from '../config/system-settings.config'
|
||||
import type { NavGroup, SidebarView } from '../types'
|
||||
|
||||
/**
|
||||
* Registered nested sidebar views.
|
||||
*
|
||||
* Each entry describes a contextual sidebar that replaces the root
|
||||
* navigation when the user enters that workspace (Vercel-style
|
||||
* "drill-in" pattern). Add new entries here to register a new view.
|
||||
*
|
||||
* Match priority is array order; the first matching `pathPattern` wins.
|
||||
*/
|
||||
const SIDEBAR_VIEWS: readonly SidebarView[] = [SYSTEM_SETTINGS_VIEW]
|
||||
|
||||
/**
|
||||
* Resolve the active nested view for the given path.
|
||||
*
|
||||
* @returns Matching {@link SidebarView}, or `null` when the root
|
||||
* navigation should be displayed.
|
||||
*/
|
||||
export function resolveSidebarView(pathname: string): SidebarView | null {
|
||||
return SIDEBAR_VIEWS.find((view) => view.pathPattern.test(pathname)) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Backwards-compatible helper for consumers (e.g. command palette) that
|
||||
* just need the navigation groups for the current path, without caring
|
||||
* about the view metadata.
|
||||
*
|
||||
* @returns Nav groups for the matched view, or `null` if no nested view
|
||||
* matches (callers should then fall back to root nav groups).
|
||||
*/
|
||||
export function getNavGroupsForPath(
|
||||
pathname: string,
|
||||
t: TFunction
|
||||
): NavGroup[] | null {
|
||||
const view = resolveSidebarView(pathname)
|
||||
return view ? view.getNavGroups(t) : null
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
/**
|
||||
* 工作区注册表使用示例
|
||||
*
|
||||
* 本文件展示如何添加新工作区,仅作示例参考,不会被编译
|
||||
*/
|
||||
|
||||
/**
|
||||
* 步骤1: 创建工作区的侧边栏配置文件
|
||||
* 例如:web/src/components/layout/config/user-management.config.ts
|
||||
*/
|
||||
/*
|
||||
import { Users, UserPlus, Shield } from 'lucide-react'
|
||||
import { type NavGroup } from '../types'
|
||||
|
||||
export const userManagementConfig: NavGroup[] = [
|
||||
{
|
||||
title: 'User Management',
|
||||
items: [
|
||||
{
|
||||
title: 'All Users',
|
||||
url: '/user-management/list',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
title: 'Create User',
|
||||
url: '/user-management/create',
|
||||
icon: UserPlus,
|
||||
},
|
||||
{
|
||||
title: 'Permissions',
|
||||
url: '/user-management/permissions',
|
||||
icon: Shield,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
*/
|
||||
|
||||
/**
|
||||
* 步骤2: 在 workspace-registry.ts 中注册新工作区
|
||||
* 在 workspaceRegistry 数组中添加配置(在默认工作区之前)
|
||||
*/
|
||||
/*
|
||||
import { userManagementConfig } from '../config/user-management.config'
|
||||
|
||||
const workspaceRegistry: WorkspaceConfig[] = [
|
||||
// System Settings 工作区
|
||||
{
|
||||
name: 'System Settings',
|
||||
pathPattern: /^\/system-settings/,
|
||||
navGroups: systemSettingsConfig,
|
||||
},
|
||||
// 新增的 User Management 工作区
|
||||
{
|
||||
name: 'User Management',
|
||||
pathPattern: /^\/user-management/, // 或使用字符串: '/user-management'
|
||||
navGroups: userManagementConfig,
|
||||
},
|
||||
// 默认工作区(必须放在最后)
|
||||
{
|
||||
name: 'Default',
|
||||
pathPattern: /.* /,
|
||||
navGroups: sidebarConfig.navGroups,
|
||||
},
|
||||
]
|
||||
*/
|
||||
|
||||
/**
|
||||
* 步骤3: (可选)在 sidebar.config.ts 中添加工作区到切换器
|
||||
*/
|
||||
/*
|
||||
export const sidebarConfig: SidebarData = {
|
||||
workspaces: [
|
||||
{
|
||||
name: '',
|
||||
logo: Command,
|
||||
plan: '',
|
||||
},
|
||||
{
|
||||
name: 'User Management',
|
||||
logo: Users,
|
||||
plan: 'Manage users',
|
||||
},
|
||||
{
|
||||
name: 'System Settings',
|
||||
logo: Settings,
|
||||
plan: 'Manage and configure',
|
||||
},
|
||||
],
|
||||
navGroups: [...],
|
||||
}
|
||||
*/
|
||||
|
||||
/**
|
||||
* 同语注:这里就完成了,现在:
|
||||
* - 侧边栏会根据当前路径自动切换显示对应的工作区菜单
|
||||
* - 搜索功能会自动显示当前工作区的菜单项
|
||||
* - 工作区切换器会显示新的工作区选项
|
||||
*
|
||||
* 无需修改任何其他文件!
|
||||
*/
|
||||
@@ -1,128 +0,0 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { type TFunction } from 'i18next'
|
||||
import {
|
||||
getSystemSettingsNavGroups,
|
||||
WORKSPACE_SYSTEM_SETTINGS_ID,
|
||||
} from '../config/system-settings.config'
|
||||
import type { NavGroup } from '../types'
|
||||
|
||||
export const WORKSPACE_IDS = {
|
||||
SYSTEM_SETTINGS: WORKSPACE_SYSTEM_SETTINGS_ID,
|
||||
DEFAULT: 'default',
|
||||
} as const
|
||||
|
||||
export type WorkspaceId = (typeof WORKSPACE_IDS)[keyof typeof WORKSPACE_IDS]
|
||||
|
||||
/**
|
||||
* Workspace configuration type
|
||||
* Each workspace contains name, path matching rules, and corresponding navigation group configuration
|
||||
*/
|
||||
export type WorkspaceConfig = {
|
||||
/** Workspace identifier (for logic) */
|
||||
id: WorkspaceId
|
||||
/** Workspace name */
|
||||
name: string
|
||||
/** Path matching rule, supports string (contains match) or regular expression */
|
||||
pathPattern: string | RegExp
|
||||
/** Sidebar navigation group configuration for this workspace */
|
||||
getNavGroups?: (t: TFunction) => NavGroup[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace registry
|
||||
*
|
||||
* Sorted by priority, first matched workspace will be used
|
||||
* Last one should be default workspace (matches all paths)
|
||||
*
|
||||
* @example
|
||||
* // Add new workspace
|
||||
* {
|
||||
* name: 'User Management',
|
||||
* pathPattern: /^\/user-management/,
|
||||
* navGroups: userManagementConfig
|
||||
* }
|
||||
*/
|
||||
const workspaceRegistry: WorkspaceConfig[] = [
|
||||
// System Settings workspace
|
||||
{
|
||||
id: WORKSPACE_IDS.SYSTEM_SETTINGS,
|
||||
name: 'System Settings',
|
||||
pathPattern: /^\/system-settings/,
|
||||
getNavGroups: getSystemSettingsNavGroups,
|
||||
},
|
||||
// Default workspace (must be last)
|
||||
{
|
||||
id: WORKSPACE_IDS.DEFAULT,
|
||||
name: 'Default',
|
||||
pathPattern: /.*/,
|
||||
// getNavGroups is undefined, will be handled by consumers (e.g. useSidebarData)
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Get matched workspace configuration based on path
|
||||
* @param pathname - Current route path
|
||||
* @returns Matched workspace configuration
|
||||
*/
|
||||
export function getWorkspaceByPath(pathname: string): WorkspaceConfig {
|
||||
const workspace = workspaceRegistry.find((ws) => {
|
||||
if (typeof ws.pathPattern === 'string') {
|
||||
return pathname.includes(ws.pathPattern)
|
||||
}
|
||||
return ws.pathPattern.test(pathname)
|
||||
})
|
||||
|
||||
// If no match, return default workspace (last one)
|
||||
return workspace || workspaceRegistry[workspaceRegistry.length - 1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get corresponding sidebar navigation group configuration based on path
|
||||
* @param pathname - Current route path
|
||||
* @returns Navigation group configuration for corresponding workspace
|
||||
*/
|
||||
export function getNavGroupsForPath(
|
||||
pathname: string,
|
||||
t: TFunction
|
||||
): NavGroup[] | undefined {
|
||||
const workspace = getWorkspaceByPath(pathname)
|
||||
return workspace.getNavGroups?.(t)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if in specified workspace
|
||||
* @param pathname - Current route path
|
||||
* @param workspaceId - Workspace identifier
|
||||
* @returns Whether in specified workspace
|
||||
*/
|
||||
export function isInWorkspace(
|
||||
pathname: string,
|
||||
workspaceId: WorkspaceId
|
||||
): boolean {
|
||||
return getWorkspaceByPath(pathname).id === workspaceId
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered workspace configurations
|
||||
* @returns Array of workspace configurations
|
||||
*/
|
||||
export function getAllWorkspaces(): WorkspaceConfig[] {
|
||||
return workspaceRegistry
|
||||
}
|
||||
+47
-13
@@ -17,17 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { type LinkProps } from '@tanstack/react-router'
|
||||
|
||||
/**
|
||||
* Workspace type
|
||||
* Used for top switcher to display different workspaces
|
||||
*/
|
||||
export type Workspace = {
|
||||
id: string
|
||||
name: string
|
||||
logo: React.ElementType
|
||||
plan: string
|
||||
}
|
||||
import { type TFunction } from 'i18next'
|
||||
|
||||
/**
|
||||
* Base navigation item type
|
||||
@@ -82,10 +72,12 @@ export type NavGroup = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar data type
|
||||
* Root sidebar data type
|
||||
*
|
||||
* Used by the default (top-level) sidebar view that lists primary
|
||||
* application navigation (chat, dashboard, admin, etc).
|
||||
*/
|
||||
export type SidebarData = {
|
||||
workspaces: Workspace[]
|
||||
navGroups: NavGroup[]
|
||||
}
|
||||
|
||||
@@ -100,3 +92,45 @@ export type TopNavLink = {
|
||||
requiresAuth?: boolean
|
||||
external?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Back-navigation descriptor for a nested sidebar view
|
||||
*/
|
||||
export type SidebarViewParent = {
|
||||
/** Destination URL for the back button */
|
||||
to: LinkProps['to'] | (string & {})
|
||||
/** Visible label, e.g. "Back to Dashboard" — already localized */
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Nested sidebar view configuration
|
||||
*
|
||||
* A nested view replaces the root navigation when the user enters a
|
||||
* dedicated workspace (e.g. System Settings). It models the modern
|
||||
* Vercel / Cloudflare "drill-in" sidebar UX: clicking a top-level entry
|
||||
* swaps the sidebar to a contextual view with a "Back" affordance.
|
||||
*/
|
||||
export type SidebarView = {
|
||||
/** Stable identifier (also drives transition animation keys) */
|
||||
id: string
|
||||
/** Path matcher that activates this view */
|
||||
pathPattern: RegExp
|
||||
/** Back-navigation descriptor; required for nested views */
|
||||
parent: SidebarViewParent
|
||||
/** Nav group builder, called per render with the active translator */
|
||||
getNavGroups: (t: TFunction) => NavGroup[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved sidebar view returned by `useSidebarView()`
|
||||
*
|
||||
* - `view === null`: root navigation (default sidebar)
|
||||
* - `view !== null`: nested workspace view (renders header + back button)
|
||||
*/
|
||||
export type ResolvedSidebarView = {
|
||||
/** Animation/identity key — falls back to a sentinel for the root view */
|
||||
key: string
|
||||
view: SidebarView | null
|
||||
navGroups: NavGroup[]
|
||||
}
|
||||
|
||||
+261
-105
@@ -8,21 +8,32 @@ License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import * as React from 'react'
|
||||
import { Command as CommandPrimitive } from 'cmdk'
|
||||
import { X } from 'lucide-react'
|
||||
import { Add01Icon } from '@hugeicons/core-free-icons'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Command, CommandGroup, CommandItem } from '@/components/ui/command'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxCollection,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
} from '@/components/ui/combobox'
|
||||
|
||||
export type Option = {
|
||||
label: string
|
||||
@@ -35,116 +46,261 @@ interface MultiSelectProps {
|
||||
onChange: (values: string[]) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
allowCreate?: boolean
|
||||
/**
|
||||
* Label shown for the "create" item in the dropdown.
|
||||
* Supports the `{{value}}` placeholder which is replaced with the typed input.
|
||||
* Falls back to `Add "{{value}}"` when omitted.
|
||||
*/
|
||||
createLabel?: string
|
||||
/** Empty state text. Defaults to "No matching items". */
|
||||
emptyText?: string
|
||||
/** Optional `id` to wire labels/aria-describedby to the input. */
|
||||
id?: string
|
||||
/** Disable the entire control. */
|
||||
disabled?: boolean
|
||||
/**
|
||||
* Limits rendered chips while keeping all values selected.
|
||||
* Hidden values remain searchable/removable from the dropdown.
|
||||
*/
|
||||
maxVisibleChips?: number
|
||||
}
|
||||
|
||||
export function MultiSelect({
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
}: MultiSelectProps) {
|
||||
const { t } = useTranslation()
|
||||
const resolvedPlaceholder = placeholder ?? t('Select items...')
|
||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [inputValue, setInputValue] = React.useState('')
|
||||
const COMMA_REGEX = /[,,\n]/
|
||||
|
||||
const handleUnselect = (value: string) => {
|
||||
onChange(selected.filter((s) => s !== value))
|
||||
function splitDraft(value: string): { completed: string[]; draft: string } {
|
||||
if (!COMMA_REGEX.test(value)) {
|
||||
return { completed: [], draft: value }
|
||||
}
|
||||
const normalized = value.replaceAll(',', ',').replaceAll('\n', ',')
|
||||
const parts = normalized.split(',')
|
||||
const draft = parts.at(-1) ?? ''
|
||||
const completed = parts
|
||||
.slice(0, -1)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
return { completed, draft }
|
||||
}
|
||||
|
||||
/**
|
||||
* MultiSelect — tags/chips style multi-select built on Base UI Combobox.
|
||||
*
|
||||
* Behaviour:
|
||||
* - Search filters built-in options (Base UI handles fuzzy filtering).
|
||||
* - When `allowCreate` is true, custom values can be added inline:
|
||||
* - Type and press Enter / "," to add a single value.
|
||||
* - Paste a comma- (or newline-) separated list to add many at once.
|
||||
* - A "Add \"<value>\"" item appears at the top of the dropdown when the
|
||||
* typed text doesn't match any option.
|
||||
* - Backspace on an empty input removes the last selected chip (Base UI default).
|
||||
* - `maxVisibleChips` can cap large selections and show a compact "+N more"
|
||||
* summary so forms do not grow vertically without bound.
|
||||
*
|
||||
* Focus/border styling is inherited from `ComboboxChips`, which uses the same
|
||||
* tokens as `Input` so it stays visually consistent with other form fields.
|
||||
*/
|
||||
export function MultiSelect(props: MultiSelectProps) {
|
||||
const { t } = useTranslation()
|
||||
const placeholder = props.placeholder ?? t('Select items...')
|
||||
|
||||
// Anchor the popup to the chips container so its width tracks the entire
|
||||
// input row, not just the leftover space at the end of wrapped chips.
|
||||
const chipsAnchorRef = useComboboxAnchor()
|
||||
|
||||
const [inputValue, setInputValue] = React.useState('')
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
const selectedSet = React.useMemo(
|
||||
() => new Set(props.selected),
|
||||
[props.selected]
|
||||
)
|
||||
|
||||
// Lookup of value -> display label so chips and items can show friendly names
|
||||
// even when the underlying option list changes (e.g. custom-added values).
|
||||
const labelMap = React.useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
for (const option of props.options) {
|
||||
map.set(option.value, option.label)
|
||||
}
|
||||
return map
|
||||
}, [props.options])
|
||||
|
||||
const trimmedInput = inputValue.trim()
|
||||
const inputMatchesExisting =
|
||||
trimmedInput.length > 0 &&
|
||||
(selectedSet.has(trimmedInput) ||
|
||||
props.options.some(
|
||||
(option) =>
|
||||
option.value.toLowerCase() === trimmedInput.toLowerCase() ||
|
||||
option.label.toLowerCase() === trimmedInput.toLowerCase()
|
||||
))
|
||||
|
||||
const canCreate =
|
||||
props.allowCreate === true &&
|
||||
trimmedInput.length > 0 &&
|
||||
!inputMatchesExisting
|
||||
|
||||
// We expose all known option values + every currently selected value to Base
|
||||
// UI's items list. This way Base UI filters them by the search query and the
|
||||
// user can still see the chip labels mapped correctly.
|
||||
const items = React.useMemo(() => {
|
||||
const set = new Set<string>(props.options.map((option) => option.value))
|
||||
for (const value of props.selected) {
|
||||
set.add(value)
|
||||
}
|
||||
if (canCreate) {
|
||||
set.add(trimmedInput)
|
||||
}
|
||||
return Array.from(set)
|
||||
}, [props.options, props.selected, canCreate, trimmedInput])
|
||||
|
||||
const addValues = React.useCallback(
|
||||
(values: string[]) => {
|
||||
const next: string[] = []
|
||||
const seen = new Set<string>(props.selected)
|
||||
for (const raw of values) {
|
||||
const value = raw.trim()
|
||||
if (!value) continue
|
||||
if (seen.has(value)) continue
|
||||
seen.add(value)
|
||||
next.push(value)
|
||||
}
|
||||
if (next.length === 0) return
|
||||
props.onChange([...props.selected, ...next])
|
||||
},
|
||||
[props]
|
||||
)
|
||||
|
||||
const handleInputValueChange = (value: string) => {
|
||||
if (!props.allowCreate) {
|
||||
setInputValue(value)
|
||||
return
|
||||
}
|
||||
const parsed = splitDraft(value)
|
||||
if (parsed.completed.length > 0) {
|
||||
addValues(parsed.completed)
|
||||
setInputValue(parsed.draft)
|
||||
return
|
||||
}
|
||||
setInputValue(value)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const input = inputRef.current
|
||||
if (input) {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (input.value === '' && selected.length > 0) {
|
||||
onChange(selected.slice(0, -1))
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
input.blur()
|
||||
const handleValueChange = (next: string[]) => {
|
||||
props.onChange(next)
|
||||
// When an item is picked (multiple mode), Base UI keeps the input but most
|
||||
// UX patterns clear it. Clearing once a value is added makes batch picking
|
||||
// feel snappier and matches popular chip-style multiselects.
|
||||
if (next.length > props.selected.length) {
|
||||
setInputValue('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// Enter without a highlighted option commits the typed value.
|
||||
if (event.key === 'Enter' && props.allowCreate && canCreate) {
|
||||
// Only fire when Base UI has no highlighted item to select. We rely on
|
||||
// the highlighted item's data attribute on the popup. If the popup is
|
||||
// closed or empty, manually commit the typed value.
|
||||
const popup = document.querySelector<HTMLElement>(
|
||||
'[data-slot="combobox-content"][data-open]'
|
||||
)
|
||||
const hasHighlight = popup?.querySelector('[data-highlighted]') != null
|
||||
if (!hasHighlight) {
|
||||
event.preventDefault()
|
||||
addValues([trimmedInput])
|
||||
setInputValue('')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selectables = options.filter(
|
||||
(option) => !selected.includes(option.value)
|
||||
)
|
||||
|
||||
return (
|
||||
<Command
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`overflow-visible bg-transparent ${className || ''}`}
|
||||
<Combobox
|
||||
multiple
|
||||
items={items}
|
||||
value={props.selected}
|
||||
onValueChange={handleValueChange}
|
||||
inputValue={inputValue}
|
||||
onInputValueChange={handleInputValueChange}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
<div className='group border-input ring-offset-background focus-within:ring-ring rounded-md border px-3 py-2 text-sm focus-within:ring-2 focus-within:ring-offset-2'>
|
||||
<div className='flex flex-wrap gap-1'>
|
||||
{selected.map((value) => {
|
||||
const option = options.find((o) => o.value === value)
|
||||
<ComboboxChips
|
||||
ref={chipsAnchorRef}
|
||||
className={cn('w-full', props.className)}
|
||||
>
|
||||
<ComboboxValue>
|
||||
{(values: string[]) => {
|
||||
const visibleValues =
|
||||
typeof props.maxVisibleChips === 'number'
|
||||
? values.slice(0, props.maxVisibleChips)
|
||||
: values
|
||||
const hiddenCount = values.length - visibleValues.length
|
||||
|
||||
return (
|
||||
<Badge key={value} variant='secondary'>
|
||||
{option?.label || value}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
aria-label='Remove'
|
||||
className='ml-1 size-auto p-0'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleUnselect(value)
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onClick={() => handleUnselect(value)}
|
||||
>
|
||||
<X
|
||||
className='text-muted-foreground hover:text-foreground h-3 w-3'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</Button>
|
||||
</Badge>
|
||||
<>
|
||||
{visibleValues.map((value) => (
|
||||
<ComboboxChip key={value}>
|
||||
<span className='max-w-[16rem] truncate'>
|
||||
{labelMap.get(value) ?? value}
|
||||
</span>
|
||||
</ComboboxChip>
|
||||
))}
|
||||
{hiddenCount > 0 && (
|
||||
<span className='bg-muted text-muted-foreground flex h-[calc(--spacing(5.25))] w-fit items-center justify-center rounded-sm px-1.5 text-xs font-medium whitespace-nowrap'>
|
||||
{t('+{{count}} more', { count: hiddenCount })}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})}
|
||||
<CommandPrimitive.Input
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
onBlur={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
placeholder={selected.length === 0 ? resolvedPlaceholder : ''}
|
||||
className='placeholder:text-muted-foreground flex-1 bg-transparent outline-none'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='relative'>
|
||||
{open && selectables.length > 0 ? (
|
||||
<div className='bg-popover text-popover-foreground animate-in absolute top-0 z-10 w-full rounded-md border shadow-md outline-none'>
|
||||
<CommandGroup className='h-full max-h-60 overflow-auto'>
|
||||
{selectables.map((option) => {
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onSelect={() => {
|
||||
setInputValue('')
|
||||
onChange([...selected, option.value])
|
||||
}}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Command>
|
||||
}}
|
||||
</ComboboxValue>
|
||||
<ComboboxChipsInput
|
||||
id={props.id}
|
||||
placeholder={props.selected.length === 0 ? placeholder : undefined}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-label={placeholder}
|
||||
/>
|
||||
</ComboboxChips>
|
||||
|
||||
<ComboboxContent anchor={chipsAnchorRef}>
|
||||
<ComboboxList>
|
||||
<ComboboxCollection>
|
||||
{(item: string) => {
|
||||
const isCreate = canCreate && item === trimmedInput
|
||||
const label = labelMap.get(item) ?? item
|
||||
return (
|
||||
<ComboboxItem
|
||||
key={item}
|
||||
value={item}
|
||||
className={isCreate ? 'text-foreground' : undefined}
|
||||
>
|
||||
{isCreate ? (
|
||||
<>
|
||||
<HugeiconsIcon
|
||||
icon={Add01Icon}
|
||||
strokeWidth={2}
|
||||
className='text-muted-foreground'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span className='truncate'>
|
||||
{props.createLabel
|
||||
? t(props.createLabel, { value: item })
|
||||
: t('Add "{{value}}"', { value: item })}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className='truncate'>{label}</span>
|
||||
)}
|
||||
</ComboboxItem>
|
||||
)
|
||||
}}
|
||||
</ComboboxCollection>
|
||||
</ComboboxList>
|
||||
<ComboboxEmpty>
|
||||
{props.emptyText ?? t('No matching items')}
|
||||
</ComboboxEmpty>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Bell } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface NotificationButtonProps {
|
||||
unreadCount: number
|
||||
onClick: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification bell button with unread badge
|
||||
* Displays in the app header next to theme switch and profile dropdown
|
||||
*/
|
||||
export function NotificationButton({
|
||||
unreadCount,
|
||||
onClick,
|
||||
className,
|
||||
}: NotificationButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className='relative'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={onClick}
|
||||
className={cn('h-9 w-9', className)}
|
||||
aria-label={t('Notifications')}
|
||||
>
|
||||
<Bell className='size-[1.2rem]' />
|
||||
</Button>
|
||||
|
||||
{unreadCount > 0 && (
|
||||
<Badge
|
||||
variant='destructive'
|
||||
className='absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center px-1 text-[10px] font-semibold tabular-nums'
|
||||
>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user