Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f53b557a17 | |||
| 4372abd787 | |||
| 9b633a4131 | |||
| ffb1e8e97a | |||
| fa334c1eb0 | |||
| fbcaf75b62 | |||
| 47e912123c | |||
| 4f03641ac7 | |||
| efc9c5844b | |||
| 5a5286967d | |||
| 80a54b5b4b | |||
| 6ba23572b2 | |||
| 378eed2bd4 | |||
| 61717ee53b | |||
| f738ee481c | |||
| 0f94c07f16 | |||
| d75e393b11 | |||
| eef921d188 | |||
| b6ad800e77 | |||
| c82242f0d2 | |||
| a8c19eec50 | |||
| 0f625f33a0 | |||
| 902593926a | |||
| b4e7c48e42 | |||
| f03c8cc709 | |||
| 6aba2b3eec | |||
| 80ee5244d9 | |||
| f87af88ca5 | |||
| 5816f69c20 | |||
| 3606367104 | |||
| 0339e36246 | |||
| 1f3eb1e419 | |||
| 6b5ee783f1 | |||
| 2f16326562 | |||
| 76469cb944 | |||
| 47d4d74bd6 | |||
| 59f3758175 | |||
| 0deab07bb6 | |||
| 7465f682f8 | |||
| 894f25ca51 | |||
| 12b103e9b6 | |||
| fdffe43533 | |||
| 809e1dce6d | |||
| 43c003e8e1 | |||
| b0bf0b949b | |||
| 2d94a24912 | |||
| a297c00cc3 | |||
| 5489c68eec | |||
| c40d00e740 | |||
| e6e86b8e8c | |||
| 3f2107fb6d | |||
| 8a3e353231 | |||
| 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 |
@@ -35,3 +35,4 @@ data/
|
||||
.test
|
||||
token_estimator_test.go
|
||||
skills-lock.json
|
||||
.playwright-mcp
|
||||
|
||||
@@ -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) {
|
||||
@@ -92,6 +96,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) {
|
||||
|
||||
@@ -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=
|
||||
|
||||
+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 {
|
||||
|
||||
+50
-39
@@ -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: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:''"`
|
||||
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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -665,6 +666,106 @@ 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("套餐价格不能为负数")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -442,10 +442,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" {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -179,7 +178,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)
|
||||
|
||||
@@ -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
+292
-338
File diff suppressed because it is too large
Load Diff
Vendored
+65
-51
@@ -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": "^5.8.0",
|
||||
"@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": "^1.16.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"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": "^1.11.20",
|
||||
"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": "^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": "^5.6.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.11.0",
|
||||
"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",
|
||||
"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": "^2.8.0",
|
||||
"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": "^3.8.3",
|
||||
"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
+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)
|
||||
|
||||
+254
-23
@@ -23,34 +23,66 @@ import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type { Element } from 'hast'
|
||||
import { CheckIcon, CopyIcon } from 'lucide-react'
|
||||
import {
|
||||
type BundledLanguage,
|
||||
codeToHtml,
|
||||
type ShikiTransformer,
|
||||
} from 'shiki/bundle/web'
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BundledLanguage, ShikiTransformer } from 'shiki'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string
|
||||
language: BundledLanguage
|
||||
collapsedLines?: number
|
||||
defaultCollapsed?: boolean
|
||||
enableCollapse?: boolean
|
||||
filename?: string
|
||||
language: BundledLanguage | string
|
||||
maxExpandedLines?: number
|
||||
/** @deprecated use collapsedLines for collapsed preview height. */
|
||||
maxCollapsedLines?: number
|
||||
showLineNumbers?: boolean
|
||||
showToolbar?: boolean
|
||||
title?: ReactNode
|
||||
}
|
||||
|
||||
type CodeBlockContextType = {
|
||||
code: string
|
||||
language: string
|
||||
}
|
||||
|
||||
const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
code: '',
|
||||
language: 'plaintext',
|
||||
})
|
||||
|
||||
const highlightCache = new Map<string, string>()
|
||||
|
||||
const LANGUAGE_ALIASES: Record<string, BundledLanguage> = {
|
||||
csharp: 'c#',
|
||||
golang: 'go',
|
||||
js: 'javascript',
|
||||
shell: 'bash',
|
||||
shellscript: 'bash',
|
||||
ts: 'typescript',
|
||||
}
|
||||
|
||||
const lineNumberTransformer: ShikiTransformer = {
|
||||
name: 'line-numbers',
|
||||
line(node: Element, line: number) {
|
||||
@@ -72,64 +104,251 @@ const lineNumberTransformer: ShikiTransformer = {
|
||||
},
|
||||
}
|
||||
|
||||
function getRequestedCodeLanguage(language?: string) {
|
||||
const normalized = language?.trim().toLowerCase() || 'plaintext'
|
||||
return LANGUAGE_ALIASES[normalized] ?? normalized
|
||||
}
|
||||
|
||||
async function normalizeCodeLanguage(language?: string) {
|
||||
const aliased = getRequestedCodeLanguage(language)
|
||||
const { bundledLanguages } = await import('shiki')
|
||||
if (aliased in bundledLanguages) {
|
||||
return aliased as BundledLanguage
|
||||
}
|
||||
|
||||
return 'plaintext'
|
||||
}
|
||||
|
||||
function escapeCodeHtml(code: string) {
|
||||
return code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function renderPlainCodeHtml(code: string, showLineNumbers: boolean) {
|
||||
const lines = code.split('\n')
|
||||
const renderedCode = lines
|
||||
.map((line, index) => {
|
||||
const escapedLine = escapeCodeHtml(line) || ' '
|
||||
if (!showLineNumbers) {
|
||||
return escapedLine
|
||||
}
|
||||
|
||||
return `<span class="inline-block min-w-10 mr-4 text-right select-none text-muted-foreground">${index + 1}</span>${escapedLine}`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
return `<pre class="shiki"><code>${renderedCode}</code></pre>`
|
||||
}
|
||||
|
||||
export async function highlightCode(
|
||||
code: string,
|
||||
language: BundledLanguage,
|
||||
language: BundledLanguage | string,
|
||||
showLineNumbers = false
|
||||
) {
|
||||
const resolvedLanguage = await normalizeCodeLanguage(language)
|
||||
const cacheKey = `${resolvedLanguage}:${showLineNumbers ? 'line' : 'plain'}:${code}`
|
||||
const cachedHtml = highlightCache.get(cacheKey)
|
||||
|
||||
if (cachedHtml) {
|
||||
return cachedHtml
|
||||
}
|
||||
|
||||
const transformers: ShikiTransformer[] = showLineNumbers
|
||||
? [lineNumberTransformer]
|
||||
: []
|
||||
|
||||
return codeToHtml(code, {
|
||||
lang: language,
|
||||
if (resolvedLanguage === 'plaintext') {
|
||||
const html = renderPlainCodeHtml(code, showLineNumbers)
|
||||
highlightCache.set(cacheKey, html)
|
||||
return html
|
||||
}
|
||||
|
||||
const { codeToHtml } = await import('shiki')
|
||||
const html = await codeToHtml(code, {
|
||||
lang: resolvedLanguage,
|
||||
themes: {
|
||||
light: 'one-light',
|
||||
dark: 'one-dark-pro',
|
||||
},
|
||||
defaultColor: false,
|
||||
transformers,
|
||||
})
|
||||
|
||||
highlightCache.set(cacheKey, html)
|
||||
return html
|
||||
}
|
||||
|
||||
function getCodeLineCount(code: string) {
|
||||
if (!code) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return code.split('\n').length
|
||||
}
|
||||
|
||||
function getDownloadFilename(language: string, filename?: string) {
|
||||
if (filename) {
|
||||
return filename
|
||||
}
|
||||
|
||||
const extension = language === 'plaintext' ? 'txt' : language
|
||||
return `code.${extension}`
|
||||
}
|
||||
|
||||
function getCodeBlockHeight(lines: number) {
|
||||
return `${Math.max(4, lines) * 1.5 + 2}rem`
|
||||
}
|
||||
|
||||
export const CodeBlock = ({
|
||||
code,
|
||||
collapsedLines = 12,
|
||||
defaultCollapsed,
|
||||
enableCollapse = true,
|
||||
filename,
|
||||
language,
|
||||
maxExpandedLines,
|
||||
maxCollapsedLines,
|
||||
showLineNumbers = false,
|
||||
showToolbar = false,
|
||||
title,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CodeBlockProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [html, setHtml] = useState<string>('')
|
||||
const [isCollapsed, setIsCollapsed] = useState(Boolean(defaultCollapsed))
|
||||
const displayLanguage = getRequestedCodeLanguage(language)
|
||||
const lineCount = useMemo(() => getCodeLineCount(code), [code])
|
||||
const previewLines = maxCollapsedLines ?? collapsedLines
|
||||
const canCollapse = enableCollapse && lineCount > previewLines
|
||||
const isCodeCollapsed = canCollapse && isCollapsed
|
||||
const displayTitle = title ?? displayLanguage
|
||||
const bodyMaxHeight = isCodeCollapsed
|
||||
? getCodeBlockHeight(previewLines)
|
||||
: maxExpandedLines
|
||||
? getCodeBlockHeight(maxExpandedLines)
|
||||
: undefined
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
highlightCode(code, language, showLineNumbers).then((next) => {
|
||||
if (!cancelled) {
|
||||
setHtml(next)
|
||||
}
|
||||
})
|
||||
highlightCode(code, language, showLineNumbers)
|
||||
.then((next) => {
|
||||
if (!cancelled) {
|
||||
setHtml(next)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setHtml(renderPlainCodeHtml(code, showLineNumbers))
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [code, language, showLineNumbers])
|
||||
|
||||
const downloadCode = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const blob = new Blob([code], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = getDownloadFilename(displayLanguage, filename)
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<CodeBlockContext.Provider value={{ code }}>
|
||||
<CodeBlockContext.Provider value={{ code, language: displayLanguage }}>
|
||||
<div
|
||||
className={cn(
|
||||
'group bg-background text-foreground relative w-full overflow-hidden rounded-md border',
|
||||
'group/code-block bg-muted/20 text-foreground my-3 w-full max-w-full overflow-hidden rounded-lg border shadow-xs',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className='relative'>
|
||||
{showToolbar && (
|
||||
<div className='bg-muted/35 border-border/70 flex min-h-10 items-center gap-2 border-b px-2 py-1.5'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='text-muted-foreground truncate font-mono text-[11px] font-medium tracking-wide uppercase'>
|
||||
{displayTitle}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center gap-1'>
|
||||
{canCollapse && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={
|
||||
isCodeCollapsed ? t('Expand') : t('Collapse')
|
||||
}
|
||||
className='size-8'
|
||||
onClick={() => setIsCollapsed((value) => !value)}
|
||||
size='icon-sm'
|
||||
type='button'
|
||||
variant='ghost'
|
||||
>
|
||||
{isCodeCollapsed ? (
|
||||
<ChevronRightIcon className='size-4' />
|
||||
) : (
|
||||
<ChevronDownIcon className='size-4' />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<p>{isCodeCollapsed ? t('Expand') : t('Collapse')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{children}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={t('Download')}
|
||||
className='size-8'
|
||||
onClick={downloadCode}
|
||||
size='icon-sm'
|
||||
type='button'
|
||||
variant='ghost'
|
||||
>
|
||||
<DownloadIcon className='size-4' />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<p>{t('Download')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className='relative min-w-0'>
|
||||
<div
|
||||
className='[&>pre]:bg-background! [&>pre]:text-foreground! overflow-hidden [&_code]:font-mono [&_code]:text-sm [&>pre]:m-0 [&>pre]:p-4 [&>pre]:text-sm'
|
||||
className={cn(
|
||||
'code-block-scroll max-w-full overflow-auto transition-[max-height] duration-200 ease-out',
|
||||
'[&_.shiki]:bg-transparent! [&_.shiki]:text-foreground! [&_code]:font-mono [&_code]:text-[13px] [&_code]:leading-6',
|
||||
'[&>pre]:m-0 [&>pre]:min-w-max [&>pre]:p-4 [&>pre]:text-[13px] [&>pre]:leading-6'
|
||||
)}
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
style={{ maxHeight: bodyMaxHeight }}
|
||||
/>
|
||||
{children && (
|
||||
<div className='absolute top-2 right-2 flex items-center gap-2'>
|
||||
{isCodeCollapsed && (
|
||||
<div className='from-muted/20 pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-linear-to-b to-background' />
|
||||
)}
|
||||
{!showToolbar && children && (
|
||||
<div className='absolute top-2 right-2 flex items-center gap-1'>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
@@ -153,6 +372,7 @@ export const CodeBlockCopyButton = ({
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockCopyButtonProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const { code } = useContext(CodeBlockContext)
|
||||
|
||||
@@ -174,15 +394,26 @@ export const CodeBlockCopyButton = ({
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon
|
||||
|
||||
return (
|
||||
const button = (
|
||||
<Button
|
||||
className={cn('shrink-0', className)}
|
||||
aria-label={isCopied ? t('Copied!') : t('Copy code')}
|
||||
className={cn('size-8 shrink-0', className)}
|
||||
onClick={copyToClipboard}
|
||||
size='icon'
|
||||
size='icon-sm'
|
||||
type='button'
|
||||
variant='ghost'
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={button} />
|
||||
<TooltipContent>
|
||||
<p>{isCopied ? t('Copied!') : t('Copy code')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ export type ConversationProps = ComponentProps<typeof StickToBottom>
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn('relative flex-1 overflow-y-auto', className)}
|
||||
className={cn('relative min-h-0 flex-1 overflow-hidden', className)}
|
||||
initial='smooth'
|
||||
resize='smooth'
|
||||
role='log'
|
||||
|
||||
+1
-1
@@ -188,7 +188,7 @@ export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'mt-4 text-sm',
|
||||
'border-border/70 mt-3 ml-2 border-l pl-4 text-sm',
|
||||
'data-closed:fade-out-0 data-closed:slide-out-to-top-2 data-open:slide-in-from-top-2 text-muted-foreground data-closed:animate-out data-open:animate-in outline-none',
|
||||
className
|
||||
)}
|
||||
|
||||
+436
-4
@@ -18,14 +18,436 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { type ComponentProps, memo } from 'react'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import {
|
||||
Children,
|
||||
type ComponentProps,
|
||||
type JSX,
|
||||
isValidElement,
|
||||
memo,
|
||||
type ReactNode,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { Streamdown, type Components } from 'streamdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
CodeBlock,
|
||||
CodeBlockCopyButton,
|
||||
} from '@/components/ai-elements/code-block'
|
||||
|
||||
type ResponseProps = ComponentProps<typeof Streamdown>
|
||||
|
||||
type CodeComponentProps = ComponentProps<'code'> & {
|
||||
node?: unknown
|
||||
'data-block'?: boolean
|
||||
}
|
||||
|
||||
type MarkdownElementProps<T extends keyof JSX.IntrinsicElements> =
|
||||
ComponentProps<T> & {
|
||||
node?: unknown
|
||||
}
|
||||
|
||||
function getCodeText(children: ReactNode) {
|
||||
if (typeof children === 'string') {
|
||||
return children.replace(/\n$/, '')
|
||||
}
|
||||
|
||||
if (Array.isArray(children)) {
|
||||
return children.join('').replace(/\n$/, '')
|
||||
}
|
||||
|
||||
return String(children ?? '')
|
||||
}
|
||||
|
||||
function getCodeLanguage(className?: string) {
|
||||
return className?.match(/language-([\w#+.-]+)/)?.[1] ?? 'plaintext'
|
||||
}
|
||||
|
||||
function isSummaryElement(child: ReactNode) {
|
||||
return isValidElement(child) && child.type === 'summary'
|
||||
}
|
||||
|
||||
function MarkdownImage({
|
||||
alt,
|
||||
className,
|
||||
node: _node,
|
||||
src,
|
||||
...props
|
||||
}: MarkdownElementProps<'img'>) {
|
||||
const [hasError, setHasError] = useState(false)
|
||||
|
||||
if (!src || hasError) {
|
||||
return (
|
||||
<span className='border-border/70 text-muted-foreground my-4 inline-flex rounded-md border px-3 py-2 text-xs italic'>
|
||||
{alt || 'Image not available'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
alt={alt}
|
||||
className={cn(
|
||||
'border-border/70 my-4 block h-auto max-h-96 max-w-full rounded-lg border object-contain',
|
||||
className
|
||||
)}
|
||||
loading='lazy'
|
||||
onError={() => setHasError(true)}
|
||||
src={src}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const responseComponents: Components = {
|
||||
h1({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'h1'>) {
|
||||
return (
|
||||
<h1
|
||||
className={cn(
|
||||
'mt-6 mb-3 text-xl font-semibold tracking-normal',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
)
|
||||
},
|
||||
h2({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'h2'>) {
|
||||
return (
|
||||
<h2
|
||||
className={cn(
|
||||
'mt-6 mb-3 text-lg font-semibold tracking-normal',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
)
|
||||
},
|
||||
h3({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'h3'>) {
|
||||
return (
|
||||
<h3
|
||||
className={cn(
|
||||
'mt-5 mb-2 text-base font-semibold tracking-normal',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
)
|
||||
},
|
||||
h4({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'h4'>) {
|
||||
return (
|
||||
<h4
|
||||
className={cn('mt-5 mb-2 text-sm font-semibold', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
)
|
||||
},
|
||||
h5({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'h5'>) {
|
||||
return (
|
||||
<h5
|
||||
className={cn(
|
||||
'text-muted-foreground mt-4 mb-2 text-sm font-semibold',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h5>
|
||||
)
|
||||
},
|
||||
h6({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'h6'>) {
|
||||
return (
|
||||
<h6
|
||||
className={cn(
|
||||
'text-muted-foreground mt-4 mb-2 text-xs font-semibold uppercase',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h6>
|
||||
)
|
||||
},
|
||||
ul({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'ul'>) {
|
||||
return (
|
||||
<ul
|
||||
className={cn(
|
||||
'my-3 list-outside list-disc space-y-1.5 pl-5',
|
||||
'[&.contains-task-list]:list-none [&.contains-task-list]:pl-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
)
|
||||
},
|
||||
ol({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'ol'>) {
|
||||
return (
|
||||
<ol
|
||||
className={cn(
|
||||
'my-3 list-outside list-decimal space-y-1.5 pl-5',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
)
|
||||
},
|
||||
li({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
'marker:text-muted-foreground pl-1 leading-7',
|
||||
'[&.task-list-item]:flex [&.task-list-item]:items-start [&.task-list-item]:gap-2 [&.task-list-item]:pl-0',
|
||||
'[&.task-list-item>input]:accent-primary [&.task-list-item>input]:mt-1.5 [&.task-list-item>input]:size-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
)
|
||||
},
|
||||
details({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'details'>) {
|
||||
const childArray = Children.toArray(children)
|
||||
const summaryChildren = childArray.filter(isSummaryElement)
|
||||
const contentChildren = childArray.filter(
|
||||
(child) => !isSummaryElement(child)
|
||||
)
|
||||
|
||||
return (
|
||||
<details className={cn('my-4', className)} {...props}>
|
||||
{summaryChildren}
|
||||
{contentChildren.length > 0 && (
|
||||
<div className='border-border/70 ml-5 border-l pl-4'>
|
||||
{contentChildren}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
)
|
||||
},
|
||||
summary({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'summary'>) {
|
||||
return (
|
||||
<summary
|
||||
className={cn(
|
||||
'text-foreground marker:text-muted-foreground mb-2 cursor-pointer text-sm font-semibold',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</summary>
|
||||
)
|
||||
},
|
||||
blockquote({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'blockquote'>) {
|
||||
return (
|
||||
<blockquote
|
||||
className={cn(
|
||||
'border-border text-muted-foreground my-4 border-l-2 pl-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
)
|
||||
},
|
||||
hr({ className, node: _node, ...props }: MarkdownElementProps<'hr'>) {
|
||||
return <hr className={cn('border-border/70 my-6', className)} {...props} />
|
||||
},
|
||||
img: MarkdownImage,
|
||||
table({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'table'>) {
|
||||
return (
|
||||
<div className='border-border/70 my-4 w-full overflow-x-auto rounded-lg border'>
|
||||
<table
|
||||
className={cn(
|
||||
'w-full min-w-max border-separate border-spacing-0 text-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
thead({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'thead'>) {
|
||||
return (
|
||||
<thead className={cn('bg-muted/60', className)} {...props}>
|
||||
{children}
|
||||
</thead>
|
||||
)
|
||||
},
|
||||
tbody({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'tbody'>) {
|
||||
return (
|
||||
<tbody className={cn('divide-border/70 divide-y', className)} {...props}>
|
||||
{children}
|
||||
</tbody>
|
||||
)
|
||||
},
|
||||
tr({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'tr'>) {
|
||||
return (
|
||||
<tr className={cn('border-border/70', className)} {...props}>
|
||||
{children}
|
||||
</tr>
|
||||
)
|
||||
},
|
||||
th({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'th'>) {
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
'text-muted-foreground px-3 py-2 text-left text-xs font-semibold whitespace-nowrap',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
)
|
||||
},
|
||||
td({
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownElementProps<'td'>) {
|
||||
return (
|
||||
<td className={cn('px-3 py-2 align-top', className)} {...props}>
|
||||
{children}
|
||||
</td>
|
||||
)
|
||||
},
|
||||
code({ children, className, ...props }: CodeComponentProps) {
|
||||
if (!props['data-block']) {
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
'bg-muted/70 text-foreground rounded px-1 py-0.5 font-mono text-[0.9em]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
const code = getCodeText(children)
|
||||
const language = getCodeLanguage(className)
|
||||
const lineCount = code.split('\n').length
|
||||
|
||||
return (
|
||||
<CodeBlock
|
||||
collapsedLines={14}
|
||||
code={code}
|
||||
defaultCollapsed={lineCount > 14}
|
||||
language={language}
|
||||
maxExpandedLines={44}
|
||||
showLineNumbers={true}
|
||||
showToolbar={true}
|
||||
title={language}
|
||||
>
|
||||
<CodeBlockCopyButton />
|
||||
</CodeBlock>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const Response = memo(
|
||||
({ className, children, ...props }: ResponseProps) => {
|
||||
({ className, children, components, ...props }: ResponseProps) => {
|
||||
const stripCustomTags = (input: unknown): unknown => {
|
||||
if (typeof input !== 'string') return input
|
||||
return (
|
||||
@@ -45,9 +467,19 @@ export const Response = memo(
|
||||
return (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||
'size-full min-w-0 text-pretty',
|
||||
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||
'[&_p]:my-3 [&_p]:leading-7',
|
||||
'[&_strong]:text-foreground [&_strong]:font-semibold',
|
||||
'[&_a]:text-primary [&_a]:underline-offset-4 hover:[&_a]:underline',
|
||||
'[&_details>summary~*]:border-border/70 [&_details]:my-4 [&_details>summary~*]:ml-5 [&_details>summary~*]:border-l [&_details>summary~*]:pl-4',
|
||||
'[&_summary]:text-foreground [&_summary::marker]:text-muted-foreground [&_summary]:mb-2 [&_summary]:cursor-pointer [&_summary]:text-sm [&_summary]:font-semibold',
|
||||
'[&_[data-streamdown=table-wrapper]]:border-0 [&_[data-streamdown=table-wrapper]]:bg-transparent [&_[data-streamdown=table-wrapper]]:p-0 [&_[data-streamdown=table-wrapper]]:shadow-none',
|
||||
'[&_[data-streamdown=table-wrapper]>div:first-child]:hidden',
|
||||
'[&_[data-streamdown=table-wrapper]>div:last-child]:border-border/70 [&_[data-streamdown=table-wrapper]>div:last-child]:rounded-lg',
|
||||
className
|
||||
)}
|
||||
components={{ ...responseComponents, ...components }}
|
||||
{...props}
|
||||
>
|
||||
{safeChildren}
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ export const SourcesContent = ({
|
||||
}: SourcesContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'mt-3 flex w-fit flex-col gap-2',
|
||||
'border-border/70 mt-3 ml-2 flex w-fit flex-col gap-2 border-l pl-4',
|
||||
'data-closed:fade-out-0 data-closed:slide-out-to-top-2 data-open:slide-in-from-top-2 data-closed:animate-out data-open:animate-in outline-none',
|
||||
className
|
||||
)}
|
||||
|
||||
+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>
|
||||
|
||||
+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>
|
||||
)
|
||||
}
|
||||
+114
-50
@@ -22,15 +22,23 @@ import { useTranslation } from 'react-i18next'
|
||||
import { getAnnouncementColorClass } from '@/lib/colors'
|
||||
import { formatDateTimeObject } from '@/lib/time'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { Markdown } from '@/components/ui/markdown'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
@@ -42,15 +50,16 @@ interface AnnouncementItem {
|
||||
publishDate?: string | Date
|
||||
}
|
||||
|
||||
interface NotificationDialogProps {
|
||||
interface NotificationPopoverProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
unreadCount: number
|
||||
activeTab: 'notice' | 'announcements'
|
||||
onTabChange: (tab: 'notice' | 'announcements') => void
|
||||
notice: string
|
||||
announcements: AnnouncementItem[]
|
||||
loading: boolean
|
||||
onCloseToday: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,7 +122,7 @@ function AnnouncementDot({ type }: { type?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'mt-1.5 inline-block h-2 w-2 shrink-0 rounded-full',
|
||||
'mt-1.5 inline-block size-2 shrink-0 rounded-full',
|
||||
getAnnouncementColorClass(type)
|
||||
)}
|
||||
/>
|
||||
@@ -123,11 +132,25 @@ function AnnouncementDot({ type }: { type?: string }) {
|
||||
/**
|
||||
* Empty state component
|
||||
*/
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
}) {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center py-12 text-center'>
|
||||
<p className='text-muted-foreground text-sm'>{message}</p>
|
||||
</div>
|
||||
<Empty className='min-h-48 border-0 p-4'>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>{icon}</EmptyMedia>
|
||||
<EmptyTitle>{title}</EmptyTitle>
|
||||
{description ? (
|
||||
<EmptyDescription>{description}</EmptyDescription>
|
||||
) : null}
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -144,15 +167,23 @@ function NoticeContent({
|
||||
t: TFunction
|
||||
}) {
|
||||
if (loading) {
|
||||
return <EmptyState message={t('Loading...')} />
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<Bell />}
|
||||
title={t('Loading...')}
|
||||
description={t('Latest platform updates and notices')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!notice) {
|
||||
return <EmptyState message={t('No announcements at this time')} />
|
||||
return (
|
||||
<EmptyState icon={<Bell />} title={t('No announcements at this time')} />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className='h-[50vh] pr-4'>
|
||||
<ScrollArea className='h-[min(52vh,28rem)] pr-3'>
|
||||
<Markdown>{notice}</Markdown>
|
||||
</ScrollArea>
|
||||
)
|
||||
@@ -171,16 +202,24 @@ function AnnouncementsContent({
|
||||
t: TFunction
|
||||
}) {
|
||||
if (loading) {
|
||||
return <EmptyState message={t('Loading...')} />
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<Megaphone />}
|
||||
title={t('Loading...')}
|
||||
description={t('Latest platform updates and notices')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (announcements.length === 0) {
|
||||
return <EmptyState message={t('No system announcements')} />
|
||||
return (
|
||||
<EmptyState icon={<Megaphone />} title={t('No system announcements')} />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className='h-[50vh] pr-4'>
|
||||
<div className='space-y-0'>
|
||||
<ScrollArea className='h-[min(52vh,28rem)] pr-3'>
|
||||
<div className='flex flex-col'>
|
||||
{announcements.map((item, idx) => {
|
||||
const publishDate = item.publishDate
|
||||
? new Date(item.publishDate)
|
||||
@@ -197,30 +236,27 @@ function AnnouncementsContent({
|
||||
<div className='py-3'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<AnnouncementDot type={item.type} />
|
||||
<div className='min-w-0 flex-1 space-y-2'>
|
||||
{/* Content */}
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-2'>
|
||||
<div className='text-sm'>
|
||||
<Markdown>{item.content || ''}</Markdown>
|
||||
</div>
|
||||
|
||||
{/* Extra info */}
|
||||
{item.extra && (
|
||||
{item.extra ? (
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
<Markdown>{item.extra}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Time */}
|
||||
{absoluteTime && (
|
||||
{absoluteTime ? (
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{relativeTime && `${relativeTime} • `}
|
||||
{relativeTime ? `${relativeTime} • ` : null}
|
||||
{absoluteTime}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{idx < announcements.length - 1 && <Separator />}
|
||||
{idx < announcements.length - 1 ? <Separator /> : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -230,25 +266,54 @@ function AnnouncementsContent({
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification dialog with Notice and Announcements tabs
|
||||
* Notification popover with Notice and Announcements tabs
|
||||
*/
|
||||
export function NotificationDialog({
|
||||
export function NotificationPopover({
|
||||
open,
|
||||
onOpenChange,
|
||||
unreadCount,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
notice,
|
||||
announcements,
|
||||
loading,
|
||||
onCloseToday,
|
||||
}: NotificationDialogProps) {
|
||||
className,
|
||||
}: NotificationPopoverProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='max-h-[90vh] sm:max-w-2xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('System Announcements')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className={cn('relative size-9', className)}
|
||||
aria-label={t('Notifications')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Bell className='size-[1.2rem]' />
|
||||
{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>
|
||||
) : null}
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
align='end'
|
||||
sideOffset={8}
|
||||
className='w-[min(26rem,calc(100vw-1rem))] gap-3 p-3'
|
||||
>
|
||||
<PopoverHeader className='gap-1 px-1'>
|
||||
<PopoverTitle>{t('System Announcements')}</PopoverTitle>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Latest platform updates and notices')}
|
||||
</p>
|
||||
</PopoverHeader>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
@@ -256,20 +321,20 @@ export function NotificationDialog({
|
||||
>
|
||||
<TabsList className='grid w-full grid-cols-2'>
|
||||
<TabsTrigger value='notice' className='gap-1.5'>
|
||||
<Bell className='h-3.5 w-3.5' />
|
||||
<Bell className='size-3.5' />
|
||||
{t('Notice')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='announcements' className='gap-1.5'>
|
||||
<Megaphone className='h-3.5 w-3.5' />
|
||||
<Megaphone className='size-3.5' />
|
||||
{t('Timeline')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='notice' className='mt-4'>
|
||||
<TabsContent value='notice' className='mt-2'>
|
||||
<NoticeContent notice={notice} loading={loading} t={t} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='announcements' className='mt-4'>
|
||||
<TabsContent value='announcements' className='mt-2'>
|
||||
<AnnouncementsContent
|
||||
announcements={announcements}
|
||||
loading={loading}
|
||||
@@ -278,13 +343,12 @@ export function NotificationDialog({
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter className='gap-2'>
|
||||
<Button variant='outline' onClick={onCloseToday}>
|
||||
{t('Close Today')}
|
||||
<div className='flex justify-end'>
|
||||
<Button size='sm' onClick={() => onOpenChange(false)}>
|
||||
{t('Close')}
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>{t('Close')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
+64
-12
@@ -74,9 +74,9 @@ export const textColorMap = {
|
||||
export type StatusVariant = keyof typeof dotColorMap
|
||||
|
||||
const sizeMap = {
|
||||
sm: 'text-xs gap-1.5',
|
||||
md: 'text-xs gap-1.5',
|
||||
lg: 'text-sm gap-2',
|
||||
sm: 'h-5 gap-1 px-1.5 text-xs leading-none',
|
||||
md: 'h-5 gap-1 px-1.5 text-xs leading-none',
|
||||
lg: 'h-6 gap-1.5 px-2 text-xs leading-none',
|
||||
} as const
|
||||
|
||||
export interface StatusBadgeProps extends Omit<
|
||||
@@ -87,7 +87,7 @@ export interface StatusBadgeProps extends Omit<
|
||||
children?: React.ReactNode
|
||||
icon?: LucideIcon
|
||||
pulse?: boolean
|
||||
/** When false, hides the leading dot */
|
||||
/** Kept for compatibility. Badges no longer render leading dots. */
|
||||
showDot?: boolean
|
||||
variant?: StatusVariant | null
|
||||
size?: 'sm' | 'md' | 'lg' | null
|
||||
@@ -131,12 +131,12 @@ export function StatusBadge({
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex w-fit shrink-0 items-center font-medium whitespace-nowrap',
|
||||
'inline-flex w-fit max-w-full shrink-0 items-center rounded-4xl font-medium tracking-normal whitespace-nowrap transition-colors',
|
||||
sizeMap[size ?? 'sm'],
|
||||
textColorMap[computedVariant],
|
||||
pulse && 'animate-pulse',
|
||||
copyable &&
|
||||
'cursor-pointer transition-opacity hover:opacity-70 active:scale-95',
|
||||
'cursor-copy hover:brightness-95 active:scale-95 dark:hover:brightness-110',
|
||||
className
|
||||
)}
|
||||
onClick={handleClick}
|
||||
@@ -152,37 +152,89 @@ export function StatusBadge({
|
||||
aria-hidden='true'
|
||||
/>
|
||||
)}
|
||||
{Icon && <Icon className='size-3 shrink-0' />}
|
||||
{Icon && <Icon className='size-3.5 shrink-0' />}
|
||||
{content}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export interface StatusBadgeListProps<T> extends Omit<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
'children'
|
||||
> {
|
||||
empty?: React.ReactNode
|
||||
getKey?: (item: T, index: number) => React.Key
|
||||
items: T[]
|
||||
max?: number
|
||||
moreLabel?: (remaining: number) => string
|
||||
renderItem: (item: T, index: number) => React.ReactNode
|
||||
}
|
||||
|
||||
export function StatusBadgeList<T>(props: StatusBadgeListProps<T>) {
|
||||
const {
|
||||
className,
|
||||
empty = <span className='text-muted-foreground text-xs'>-</span>,
|
||||
getKey,
|
||||
items,
|
||||
max = 2,
|
||||
moreLabel,
|
||||
renderItem,
|
||||
...domProps
|
||||
} = props
|
||||
|
||||
if (items.length === 0) {
|
||||
return empty
|
||||
}
|
||||
|
||||
const displayed = items.slice(0, max)
|
||||
const remaining = items.length - max
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex max-w-full items-center gap-1 overflow-hidden',
|
||||
className
|
||||
)}
|
||||
{...domProps}
|
||||
>
|
||||
{displayed.map((item, index) => (
|
||||
<React.Fragment key={getKey?.(item, index) ?? index}>
|
||||
{renderItem(item, index)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<StatusBadge
|
||||
label={moreLabel?.(remaining) ?? `+${remaining}`}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='shrink-0'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const statusPresets = {
|
||||
active: {
|
||||
variant: 'success' as const,
|
||||
label: 'Active',
|
||||
showDot: true,
|
||||
},
|
||||
inactive: {
|
||||
variant: 'neutral' as const,
|
||||
label: 'Inactive',
|
||||
showDot: true,
|
||||
},
|
||||
invited: {
|
||||
variant: 'info' as const,
|
||||
label: 'Invited',
|
||||
showDot: true,
|
||||
},
|
||||
suspended: {
|
||||
variant: 'danger' as const,
|
||||
label: 'Suspended',
|
||||
showDot: true,
|
||||
},
|
||||
pending: {
|
||||
variant: 'warning' as const,
|
||||
label: 'Pending',
|
||||
showDot: true,
|
||||
pulse: true,
|
||||
},
|
||||
} as const
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
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 { cn } from '@/lib/utils'
|
||||
|
||||
type TableIdProps = {
|
||||
className?: string
|
||||
value: number | string
|
||||
}
|
||||
|
||||
export function TableId(props: TableIdProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'text-muted-foreground inline-block font-mono tabular-nums',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.value}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -107,7 +107,7 @@ function Calendar({
|
||||
: 'flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground',
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: 'w-full border-collapse',
|
||||
month_grid: 'w-full border-collapse',
|
||||
weekdays: cn('flex', defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
'flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none',
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ function DrawerContent({
|
||||
<DrawerPrimitive.Content
|
||||
data-slot='drawer-content'
|
||||
className={cn(
|
||||
'group/drawer-content bg-popover text-popover-foreground fixed z-50 flex h-auto flex-col text-sm data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm',
|
||||
'group/drawer-content bg-background text-foreground fixed z-50 flex h-auto flex-col overflow-hidden text-sm shadow-none data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
+99
-1
@@ -31,7 +31,99 @@ import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
const Form = FormProvider
|
||||
type FormRootContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormRootContext = React.createContext<FormRootContextValue | null>(null)
|
||||
|
||||
function getFormScopedSelector(formId: string, selector: string): string {
|
||||
return `[data-form-root="${formId}"]${selector}`
|
||||
}
|
||||
|
||||
function hasFormErrors(errors: unknown): boolean {
|
||||
return (
|
||||
typeof errors === 'object' &&
|
||||
errors !== null &&
|
||||
Object.keys(errors).length > 0
|
||||
)
|
||||
}
|
||||
|
||||
function getFirstFormErrorTarget(
|
||||
invalidControl: HTMLElement | null,
|
||||
errorMessage: HTMLElement | null
|
||||
): HTMLElement | null {
|
||||
if (!invalidControl) return errorMessage
|
||||
if (!errorMessage) return invalidControl
|
||||
|
||||
const position = invalidControl.compareDocumentPosition(errorMessage)
|
||||
return position & Node.DOCUMENT_POSITION_PRECEDING
|
||||
? errorMessage
|
||||
: invalidControl
|
||||
}
|
||||
|
||||
function FormValidationFocus() {
|
||||
const formContext = React.useContext(FormRootContext)
|
||||
const { control } = useFormContext()
|
||||
const { errors, submitCount } = useFormState({ control })
|
||||
const handledSubmitCountRef = React.useRef(0)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!formContext || submitCount === 0 || !hasFormErrors(errors)) return
|
||||
if (handledSubmitCountRef.current === submitCount) return
|
||||
|
||||
handledSubmitCountRef.current = submitCount
|
||||
|
||||
const animationFrameId = window.requestAnimationFrame(() => {
|
||||
const invalidControl = document.querySelector<HTMLElement>(
|
||||
getFormScopedSelector(formContext.id, '[aria-invalid="true"]')
|
||||
)
|
||||
const errorMessage = document.querySelector<HTMLElement>(
|
||||
getFormScopedSelector(formContext.id, '[data-slot="form-message"]')
|
||||
)
|
||||
const target = getFirstFormErrorTarget(invalidControl, errorMessage)
|
||||
if (!target) return
|
||||
|
||||
const formItem = target.closest<HTMLElement>(
|
||||
getFormScopedSelector(formContext.id, '[data-slot="form-item"]')
|
||||
)
|
||||
const scrollTarget = formItem ?? target
|
||||
const focusTarget =
|
||||
target === invalidControl
|
||||
? invalidControl
|
||||
: (formItem?.querySelector<HTMLElement>(
|
||||
'[aria-invalid="true"], input, textarea, select, button, [tabindex]:not([tabindex="-1"])'
|
||||
) ?? null)
|
||||
|
||||
scrollTarget.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
focusTarget?.focus({ preventScroll: true })
|
||||
})
|
||||
|
||||
return () => window.cancelAnimationFrame(animationFrameId)
|
||||
}, [errors, formContext, submitCount])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function Form<TFieldValues extends FieldValues = FieldValues>({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof FormProvider<TFieldValues>>) {
|
||||
const reactId = React.useId()
|
||||
const id = React.useMemo(
|
||||
() => `form-${reactId.replaceAll(/[^a-zA-Z0-9_-]/g, '_')}`,
|
||||
[reactId]
|
||||
)
|
||||
|
||||
return (
|
||||
<FormRootContext.Provider value={{ id }}>
|
||||
<FormProvider {...props}>
|
||||
<FormValidationFocus />
|
||||
{children}
|
||||
</FormProvider>
|
||||
</FormRootContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
@@ -90,11 +182,13 @@ const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
const id = React.useId()
|
||||
const formContext = React.useContext(FormRootContext)
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot='form-item'
|
||||
data-form-root={formContext?.id}
|
||||
className={cn('grid gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -124,11 +218,13 @@ function FormControl({
|
||||
...props
|
||||
}: { children: React.ReactElement } & Record<string, unknown>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
const formContext = React.useContext(FormRootContext)
|
||||
|
||||
return useRender({
|
||||
render: children,
|
||||
props: {
|
||||
'data-slot': 'form-control',
|
||||
'data-form-root': formContext?.id,
|
||||
id: formItemId,
|
||||
'aria-describedby': !error
|
||||
? `${formDescriptionId}`
|
||||
@@ -154,6 +250,7 @@ function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const formContext = React.useContext(FormRootContext)
|
||||
const { t } = useTranslation()
|
||||
const body = error ? String(error?.message ?? '') : props.children
|
||||
|
||||
@@ -166,6 +263,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<p
|
||||
data-slot='form-message'
|
||||
data-form-root={formContext?.id}
|
||||
id={formMessageId}
|
||||
className={cn('text-destructive text-sm', className)}
|
||||
{...props}
|
||||
|
||||
+31
-24
@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { Select as SelectPrimitive } from '@base-ui/react/select'
|
||||
import {
|
||||
UnfoldMoreIcon,
|
||||
@@ -97,32 +98,38 @@ function SelectContent({
|
||||
SelectPrimitive.Positioner.Props,
|
||||
'align' | 'alignOffset' | 'side' | 'sideOffset' | 'alignItemWithTrigger'
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className='isolate z-50'
|
||||
const isMobile = useMediaQuery('(max-width: 640px)')
|
||||
|
||||
const content = (
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className='isolate z-50'
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot='select-content'
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg shadow-md ring-1 duration-100 data-[align-trigger=true]:animate-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot='select-content'
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg shadow-md ring-1 duration-100 data-[align-trigger=true]:animate-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
)
|
||||
|
||||
if (isMobile) {
|
||||
return content
|
||||
}
|
||||
|
||||
return <SelectPrimitive.Portal>{content}</SelectPrimitive.Portal>
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ function SheetContent({
|
||||
data-slot='sheet-content'
|
||||
data-side={side}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0',
|
||||
'bg-background text-foreground fixed z-50 flex flex-col gap-4 overflow-hidden bg-clip-padding text-sm shadow-none transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0',
|
||||
side === 'right' &&
|
||||
'inset-y-0 right-0 h-full w-3/4 border-l data-ending-style:translate-x-[2.5rem] data-starting-style:translate-x-[2.5rem] sm:max-w-sm',
|
||||
side === 'left' &&
|
||||
|
||||
+13
-10
@@ -40,6 +40,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
@@ -418,7 +419,7 @@ function SidebarGroupLabel({
|
||||
props: mergeProps<'div'>(
|
||||
{
|
||||
className: cn(
|
||||
'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
className
|
||||
),
|
||||
},
|
||||
@@ -556,15 +557,17 @@ function SidebarMenuButton({
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
{comp}
|
||||
<TooltipContent
|
||||
side='right'
|
||||
align='center'
|
||||
hidden={state !== 'collapsed' || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
<TooltipProvider delay={0}>
|
||||
<Tooltip>
|
||||
{comp}
|
||||
<TooltipContent
|
||||
side='right'
|
||||
align='center'
|
||||
hidden={state !== 'collapsed' || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+23
-9
@@ -26,15 +26,15 @@ import {
|
||||
Loading03Icon,
|
||||
} from '@hugeicons/core-free-icons'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { Toaster as Sonner, type ToasterProps } from 'sonner'
|
||||
import { useTheme } from '@/context/theme-provider'
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = 'system' } = useTheme()
|
||||
const Toaster = (props: ToasterProps) => {
|
||||
const { resolvedTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps['theme']}
|
||||
theme={resolvedTheme}
|
||||
className='toaster group'
|
||||
icons={{
|
||||
success: (
|
||||
@@ -78,14 +78,28 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
'--normal-bg': 'var(--popover)',
|
||||
'--normal-text': 'var(--popover-foreground)',
|
||||
'--normal-border': 'var(--border)',
|
||||
'--success-bg':
|
||||
'color-mix(in oklch, var(--success) 16%, var(--popover))',
|
||||
'--success-border':
|
||||
'color-mix(in oklch, var(--success) 35%, var(--border))',
|
||||
'--success-text': 'var(--success)',
|
||||
'--info-bg': 'color-mix(in oklch, var(--info) 16%, var(--popover))',
|
||||
'--info-border':
|
||||
'color-mix(in oklch, var(--info) 35%, var(--border))',
|
||||
'--info-text': 'var(--info)',
|
||||
'--warning-bg':
|
||||
'color-mix(in oklch, var(--warning) 18%, var(--popover))',
|
||||
'--warning-border':
|
||||
'color-mix(in oklch, var(--warning) 38%, var(--border))',
|
||||
'--warning-text': 'var(--warning)',
|
||||
'--error-bg':
|
||||
'color-mix(in oklch, var(--destructive) 16%, var(--popover))',
|
||||
'--error-border':
|
||||
'color-mix(in oklch, var(--destructive) 35%, var(--border))',
|
||||
'--error-text': 'var(--destructive)',
|
||||
'--border-radius': 'var(--radius)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: 'cn-toast',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
+5
-2
@@ -25,11 +25,14 @@ function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='table-container'
|
||||
className='relative w-full overflow-x-auto'
|
||||
className='relative w-full overflow-x-auto overflow-y-hidden'
|
||||
>
|
||||
<table
|
||||
data-slot='table'
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
className={cn(
|
||||
'w-full caption-bottom text-sm tabular-nums [&_td]:text-sm [&_td_*]:text-sm [&_th]:text-sm [&_th_*]:text-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+37
-2
@@ -29,11 +29,14 @@ import {
|
||||
CONTENT_LAYOUT_VALUES,
|
||||
type ContentLayout,
|
||||
DEFAULT_THEME_CUSTOMIZATION,
|
||||
resolveThemeFont,
|
||||
THEME_COOKIE_KEYS,
|
||||
THEME_FONT_VALUES,
|
||||
THEME_PRESET_VALUES,
|
||||
THEME_RADIUS_VALUES,
|
||||
THEME_SCALE_VALUES,
|
||||
type ThemeCustomization,
|
||||
type ThemeFont,
|
||||
type ThemePreset,
|
||||
type ThemeRadius,
|
||||
type ThemeScale,
|
||||
@@ -65,6 +68,7 @@ type ThemeCustomizationContextType = {
|
||||
defaults: ThemeCustomization
|
||||
customization: ThemeCustomization
|
||||
setPreset: (preset: ThemePreset) => void
|
||||
setFont: (font: ThemeFont) => void
|
||||
setRadius: (radius: ThemeRadius) => void
|
||||
setScale: (scale: ThemeScale) => void
|
||||
setContentLayout: (contentLayout: ContentLayout) => void
|
||||
@@ -79,6 +83,7 @@ const FALLBACK_CONTEXT: ThemeCustomizationContextType = {
|
||||
defaults: DEFAULT_THEME_CUSTOMIZATION,
|
||||
customization: DEFAULT_THEME_CUSTOMIZATION,
|
||||
setPreset: () => {},
|
||||
setFont: () => {},
|
||||
setRadius: () => {},
|
||||
setScale: () => {},
|
||||
setContentLayout: () => {},
|
||||
@@ -98,6 +103,13 @@ export function ThemeCustomizationProvider(props: {
|
||||
DEFAULT_THEME_CUSTOMIZATION.preset
|
||||
)
|
||||
)
|
||||
const [font, _setFont] = useState<ThemeFont>(() =>
|
||||
readCookie<ThemeFont>(
|
||||
THEME_COOKIE_KEYS.font,
|
||||
THEME_FONT_VALUES,
|
||||
DEFAULT_THEME_CUSTOMIZATION.font
|
||||
)
|
||||
)
|
||||
const [radius, _setRadius] = useState<ThemeRadius>(() =>
|
||||
readCookie<ThemeRadius>(
|
||||
THEME_COOKIE_KEYS.radius,
|
||||
@@ -129,6 +141,16 @@ export function ThemeCustomizationProvider(props: {
|
||||
)
|
||||
}, [preset])
|
||||
|
||||
// Font is the one axis where we resolve before writing the attribute:
|
||||
// the persisted preference may be `default`, but CSS works in terms of
|
||||
// the concrete `sans`/`serif` choice that should drive the cascade.
|
||||
// Resolving here (instead of in CSS via `:not()` selectors) keeps the
|
||||
// stylesheet to one simple `[data-theme-font='serif']` selector and lets
|
||||
// future presets opt into typography via `PRESET_DEFAULT_FONT` alone.
|
||||
useEffect(() => {
|
||||
applyAttribute('data-theme-font', resolveThemeFont(font, preset))
|
||||
}, [font, preset])
|
||||
|
||||
useEffect(() => {
|
||||
applyAttribute(
|
||||
'data-theme-radius',
|
||||
@@ -156,6 +178,15 @@ export function ThemeCustomizationProvider(props: {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setFont = useCallback((value: ThemeFont) => {
|
||||
_setFont(value)
|
||||
if (value === DEFAULT_THEME_CUSTOMIZATION.font) {
|
||||
removeCookie(THEME_COOKIE_KEYS.font)
|
||||
} else {
|
||||
setCookie(THEME_COOKIE_KEYS.font, value, COOKIE_MAX_AGE)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setRadius = useCallback((value: ThemeRadius) => {
|
||||
_setRadius(value)
|
||||
if (value === DEFAULT_THEME_CUSTOMIZATION.radius) {
|
||||
@@ -185,16 +216,18 @@ export function ThemeCustomizationProvider(props: {
|
||||
|
||||
const resetCustomization = useCallback(() => {
|
||||
setPreset(DEFAULT_THEME_CUSTOMIZATION.preset)
|
||||
setFont(DEFAULT_THEME_CUSTOMIZATION.font)
|
||||
setRadius(DEFAULT_THEME_CUSTOMIZATION.radius)
|
||||
setScale(DEFAULT_THEME_CUSTOMIZATION.scale)
|
||||
setContentLayout(DEFAULT_THEME_CUSTOMIZATION.contentLayout)
|
||||
}, [setPreset, setRadius, setScale, setContentLayout])
|
||||
}, [setPreset, setFont, setRadius, setScale, setContentLayout])
|
||||
|
||||
const value = useMemo<ThemeCustomizationContextType>(
|
||||
() => ({
|
||||
defaults: DEFAULT_THEME_CUSTOMIZATION,
|
||||
customization: { preset, radius, scale, contentLayout },
|
||||
customization: { preset, font, radius, scale, contentLayout },
|
||||
setPreset,
|
||||
setFont,
|
||||
setRadius,
|
||||
setScale,
|
||||
setContentLayout,
|
||||
@@ -202,10 +235,12 @@ export function ThemeCustomizationProvider(props: {
|
||||
}),
|
||||
[
|
||||
preset,
|
||||
font,
|
||||
radius,
|
||||
scale,
|
||||
contentLayout,
|
||||
setPreset,
|
||||
setFont,
|
||||
setRadius,
|
||||
setScale,
|
||||
setContentLayout,
|
||||
|
||||
+11
-2
@@ -67,6 +67,7 @@ export function ForgotPasswordForm({
|
||||
resolver: zodResolver(forgotPasswordFormSchema),
|
||||
defaultValues: { email: '' },
|
||||
})
|
||||
const turnstileReady = !isTurnstileEnabled || Boolean(turnstileToken)
|
||||
|
||||
async function onSubmit(data: z.infer<typeof forgotPasswordFormSchema>) {
|
||||
if (!validateTurnstile()) return
|
||||
@@ -78,6 +79,8 @@ export function ForgotPasswordForm({
|
||||
form.reset()
|
||||
startCountdown()
|
||||
toast.success(t('Reset email sent, please check your inbox'))
|
||||
} else {
|
||||
toast.error(res?.message || t('Failed to send reset email'))
|
||||
}
|
||||
} catch (_error) {
|
||||
// Errors are handled by global interceptor
|
||||
@@ -107,8 +110,14 @@ export function ForgotPasswordForm({
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type='submit' className='mt-2' disabled={isLoading || isActive}>
|
||||
{isActive ? `Resend (${secondsLeft}s)` : 'Send reset email'}
|
||||
<Button
|
||||
type='submit'
|
||||
className='mt-2'
|
||||
disabled={isLoading || isActive || !turnstileReady}
|
||||
>
|
||||
{isActive
|
||||
? t('Resend ({{seconds}}s)', { seconds: secondsLeft })
|
||||
: t('Send reset email')}
|
||||
{isLoading ? <Loader2 className='animate-spin' /> : <ArrowRight />}
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -61,6 +61,9 @@ export function useEmailVerification(options?: UseEmailVerificationOptions) {
|
||||
toast.success(i18next.t('Verification email sent'))
|
||||
return true
|
||||
}
|
||||
toast.error(
|
||||
res?.message || i18next.t('Failed to send verification email')
|
||||
)
|
||||
return false
|
||||
} catch (_error) {
|
||||
// Errors are handled by global interceptor
|
||||
|
||||
+115
-86
@@ -81,6 +81,10 @@ export function UserAuthForm({
|
||||
const passkeyLoginEnabled = Boolean(
|
||||
status?.passkey_login ?? status?.data?.passkey_login
|
||||
)
|
||||
const passwordLoginEnabled =
|
||||
(status?.password_login_enabled ??
|
||||
status?.data?.password_login_enabled ??
|
||||
true) !== false
|
||||
const {
|
||||
isTurnstileEnabled,
|
||||
turnstileSiteKey,
|
||||
@@ -98,6 +102,16 @@ export function UserAuthForm({
|
||||
!passkeySupported ||
|
||||
(requiresLegalConsent && !agreedToLegal)
|
||||
const hasWeChatLogin = Boolean(status?.wechat_login)
|
||||
const hasOAuthLogin = Boolean(
|
||||
status?.github_oauth ||
|
||||
status?.discord_oauth ||
|
||||
status?.oidc_enabled ||
|
||||
status?.linuxdo_oauth ||
|
||||
status?.telegram_oauth ||
|
||||
(status?.custom_oauth_providers?.length ?? 0) > 0
|
||||
)
|
||||
const hasAlternativeLogin =
|
||||
passkeyLoginEnabled || hasWeChatLogin || hasOAuthLogin
|
||||
|
||||
useEffect(() => {
|
||||
if (requiresLegalConsent) {
|
||||
@@ -275,6 +289,42 @@ export function UserAuthForm({
|
||||
}
|
||||
}
|
||||
|
||||
const alternativeLoginMethods = (
|
||||
<>
|
||||
{passkeyLoginEnabled && (
|
||||
<div className='mt-2 space-y-1'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
disabled={passkeyButtonDisabled}
|
||||
onClick={handlePasskeyLogin}
|
||||
className='h-11 w-full justify-center gap-2 rounded-lg'
|
||||
>
|
||||
{isPasskeyLoading ? (
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<KeyRound className='h-4 w-4' />
|
||||
)}
|
||||
{t('Sign in with Passkey')}
|
||||
</Button>
|
||||
{!passkeySupported && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Passkey is not supported on this device.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OAuth Providers */}
|
||||
<OAuthProviders
|
||||
status={status}
|
||||
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
|
||||
onWeChatLogin={hasWeChatLogin ? handleOpenWeChatDialog : undefined}
|
||||
isWeChatLoading={isWeChatSubmitting}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -282,63 +332,72 @@ export function UserAuthForm({
|
||||
className={cn('grid gap-4', className)}
|
||||
{...props}
|
||||
>
|
||||
{/* Username Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='username'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Username or Email')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('Enter your username or email')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{hasAlternativeLogin && alternativeLoginMethods}
|
||||
|
||||
{/* Password Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem className='relative'>
|
||||
<FormLabel>{t('Password')}</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder={t('Enter password')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<Link
|
||||
to='/forgot-password'
|
||||
className='text-muted-foreground absolute end-0 -top-0.5 z-10 text-sm font-medium hover:opacity-75'
|
||||
>
|
||||
{t('Forgot password?')}
|
||||
</Link>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type='submit'
|
||||
className='mt-2 w-full justify-center gap-2'
|
||||
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
|
||||
>
|
||||
{isLoading ? <Loader2 className='animate-spin' /> : <LogIn />}
|
||||
{t('Sign in')}
|
||||
</Button>
|
||||
|
||||
{/* Turnstile */}
|
||||
{isTurnstileEnabled && (
|
||||
<div className='mt-2'>
|
||||
<Turnstile
|
||||
siteKey={turnstileSiteKey}
|
||||
onVerify={setTurnstileToken}
|
||||
{passwordLoginEnabled && (
|
||||
<>
|
||||
{/* Username Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='username'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Username or Email')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('Enter your username or email')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem className='relative'>
|
||||
<FormLabel>{t('Password')}</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
placeholder={t('Enter password')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<Link
|
||||
to='/forgot-password'
|
||||
className='text-muted-foreground absolute end-0 -top-0.5 z-10 text-sm font-medium hover:opacity-75'
|
||||
>
|
||||
{t('Forgot password?')}
|
||||
</Link>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type='submit'
|
||||
className='mt-2 w-full justify-center gap-2'
|
||||
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
|
||||
>
|
||||
{isLoading ? <Loader2 className='animate-spin' /> : <LogIn />}
|
||||
{t('Sign in')}
|
||||
</Button>
|
||||
|
||||
{/* Turnstile */}
|
||||
{isTurnstileEnabled && (
|
||||
<div className='mt-2'>
|
||||
<Turnstile
|
||||
siteKey={turnstileSiteKey}
|
||||
onVerify={setTurnstileToken}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<LegalConsent
|
||||
@@ -348,37 +407,7 @@ export function UserAuthForm({
|
||||
className='mt-1'
|
||||
/>
|
||||
|
||||
{passkeyLoginEnabled && (
|
||||
<div className='mt-2 space-y-1'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
disabled={passkeyButtonDisabled}
|
||||
onClick={handlePasskeyLogin}
|
||||
className='h-11 w-full justify-center gap-2 rounded-lg'
|
||||
>
|
||||
{isPasskeyLoading ? (
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<KeyRound className='h-4 w-4' />
|
||||
)}
|
||||
{t('Sign in with Passkey')}
|
||||
</Button>
|
||||
{!passkeySupported && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Passkey is not supported on this device.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OAuth Providers */}
|
||||
<OAuthProviders
|
||||
status={status}
|
||||
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
|
||||
onWeChatLogin={hasWeChatLogin ? handleOpenWeChatDialog : undefined}
|
||||
isWeChatLoading={isWeChatSubmitting}
|
||||
/>
|
||||
{!hasAlternativeLogin && alternativeLoginMethods}
|
||||
</form>
|
||||
|
||||
{hasWeChatLogin && (
|
||||
|
||||
+13
-12
@@ -35,18 +35,19 @@ export function SignIn() {
|
||||
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
|
||||
{t('Sign in')}
|
||||
</h2>
|
||||
{!status?.self_use_mode_enabled && status?.register_enabled !== false && (
|
||||
<p className='text-muted-foreground text-left text-sm sm:text-base'>
|
||||
{t("Don't have an account?")}{' '}
|
||||
<Link
|
||||
to='/sign-up'
|
||||
className='hover:text-primary font-medium underline underline-offset-4'
|
||||
>
|
||||
{t('Sign up')}
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
{!status?.self_use_mode_enabled &&
|
||||
status?.register_enabled !== false && (
|
||||
<p className='text-muted-foreground text-left text-sm sm:text-base'>
|
||||
{t("Don't have an account?")}{' '}
|
||||
<Link
|
||||
to='/sign-up'
|
||||
className='hover:text-primary font-medium underline underline-offset-4'
|
||||
>
|
||||
{t('Sign up')}
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<UserAuthForm redirectTo={redirect} />
|
||||
|
||||
@@ -53,7 +53,10 @@ import { registerFormSchema } from '@/features/auth/constants'
|
||||
import { useAuthRedirect } from '@/features/auth/hooks/use-auth-redirect'
|
||||
import { useEmailVerification } from '@/features/auth/hooks/use-email-verification'
|
||||
import { useTurnstile } from '@/features/auth/hooks/use-turnstile'
|
||||
import { getAffiliateCode } from '@/features/auth/lib/storage'
|
||||
import {
|
||||
getAffiliateCode,
|
||||
saveAffiliateCode,
|
||||
} from '@/features/auth/lib/storage'
|
||||
|
||||
export function SignUpForm({
|
||||
className,
|
||||
@@ -107,6 +110,7 @@ export function SignUpForm({
|
||||
status?.data?.oauth_register_enabled ??
|
||||
true
|
||||
const hasWeChatLogin = Boolean(status?.wechat_login)
|
||||
const turnstileReady = !isTurnstileEnabled || Boolean(turnstileToken)
|
||||
|
||||
const wechatQrCodeUrl = useMemo(() => {
|
||||
return (
|
||||
@@ -130,6 +134,13 @@ export function SignUpForm({
|
||||
}
|
||||
}, [requiresLegalConsent])
|
||||
|
||||
useEffect(() => {
|
||||
const aff = new URLSearchParams(window.location.search).get('aff')?.trim()
|
||||
if (aff) {
|
||||
saveAffiliateCode(aff)
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function onSubmit(data: z.infer<typeof registerFormSchema>) {
|
||||
if (requiresLegalConsent && !agreedToLegal) {
|
||||
toast.error(legalConsentErrorMessage)
|
||||
@@ -164,6 +175,8 @@ export function SignUpForm({
|
||||
if (res?.success) {
|
||||
toast.success(t('Account created! Please sign in'))
|
||||
redirectToLogin()
|
||||
} else {
|
||||
toast.error(res?.message || t('Failed to create account'))
|
||||
}
|
||||
} catch (_error) {
|
||||
// Errors are handled by global interceptor
|
||||
@@ -307,7 +320,13 @@ export function SignUpForm({
|
||||
<Button
|
||||
variant='outline'
|
||||
type='button'
|
||||
disabled={isLoading || isSendingCode || isActive || !emailValue}
|
||||
disabled={
|
||||
isLoading ||
|
||||
isSendingCode ||
|
||||
isActive ||
|
||||
!emailValue ||
|
||||
!turnstileReady
|
||||
}
|
||||
onClick={handleSendVerificationCode}
|
||||
>
|
||||
{isActive ? (
|
||||
@@ -319,7 +338,6 @@ export function SignUpForm({
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -344,7 +362,11 @@ export function SignUpForm({
|
||||
<Button
|
||||
type='submit'
|
||||
className='mt-2 w-full justify-center gap-2'
|
||||
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
|
||||
disabled={
|
||||
isLoading ||
|
||||
(requiresLegalConsent && !agreedToLegal) ||
|
||||
!turnstileReady
|
||||
}
|
||||
>
|
||||
{isLoading ? <Loader2 className='h-4 w-4 animate-spin' /> : null}
|
||||
{t('Create account')}
|
||||
|
||||
+2
@@ -126,6 +126,7 @@ export interface SystemStatus {
|
||||
privacy_policy_enabled?: boolean
|
||||
oauth_register_enabled?: boolean
|
||||
register_enabled?: boolean
|
||||
password_login_enabled?: boolean
|
||||
password_register_enabled?: boolean
|
||||
custom_oauth_providers?: CustomOAuthProviderInfo[]
|
||||
[key: string]: unknown
|
||||
@@ -168,6 +169,7 @@ export interface SystemStatus {
|
||||
privacy_policy_enabled?: boolean
|
||||
oauth_register_enabled?: boolean
|
||||
register_enabled?: boolean
|
||||
password_login_enabled?: boolean
|
||||
password_register_enabled?: boolean
|
||||
custom_oauth_providers?: CustomOAuthProviderInfo[]
|
||||
[key: string]: unknown
|
||||
|
||||
+90
-38
@@ -16,8 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import { api } from '@/lib/api'
|
||||
import { api, type ApiRequestConfig } from '@/lib/api'
|
||||
import { getGroups as getUserGroups } from '@/features/users/api'
|
||||
import type {
|
||||
AddChannelRequest,
|
||||
@@ -39,11 +38,13 @@ import type {
|
||||
TagOperationParams,
|
||||
} from './types'
|
||||
|
||||
// Extended API config types
|
||||
interface ExtendedApiConfig extends AxiosRequestConfig {
|
||||
skipBusinessError?: boolean
|
||||
disableDuplicate?: boolean
|
||||
}
|
||||
const channelActionConfig = (
|
||||
config: ApiRequestConfig = {}
|
||||
): ApiRequestConfig => ({
|
||||
...config,
|
||||
skipBusinessError: true,
|
||||
skipErrorHandler: true,
|
||||
})
|
||||
|
||||
export type CodexOAuthStartResponse = {
|
||||
success: boolean
|
||||
@@ -125,7 +126,7 @@ export async function getChannel(id: number): Promise<GetChannelResponse> {
|
||||
export async function createChannel(
|
||||
data: AddChannelRequest
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const res = await api.post('/api/channel', data)
|
||||
const res = await api.post('/api/channel', data, channelActionConfig())
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -136,7 +137,11 @@ export async function updateChannel(
|
||||
id: number,
|
||||
data: Partial<Channel>
|
||||
): Promise<{ success: boolean; message?: string; data?: Channel }> {
|
||||
const res = await api.put('/api/channel/', { id, ...data })
|
||||
const res = await api.put(
|
||||
'/api/channel/',
|
||||
{ id, ...data },
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -146,7 +151,7 @@ export async function updateChannel(
|
||||
export async function deleteChannel(
|
||||
id: number
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const res = await api.delete(`/api/channel/${id}`)
|
||||
const res = await api.delete(`/api/channel/${id}`, channelActionConfig())
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -156,7 +161,7 @@ export async function deleteChannel(
|
||||
export async function batchDeleteChannels(
|
||||
data: BatchDeleteParams
|
||||
): Promise<{ success: boolean; message?: string; data?: number }> {
|
||||
const res = await api.post('/api/channel/batch', data)
|
||||
const res = await api.post('/api/channel/batch', data, channelActionConfig())
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -166,7 +171,11 @@ export async function batchDeleteChannels(
|
||||
export async function batchSetChannelTag(
|
||||
data: BatchSetTagParams
|
||||
): Promise<{ success: boolean; message?: string; data?: number }> {
|
||||
const res = await api.post('/api/channel/batch/tag', data)
|
||||
const res = await api.post(
|
||||
'/api/channel/batch/tag',
|
||||
data,
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -181,7 +190,10 @@ export async function testChannel(
|
||||
id: number,
|
||||
params?: { model?: string; endpoint_type?: string; stream?: boolean }
|
||||
): Promise<ChannelTestResponse> {
|
||||
const res = await api.get(`/api/channel/test/${id}`, { params })
|
||||
const res = await api.get(
|
||||
`/api/channel/test/${id}`,
|
||||
channelActionConfig({ params })
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -191,7 +203,10 @@ export async function testChannel(
|
||||
export async function updateChannelBalance(
|
||||
id: number
|
||||
): Promise<ChannelBalanceResponse> {
|
||||
const res = await api.get(`/api/channel/update_balance/${id}`)
|
||||
const res = await api.get(
|
||||
`/api/channel/update_balance/${id}`,
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -201,7 +216,10 @@ export async function updateChannelBalance(
|
||||
export async function fetchUpstreamModels(
|
||||
id: number
|
||||
): Promise<FetchModelsResponse> {
|
||||
const res = await api.get(`/api/channel/fetch_models/${id}`)
|
||||
const res = await api.get(
|
||||
`/api/channel/fetch_models/${id}`,
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -212,7 +230,11 @@ export async function copyChannel(
|
||||
id: number,
|
||||
params: CopyChannelParams = {}
|
||||
): Promise<CopyChannelResponse> {
|
||||
const res = await api.post(`/api/channel/copy/${id}`, null, { params })
|
||||
const res = await api.post(
|
||||
`/api/channel/copy/${id}`,
|
||||
null,
|
||||
channelActionConfig({ params })
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -224,7 +246,11 @@ export async function fixChannelAbilities(): Promise<{
|
||||
message?: string
|
||||
data?: { success: number; fails: number }
|
||||
}> {
|
||||
const res = await api.post('/api/channel/fix')
|
||||
const res = await api.post(
|
||||
'/api/channel/fix',
|
||||
undefined,
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -236,7 +262,7 @@ export async function deleteDisabledChannels(): Promise<{
|
||||
message?: string
|
||||
data?: number
|
||||
}> {
|
||||
const res = await api.delete('/api/channel/disabled')
|
||||
const res = await api.delete('/api/channel/disabled', channelActionConfig())
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -248,7 +274,11 @@ export async function getChannelKey(
|
||||
code?: string
|
||||
): Promise<{ success: boolean; message?: string; data?: { key: string } }> {
|
||||
const payload = code ? { code } : undefined
|
||||
const res = await api.post(`/api/channel/${id}/key`, payload)
|
||||
const res = await api.post(
|
||||
`/api/channel/${id}/key`,
|
||||
payload,
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -257,19 +287,21 @@ export async function getChannelKey(
|
||||
// ============================================================================
|
||||
|
||||
export async function startCodexOAuth(): Promise<CodexOAuthStartResponse> {
|
||||
const config: ExtendedApiConfig = { skipBusinessError: true }
|
||||
const res = await api.post('/api/channel/codex/oauth/start', {}, config)
|
||||
const res = await api.post(
|
||||
'/api/channel/codex/oauth/start',
|
||||
{},
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function completeCodexOAuth(
|
||||
input: string
|
||||
): Promise<CodexOAuthCompleteResponse> {
|
||||
const config: ExtendedApiConfig = { skipBusinessError: true }
|
||||
const res = await api.post(
|
||||
'/api/channel/codex/oauth/complete',
|
||||
{ input },
|
||||
config
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
@@ -277,11 +309,10 @@ export async function completeCodexOAuth(
|
||||
export async function refreshCodexCredential(
|
||||
channelId: number
|
||||
): Promise<CodexCredentialRefreshResponse> {
|
||||
const config: ExtendedApiConfig = { skipBusinessError: true }
|
||||
const res = await api.post(
|
||||
`/api/channel/${channelId}/codex/refresh`,
|
||||
{},
|
||||
config
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
@@ -289,11 +320,10 @@ export async function refreshCodexCredential(
|
||||
export async function getCodexUsage(
|
||||
channelId: number
|
||||
): Promise<CodexUsageResponse> {
|
||||
const config: ExtendedApiConfig = {
|
||||
skipBusinessError: true,
|
||||
disableDuplicate: true,
|
||||
}
|
||||
const res = await api.get(`/api/channel/${channelId}/codex/usage`, config)
|
||||
const res = await api.get(
|
||||
`/api/channel/${channelId}/codex/usage`,
|
||||
channelActionConfig({ disableDuplicate: true })
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -307,7 +337,11 @@ export async function getCodexUsage(
|
||||
export async function manageMultiKeys(
|
||||
params: MultiKeyManageParams
|
||||
): Promise<MultiKeyStatusResponse | { success: boolean; message?: string }> {
|
||||
const res = await api.post('/api/channel/multi_key/manage', params)
|
||||
const res = await api.post(
|
||||
'/api/channel/multi_key/manage',
|
||||
params,
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -417,7 +451,11 @@ export async function deleteDisabledMultiKeys(
|
||||
export async function enableTagChannels(
|
||||
tag: string
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const res = await api.post('/api/channel/tag/enabled', { tag })
|
||||
const res = await api.post(
|
||||
'/api/channel/tag/enabled',
|
||||
{ tag },
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -427,7 +465,11 @@ export async function enableTagChannels(
|
||||
export async function disableTagChannels(
|
||||
tag: string
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const res = await api.post('/api/channel/tag/disabled', { tag })
|
||||
const res = await api.post(
|
||||
'/api/channel/tag/disabled',
|
||||
{ tag },
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -437,7 +479,7 @@ export async function disableTagChannels(
|
||||
export async function editTagChannels(
|
||||
params: TagOperationParams
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const res = await api.put('/api/channel/tag', params)
|
||||
const res = await api.put('/api/channel/tag', params, channelActionConfig())
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -463,7 +505,11 @@ export async function fetchModels(data: {
|
||||
type: number
|
||||
key: string
|
||||
}): Promise<FetchModelsResponse> {
|
||||
const res = await api.post('/api/channel/fetch_models', data)
|
||||
const res = await api.post(
|
||||
'/api/channel/fetch_models',
|
||||
data,
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -474,7 +520,10 @@ export async function deleteOllamaModel(params: {
|
||||
channel_id: number
|
||||
model_name: string
|
||||
}): Promise<{ success: boolean; message?: string }> {
|
||||
const res = await api.delete('/api/channel/ollama/delete', { data: params })
|
||||
const res = await api.delete(
|
||||
'/api/channel/ollama/delete',
|
||||
channelActionConfig({ data: params })
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -485,7 +534,7 @@ export async function testAllChannels(): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
}> {
|
||||
const res = await api.get('/api/channel/test')
|
||||
const res = await api.get('/api/channel/test', channelActionConfig())
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -496,7 +545,10 @@ export async function updateAllChannelsBalance(): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
}> {
|
||||
const res = await api.get('/api/channel/update_balance')
|
||||
const res = await api.get(
|
||||
'/api/channel/update_balance',
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
|
||||
+95
-105
@@ -26,6 +26,7 @@ import {
|
||||
ChevronRight,
|
||||
ListOrdered,
|
||||
Shuffle,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@@ -35,8 +36,7 @@ import {
|
||||
formatQuota as formatQuotaValue,
|
||||
} from '@/lib/format'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { cn, truncateText } from '@/lib/utils'
|
||||
import { TruncatedText } from '@/components/truncated-text'
|
||||
import { truncateText } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
@@ -48,11 +48,9 @@ import {
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { DataTableColumnHeader } from '@/components/data-table/column-header'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import {
|
||||
StatusBadge,
|
||||
dotColorMap,
|
||||
textColorMap,
|
||||
} from '@/components/status-badge'
|
||||
import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
|
||||
import { TableId } from '@/components/table-id'
|
||||
import { TruncatedText } from '@/components/truncated-text'
|
||||
import { getCodexUsage } from '../api'
|
||||
import { CHANNEL_STATUS_CONFIG, MODEL_FETCHABLE_TYPES } from '../constants'
|
||||
import {
|
||||
@@ -107,25 +105,12 @@ function renderLimitedItems(
|
||||
items: React.ReactNode[],
|
||||
maxDisplay: number = 2
|
||||
): React.ReactNode {
|
||||
if (items.length === 0)
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
|
||||
const displayed = items.slice(0, maxDisplay)
|
||||
const remaining = items.length - maxDisplay
|
||||
|
||||
return (
|
||||
<div className='flex max-w-full items-center gap-1 overflow-hidden'>
|
||||
{displayed}
|
||||
{remaining > 0 && (
|
||||
<StatusBadge
|
||||
label={`+${remaining}`}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='flex-shrink-0'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<StatusBadgeList
|
||||
items={items}
|
||||
max={maxDisplay}
|
||||
renderItem={(item) => item}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -317,15 +302,18 @@ function BalanceCell({ channel }: { channel: Channel }) {
|
||||
|
||||
const usedDisplay = withSuffix(formatQuotaValue(usedQuota))
|
||||
const remainingDisplay = withSuffix(formatBalance(balance))
|
||||
const usedLabel = `${t('Used:')} ${usedDisplay}`
|
||||
const remainingLabel = `${t('Remaining:')} ${remainingDisplay}`
|
||||
|
||||
// Tag row: only show cumulative used quota
|
||||
if (isTagRow) {
|
||||
return (
|
||||
<StatusBadge
|
||||
label={`Used: ${usedDisplay}`}
|
||||
label={usedLabel}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
showDot={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -361,52 +349,55 @@ function BalanceCell({ channel }: { channel: Channel }) {
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className='flex items-center gap-1.5 text-xs font-medium'>
|
||||
<span
|
||||
className={cn(
|
||||
'size-1.5 shrink-0 rounded-full',
|
||||
dotColorMap[isUpdating ? 'neutral' : variant]
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<span className='text-muted-foreground cursor-help' />}
|
||||
>
|
||||
{usedDisplay}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{t('Used:')} {usedDisplay}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className='text-muted-foreground/30'>·</span>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span
|
||||
className={cn(
|
||||
'cursor-pointer transition-opacity hover:opacity-70',
|
||||
<StatusBadge
|
||||
label={usedDisplay}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
showDot={false}
|
||||
className='cursor-help'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<p>{usedLabel}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<StatusBadge
|
||||
label={
|
||||
isUpdating
|
||||
? t('Updating...')
|
||||
: channel.type === 57
|
||||
? t('Account Info')
|
||||
: remainingDisplay
|
||||
}
|
||||
variant={
|
||||
channel.type === 57
|
||||
? 'text-primary'
|
||||
: textColorMap[isUpdating ? 'neutral' : variant]
|
||||
)}
|
||||
? 'info'
|
||||
: isUpdating
|
||||
? 'neutral'
|
||||
: variant
|
||||
}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
showDot={false}
|
||||
className='cursor-pointer'
|
||||
onClick={handleClickUpdate}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isUpdating
|
||||
? 'Updating...'
|
||||
: channel.type === 57
|
||||
? t('Account Info')
|
||||
: remainingDisplay}
|
||||
</TooltipTrigger>
|
||||
/>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{channel.type === 57
|
||||
? t('Click to view Codex usage')
|
||||
: `${t('Remaining:')} ${remainingDisplay}`}
|
||||
: remainingLabel}
|
||||
</p>
|
||||
{channel.type !== 57 && <p>{t('Click to update balance')}</p>}
|
||||
</TooltipContent>
|
||||
@@ -491,15 +482,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const id = row.getValue('id') as number
|
||||
return (
|
||||
<StatusBadge
|
||||
label={String(id)}
|
||||
variant='neutral'
|
||||
copyText={String(id)}
|
||||
size='sm'
|
||||
className='font-mono'
|
||||
/>
|
||||
)
|
||||
return <TableId value={id} />
|
||||
},
|
||||
size: 80,
|
||||
},
|
||||
@@ -515,7 +498,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
const isTagRow = isTagAggregateRow(row.original)
|
||||
const name = row.getValue('name') as string
|
||||
const channel = row.original
|
||||
const isMultiKey = isMultiKeyChannel(channel)
|
||||
|
||||
// Tag row with expand/collapse
|
||||
if (isTagRow) {
|
||||
@@ -552,6 +534,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
// Regular channel row
|
||||
const settings = parseChannelSettings(channel.setting)
|
||||
const isPassThrough = settings.pass_through_body_enabled === true
|
||||
const hasParamOverride = Boolean(channel.param_override?.trim())
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
@@ -578,13 +561,19 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{isMultiKey && (
|
||||
<StatusBadge
|
||||
label={`${channel.channel_info.multi_key_size} keys`}
|
||||
variant='purple'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
{hasParamOverride && (
|
||||
<TooltipProvider delay={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<SlidersHorizontal className='text-info h-3.5 w-3.5 flex-shrink-0' />
|
||||
}
|
||||
></TooltipTrigger>
|
||||
<TooltipContent side='top'>
|
||||
{t('Override request parameters')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<UpstreamUpdateTags channel={channel} />
|
||||
</div>
|
||||
@@ -634,7 +623,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
const typeNameKey = getChannelTypeLabel(type)
|
||||
const typeName = t(typeNameKey)
|
||||
const iconName = getChannelTypeIcon(type)
|
||||
const icon = getLobeIcon(`${iconName}.Color`, 20)
|
||||
const icon = getLobeIcon(`${iconName}.Color`, 14)
|
||||
const channel = row.original as Channel
|
||||
const isMultiKey = isMultiKeyChannel(channel)
|
||||
const multiKeyMode = channel.channel_info?.multi_key_mode ?? 'random'
|
||||
@@ -654,31 +643,30 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
{isMultiKey && (
|
||||
<TooltipProvider delay={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className='border-border bg-muted text-primary inline-flex h-6 w-6 items-center justify-center rounded-md border' />
|
||||
}
|
||||
>
|
||||
<MultiKeyModeIcon className='h-3.5 w-3.5' />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top'>
|
||||
{multiKeyTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{icon}
|
||||
</div>
|
||||
{isMultiKey && (
|
||||
<TooltipProvider delay={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className='border-border bg-muted text-primary inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border' />
|
||||
}
|
||||
>
|
||||
<MultiKeyModeIcon className='h-3 w-3' />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top'>{multiKeyTooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<StatusBadge
|
||||
label={typeName}
|
||||
autoColor={typeName}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
showDot={false}
|
||||
className='gap-1 pl-1'
|
||||
>
|
||||
{icon}
|
||||
<span className='truncate'>{typeName}</span>
|
||||
</StatusBadge>
|
||||
{isIonet && (
|
||||
<TooltipProvider delay={100}>
|
||||
<Tooltip>
|
||||
@@ -695,8 +683,13 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className='text-muted-foreground/30'>·</span>
|
||||
<span className={cn(textColorMap.purple)}>IO.NET</span>
|
||||
<StatusBadge
|
||||
label='IO.NET'
|
||||
variant='purple'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='cursor-pointer'
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top'>
|
||||
<div className='max-w-xs space-y-1'>
|
||||
@@ -747,7 +740,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
<StatusBadge
|
||||
label={`Active (${childrenCount})`}
|
||||
variant='success'
|
||||
showDot
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
@@ -806,7 +798,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
<StatusBadge
|
||||
label={label}
|
||||
variant={config.variant}
|
||||
showDot={config.showDot}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
@@ -835,7 +826,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
<StatusBadge
|
||||
label={label}
|
||||
variant={config.variant}
|
||||
showDot={config.showDot}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
|
||||
@@ -56,6 +56,7 @@ export function ChannelsPrimaryButtons() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
setOpen,
|
||||
setCurrentRow,
|
||||
enableTagMode,
|
||||
setEnableTagMode,
|
||||
idSort,
|
||||
@@ -104,7 +105,13 @@ export function ChannelsPrimaryButtons() {
|
||||
</div>
|
||||
|
||||
{/* Create Channel */}
|
||||
<Button onClick={() => setOpen('create-channel')} size='sm'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setCurrentRow(null)
|
||||
setOpen('create-channel')
|
||||
}}
|
||||
size='sm'
|
||||
>
|
||||
<Plus className='h-4 w-4' />
|
||||
<span className='max-sm:hidden'>{t('Create Channel')}</span>
|
||||
<span className='sm:hidden'>{t('Create')}</span>
|
||||
|
||||
+51
-13
@@ -28,6 +28,7 @@ import {
|
||||
} from '@tanstack/react-table'
|
||||
import { Check, Copy, Info, Loader2, Settings } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -74,6 +75,12 @@ import {
|
||||
} from '@/components/ui/tooltip'
|
||||
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
|
||||
import { DataTablePagination } from '@/components/data-table/pagination'
|
||||
import {
|
||||
sideDrawerContentClassName,
|
||||
sideDrawerFooterClassName,
|
||||
sideDrawerFormClassName,
|
||||
sideDrawerHeaderClassName,
|
||||
} from '@/components/drawer-layout'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { formatResponseTime, handleTestChannel } from '../../lib'
|
||||
import { useChannels } from '../channels-provider'
|
||||
@@ -302,11 +309,12 @@ export function ChannelTestDialog({
|
||||
}, [])
|
||||
|
||||
const testSingleModel = useCallback(
|
||||
async (model: string) => {
|
||||
async (model: string, silent = false): Promise<TestResult | undefined> => {
|
||||
if (!currentRow) return
|
||||
|
||||
markModelTesting(model, true)
|
||||
updateTestResult(model, { status: 'testing' })
|
||||
let finalResult: TestResult | undefined
|
||||
|
||||
try {
|
||||
await handleTestChannel(
|
||||
@@ -315,24 +323,28 @@ export function ChannelTestDialog({
|
||||
testModel: model,
|
||||
endpointType: endpointType === 'auto' ? undefined : endpointType,
|
||||
stream: isStreamTest || undefined,
|
||||
silent,
|
||||
},
|
||||
(success, responseTime, error, errorCode) => {
|
||||
updateTestResult(model, {
|
||||
finalResult = {
|
||||
status: success ? 'success' : 'error',
|
||||
responseTime,
|
||||
error,
|
||||
errorCode,
|
||||
})
|
||||
}
|
||||
updateTestResult(model, finalResult)
|
||||
}
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
updateTestResult(model, {
|
||||
finalResult = {
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : t('Test failed'),
|
||||
})
|
||||
}
|
||||
updateTestResult(model, finalResult)
|
||||
} finally {
|
||||
markModelTesting(model, false)
|
||||
}
|
||||
return finalResult
|
||||
},
|
||||
[
|
||||
currentRow,
|
||||
@@ -350,15 +362,41 @@ export function ChannelTestDialog({
|
||||
|
||||
setIsBatchTesting(true)
|
||||
try {
|
||||
await Promise.allSettled(
|
||||
modelsToTest.map((modelName) => testSingleModel(modelName))
|
||||
const settled = await Promise.allSettled(
|
||||
modelsToTest.map((modelName) => testSingleModel(modelName, true))
|
||||
)
|
||||
const results = settled
|
||||
.map((result) =>
|
||||
result.status === 'fulfilled' ? result.value : undefined
|
||||
)
|
||||
.filter((result): result is TestResult => Boolean(result))
|
||||
const successCount = results.filter(
|
||||
(result) => result.status === 'success'
|
||||
).length
|
||||
const failedCount = modelsToTest.length - successCount
|
||||
if (failedCount > 0) {
|
||||
toast.error(
|
||||
t(
|
||||
'Batch test completed: {{success}} succeeded, {{failed}} failed',
|
||||
{
|
||||
success: successCount,
|
||||
failed: failedCount,
|
||||
}
|
||||
)
|
||||
)
|
||||
} else {
|
||||
toast.success(
|
||||
t('Batch test completed: {{count}} succeeded', {
|
||||
count: successCount,
|
||||
})
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
setIsBatchTesting(false)
|
||||
setRowSelection({})
|
||||
}
|
||||
},
|
||||
[testSingleModel]
|
||||
[t, testSingleModel]
|
||||
)
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -801,19 +839,19 @@ function FailureDetailsSheet({
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
className={
|
||||
isMobile
|
||||
? 'max-h-[85dvh] gap-0 overflow-hidden rounded-t-xl p-0'
|
||||
: 'h-dvh w-full gap-0 overflow-hidden p-0 sm:max-w-lg'
|
||||
? sideDrawerContentClassName('h-auto max-h-[85dvh] rounded-t-xl')
|
||||
: sideDrawerContentClassName('sm:max-w-lg')
|
||||
}
|
||||
>
|
||||
{details && (
|
||||
<>
|
||||
<SheetHeader className='border-b px-4 py-3 text-start sm:px-5 sm:py-4'>
|
||||
<SheetHeader className={sideDrawerHeaderClassName('sm:px-5')}>
|
||||
<SheetTitle className='pr-10'>{t('Details')}</SheetTitle>
|
||||
<SheetDescription className='pr-10 wrap-break-word'>
|
||||
{details.model}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className='min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-3 sm:px-5 sm:py-4'>
|
||||
<div className={sideDrawerFormClassName('gap-4 sm:px-5')}>
|
||||
<section className='space-y-1'>
|
||||
<div className='text-muted-foreground text-xs font-medium'>
|
||||
{t('Model')}
|
||||
@@ -837,7 +875,7 @@ function FailureDetailsSheet({
|
||||
</pre>
|
||||
</section>
|
||||
</div>
|
||||
<SheetFooter className='border-t px-4 py-3 sm:flex-row sm:justify-end sm:px-5'>
|
||||
<SheetFooter className={sideDrawerFooterClassName('sm:px-5')}>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full sm:w-auto'
|
||||
|
||||
+26
-17
@@ -67,6 +67,7 @@ type FetchModelsDialogProps = {
|
||||
redirectSourceModels?: string[]
|
||||
customFetcher?: () => Promise<string[]>
|
||||
existingModelsOverride?: string[]
|
||||
channelName?: string | null
|
||||
}
|
||||
|
||||
export function FetchModelsDialog({
|
||||
@@ -77,9 +78,11 @@ export function FetchModelsDialog({
|
||||
redirectSourceModels = [],
|
||||
customFetcher,
|
||||
existingModelsOverride,
|
||||
channelName,
|
||||
}: FetchModelsDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { currentRow } = useChannels()
|
||||
const activeChannel = customFetcher ? null : currentRow
|
||||
const queryClient = useQueryClient()
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
@@ -90,9 +93,8 @@ export function FetchModelsDialog({
|
||||
// Parse existing models
|
||||
const existingModels = useMemo(
|
||||
() =>
|
||||
existingModelsOverride ??
|
||||
parseModelsString(currentRow?.models || ''),
|
||||
[existingModelsOverride, currentRow?.models]
|
||||
existingModelsOverride ?? parseModelsString(activeChannel?.models || ''),
|
||||
[existingModelsOverride, activeChannel?.models]
|
||||
)
|
||||
|
||||
// Categorize models with redirect models
|
||||
@@ -127,14 +129,14 @@ export function FetchModelsDialog({
|
||||
}, [fetchedModelSet, redirectSourceKeysSet, searchKeyword, selectedModels])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && (currentRow || customFetcher)) {
|
||||
if (open && (activeChannel || customFetcher)) {
|
||||
handleFetchModels()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, currentRow?.id, customFetcher])
|
||||
}, [open, activeChannel?.id, customFetcher])
|
||||
|
||||
const handleFetchModels = async () => {
|
||||
if (!currentRow && !customFetcher) return
|
||||
if (!activeChannel && !customFetcher) return
|
||||
|
||||
setIsFetching(true)
|
||||
try {
|
||||
@@ -144,7 +146,7 @@ export function FetchModelsDialog({
|
||||
setSelectedModels(existingModels)
|
||||
toast.success(t('Fetched {{count}} models', { count: list.length }))
|
||||
} else {
|
||||
const response = await fetchUpstreamModels(currentRow!.id)
|
||||
const response = await fetchUpstreamModels(activeChannel!.id)
|
||||
if (response.success) {
|
||||
const list = Array.isArray(response.data) ? response.data : []
|
||||
setFetchedModels(list)
|
||||
@@ -175,11 +177,11 @@ export function FetchModelsDialog({
|
||||
}
|
||||
|
||||
// Otherwise, directly save to API (standalone mode)
|
||||
if (!currentRow) return
|
||||
if (!activeChannel) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const modelsString = selectedModels.join(',')
|
||||
const response = await updateChannel(currentRow.id, {
|
||||
const response = await updateChannel(activeChannel.id, {
|
||||
models: modelsString,
|
||||
})
|
||||
if (response.success) {
|
||||
@@ -369,16 +371,23 @@ export function FetchModelsDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Fetch Models')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{currentRow
|
||||
? <>
|
||||
{t('Fetch available models for:')}{' '}
|
||||
<strong>{currentRow.name}</strong>
|
||||
</>
|
||||
: t('Fetch available models from upstream')}
|
||||
{activeChannel ? (
|
||||
<>
|
||||
{t('Fetch available models for:')}{' '}
|
||||
<strong>{activeChannel.name}</strong>
|
||||
</>
|
||||
) : channelName ? (
|
||||
<>
|
||||
{t('Fetch available models for:')}{' '}
|
||||
<strong>{channelName}</strong>
|
||||
</>
|
||||
) : (
|
||||
t('Fetch available models from upstream')
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!currentRow && !customFetcher ? (
|
||||
{!activeChannel && !customFetcher ? (
|
||||
<div className='text-muted-foreground py-8 text-center'>
|
||||
{t('No channel selected')}
|
||||
</div>
|
||||
@@ -413,7 +422,7 @@ export function FetchModelsDialog({
|
||||
|
||||
{/* Tabs for New vs Existing vs Removed */}
|
||||
<Tabs
|
||||
key={`${currentRow?.id}-${fetchedModels.length}-${removedModels.length}`}
|
||||
key={`${activeChannel?.id ?? 'custom'}-${fetchedModels.length}-${removedModels.length}`}
|
||||
defaultValue={
|
||||
newModels.length > 0
|
||||
? 'new'
|
||||
|
||||
+2
@@ -135,6 +135,8 @@ export function MultiKeyManageDialog({
|
||||
setEnabledCount(response.data.enabled_count || 0)
|
||||
setManualDisabledCount(response.data.manual_disabled_count || 0)
|
||||
setAutoDisabledCount(response.data.auto_disabled_count || 0)
|
||||
} else {
|
||||
toast.error(response.message || t('Failed to load key status'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
|
||||
+15
-7
@@ -211,14 +211,22 @@ export function OllamaModelsDialog({
|
||||
? Array.from(new Set(selected))
|
||||
: Array.from(new Set([...existingModels, ...selected]))
|
||||
|
||||
const res = await updateChannel(currentRow.id, { models: next.join(',') })
|
||||
if (res.success) {
|
||||
toast.success(
|
||||
mode === 'replace'
|
||||
? t('Models updated successfully')
|
||||
: t('Models appended successfully')
|
||||
try {
|
||||
const res = await updateChannel(currentRow.id, { models: next.join(',') })
|
||||
if (res.success) {
|
||||
toast.success(
|
||||
mode === 'replace'
|
||||
? t('Models updated successfully')
|
||||
: t('Models appended successfully')
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
} else {
|
||||
toast.error(res.message || t('Failed to update models'))
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t('Failed to update models')
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -107,7 +107,7 @@ export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
|
||||
const anyAdd = selectedAddArr.length > 0
|
||||
const anyRemove = selectedRemoveArr.length > 0
|
||||
|
||||
if (hasAdd && hasRemove && (!anyAdd || !anyRemove)) {
|
||||
if (hasAdd && hasRemove && anyAdd !== anyRemove) {
|
||||
setPartialConfirmOpen(true)
|
||||
return
|
||||
}
|
||||
@@ -278,7 +278,8 @@ export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
|
||||
onClick={handleConfirm}
|
||||
disabled={
|
||||
props.confirmLoading ||
|
||||
(selectedAdd.size === 0 && selectedRemove.size === 0)
|
||||
(props.addModels.length === 0 &&
|
||||
props.removeModels.length === 0)
|
||||
}
|
||||
>
|
||||
{t('Confirm')}
|
||||
|
||||
+2210
-2167
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user