Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7791b78429 | |||
| cb5c0453f5 | |||
| 4d20e053cb | |||
| 0ff9c35e62 | |||
| 0bbcaa8999 | |||
| 1e9ff8a0de | |||
| 9a2e60dff2 | |||
| b596de739d | |||
| 45d54c1613 | |||
| 086044650d | |||
| 0c7aceb831 | |||
| b2e25b7df2 | |||
| 230a3592f8 | |||
| afb470e405 | |||
| 1588027084 | |||
| 38bf2d8daa |
@@ -33,16 +33,18 @@ jobs:
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web/default
|
||||
bun install
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd default
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
- name: Build Frontend (classic)
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web/classic
|
||||
bun install
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd classic
|
||||
VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
- name: Set up Go
|
||||
@@ -91,16 +93,18 @@ jobs:
|
||||
CI: ""
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
run: |
|
||||
cd web/default
|
||||
bun install
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd default
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
- name: Build Frontend (classic)
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web/classic
|
||||
bun install
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd classic
|
||||
VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
- name: Set up Go
|
||||
@@ -146,16 +150,18 @@ jobs:
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web/default
|
||||
bun install
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd default
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
- name: Build Frontend (classic)
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web/classic
|
||||
bun install
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd classic
|
||||
VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
- name: Set up Go
|
||||
|
||||
+18
-16
@@ -1,22 +1,24 @@
|
||||
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder
|
||||
|
||||
WORKDIR /build
|
||||
COPY web/default/package.json .
|
||||
COPY web/default/bun.lock .
|
||||
RUN bun install
|
||||
COPY ./web/default .
|
||||
COPY ./VERSION .
|
||||
RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
|
||||
WORKDIR /build/web
|
||||
COPY web/package.json web/bun.lock ./
|
||||
COPY web/default/package.json ./default/package.json
|
||||
COPY web/classic/package.json ./classic/package.json
|
||||
RUN bun install --frozen-lockfile
|
||||
COPY ./web/default ./default
|
||||
COPY ./VERSION /build/VERSION
|
||||
RUN cd default && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
|
||||
|
||||
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder-classic
|
||||
|
||||
WORKDIR /build
|
||||
COPY web/classic/package.json .
|
||||
COPY web/classic/bun.lock .
|
||||
RUN bun install
|
||||
COPY ./web/classic .
|
||||
COPY ./VERSION .
|
||||
RUN VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
|
||||
WORKDIR /build/web
|
||||
COPY web/package.json web/bun.lock ./
|
||||
COPY web/default/package.json ./default/package.json
|
||||
COPY web/classic/package.json ./classic/package.json
|
||||
RUN bun install --frozen-lockfile
|
||||
COPY ./web/classic ./classic
|
||||
COPY ./VERSION /build/VERSION
|
||||
RUN cd classic && VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
|
||||
|
||||
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2
|
||||
ENV GO111MODULE=on CGO_ENABLED=0
|
||||
@@ -32,8 +34,8 @@ ADD go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
COPY --from=builder /build/dist ./web/default/dist
|
||||
COPY --from=builder-classic /build/dist ./web/classic/dist
|
||||
COPY --from=builder /build/web/default/dist ./web/default/dist
|
||||
COPY --from=builder-classic /build/web/classic/dist ./web/classic/dist
|
||||
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
|
||||
|
||||
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
|
||||
|
||||
@@ -41,6 +41,7 @@ func GetSubscriptionPlans(c *gin.Context) {
|
||||
}
|
||||
result := make([]SubscriptionPlanDTO, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
p.NormalizeDefaults()
|
||||
result = append(result, SubscriptionPlanDTO{
|
||||
Plan: p,
|
||||
})
|
||||
@@ -125,6 +126,7 @@ func AdminListSubscriptionPlans(c *gin.Context) {
|
||||
}
|
||||
result := make([]SubscriptionPlanDTO, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
p.NormalizeDefaults()
|
||||
result = append(result, SubscriptionPlanDTO{
|
||||
Plan: p,
|
||||
})
|
||||
@@ -163,6 +165,9 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
|
||||
req.Plan.Currency = "USD"
|
||||
}
|
||||
req.Plan.Currency = "USD"
|
||||
if req.Plan.AllowBalancePay == nil {
|
||||
req.Plan.AllowBalancePay = common.GetPointer(true)
|
||||
}
|
||||
if req.Plan.DurationUnit == "" {
|
||||
req.Plan.DurationUnit = model.SubscriptionDurationMonth
|
||||
}
|
||||
@@ -279,6 +284,9 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
||||
"quota_reset_custom_seconds": req.Plan.QuotaResetCustomSeconds,
|
||||
"updated_at": common.GetTimestamp(),
|
||||
}
|
||||
if req.Plan.AllowBalancePay != nil {
|
||||
updateMap["allow_balance_pay"] = *req.Plan.AllowBalancePay
|
||||
}
|
||||
if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
FRONTEND_DIR = ./web/default
|
||||
FRONTEND_CLASSIC_DIR = ./web/classic
|
||||
BACKEND_DIR = .
|
||||
DEV_FRONTEND_DEFAULT_PORT ?= 5173
|
||||
DEV_FRONTEND_CLASSIC_PORT ?= 5174
|
||||
DEV_COMPOSE_FILE = docker-compose.dev.yml
|
||||
DEV_POSTGRES_SERVICE = postgres
|
||||
DEV_BACKEND_SERVICE = new-api
|
||||
@@ -14,11 +16,13 @@ all: build-all-frontends start-backend
|
||||
|
||||
build-frontend:
|
||||
@echo "Building default frontend..."
|
||||
@cd $(FRONTEND_DIR) && bun install && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
|
||||
@cd ./web && bun install --frozen-lockfile
|
||||
@cd $(FRONTEND_DIR) && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
|
||||
|
||||
build-frontend-classic:
|
||||
@echo "Building classic frontend..."
|
||||
@cd $(FRONTEND_CLASSIC_DIR) && bun install && VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
|
||||
@cd ./web && bun install --frozen-lockfile
|
||||
@cd $(FRONTEND_CLASSIC_DIR) && VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
|
||||
|
||||
build-all-frontends: build-frontend build-frontend-classic
|
||||
|
||||
@@ -35,12 +39,35 @@ dev-api-rebuild:
|
||||
@docker compose -f $(DEV_COMPOSE_FILE) up -d --build $(DEV_BACKEND_SERVICE)
|
||||
|
||||
dev-web:
|
||||
@echo "Starting frontend dev server..."
|
||||
@cd $(FRONTEND_DIR) && bun install && bun run dev
|
||||
@echo "Starting both frontend dev servers..."
|
||||
@echo "Default frontend: http://localhost:$(DEV_FRONTEND_DEFAULT_PORT)"
|
||||
@echo "Classic frontend: http://localhost:$(DEV_FRONTEND_CLASSIC_PORT)"
|
||||
@cd ./web && bun install
|
||||
@(cd $(FRONTEND_DIR) && bun run dev -- --host 0.0.0.0 --port $(DEV_FRONTEND_DEFAULT_PORT)) & \
|
||||
default_pid=$$!; \
|
||||
(cd $(FRONTEND_CLASSIC_DIR) && bun run dev -- --host 0.0.0.0 --port $(DEV_FRONTEND_CLASSIC_PORT)) & \
|
||||
classic_pid=$$!; \
|
||||
trap 'kill $$default_pid $$classic_pid 2>/dev/null; wait $$default_pid $$classic_pid 2>/dev/null; exit 130' INT TERM; \
|
||||
while kill -0 $$default_pid 2>/dev/null && kill -0 $$classic_pid 2>/dev/null; do \
|
||||
sleep 1; \
|
||||
done; \
|
||||
if ! kill -0 $$default_pid 2>/dev/null; then \
|
||||
wait $$default_pid; \
|
||||
status=$$?; \
|
||||
kill $$classic_pid 2>/dev/null; \
|
||||
wait $$classic_pid 2>/dev/null; \
|
||||
exit $$status; \
|
||||
fi; \
|
||||
wait $$classic_pid; \
|
||||
status=$$?; \
|
||||
kill $$default_pid 2>/dev/null; \
|
||||
wait $$default_pid 2>/dev/null; \
|
||||
exit $$status
|
||||
|
||||
dev-web-classic:
|
||||
@echo "Starting classic frontend dev server..."
|
||||
@cd $(FRONTEND_CLASSIC_DIR) && bun install && bun run dev
|
||||
@cd ./web && bun install
|
||||
@cd $(FRONTEND_CLASSIC_DIR) && bun run dev -- --host 0.0.0.0 --port $(DEV_FRONTEND_CLASSIC_PORT)
|
||||
|
||||
dev: dev-api dev-web
|
||||
|
||||
|
||||
+3
-3
@@ -32,9 +32,9 @@ func applyExplicitLogTextFilter(tx *gorm.DB, column string, value string) (*gorm
|
||||
}
|
||||
|
||||
type Log struct {
|
||||
Id int `json:"id" gorm:"index:idx_created_at_id,priority:1;index:idx_user_id_id,priority:2"`
|
||||
Id int `json:"id" gorm:"index:idx_created_at_id,priority:2;index:idx_user_id_id,priority:2"`
|
||||
UserId int `json:"user_id" gorm:"index;index:idx_user_id_id,priority:1"`
|
||||
CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:2;index:idx_created_at_type"`
|
||||
CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:1;index:idx_created_at_type"`
|
||||
Type int `json:"type" gorm:"index:idx_created_at_type"`
|
||||
Content string `json:"content"`
|
||||
Username string `json:"username" gorm:"index;index:index_username_model_name,priority:2;default:''"`
|
||||
@@ -354,7 +354,7 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
err = tx.Order("logs.id desc").Limit(num).Offset(startIdx).Find(&logs).Error
|
||||
err = tx.Order("logs.created_at desc, logs.id desc").Limit(num).Offset(startIdx).Find(&logs).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
@@ -397,6 +397,7 @@ func ensureSubscriptionPlanTableSQLite() error {
|
||||
` + "`custom_seconds`" + ` bigint NOT NULL DEFAULT 0,
|
||||
` + "`enabled`" + ` numeric DEFAULT 1,
|
||||
` + "`sort_order`" + ` integer DEFAULT 0,
|
||||
` + "`allow_balance_pay`" + ` numeric DEFAULT 1,
|
||||
` + "`stripe_price_id`" + ` varchar(128) DEFAULT '',
|
||||
` + "`creem_product_id`" + ` varchar(128) DEFAULT '',
|
||||
` + "`waffo_pancake_product_id`" + ` varchar(128) DEFAULT '',
|
||||
@@ -431,6 +432,7 @@ PRIMARY KEY (` + "`id`" + `)
|
||||
{Name: "custom_seconds", DDL: "`custom_seconds` bigint NOT NULL DEFAULT 0"},
|
||||
{Name: "enabled", DDL: "`enabled` numeric DEFAULT 1"},
|
||||
{Name: "sort_order", DDL: "`sort_order` integer DEFAULT 0"},
|
||||
{Name: "allow_balance_pay", DDL: "`allow_balance_pay` numeric DEFAULT 1"},
|
||||
{Name: "stripe_price_id", DDL: "`stripe_price_id` varchar(128) DEFAULT ''"},
|
||||
{Name: "creem_product_id", DDL: "`creem_product_id` varchar(128) DEFAULT ''"},
|
||||
{Name: "waffo_pancake_product_id", DDL: "`waffo_pancake_product_id` varchar(128) DEFAULT ''"},
|
||||
|
||||
@@ -160,6 +160,8 @@ type SubscriptionPlan struct {
|
||||
Enabled bool `json:"enabled" gorm:"default:true"`
|
||||
SortOrder int `json:"sort_order" gorm:"type:int;default:0"`
|
||||
|
||||
AllowBalancePay *bool `json:"allow_balance_pay" gorm:"default:true"`
|
||||
|
||||
StripePriceId string `json:"stripe_price_id" gorm:"type:varchar(128);default:''"`
|
||||
CreemProductId string `json:"creem_product_id" gorm:"type:varchar(128);default:''"`
|
||||
WaffoPancakeProductId string `json:"waffo_pancake_product_id" gorm:"type:varchar(128);default:''"`
|
||||
@@ -193,6 +195,12 @@ func (p *SubscriptionPlan) BeforeUpdate(tx *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *SubscriptionPlan) NormalizeDefaults() {
|
||||
if p.AllowBalancePay == nil {
|
||||
p.AllowBalancePay = common.GetPointer(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Subscription order (payment -> webhook -> create UserSubscription)
|
||||
type SubscriptionOrder struct {
|
||||
Id int `json:"id"`
|
||||
@@ -360,6 +368,7 @@ func getSubscriptionPlanByIdTx(tx *gorm.DB, id int) (*SubscriptionPlan, error) {
|
||||
key := subscriptionPlanCacheKey(id)
|
||||
if key != "" {
|
||||
if cached, found, err := getSubscriptionPlanCache().Get(key); err == nil && found {
|
||||
cached.NormalizeDefaults()
|
||||
return &cached, nil
|
||||
}
|
||||
}
|
||||
@@ -371,6 +380,7 @@ func getSubscriptionPlanByIdTx(tx *gorm.DB, id int) (*SubscriptionPlan, error) {
|
||||
if err := query.Where("id = ?", id).First(&plan).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plan.NormalizeDefaults()
|
||||
_ = getSubscriptionPlanCache().SetWithTTL(key, plan, subscriptionPlanCacheTTL())
|
||||
return &plan, nil
|
||||
}
|
||||
@@ -701,6 +711,9 @@ func PurchaseSubscriptionWithBalance(userId int, planId int) error {
|
||||
if plan.PriceAmount < 0 {
|
||||
return errors.New("套餐价格不能为负数")
|
||||
}
|
||||
if plan.AllowBalancePay != nil && !*plan.AllowBalancePay {
|
||||
return errors.New("该套餐不允许使用余额兑换")
|
||||
}
|
||||
|
||||
requiredQuota, err := calcSubscriptionBalanceQuota(plan.PriceAmount)
|
||||
if err != nil {
|
||||
|
||||
@@ -19,6 +19,7 @@ var awsModelIDMap = map[string]string{
|
||||
"claude-opus-4-5-20251101": "anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
"claude-opus-4-6": "anthropic.claude-opus-4-6-v1",
|
||||
"claude-opus-4-7": "anthropic.claude-opus-4-7",
|
||||
"claude-opus-4-8": "anthropic.claude-opus-4-8",
|
||||
// Nova models
|
||||
"nova-micro-v1:0": "amazon.nova-micro-v1:0",
|
||||
"nova-lite-v1:0": "amazon.nova-lite-v1:0",
|
||||
@@ -97,6 +98,11 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{
|
||||
"ap": true,
|
||||
"eu": true,
|
||||
},
|
||||
"anthropic.claude-opus-4-8": {
|
||||
"us": true,
|
||||
"ap": true,
|
||||
"eu": true,
|
||||
},
|
||||
"anthropic.claude-haiku-4-5-20251001-v1:0": {
|
||||
"us": true,
|
||||
"ap": true,
|
||||
|
||||
@@ -33,6 +33,13 @@ var ModelList = []string{
|
||||
"claude-opus-4-7-medium",
|
||||
"claude-opus-4-7-low",
|
||||
"claude-opus-4-7-thinking",
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-4-8-max",
|
||||
"claude-opus-4-8-xhigh",
|
||||
"claude-opus-4-8-high",
|
||||
"claude-opus-4-8-medium",
|
||||
"claude-opus-4-8-low",
|
||||
"claude-opus-4-8-thinking",
|
||||
}
|
||||
|
||||
var ChannelName = "claude"
|
||||
|
||||
@@ -154,14 +154,17 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
|
||||
}
|
||||
|
||||
if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(textRequest.Model); ok && effortLevel != "" &&
|
||||
(strings.HasPrefix(textRequest.Model, "claude-opus-4-6") || strings.HasPrefix(textRequest.Model, "claude-opus-4-7")) {
|
||||
(strings.HasPrefix(textRequest.Model, "claude-opus-4-6") ||
|
||||
strings.HasPrefix(textRequest.Model, "claude-opus-4-7") ||
|
||||
strings.HasPrefix(textRequest.Model, "claude-opus-4-8")) {
|
||||
claudeRequest.Model = baseModel
|
||||
claudeRequest.Thinking = &dto.Thinking{
|
||||
Type: "adaptive",
|
||||
}
|
||||
claudeRequest.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
|
||||
if strings.HasPrefix(baseModel, "claude-opus-4-7") {
|
||||
// Opus 4.7 rejects non-default temperature/top_p/top_k with 400
|
||||
if strings.HasPrefix(baseModel, "claude-opus-4-7") ||
|
||||
strings.HasPrefix(baseModel, "claude-opus-4-8") {
|
||||
// Opus 4.7/4.8 reject non-default temperature/top_p/top_k with 400
|
||||
// and defaults display to "omitted"; restore the 4.6 visible summary.
|
||||
claudeRequest.Thinking.Display = "summarized"
|
||||
claudeRequest.Temperature = nil
|
||||
@@ -175,8 +178,9 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
|
||||
strings.HasSuffix(textRequest.Model, "-thinking") {
|
||||
|
||||
trimmedModel := strings.TrimSuffix(textRequest.Model, "-thinking")
|
||||
if strings.HasPrefix(trimmedModel, "claude-opus-4-7") {
|
||||
// Opus 4.7 rejects thinking.type="enabled"; use adaptive at high effort.
|
||||
if strings.HasPrefix(trimmedModel, "claude-opus-4-7") ||
|
||||
strings.HasPrefix(trimmedModel, "claude-opus-4-8") {
|
||||
// Opus 4.7/4.8 reject thinking.type="enabled"; use adaptive at high effort.
|
||||
claudeRequest.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"}
|
||||
claudeRequest.OutputConfig = json.RawMessage(`{"effort":"high"}`)
|
||||
claudeRequest.Temperature = nil
|
||||
|
||||
@@ -9,6 +9,10 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func commonPointer[T any](value T) *T {
|
||||
return &value
|
||||
}
|
||||
|
||||
func TestFormatClaudeResponseInfo_MessageStart(t *testing.T) {
|
||||
claudeInfo := &ClaudeResponseInfo{
|
||||
Usage: &dto.Usage{},
|
||||
@@ -310,6 +314,58 @@ func TestRequestOpenAI2ClaudeMessage_IgnoresUnsupportedFileContent(t *testing.T)
|
||||
require.Equal(t, "see attachment", *content[0].Text)
|
||||
}
|
||||
|
||||
func TestRequestOpenAI2ClaudeMessage_ClaudeOpus48HighUsesAdaptiveThinking(t *testing.T) {
|
||||
request := dto.GeneralOpenAIRequest{
|
||||
Model: "claude-opus-4-8-high",
|
||||
Temperature: commonPointer(0.7),
|
||||
TopP: commonPointer(0.9),
|
||||
TopK: commonPointer(40),
|
||||
Messages: []dto.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: "hello",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "claude-opus-4-8", claudeRequest.Model)
|
||||
require.NotNil(t, claudeRequest.Thinking)
|
||||
require.Equal(t, "adaptive", claudeRequest.Thinking.Type)
|
||||
require.Equal(t, "summarized", claudeRequest.Thinking.Display)
|
||||
require.JSONEq(t, `{"effort":"high"}`, string(claudeRequest.OutputConfig))
|
||||
require.Nil(t, claudeRequest.Temperature)
|
||||
require.Nil(t, claudeRequest.TopP)
|
||||
require.Nil(t, claudeRequest.TopK)
|
||||
}
|
||||
|
||||
func TestRequestOpenAI2ClaudeMessage_ClaudeOpus48ThinkingUsesAdaptiveHighEffort(t *testing.T) {
|
||||
request := dto.GeneralOpenAIRequest{
|
||||
Model: "claude-opus-4-8-thinking",
|
||||
Temperature: commonPointer(0.7),
|
||||
TopP: commonPointer(0.9),
|
||||
TopK: commonPointer(40),
|
||||
Messages: []dto.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: "hello",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "claude-opus-4-8", claudeRequest.Model)
|
||||
require.NotNil(t, claudeRequest.Thinking)
|
||||
require.Equal(t, "adaptive", claudeRequest.Thinking.Type)
|
||||
require.Equal(t, "summarized", claudeRequest.Thinking.Display)
|
||||
require.JSONEq(t, `{"effort":"high"}`, string(claudeRequest.OutputConfig))
|
||||
require.Nil(t, claudeRequest.Temperature)
|
||||
require.Nil(t, claudeRequest.TopP)
|
||||
require.Nil(t, claudeRequest.TopK)
|
||||
}
|
||||
|
||||
func TestRequestOpenAI2ClaudeMessage_SupportsPDFFileContent(t *testing.T) {
|
||||
request := dto.GeneralOpenAIRequest{
|
||||
Model: "claude-3-5-sonnet",
|
||||
|
||||
@@ -45,6 +45,7 @@ var claudeModelMap = map[string]string{
|
||||
"claude-opus-4-5-20251101": "claude-opus-4-5@20251101",
|
||||
"claude-opus-4-6": "claude-opus-4-6",
|
||||
"claude-opus-4-7": "claude-opus-4-7",
|
||||
"claude-opus-4-8": "claude-opus-4-8",
|
||||
}
|
||||
|
||||
const anthropicVersion = "vertex-2023-10-16"
|
||||
|
||||
@@ -53,14 +53,17 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
}
|
||||
|
||||
if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(request.Model); ok && effortLevel != "" &&
|
||||
(strings.HasPrefix(request.Model, "claude-opus-4-6") || strings.HasPrefix(request.Model, "claude-opus-4-7")) {
|
||||
(strings.HasPrefix(request.Model, "claude-opus-4-6") ||
|
||||
strings.HasPrefix(request.Model, "claude-opus-4-7") ||
|
||||
strings.HasPrefix(request.Model, "claude-opus-4-8")) {
|
||||
request.Model = baseModel
|
||||
request.Thinking = &dto.Thinking{
|
||||
Type: "adaptive",
|
||||
}
|
||||
request.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
|
||||
if strings.HasPrefix(request.Model, "claude-opus-4-7") {
|
||||
// Opus 4.7 rejects non-default temperature/top_p/top_k with 400
|
||||
if strings.HasPrefix(request.Model, "claude-opus-4-7") ||
|
||||
strings.HasPrefix(request.Model, "claude-opus-4-8") {
|
||||
// Opus 4.7/4.8 reject non-default temperature/top_p/top_k with 400
|
||||
// and defaults display to "omitted"; restore the 4.6 visible summary.
|
||||
request.Thinking.Display = "summarized"
|
||||
request.Temperature = nil
|
||||
@@ -74,8 +77,9 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
strings.HasSuffix(request.Model, "-thinking") {
|
||||
if request.Thinking == nil {
|
||||
baseModel := strings.TrimSuffix(request.Model, "-thinking")
|
||||
if strings.HasPrefix(baseModel, "claude-opus-4-7") {
|
||||
// Opus 4.7 rejects thinking.type="enabled"; use adaptive at high effort.
|
||||
if strings.HasPrefix(baseModel, "claude-opus-4-7") ||
|
||||
strings.HasPrefix(baseModel, "claude-opus-4-8") {
|
||||
// Opus 4.7/4.8 reject thinking.type="enabled"; use adaptive at high effort.
|
||||
request.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"}
|
||||
request.OutputConfig = json.RawMessage(`{"effort":"high"}`)
|
||||
request.Temperature = nil
|
||||
|
||||
@@ -71,6 +71,13 @@ var defaultCacheRatio = map[string]float64{
|
||||
"claude-opus-4-7-high": 0.1,
|
||||
"claude-opus-4-7-medium": 0.1,
|
||||
"claude-opus-4-7-low": 0.1,
|
||||
"claude-opus-4-8": 0.1,
|
||||
"claude-opus-4-8-thinking": 0.1,
|
||||
"claude-opus-4-8-max": 0.1,
|
||||
"claude-opus-4-8-xhigh": 0.1,
|
||||
"claude-opus-4-8-high": 0.1,
|
||||
"claude-opus-4-8-medium": 0.1,
|
||||
"claude-opus-4-8-low": 0.1,
|
||||
}
|
||||
|
||||
var defaultCreateCacheRatio = map[string]float64{
|
||||
@@ -106,6 +113,13 @@ var defaultCreateCacheRatio = map[string]float64{
|
||||
"claude-opus-4-7-high": 1.25,
|
||||
"claude-opus-4-7-medium": 1.25,
|
||||
"claude-opus-4-7-low": 1.25,
|
||||
"claude-opus-4-8": 1.25,
|
||||
"claude-opus-4-8-thinking": 1.25,
|
||||
"claude-opus-4-8-max": 1.25,
|
||||
"claude-opus-4-8-xhigh": 1.25,
|
||||
"claude-opus-4-8-high": 1.25,
|
||||
"claude-opus-4-8-medium": 1.25,
|
||||
"claude-opus-4-8-low": 1.25,
|
||||
}
|
||||
|
||||
//var defaultCreateCacheRatio = map[string]float64{}
|
||||
|
||||
@@ -152,6 +152,12 @@ var defaultModelRatio = map[string]float64{
|
||||
"claude-opus-4-7-high": 2.5,
|
||||
"claude-opus-4-7-medium": 2.5,
|
||||
"claude-opus-4-7-low": 2.5,
|
||||
"claude-opus-4-8": 2.5,
|
||||
"claude-opus-4-8-max": 2.5,
|
||||
"claude-opus-4-8-xhigh": 2.5,
|
||||
"claude-opus-4-8-high": 2.5,
|
||||
"claude-opus-4-8-medium": 2.5,
|
||||
"claude-opus-4-8-low": 2.5,
|
||||
"claude-3-opus-20240229": 7.5, // $15 / 1M tokens
|
||||
"claude-opus-4-20250514": 7.5,
|
||||
"claude-opus-4-1-20250805": 7.5,
|
||||
|
||||
Vendored
+1136
-371
File diff suppressed because it is too large
Load Diff
Vendored
-2379
File diff suppressed because it is too large
Load Diff
Vendored
-1
@@ -24,6 +24,5 @@
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Vendored
+21
-20
@@ -4,30 +4,32 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@douyinfe/semi-illustrations": "^2.69.1",
|
||||
"@douyinfe/semi-icons": "^2.63.1",
|
||||
"@douyinfe/semi-ui": "^2.69.1",
|
||||
"@lobehub/icons": "^2.0.0",
|
||||
"@lobehub/icons": "catalog:",
|
||||
"@visactor/react-vchart": "~1.8.8",
|
||||
"@visactor/vchart": "~1.8.8",
|
||||
"@visactor/vchart-semi-theme": "~1.8.8",
|
||||
"axios": "1.15.2",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.11",
|
||||
"axios": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"history": "^5.3.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"i18next": "^23.16.8",
|
||||
"i18next-browser-languagedetector": "^7.2.0",
|
||||
"katex": "^0.16.22",
|
||||
"lucide-react": "^0.511.0",
|
||||
"marked": "^4.1.1",
|
||||
"mermaid": "^11.6.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"qrcode.react": "catalog:",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-fireworks": "^1.0.4",
|
||||
"react-i18next": "^13.0.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-icons": "catalog:",
|
||||
"react-markdown": "catalog:",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-telegram-login": "^1.1.2",
|
||||
"react-toastify": "^9.0.8",
|
||||
@@ -35,20 +37,20 @@
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-gfm": "catalog:",
|
||||
"remark-math": "^6.0.0",
|
||||
"sse.js": "^2.6.0",
|
||||
"sse.js": "catalog:",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"use-debounce": "^10.0.4"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"dev": "rsbuild dev",
|
||||
"build": "rsbuild build",
|
||||
"lint": "prettier . --check",
|
||||
"lint:fix": "prettier . --write",
|
||||
"eslint": "bunx eslint \"**/*.{js,jsx}\" --cache",
|
||||
"eslint:fix": "bunx eslint \"**/*.{js,jsx}\" --fix --cache",
|
||||
"preview": "vite preview",
|
||||
"preview": "rsbuild preview",
|
||||
"i18n:extract": "bunx i18next-cli extract",
|
||||
"i18n:status": "bunx i18next-cli status",
|
||||
"i18n:sync": "bunx i18next-cli sync",
|
||||
@@ -73,20 +75,19 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6",
|
||||
"@rsbuild/core": "^2.0.7",
|
||||
"@rsbuild/plugin-react": "^2.0.0",
|
||||
"@so1ve/prettier-config": "^3.1.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"code-inspector-plugin": "^1.3.3",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-plugin-header": "^3.1.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"i18next-cli": "^1.10.3",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.0.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"prettier": "catalog:",
|
||||
"tailwindcss": "^3",
|
||||
"typescript": "4.4.2",
|
||||
"vite": "^5.2.0"
|
||||
"typescript": "4.4.2"
|
||||
},
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
|
||||
Vendored
+106
@@ -0,0 +1,106 @@
|
||||
import path from 'path'
|
||||
import { createRequire } from 'module'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { defineConfig, loadEnv } from '@rsbuild/core'
|
||||
import { pluginReact } from '@rsbuild/plugin-react'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const require = createRequire(import.meta.url)
|
||||
const semiUiDir = path.resolve(
|
||||
path.dirname(require.resolve('@douyinfe/semi-ui')),
|
||||
'../..',
|
||||
)
|
||||
|
||||
export default defineConfig(({ envMode }) => {
|
||||
const env = loadEnv({ mode: envMode, prefixes: ['VITE_'] })
|
||||
const clientServerUrl =
|
||||
process.env.VITE_REACT_APP_SERVER_URL ||
|
||||
env.rawPublicVars.VITE_REACT_APP_SERVER_URL ||
|
||||
''
|
||||
const proxyServerUrl =
|
||||
clientServerUrl ||
|
||||
'http://localhost:3000'
|
||||
const isProd = envMode === 'production'
|
||||
const devProxy = Object.fromEntries(
|
||||
(['/api', '/mj', '/pg'] as const).map((key) => [
|
||||
key,
|
||||
{ target: proxyServerUrl, changeOrigin: true },
|
||||
]),
|
||||
) as Record<string, { target: string; changeOrigin: boolean }>
|
||||
|
||||
return {
|
||||
plugins: [pluginReact()],
|
||||
source: {
|
||||
entry: {
|
||||
index: './src/index.jsx',
|
||||
},
|
||||
define: {
|
||||
'import.meta.env.VITE_REACT_APP_SERVER_URL': JSON.stringify(
|
||||
clientServerUrl,
|
||||
),
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@douyinfe/semi-ui/dist/css/semi.css': path.resolve(
|
||||
semiUiDir,
|
||||
'dist/css/semi.css',
|
||||
),
|
||||
},
|
||||
},
|
||||
html: {
|
||||
template: './index.html',
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
strictPort: true,
|
||||
proxy: devProxy,
|
||||
},
|
||||
output: {
|
||||
minify: isProd,
|
||||
target: 'web',
|
||||
distPath: {
|
||||
root: 'dist',
|
||||
},
|
||||
},
|
||||
performance: {
|
||||
removeConsole: isProd ? ['log'] : false,
|
||||
buildCache: {
|
||||
cacheDigest: [process.env.VITE_REACT_APP_VERSION],
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
rspack: {
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /src[\\/].*\.js$/,
|
||||
type: 'javascript/auto',
|
||||
use: [
|
||||
{
|
||||
loader: 'builtin:swc-loader',
|
||||
options: {
|
||||
jsc: {
|
||||
parser: {
|
||||
syntax: 'ecmascript',
|
||||
jsx: true,
|
||||
},
|
||||
transform: {
|
||||
react: {
|
||||
runtime: 'automatic',
|
||||
development: !isProd,
|
||||
refresh: !isProd,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
+1
-1
@@ -947,7 +947,7 @@ const LoginForm = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div
|
||||
className='blur-ball blur-ball-indigo'
|
||||
|
||||
@@ -104,7 +104,7 @@ const PasswordResetConfirm = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div
|
||||
className='blur-ball blur-ball-indigo'
|
||||
|
||||
+1
-1
@@ -104,7 +104,7 @@ const PasswordResetForm = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div
|
||||
className='blur-ball blur-ball-indigo'
|
||||
|
||||
+1
-1
@@ -770,7 +770,7 @@ const RegisterForm = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div
|
||||
className='blur-ball blur-ball-indigo'
|
||||
|
||||
@@ -141,7 +141,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
// 显示加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex justify-center items-center min-h-screen'>
|
||||
<div className='classic-page-fill flex justify-center items-center'>
|
||||
<Spin size='large' />
|
||||
</div>
|
||||
);
|
||||
@@ -150,7 +150,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
// 如果没有内容,显示空状态
|
||||
if (!content || content.trim() === '') {
|
||||
return (
|
||||
<div className='flex justify-center items-center min-h-screen bg-gray-50'>
|
||||
<div className='classic-page-fill flex justify-center items-center bg-gray-50'>
|
||||
<Empty
|
||||
title={t('管理员未设置' + title + '内容')}
|
||||
image={
|
||||
@@ -168,7 +168,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
// 如果是 URL,显示链接卡片
|
||||
if (isUrl(content)) {
|
||||
return (
|
||||
<div className='flex justify-center items-center min-h-screen bg-gray-50 p-4'>
|
||||
<div className='classic-page-fill flex justify-center items-center bg-gray-50 p-4'>
|
||||
<Card className='max-w-md w-full'>
|
||||
<div className='text-center'>
|
||||
<Title heading={4} className='mb-4'>
|
||||
@@ -196,7 +196,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
// 如果是 HTML 内容,直接渲染
|
||||
if (isHtmlContent(content)) {
|
||||
return (
|
||||
<div className='min-h-screen bg-gray-50'>
|
||||
<div className='classic-page-fill bg-gray-50'>
|
||||
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='bg-white rounded-lg shadow-sm p-8'>
|
||||
<Title heading={2} className='text-center mb-8'>
|
||||
@@ -214,7 +214,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
|
||||
// 其他内容统一使用 Markdown 渲染器
|
||||
return (
|
||||
<div className='min-h-screen bg-gray-50'>
|
||||
<div className='classic-page-fill bg-gray-50'>
|
||||
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='bg-white rounded-lg shadow-sm p-8'>
|
||||
<Title heading={2} className='text-center mb-8'>
|
||||
|
||||
+10
-5
@@ -71,6 +71,7 @@ const PageLayout = () => {
|
||||
|
||||
const isConsoleRoute = location.pathname.startsWith('/console');
|
||||
const showSider = isConsoleRoute && (!isMobile || drawerOpen);
|
||||
const isFixedLayout = isConsoleRoute || location.pathname === '/pricing';
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile && drawerOpen && collapsed) {
|
||||
@@ -146,11 +147,11 @@ const PageLayout = () => {
|
||||
|
||||
return (
|
||||
<Layout
|
||||
className='app-layout'
|
||||
className={`app-layout${isFixedLayout ? ' app-layout-fixed' : ''}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: isMobile ? 'visible' : 'hidden',
|
||||
overflow: isFixedLayout && !isMobile ? 'hidden' : 'visible',
|
||||
}}
|
||||
>
|
||||
<Header
|
||||
@@ -171,9 +172,10 @@ const PageLayout = () => {
|
||||
</Header>
|
||||
<Layout
|
||||
style={{
|
||||
overflow: isMobile ? 'visible' : 'auto',
|
||||
overflow: isFixedLayout && !isMobile ? 'auto' : 'visible',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: '1 1 auto',
|
||||
}}
|
||||
>
|
||||
{showSider && (
|
||||
@@ -206,15 +208,18 @@ const PageLayout = () => {
|
||||
flex: '1 1 auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<Content
|
||||
className={isFixedLayout ? undefined : 'public-page-content'}
|
||||
style={{
|
||||
flex: '1 0 auto',
|
||||
overflowY: isMobile ? 'visible' : 'hidden',
|
||||
flex: isFixedLayout ? '1 0 auto' : '1 1 auto',
|
||||
overflowY: isFixedLayout && !isMobile ? 'hidden' : 'visible',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
padding: shouldInnerPadding ? (isMobile ? '5px' : '24px') : '0',
|
||||
position: 'relative',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
|
||||
Vendored
+43
-9
@@ -17,12 +17,46 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
export * from './channel.constants';
|
||||
export * from './user.constants';
|
||||
export * from './toast.constants';
|
||||
export * from './common.constant';
|
||||
export * from './dashboard.constants';
|
||||
export * from './playground.constants';
|
||||
export * from './redemption.constants';
|
||||
export * from './channel-affinity-template.constants';
|
||||
export * from './billing.constants';
|
||||
export {
|
||||
CHANNEL_OPTIONS,
|
||||
MODEL_FETCHABLE_CHANNEL_TYPES,
|
||||
MODEL_TABLE_PAGE_SIZE,
|
||||
} from './channel.constants';
|
||||
export { userConstants } from './user.constants';
|
||||
export { toastConstants } from './toast.constants';
|
||||
export {
|
||||
ITEMS_PER_PAGE,
|
||||
DEFAULT_ENDPOINT,
|
||||
TABLE_COMPACT_MODES_KEY,
|
||||
API_ENDPOINTS,
|
||||
TASK_ACTION_GENERATE,
|
||||
TASK_ACTION_TEXT_GENERATE,
|
||||
TASK_ACTION_FIRST_TAIL_GENERATE,
|
||||
TASK_ACTION_REFERENCE_GENERATE,
|
||||
TASK_ACTION_REMIX_GENERATE,
|
||||
} from './common.constant';
|
||||
export {
|
||||
REDEMPTION_STATUS,
|
||||
REDEMPTION_STATUS_MAP,
|
||||
REDEMPTION_ACTIONS,
|
||||
} from './redemption.constants';
|
||||
export {
|
||||
CODEX_CLI_HEADER_PASSTHROUGH_HEADERS,
|
||||
CLAUDE_CLI_HEADER_PASSTHROUGH_HEADERS,
|
||||
CODEX_CLI_HEADER_PASSTHROUGH_TEMPLATE,
|
||||
CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE,
|
||||
CHANNEL_AFFINITY_RULE_TEMPLATES,
|
||||
cloneChannelAffinityTemplate,
|
||||
} from './channel-affinity-template.constants';
|
||||
export {
|
||||
BILLING_VARS,
|
||||
BILLING_VAR_KEYS,
|
||||
BILLING_PRICING_VARS,
|
||||
BILLING_EXTRA_VARS,
|
||||
BILLING_VAR_KEY_TO_FIELD,
|
||||
BILLING_VAR_FIELD_TO_LABEL,
|
||||
BILLING_VAR_FIELD_TO_SHORT_LABEL,
|
||||
BILLING_CACHE_VAR_MAP,
|
||||
BILLING_VAR_REGEX,
|
||||
BILLING_CONDITION_VARS,
|
||||
} from './billing.constants';
|
||||
|
||||
Vendored
+2
-2
@@ -94,7 +94,6 @@ import {
|
||||
SiGitlab,
|
||||
SiGoogle,
|
||||
SiKeycloak,
|
||||
SiLinkedin,
|
||||
SiNextcloud,
|
||||
SiNotion,
|
||||
SiOkta,
|
||||
@@ -106,6 +105,7 @@ import {
|
||||
SiWechat,
|
||||
SiX,
|
||||
} from 'react-icons/si';
|
||||
import { FaLinkedin } from 'react-icons/fa';
|
||||
|
||||
// 获取侧边栏Lucide图标组件
|
||||
export function getLucideIcon(key, selected = false) {
|
||||
@@ -509,7 +509,7 @@ const oauthProviderIconMap = {
|
||||
google: SiGoogle,
|
||||
discord: SiDiscord,
|
||||
facebook: SiFacebook,
|
||||
linkedin: SiLinkedin,
|
||||
linkedin: FaLinkedin,
|
||||
x: SiX,
|
||||
twitter: SiX,
|
||||
slack: SiSlack,
|
||||
|
||||
Vendored
+1
-1
@@ -123,7 +123,7 @@ export function showError(error) {
|
||||
console.error(error);
|
||||
if (error.message) {
|
||||
if (error.name === 'AxiosError') {
|
||||
switch (error.response.status) {
|
||||
switch (error.response?.status) {
|
||||
case 401:
|
||||
// 清除用户状态
|
||||
localStorage.removeItem('user');
|
||||
|
||||
Vendored
+28
-6
@@ -31,18 +31,40 @@ body {
|
||||
background-color: var(--semi-color-bg-0);
|
||||
}
|
||||
|
||||
/* 桌面端禁止 body 纵向滚动 - 防止 VChart tooltip 触发页面滚动条 */
|
||||
@media (min-width: 768px) {
|
||||
body {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.app-layout {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
.app-layout-fixed {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.public-page-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.classic-page-fill {
|
||||
flex: 1 0 auto;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.classic-home-page,
|
||||
.classic-home-default {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.classic-home-default {
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
.classic-home-hero {
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
.app-sider {
|
||||
height: calc(100vh - 64px);
|
||||
height: calc(100dvh - 64px);
|
||||
|
||||
Vendored
+2
@@ -1,3 +1,5 @@
|
||||
import '@douyinfe/semi-ui/react19-adapter';
|
||||
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
|
||||
+8
-3
@@ -133,9 +133,9 @@ const About = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<div className='classic-page-fill flex flex-col pt-[60px] px-2'>
|
||||
{aboutLoaded && about === '' ? (
|
||||
<div className='flex justify-center items-center h-screen p-8'>
|
||||
<div className='flex flex-1 justify-center items-center p-8'>
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationConstruction style={{ width: 150, height: 150 }} />
|
||||
@@ -156,7 +156,12 @@ const About = () => {
|
||||
{about.startsWith('https://') ? (
|
||||
<iframe
|
||||
src={about}
|
||||
style={{ width: '100%', height: '100vh', border: 'none' }}
|
||||
style={{
|
||||
width: '100%',
|
||||
flex: '1 1 auto',
|
||||
minHeight: 0,
|
||||
border: 'none',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ import { useTranslation } from 'react-i18next';
|
||||
const Forbidden = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className='flex justify-center items-center h-screen p-8'>
|
||||
<div className='classic-page-fill flex justify-center items-center p-8'>
|
||||
<Empty
|
||||
image={<IllustrationNoAccess style={{ width: 250, height: 250 }} />}
|
||||
darkModeImage={
|
||||
|
||||
Vendored
+6
-6
@@ -149,20 +149,20 @@ const Home = () => {
|
||||
}, [endpointItems.length]);
|
||||
|
||||
return (
|
||||
<div className='w-full overflow-x-hidden'>
|
||||
<div className='classic-page-fill classic-home-page w-full overflow-x-hidden'>
|
||||
<NoticeModal
|
||||
visible={noticeVisible}
|
||||
onClose={() => setNoticeVisible(false)}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
{homePageContentLoaded && homePageContent === '' ? (
|
||||
<div className='w-full overflow-x-hidden'>
|
||||
<div className='classic-home-default w-full overflow-x-hidden'>
|
||||
{/* Banner 部分 */}
|
||||
<div className='w-full border-b border-semi-color-border min-h-[500px] md:min-h-[600px] lg:min-h-[700px] relative overflow-x-hidden'>
|
||||
<div className='classic-home-hero w-full border-b border-semi-color-border relative overflow-x-hidden'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div className='blur-ball blur-ball-indigo' />
|
||||
<div className='blur-ball blur-ball-teal' />
|
||||
<div className='flex items-center justify-center h-full px-4 py-20 md:py-24 lg:py-32 mt-10'>
|
||||
<div className='flex items-center justify-center px-4 pt-24 pb-8'>
|
||||
{/* 居中内容区 */}
|
||||
<div className='flex flex-col items-center justify-center text-center max-w-4xl mx-auto'>
|
||||
<div className='flex flex-col items-center justify-center mb-6 md:mb-8'>
|
||||
@@ -335,11 +335,11 @@ const Home = () => {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='overflow-x-hidden w-full'>
|
||||
<div className='classic-page-fill overflow-x-hidden w-full'>
|
||||
{homePageContent.startsWith('https://') ? (
|
||||
<iframe
|
||||
src={homePageContent}
|
||||
className='w-full h-screen border-none'
|
||||
className='w-full h-full border-none'
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ import { useTranslation } from 'react-i18next';
|
||||
const NotFound = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className='flex justify-center items-center h-screen p-8'>
|
||||
<div className='classic-page-fill flex justify-center items-center p-8'>
|
||||
<Empty
|
||||
image={<IllustrationNotFound style={{ width: 250, height: 250 }} />}
|
||||
darkModeImage={
|
||||
|
||||
Vendored
-107
@@ -1,107 +0,0 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig, transformWithEsbuild } from 'vite';
|
||||
import pkg from '@douyinfe/vite-plugin-semi';
|
||||
import path from 'path';
|
||||
import { codeInspectorPlugin } from 'code-inspector-plugin';
|
||||
const { vitePluginSemi } = pkg;
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
codeInspectorPlugin({
|
||||
bundler: 'vite',
|
||||
}),
|
||||
{
|
||||
name: 'treat-js-files-as-jsx',
|
||||
async transform(code, id) {
|
||||
if (!/src\/.*\.js$/.test(id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use the exposed transform from vite, instead of directly
|
||||
// transforming with esbuild
|
||||
return transformWithEsbuild(code, id, {
|
||||
loader: 'jsx',
|
||||
jsx: 'automatic',
|
||||
});
|
||||
},
|
||||
},
|
||||
react(),
|
||||
vitePluginSemi({
|
||||
cssLayer: true,
|
||||
}),
|
||||
],
|
||||
optimizeDeps: {
|
||||
force: true,
|
||||
esbuildOptions: {
|
||||
loader: {
|
||||
'.js': 'jsx',
|
||||
'.json': 'json',
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'react-core': ['react', 'react-dom', 'react-router-dom'],
|
||||
'semi-ui': ['@douyinfe/semi-icons', '@douyinfe/semi-ui'],
|
||||
tools: ['axios', 'history', 'marked'],
|
||||
'react-components': [
|
||||
'react-dropzone',
|
||||
'react-fireworks',
|
||||
'react-telegram-login',
|
||||
'react-toastify',
|
||||
'react-turnstile',
|
||||
],
|
||||
i18n: [
|
||||
'i18next',
|
||||
'react-i18next',
|
||||
'i18next-browser-languagedetector',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/mj': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/pg': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Vendored
+10
-10
@@ -24,7 +24,7 @@
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@hugeicons/core-free-icons": "^4.1.4",
|
||||
"@hugeicons/react": "^1.1.6",
|
||||
"@lobehub/icons": "^5.8.0",
|
||||
"@lobehub/icons": "catalog:",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@tanstack/react-query": "^5.100.14",
|
||||
"@tanstack/react-router": "^1.170.8",
|
||||
@@ -34,12 +34,12 @@
|
||||
"@visactor/vchart": "^2.0.22",
|
||||
"ai": "^6.0.191",
|
||||
"auto-skeleton-react": "^1.0.5",
|
||||
"axios": "^1.16.1",
|
||||
"axios": "catalog:",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"clsx": "catalog:",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.3.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"dayjs": "catalog:",
|
||||
"i18next": "^26.2.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"input-otp": "^1.4.2",
|
||||
@@ -47,22 +47,22 @@
|
||||
"motion": "^12.40.0",
|
||||
"nanoid": "^5.1.11",
|
||||
"next-themes": "^0.4.6",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"qrcode.react": "catalog:",
|
||||
"react": "^19.2.6",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.76.1",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-icons": "catalog:",
|
||||
"react-markdown": "catalog:",
|
||||
"react-resizable-panels": "^4.11.2",
|
||||
"react-top-loading-bar": "^3.0.2",
|
||||
"recharts": "3.8.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-gfm": "catalog:",
|
||||
"shiki": "^4.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"sse.js": "^2.8.0",
|
||||
"sse.js": "catalog:",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
@@ -92,7 +92,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"knip": "^6.14.2",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier": "catalog:",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"shadcn": "^4.8.0",
|
||||
"typescript": "~6.0.3",
|
||||
|
||||
Vendored
+1
@@ -65,6 +65,7 @@ export default defineConfig(({ envMode }) => {
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
strictPort: true,
|
||||
proxy: devProxy,
|
||||
},
|
||||
output: {
|
||||
|
||||
+23
-254
@@ -23,66 +23,34 @@ 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 {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BundledLanguage, ShikiTransformer } from 'shiki'
|
||||
type BundledLanguage,
|
||||
codeToHtml,
|
||||
type ShikiTransformer,
|
||||
} from 'shiki/bundle/web'
|
||||
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
|
||||
collapsedLines?: number
|
||||
defaultCollapsed?: boolean
|
||||
enableCollapse?: boolean
|
||||
filename?: string
|
||||
language: BundledLanguage | string
|
||||
maxExpandedLines?: number
|
||||
/** @deprecated use collapsedLines for collapsed preview height. */
|
||||
maxCollapsedLines?: number
|
||||
language: BundledLanguage
|
||||
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) {
|
||||
@@ -104,251 +72,64 @@ 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 | string,
|
||||
language: BundledLanguage,
|
||||
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]
|
||||
: []
|
||||
|
||||
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,
|
||||
return codeToHtml(code, {
|
||||
lang: language,
|
||||
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)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setHtml(renderPlainCodeHtml(code, showLineNumbers))
|
||||
}
|
||||
})
|
||||
highlightCode(code, language, showLineNumbers).then((next) => {
|
||||
if (!cancelled) {
|
||||
setHtml(next)
|
||||
}
|
||||
})
|
||||
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, language: displayLanguage }}>
|
||||
<CodeBlockContext.Provider value={{ code }}>
|
||||
<div
|
||||
className={cn(
|
||||
'group/code-block bg-muted/20 text-foreground my-3 w-full max-w-full overflow-hidden rounded-lg border shadow-xs',
|
||||
'group bg-background text-foreground relative w-full overflow-hidden rounded-md border',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{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='relative'>
|
||||
<div
|
||||
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'
|
||||
)}
|
||||
className='[&>pre]:bg-background! [&>pre]:text-foreground! overflow-hidden [&_code]:font-mono [&_code]:text-sm [&>pre]:m-0 [&>pre]:p-4 [&>pre]:text-sm'
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
style={{ maxHeight: bodyMaxHeight }}
|
||||
/>
|
||||
{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 className='absolute top-2 right-2 flex items-center gap-2'>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
@@ -372,7 +153,6 @@ export const CodeBlockCopyButton = ({
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockCopyButtonProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const { code } = useContext(CodeBlockContext)
|
||||
|
||||
@@ -394,26 +174,15 @@ export const CodeBlockCopyButton = ({
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon
|
||||
|
||||
const button = (
|
||||
return (
|
||||
<Button
|
||||
aria-label={isCopied ? t('Copied!') : t('Copy code')}
|
||||
className={cn('size-8 shrink-0', className)}
|
||||
className={cn('shrink-0', className)}
|
||||
onClick={copyToClipboard}
|
||||
size='icon-sm'
|
||||
type='button'
|
||||
size='icon'
|
||||
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 min-h-0 flex-1 overflow-hidden', className)}
|
||||
className={cn('relative flex-1 overflow-y-auto', className)}
|
||||
initial='smooth'
|
||||
resize='smooth'
|
||||
role='log'
|
||||
|
||||
+1
-1
@@ -188,7 +188,7 @@ export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'border-border/70 mt-3 ml-2 border-l pl-4 text-sm',
|
||||
'mt-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
|
||||
)}
|
||||
|
||||
+4
-436
@@ -18,436 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Children,
|
||||
type ComponentProps,
|
||||
type JSX,
|
||||
isValidElement,
|
||||
memo,
|
||||
type ReactNode,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { Streamdown, type Components } from 'streamdown'
|
||||
import { type ComponentProps, memo } from 'react'
|
||||
import { Streamdown } 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, components, ...props }: ResponseProps) => {
|
||||
({ className, children, ...props }: ResponseProps) => {
|
||||
const stripCustomTags = (input: unknown): unknown => {
|
||||
if (typeof input !== 'string') return input
|
||||
return (
|
||||
@@ -467,19 +45,9 @@ export const Response = memo(
|
||||
return (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
'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',
|
||||
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||
className
|
||||
)}
|
||||
components={{ ...responseComponents, ...components }}
|
||||
{...props}
|
||||
>
|
||||
{safeChildren}
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ export const SourcesContent = ({
|
||||
}: SourcesContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'border-border/70 mt-3 ml-2 flex w-fit flex-col gap-2 border-l pl-4',
|
||||
'mt-3 flex w-fit flex-col gap-2',
|
||||
'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
|
||||
)}
|
||||
|
||||
+23
-13
@@ -24,7 +24,7 @@ import {
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { type SubmitErrorHandler, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
@@ -140,6 +140,7 @@ import {
|
||||
hasModelConfigChanged,
|
||||
findMissingModelsInMapping,
|
||||
validateModelMappingJson,
|
||||
hasAdvancedSettingsErrors,
|
||||
} from '../../lib'
|
||||
import {
|
||||
collectInvalidStatusCodeEntries,
|
||||
@@ -204,7 +205,6 @@ function readAdvancedSettingsPreference(): boolean {
|
||||
|
||||
function hasAdvancedSettingsValues(values: ChannelFormValues): boolean {
|
||||
return Boolean(
|
||||
values.model_mapping?.trim() ||
|
||||
values.param_override?.trim() ||
|
||||
values.header_override?.trim() ||
|
||||
values.status_code_mapping?.trim() ||
|
||||
@@ -1008,6 +1008,26 @@ export function ChannelMutateDrawer({
|
||||
]
|
||||
)
|
||||
|
||||
const handleAdvancedSettingsOpenChange = useCallback((nextOpen: boolean) => {
|
||||
setAdvancedSettingsOpen(nextOpen)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(
|
||||
ADVANCED_SETTINGS_EXPANDED_KEY,
|
||||
String(nextOpen)
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onInvalid: SubmitErrorHandler<ChannelFormValues> = useCallback(
|
||||
(errors) => {
|
||||
if (hasAdvancedSettingsErrors(errors)) {
|
||||
handleAdvancedSettingsOpenChange(true)
|
||||
}
|
||||
toast.error(t('Please fix the highlighted fields before saving'))
|
||||
},
|
||||
[handleAdvancedSettingsOpenChange, t]
|
||||
)
|
||||
|
||||
// Handle drawer close
|
||||
const handleOpenChange = useCallback(
|
||||
(v: boolean) => {
|
||||
@@ -1020,16 +1040,6 @@ export function ChannelMutateDrawer({
|
||||
[onOpenChange, form]
|
||||
)
|
||||
|
||||
const handleAdvancedSettingsOpenChange = useCallback((nextOpen: boolean) => {
|
||||
setAdvancedSettingsOpen(nextOpen)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(
|
||||
ADVANCED_SETTINGS_EXPANDED_KEY,
|
||||
String(nextOpen)
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={handleOpenChange}>
|
||||
@@ -1060,7 +1070,7 @@ export function ChannelMutateDrawer({
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='channel-form'
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
onSubmit={form.handleSubmit(onSubmit, onInvalid)}
|
||||
className={sideDrawerFormClassName('gap-5')}
|
||||
>
|
||||
{isChannelDetailLoading ? (
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
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 { FieldPath } from 'react-hook-form'
|
||||
import type { ChannelFormValues } from './channel-form'
|
||||
|
||||
type ChannelFormErrorMap = Partial<
|
||||
Record<FieldPath<ChannelFormValues>, unknown>
|
||||
>
|
||||
|
||||
const ADVANCED_SETTINGS_FIELDS = new Set<FieldPath<ChannelFormValues>>([
|
||||
'priority',
|
||||
'weight',
|
||||
'test_model',
|
||||
'auto_ban',
|
||||
'tag',
|
||||
'remark',
|
||||
'param_override',
|
||||
'header_override',
|
||||
'status_code_mapping',
|
||||
'force_format',
|
||||
'thinking_to_content',
|
||||
'pass_through_body_enabled',
|
||||
'proxy',
|
||||
'system_prompt',
|
||||
'system_prompt_override',
|
||||
'allow_service_tier',
|
||||
'disable_store',
|
||||
'allow_safety_identifier',
|
||||
'allow_include_obfuscation',
|
||||
'allow_inference_geo',
|
||||
'allow_speed',
|
||||
'claude_beta_query',
|
||||
'upstream_model_update_check_enabled',
|
||||
'upstream_model_update_auto_sync_enabled',
|
||||
'upstream_model_update_ignored_models',
|
||||
])
|
||||
|
||||
export function isAdvancedSettingsField(
|
||||
fieldName: string
|
||||
): fieldName is FieldPath<ChannelFormValues> {
|
||||
return ADVANCED_SETTINGS_FIELDS.has(fieldName as FieldPath<ChannelFormValues>)
|
||||
}
|
||||
|
||||
export function hasAdvancedSettingsErrors(
|
||||
errors: ChannelFormErrorMap
|
||||
): boolean {
|
||||
return Object.keys(errors).some((fieldName) =>
|
||||
isAdvancedSettingsField(fieldName)
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
// Re-export all library functions
|
||||
export * from './channel-actions'
|
||||
export * from './channel-form-errors'
|
||||
export * from './channel-form'
|
||||
export * from './channel-type-config'
|
||||
export * from './channel-utils'
|
||||
|
||||
@@ -189,6 +189,7 @@ export function CCSwitchDialog(props: Props) {
|
||||
onValueChange={setName}
|
||||
placeholder={currentConfig.defaultName}
|
||||
emptyText=''
|
||||
allowCustomValue={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
+1
-3
@@ -29,11 +29,9 @@ import type {
|
||||
* Send chat completion request (non-streaming)
|
||||
*/
|
||||
export async function sendChatCompletion(
|
||||
payload: ChatCompletionRequest,
|
||||
signal?: AbortSignal
|
||||
payload: ChatCompletionRequest
|
||||
): Promise<ChatCompletionResponse> {
|
||||
const res = await api.post(API_ENDPOINTS.CHAT_COMPLETIONS, payload, {
|
||||
signal,
|
||||
skipErrorHandler: true,
|
||||
} as Record<string, unknown>)
|
||||
return res.data
|
||||
|
||||
+56
-129
@@ -16,33 +16,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
Edit,
|
||||
MoreHorizontal,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Copy, Check, RefreshCw, Edit, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { MESSAGE_ACTION_LABELS } from '../constants'
|
||||
import { useMessageActionGuard } from '../hooks/use-message-action-guard'
|
||||
import {
|
||||
getMessageActionState,
|
||||
getMessageActionsVisibilityClass,
|
||||
} from '../lib'
|
||||
import type { Message } from '../types'
|
||||
import { MessageActionButton } from './message-action-button'
|
||||
|
||||
@@ -57,15 +36,6 @@ interface MessageActionsProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
type MessageActionItem = {
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
onClick: () => void
|
||||
variant?: 'default' | 'destructive'
|
||||
}
|
||||
|
||||
export function MessageActions({
|
||||
message,
|
||||
onCopy,
|
||||
@@ -76,12 +46,14 @@ export function MessageActions({
|
||||
alwaysVisible = false,
|
||||
className = '',
|
||||
}: MessageActionsProps) {
|
||||
const { t } = useTranslation()
|
||||
const { copiedText, copyToClipboard } = useCopyToClipboard()
|
||||
const { guardAction } = useMessageActionGuard(isGenerating)
|
||||
|
||||
const { content, hasContent, isAssistant, isLoading } =
|
||||
getMessageActionState(message)
|
||||
const isAssistant = message.from === 'assistant'
|
||||
const hasContent = message.versions.some((v) => v.content)
|
||||
const isLoading =
|
||||
message.status === 'loading' || message.status === 'streaming'
|
||||
const content = message.versions[0]?.content || ''
|
||||
const isCopied = copiedText === content
|
||||
|
||||
const handleCopy = () => {
|
||||
@@ -97,105 +69,60 @@ export function MessageActions({
|
||||
const handleEdit = guardAction(() => onEdit?.(message))
|
||||
const handleDelete = guardAction(() => onDelete?.(message))
|
||||
|
||||
const visibilityClass = getMessageActionsVisibilityClass(alwaysVisible)
|
||||
const actions: MessageActionItem[] = []
|
||||
|
||||
if (hasContent) {
|
||||
actions.push({
|
||||
className: isCopied ? 'text-green-600' : '',
|
||||
icon: isCopied ? Check : Copy,
|
||||
label: isCopied ? MESSAGE_ACTION_LABELS.COPIED : MESSAGE_ACTION_LABELS.COPY,
|
||||
onClick: handleCopy,
|
||||
})
|
||||
}
|
||||
|
||||
if (isAssistant && !isLoading && onRegenerate) {
|
||||
actions.push({
|
||||
disabled: isGenerating,
|
||||
icon: RefreshCw,
|
||||
label: MESSAGE_ACTION_LABELS.REGENERATE,
|
||||
onClick: handleRegenerate,
|
||||
})
|
||||
}
|
||||
|
||||
if (hasContent && onEdit) {
|
||||
actions.push({
|
||||
disabled: isGenerating,
|
||||
icon: Edit,
|
||||
label: MESSAGE_ACTION_LABELS.EDIT,
|
||||
onClick: handleEdit,
|
||||
})
|
||||
}
|
||||
|
||||
if (onDelete) {
|
||||
actions.push({
|
||||
disabled: isGenerating,
|
||||
icon: Trash2,
|
||||
label: MESSAGE_ACTION_LABELS.DELETE,
|
||||
onClick: handleDelete,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
if (actions.length === 0) return null
|
||||
const visibilityClass = alwaysVisible
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100 max-md:opacity-100'
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipProvider delay={300}>
|
||||
<div
|
||||
className={`hidden items-center gap-0.5 transition-opacity md:flex ${visibilityClass} ${className}`}
|
||||
>
|
||||
{actions.map((action) => (
|
||||
<MessageActionButton
|
||||
className={action.className}
|
||||
disabled={action.disabled}
|
||||
icon={action.icon}
|
||||
key={action.label}
|
||||
label={action.label}
|
||||
onClick={action.onClick}
|
||||
variant={action.variant}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
<div className={`md:hidden ${className}`}>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={t('Open menu')}
|
||||
className='data-popup-open:bg-muted size-11 text-muted-foreground hover:text-foreground'
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
/>
|
||||
<TooltipProvider delay={300}>
|
||||
<div
|
||||
className={`flex items-center gap-0.5 transition-opacity ${visibilityClass} ${className}`}
|
||||
>
|
||||
{/* Copy */}
|
||||
{hasContent && (
|
||||
<MessageActionButton
|
||||
icon={isCopied ? Check : Copy}
|
||||
label={
|
||||
isCopied
|
||||
? MESSAGE_ACTION_LABELS.COPIED
|
||||
: MESSAGE_ACTION_LABELS.COPY
|
||||
}
|
||||
>
|
||||
<MoreHorizontal className='size-4' />
|
||||
<span className='sr-only'>{t('Open menu')}</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-44'>
|
||||
{actions.map((action) => {
|
||||
const Icon = action.icon
|
||||
onClick={handleCopy}
|
||||
className={isCopied ? 'text-green-600' : ''}
|
||||
/>
|
||||
)}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className='min-h-11'
|
||||
disabled={action.disabled}
|
||||
key={action.label}
|
||||
onClick={action.onClick}
|
||||
variant={action.variant}
|
||||
>
|
||||
{action.label}
|
||||
<DropdownMenuShortcut>
|
||||
<Icon className='size-4' />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{/* Regenerate - only for assistant messages */}
|
||||
{isAssistant && !isLoading && onRegenerate && (
|
||||
<MessageActionButton
|
||||
icon={RefreshCw}
|
||||
label={MESSAGE_ACTION_LABELS.REGENERATE}
|
||||
onClick={handleRegenerate}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit */}
|
||||
{hasContent && onEdit && (
|
||||
<MessageActionButton
|
||||
icon={Edit}
|
||||
label={MESSAGE_ACTION_LABELS.EDIT}
|
||||
onClick={handleEdit}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete */}
|
||||
{onDelete && (
|
||||
<MessageActionButton
|
||||
icon={Trash2}
|
||||
label={MESSAGE_ACTION_LABELS.DELETE}
|
||||
onClick={handleDelete}
|
||||
disabled={isGenerating}
|
||||
variant='destructive'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,83 +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 { Edit, RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
type MessageErrorActionsProps = {
|
||||
disabled?: boolean
|
||||
onDelete?: () => void
|
||||
onEditPrompt?: () => void
|
||||
onRetry?: () => void
|
||||
}
|
||||
|
||||
export function MessageErrorActions({
|
||||
disabled = false,
|
||||
onDelete,
|
||||
onEditPrompt,
|
||||
onRetry,
|
||||
}: MessageErrorActionsProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!onRetry && !onEditPrompt && !onDelete) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-wrap gap-2 pt-2'>
|
||||
{onRetry && (
|
||||
<Button
|
||||
className='max-md:min-h-11'
|
||||
disabled={disabled}
|
||||
onClick={onRetry}
|
||||
size='sm'
|
||||
>
|
||||
<RefreshCw className='size-3.5' />
|
||||
{t('Retry')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onEditPrompt && (
|
||||
<Button
|
||||
className='max-md:min-h-11'
|
||||
disabled={disabled}
|
||||
onClick={onEditPrompt}
|
||||
size='sm'
|
||||
variant='outline'
|
||||
>
|
||||
<Edit className='size-3.5' />
|
||||
{t('Edit')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onDelete && (
|
||||
<Button
|
||||
className='max-md:min-h-11'
|
||||
disabled={disabled}
|
||||
onClick={onDelete}
|
||||
size='sm'
|
||||
variant='destructive'
|
||||
>
|
||||
<Trash2 className='size-3.5' />
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -16,62 +16,54 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { AlertCircle, AlertTriangle, Settings } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
getMessageErrorState,
|
||||
isAdminRole,
|
||||
MODEL_PRICING_SETTINGS_PATH,
|
||||
} from '../lib'
|
||||
import { MESSAGE_STATUS } from '../constants'
|
||||
import type { Message } from '../types'
|
||||
|
||||
interface MessageErrorProps {
|
||||
message: Message
|
||||
className?: string
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Display error messages using Alert component
|
||||
* Following ai-elements pattern for error handling
|
||||
*/
|
||||
export function MessageError({
|
||||
message,
|
||||
className = '',
|
||||
actions,
|
||||
}: MessageErrorProps) {
|
||||
export function MessageError({ message, className = '' }: MessageErrorProps) {
|
||||
const { t } = useTranslation()
|
||||
const user = useAuthStore((s) => s.auth.user)
|
||||
const errorState = getMessageErrorState(message, isAdminRole(user?.role))
|
||||
const isAdmin = user?.role != null && user.role >= 10
|
||||
|
||||
if (!errorState) {
|
||||
if (message.status !== MESSAGE_STATUS.ERROR) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (errorState.kind === 'model-price') {
|
||||
const errorContent =
|
||||
message.versions[0]?.content || 'An unknown error occurred'
|
||||
|
||||
if (message.errorCode === 'model_price_error') {
|
||||
return (
|
||||
<Alert variant='default' className={className}>
|
||||
<AlertTriangle className='text-orange-500' />
|
||||
<AlertTitle>{t('Model Price Not Configured')}</AlertTitle>
|
||||
<AlertDescription className='space-y-2'>
|
||||
<p>{errorState.content}</p>
|
||||
{errorState.showSettingsLink && (
|
||||
<p>{errorContent}</p>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
window.open(MODEL_PRICING_SETTINGS_PATH, '_blank')
|
||||
window.open('/system-settings/billing/model-pricing', '_blank')
|
||||
}
|
||||
>
|
||||
<Settings className='mr-1 h-3.5 w-3.5' />
|
||||
{t('Go to Settings')}
|
||||
</Button>
|
||||
)}
|
||||
{actions}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
@@ -81,10 +73,7 @@ export function MessageError({
|
||||
<Alert variant='destructive' className={className}>
|
||||
<AlertCircle />
|
||||
<AlertTitle>{t('Error')}</AlertTitle>
|
||||
<AlertDescription className='space-y-2'>
|
||||
<p>{errorState.content}</p>
|
||||
{actions}
|
||||
</AlertDescription>
|
||||
<AlertDescription>{errorContent}</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
+216
-95
@@ -16,25 +16,44 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Branch,
|
||||
BranchMessages,
|
||||
BranchNext,
|
||||
BranchPage,
|
||||
BranchPrevious,
|
||||
BranchSelector,
|
||||
} from '@/components/ai-elements/branch'
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from '@/components/ai-elements/conversation'
|
||||
import { Message } from '@/components/ai-elements/message'
|
||||
import { Loader } from '@/components/ai-elements/loader'
|
||||
import { Message, MessageContent } from '@/components/ai-elements/message'
|
||||
import {
|
||||
getChatMessageRenderState,
|
||||
getEditingMessageContent,
|
||||
getPreviousUserMessage,
|
||||
isErrorMessage,
|
||||
} from '../lib'
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from '@/components/ai-elements/reasoning'
|
||||
import { Response } from '@/components/ai-elements/response'
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer'
|
||||
import {
|
||||
Source,
|
||||
Sources,
|
||||
SourcesContent,
|
||||
SourcesTrigger,
|
||||
} from '@/components/ai-elements/sources'
|
||||
import { MESSAGE_ROLES } from '../constants'
|
||||
import { getMessageContentStyles } from '../lib/message-styles'
|
||||
import { parseThinkTags } from '../lib/message-utils'
|
||||
import type { Message as MessageType } from '../types'
|
||||
import { MessageActions } from './message-actions'
|
||||
import { MessageErrorActions } from './message-error-actions'
|
||||
import { PlaygroundEmptyState } from './playground-empty-state'
|
||||
import { PlaygroundMessageContent } from './playground-message-content'
|
||||
import { PlaygroundMessageEditor } from './playground-message-editor'
|
||||
import { MessageError } from './message-error'
|
||||
|
||||
interface PlaygroundChatProps {
|
||||
messages: MessageType[]
|
||||
@@ -42,7 +61,6 @@ interface PlaygroundChatProps {
|
||||
onRegenerateMessage?: (message: MessageType) => void
|
||||
onEditMessage?: (message: MessageType) => void
|
||||
onDeleteMessage?: (message: MessageType) => void
|
||||
onSelectPrompt?: (prompt: string) => void
|
||||
isGenerating?: boolean
|
||||
editingKey?: string | null
|
||||
onSaveEdit?: (newContent: string) => void
|
||||
@@ -56,7 +74,6 @@ export function PlaygroundChat({
|
||||
onRegenerateMessage,
|
||||
onEditMessage,
|
||||
onDeleteMessage,
|
||||
onSelectPrompt,
|
||||
isGenerating = false,
|
||||
editingKey,
|
||||
onSaveEdit,
|
||||
@@ -68,100 +85,204 @@ export function PlaygroundChat({
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingKey) return
|
||||
const content = getEditingMessageContent(messages, editingKey)
|
||||
const message = messages.find((m) => m.key === editingKey)
|
||||
const content = message?.versions?.[0]?.content || ''
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setEditText(content)
|
||||
|
||||
setOriginalText(content)
|
||||
}, [editingKey, messages])
|
||||
|
||||
const isEditing = (key: string) => editingKey === key
|
||||
const isEmpty = useMemo(() => !editText.trim(), [editText])
|
||||
const isChanged = useMemo(
|
||||
() => editText !== originalText,
|
||||
[editText, originalText]
|
||||
)
|
||||
return (
|
||||
<Conversation>
|
||||
{/* Remove outer padding; apply padding to inner centered container to align with input */}
|
||||
<ConversationContent className='p-0'>
|
||||
<div className='mx-auto w-full max-w-4xl px-4 py-4'>
|
||||
{messages.length === 0 && onSelectPrompt ? (
|
||||
<PlaygroundEmptyState onSelectPrompt={onSelectPrompt} />
|
||||
) : (
|
||||
messages.map((message, messageIndex) => {
|
||||
const { alwaysShowActions, content, isEditing } =
|
||||
getChatMessageRenderState(
|
||||
messages,
|
||||
message,
|
||||
messageIndex,
|
||||
editingKey
|
||||
)
|
||||
const isError = isErrorMessage(message)
|
||||
const previousUserMessage = isError
|
||||
? getPreviousUserMessage(messages, messageIndex)
|
||||
: null
|
||||
|
||||
return (
|
||||
<Message
|
||||
className={
|
||||
message.from === 'assistant'
|
||||
? 'group flex-row-reverse py-3'
|
||||
: 'group flex-row-reverse py-1.5'
|
||||
}
|
||||
from={message.from}
|
||||
key={message.key}
|
||||
>
|
||||
<div className='w-full min-w-0 flex-1 basis-full'>
|
||||
{isEditing ? (
|
||||
<PlaygroundMessageEditor
|
||||
editText={editText}
|
||||
message={message}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onEditTextChange={setEditText}
|
||||
onSaveEdit={onSaveEdit}
|
||||
onSaveEditAndSubmit={onSaveEditAndSubmit}
|
||||
originalText={originalText}
|
||||
/>
|
||||
) : (
|
||||
<PlaygroundMessageContent
|
||||
actions={
|
||||
<MessageActions
|
||||
message={message}
|
||||
onCopy={onCopyMessage}
|
||||
onRegenerate={onRegenerateMessage}
|
||||
onEdit={onEditMessage}
|
||||
onDelete={onDeleteMessage}
|
||||
isGenerating={isGenerating}
|
||||
alwaysVisible={alwaysShowActions}
|
||||
className='mt-2'
|
||||
/>
|
||||
}
|
||||
message={message}
|
||||
errorActions={
|
||||
isError ? (
|
||||
<MessageErrorActions
|
||||
disabled={isGenerating}
|
||||
onRetry={
|
||||
onRegenerateMessage
|
||||
? () => onRegenerateMessage(message)
|
||||
: undefined
|
||||
}
|
||||
onEditPrompt={
|
||||
onEditMessage && previousUserMessage
|
||||
? () => onEditMessage(previousUserMessage)
|
||||
: undefined
|
||||
}
|
||||
onDelete={
|
||||
onDeleteMessage
|
||||
? () => onDeleteMessage(message)
|
||||
: undefined
|
||||
}
|
||||
{messages.map((message, messageIndex) => {
|
||||
const { versions = [] } = message
|
||||
const isLastAssistantMessage =
|
||||
messageIndex === messages.length - 1 &&
|
||||
message.from === MESSAGE_ROLES.ASSISTANT
|
||||
return (
|
||||
<Branch defaultBranch={0} key={message.key}>
|
||||
<BranchMessages>
|
||||
{versions.map((version, versionIndex) => (
|
||||
<Message
|
||||
className='group flex-row-reverse'
|
||||
from={message.from}
|
||||
key={`${message.key}-${version.id}-${versionIndex}`}
|
||||
>
|
||||
<div className='w-full min-w-0 flex-1 basis-full py-1'>
|
||||
{isEditing(message.key) ? (
|
||||
<div className='space-y-2'>
|
||||
<Textarea
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
className='font-mono text-sm'
|
||||
rows={8}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
versionContent={content}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Message>
|
||||
)
|
||||
})
|
||||
)}
|
||||
<div className='flex gap-2'>
|
||||
{/* Save & Submit only makes sense for user messages */}
|
||||
{message.from === MESSAGE_ROLES.USER && (
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
onSaveEditAndSubmit?.(editText)
|
||||
}
|
||||
disabled={isEmpty || !isChanged}
|
||||
>
|
||||
Save & Submit
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={() => onSaveEdit?.(editText)}
|
||||
disabled={isEmpty || !isChanged}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => onCancelEdit?.(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{(() => {
|
||||
const isAssistant =
|
||||
message.from === MESSAGE_ROLES.ASSISTANT
|
||||
const hasSources = !!message.sources?.length
|
||||
const showReasoning =
|
||||
isAssistant && !!message.reasoning?.content
|
||||
const showLoader =
|
||||
isAssistant &&
|
||||
!message.isReasoningStreaming &&
|
||||
(message.status === 'loading' ||
|
||||
(message.status === 'streaming' &&
|
||||
!version.content))
|
||||
const showMessageContent =
|
||||
(message.from === MESSAGE_ROLES.USER ||
|
||||
!message.isReasoningStreaming) &&
|
||||
!!version.content
|
||||
|
||||
// Extract visible content (remove <think> tags for assistant messages)
|
||||
const displayContent = isAssistant
|
||||
? parseThinkTags(version.content).visibleContent
|
||||
: version.content
|
||||
|
||||
const actions = (
|
||||
<MessageActions
|
||||
message={message}
|
||||
onCopy={onCopyMessage}
|
||||
onRegenerate={onRegenerateMessage}
|
||||
onEdit={onEditMessage}
|
||||
onDelete={onDeleteMessage}
|
||||
isGenerating={isGenerating}
|
||||
alwaysVisible={isLastAssistantMessage}
|
||||
className='mt-1'
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sources */}
|
||||
{hasSources && (
|
||||
<Sources>
|
||||
<SourcesTrigger
|
||||
count={message.sources!.length}
|
||||
/>
|
||||
<SourcesContent>
|
||||
{message.sources!.map(
|
||||
(source, sourceIndex) => (
|
||||
<Source
|
||||
href={source.href}
|
||||
key={`${message.key}-source-${sourceIndex}`}
|
||||
title={source.title}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</SourcesContent>
|
||||
</Sources>
|
||||
)}
|
||||
|
||||
{/* Reasoning */}
|
||||
{showReasoning && (
|
||||
<Reasoning
|
||||
defaultOpen={true}
|
||||
isStreaming={message.isReasoningStreaming}
|
||||
>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
{message.reasoning!.content}
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
)}
|
||||
|
||||
{/* Loader */}
|
||||
{showLoader && (
|
||||
<div className='flex items-center gap-2 py-2'>
|
||||
<Loader />
|
||||
<Shimmer className='text-sm' duration={1}>
|
||||
Responding...
|
||||
</Shimmer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error or Content */}
|
||||
{message.status === 'error' ? (
|
||||
<>
|
||||
<MessageError
|
||||
message={message}
|
||||
className='mb-2'
|
||||
/>
|
||||
{actions}
|
||||
</>
|
||||
) : (
|
||||
showMessageContent && (
|
||||
<>
|
||||
<MessageContent
|
||||
variant='flat'
|
||||
className={cn(
|
||||
getMessageContentStyles()
|
||||
)}
|
||||
>
|
||||
<Response>{displayContent}</Response>
|
||||
</MessageContent>
|
||||
{actions}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Message>
|
||||
))}
|
||||
</BranchMessages>
|
||||
|
||||
{/* Branch selector for multiple versions */}
|
||||
{versions.length > 1 && (
|
||||
<BranchSelector className='px-0' from={message.from}>
|
||||
<BranchPrevious />
|
||||
<BranchPage />
|
||||
<BranchNext />
|
||||
</BranchSelector>
|
||||
)}
|
||||
</Branch>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
|
||||
@@ -1,83 +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 {
|
||||
BarChartIcon,
|
||||
CodeSquareIcon,
|
||||
GraduationCapIcon,
|
||||
MessageSquarePlusIcon,
|
||||
NotepadTextIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
type PlaygroundEmptyStateProps = {
|
||||
onSelectPrompt: (prompt: string) => void
|
||||
}
|
||||
|
||||
const starterPrompts = [
|
||||
{ icon: BarChartIcon, text: 'Analyze data' },
|
||||
{ icon: NotepadTextIcon, text: 'Summarize text' },
|
||||
{ icon: CodeSquareIcon, text: 'Code' },
|
||||
{ icon: GraduationCapIcon, text: 'Get advice' },
|
||||
]
|
||||
|
||||
export function PlaygroundEmptyState({
|
||||
onSelectPrompt,
|
||||
}: PlaygroundEmptyStateProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='flex min-h-[min(520px,calc(100svh-18rem))] items-center justify-center px-1 py-8 md:py-12'>
|
||||
<div className='grid w-full max-w-2xl gap-5 text-center'>
|
||||
<div className='mx-auto flex size-11 items-center justify-center rounded-xl border bg-muted/50 text-muted-foreground'>
|
||||
<MessageSquarePlusIcon className='size-5' aria-hidden='true' />
|
||||
</div>
|
||||
|
||||
<div className='grid gap-2'>
|
||||
<h2 className='text-balance text-xl font-semibold tracking-tight md:text-2xl'>
|
||||
{t('Start a playground chat')}
|
||||
</h2>
|
||||
<p className='mx-auto max-w-lg text-balance text-sm leading-6 text-muted-foreground'>
|
||||
{t(
|
||||
'Test a model with a starter prompt, or write your own request below.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-2 sm:grid-cols-2'>
|
||||
{starterPrompts.map(({ icon: Icon, text }) => {
|
||||
const prompt = t(text)
|
||||
|
||||
return (
|
||||
<Button
|
||||
className='h-auto min-h-11 justify-start gap-2 whitespace-normal px-3 py-2.5 text-left'
|
||||
key={text}
|
||||
onClick={() => onSelectPrompt(prompt)}
|
||||
variant='outline'
|
||||
>
|
||||
<Icon className='size-4 text-muted-foreground' />
|
||||
<span>{prompt}</span>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-127
@@ -1,127 +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 { SendIcon, SquareIcon } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PromptInputButton } from '@/components/ai-elements/prompt-input'
|
||||
import { ModelGroupSelector } from '@/components/model-group-selector'
|
||||
import { getInputControlState } from '../lib'
|
||||
import type { GroupOption, ModelOption } from '../types'
|
||||
|
||||
type PlaygroundInputControlsProps = {
|
||||
disabled?: boolean
|
||||
groups: GroupOption[]
|
||||
groupValue: string
|
||||
isGenerating?: boolean
|
||||
isModelLoading?: boolean
|
||||
models: ModelOption[]
|
||||
modelValue: string
|
||||
onGroupChange: (value: string) => void
|
||||
onModelChange: (value: string) => void
|
||||
onStop?: () => void
|
||||
text: string
|
||||
tools: ReactNode
|
||||
}
|
||||
|
||||
export function PlaygroundInputControls({
|
||||
disabled,
|
||||
groups,
|
||||
groupValue,
|
||||
isGenerating,
|
||||
isModelLoading = false,
|
||||
models,
|
||||
modelValue,
|
||||
onGroupChange,
|
||||
onModelChange,
|
||||
onStop,
|
||||
text,
|
||||
tools,
|
||||
}: PlaygroundInputControlsProps) {
|
||||
const { t } = useTranslation()
|
||||
const { canSubmit, isSelectorDisabled, shouldShowStop } =
|
||||
getInputControlState({
|
||||
disabled,
|
||||
groups,
|
||||
hasStopHandler: Boolean(onStop),
|
||||
isGenerating,
|
||||
isModelLoading,
|
||||
models,
|
||||
text,
|
||||
})
|
||||
|
||||
const renderSelector = () => (
|
||||
<ModelGroupSelector
|
||||
className='gap-1.5 md:gap-2'
|
||||
selectedModel={modelValue}
|
||||
models={models}
|
||||
onModelChange={onModelChange}
|
||||
selectedGroup={groupValue}
|
||||
groups={groups}
|
||||
onGroupChange={onGroupChange}
|
||||
disabled={isSelectorDisabled}
|
||||
/>
|
||||
)
|
||||
|
||||
const renderSubmitButton = () => (
|
||||
<>
|
||||
{shouldShowStop ? (
|
||||
<PromptInputButton
|
||||
className='text-foreground font-medium'
|
||||
onClick={onStop}
|
||||
variant='secondary'
|
||||
>
|
||||
<SquareIcon className='fill-current' size={16} />
|
||||
<span className='hidden sm:inline'>{t('Stop')}</span>
|
||||
<span className='sr-only sm:hidden'>{t('Stop')}</span>
|
||||
</PromptInputButton>
|
||||
) : (
|
||||
<PromptInputButton
|
||||
className='text-foreground font-medium'
|
||||
disabled={!canSubmit}
|
||||
type='submit'
|
||||
variant='secondary'
|
||||
>
|
||||
<SendIcon size={16} />
|
||||
<span className='hidden sm:inline'>{t('Send')}</span>
|
||||
<span className='sr-only sm:hidden'>{t('Send')}</span>
|
||||
</PromptInputButton>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='flex w-full flex-col gap-2 md:flex-row md:items-center md:justify-between'>
|
||||
<div className='flex min-w-0 items-center justify-end md:hidden'>
|
||||
{renderSelector()}
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between gap-2 md:justify-start'>
|
||||
{tools}
|
||||
<div className='flex items-center gap-1.5 md:hidden'>
|
||||
{renderSubmitButton()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='hidden items-center gap-2 md:flex'>
|
||||
{renderSelector()}
|
||||
{renderSubmitButton()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,138 +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 { GlobeIcon, PaperclipIcon, Trash2Icon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
PromptInputButton,
|
||||
PromptInputTools,
|
||||
} from '@/components/ai-elements/prompt-input'
|
||||
import {
|
||||
ATTACHMENT_ACTIONS,
|
||||
getAttachmentActionNotice,
|
||||
getSearchActionNotice,
|
||||
} from '../lib'
|
||||
|
||||
type PlaygroundInputToolsProps = {
|
||||
disabled?: boolean
|
||||
hasMessages?: boolean
|
||||
onClearMessages?: () => void
|
||||
}
|
||||
|
||||
export function PlaygroundInputTools({
|
||||
disabled,
|
||||
hasMessages = false,
|
||||
onClearMessages,
|
||||
}: PlaygroundInputToolsProps) {
|
||||
const { t } = useTranslation()
|
||||
const [clearConfirmOpen, setClearConfirmOpen] = useState(false)
|
||||
|
||||
const handleFileAction = (action: string) => {
|
||||
const notice = getAttachmentActionNotice(action)
|
||||
toast.info(t(notice.title), {
|
||||
description: notice.description,
|
||||
})
|
||||
}
|
||||
|
||||
const handleSearchAction = () => {
|
||||
const notice = getSearchActionNotice()
|
||||
toast.info(t(notice.title))
|
||||
}
|
||||
|
||||
const handleClearMessages = () => {
|
||||
onClearMessages?.()
|
||||
setClearConfirmOpen(false)
|
||||
toast.success(t('Conversation cleared'))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PromptInputTools>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<PromptInputButton
|
||||
className='border font-medium'
|
||||
disabled={disabled}
|
||||
variant='outline'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PaperclipIcon size={16} />
|
||||
<span className='hidden sm:inline'>{t('Attach')}</span>
|
||||
<span className='sr-only sm:hidden'>{t('Attach')}</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start'>
|
||||
{ATTACHMENT_ACTIONS.map(({ action, icon: Icon, label }) => (
|
||||
<DropdownMenuItem
|
||||
key={action}
|
||||
onClick={() => handleFileAction(action)}
|
||||
>
|
||||
<Icon className='mr-2' size={16} />
|
||||
{t(label)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<PromptInputButton
|
||||
className='border font-medium'
|
||||
disabled={disabled}
|
||||
onClick={handleSearchAction}
|
||||
variant='outline'
|
||||
>
|
||||
<GlobeIcon size={16} />
|
||||
<span className='hidden sm:inline'>{t('Search')}</span>
|
||||
<span className='sr-only sm:hidden'>{t('Search')}</span>
|
||||
</PromptInputButton>
|
||||
|
||||
<PromptInputButton
|
||||
className='border font-medium text-muted-foreground hover:text-destructive'
|
||||
disabled={disabled || !hasMessages || !onClearMessages}
|
||||
onClick={() => setClearConfirmOpen(true)}
|
||||
variant='outline'
|
||||
>
|
||||
<Trash2Icon size={16} />
|
||||
<span className='hidden sm:inline'>{t('Clear chat history')}</span>
|
||||
<span className='sr-only sm:hidden'>{t('Clear chat history')}</span>
|
||||
</PromptInputButton>
|
||||
</PromptInputTools>
|
||||
|
||||
<ConfirmDialog
|
||||
destructive
|
||||
desc={t(
|
||||
'All playground messages saved in this browser will be removed. This cannot be undone.'
|
||||
)}
|
||||
confirmText={t('Clear')}
|
||||
handleConfirm={handleClearMessages}
|
||||
open={clearConfirmOpen}
|
||||
onOpenChange={setClearConfirmOpen}
|
||||
title={t('Clear chat history?')}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+155
-33
@@ -17,18 +17,40 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
PaperclipIcon,
|
||||
FileIcon,
|
||||
ImageIcon,
|
||||
ScreenShareIcon,
|
||||
CameraIcon,
|
||||
GlobeIcon,
|
||||
SendIcon,
|
||||
SquareIcon,
|
||||
BarChartIcon,
|
||||
BoxIcon,
|
||||
NotepadTextIcon,
|
||||
CodeSquareIcon,
|
||||
GraduationCapIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputButton,
|
||||
PromptInputFooter,
|
||||
PromptInputTextarea,
|
||||
PromptInputTools,
|
||||
type PromptInputMessage,
|
||||
} from '@/components/ai-elements/prompt-input'
|
||||
import { getSubmittableInputText } from '../lib'
|
||||
import { Suggestion, Suggestions } from '@/components/ai-elements/suggestion'
|
||||
import { ModelGroupSelector } from '@/components/model-group-selector'
|
||||
import type { ModelOption, GroupOption } from '../types'
|
||||
import { PlaygroundInputControls } from './playground-input-controls'
|
||||
import { PlaygroundInputTools } from './playground-input-tools'
|
||||
import { PlaygroundSuggestions } from './playground-suggestions'
|
||||
|
||||
interface PlaygroundInputProps {
|
||||
onSubmit: (text: string) => void
|
||||
@@ -42,10 +64,17 @@ interface PlaygroundInputProps {
|
||||
groups: GroupOption[]
|
||||
groupValue: string
|
||||
onGroupChange: (value: string) => void
|
||||
hasMessages?: boolean
|
||||
onClearMessages?: () => void
|
||||
}
|
||||
|
||||
const suggestions = [
|
||||
{ icon: BarChartIcon, text: 'Analyze data', color: '#76d0eb' },
|
||||
{ icon: BoxIcon, text: 'Surprise me', color: '#76d0eb' },
|
||||
{ icon: NotepadTextIcon, text: 'Summarize text', color: '#ea8444' },
|
||||
{ icon: CodeSquareIcon, text: 'Code', color: '#6c71ff' },
|
||||
{ icon: GraduationCapIcon, text: 'Get advice', color: '#76d0eb' },
|
||||
{ icon: null, text: 'More' },
|
||||
]
|
||||
|
||||
export function PlaygroundInput({
|
||||
onSubmit,
|
||||
onStop,
|
||||
@@ -58,20 +87,30 @@ export function PlaygroundInput({
|
||||
groups,
|
||||
groupValue,
|
||||
onGroupChange,
|
||||
hasMessages = false,
|
||||
onClearMessages,
|
||||
}: PlaygroundInputProps) {
|
||||
const { t } = useTranslation()
|
||||
const [text, setText] = useState('')
|
||||
|
||||
const handleSubmit = (message: PromptInputMessage) => {
|
||||
const submittableText = getSubmittableInputText(message, disabled)
|
||||
const isModelSelectDisabled =
|
||||
disabled || isModelLoading || models.length === 0
|
||||
const isGroupSelectDisabled = disabled || groups.length === 0
|
||||
|
||||
if (!submittableText) return
|
||||
onSubmit(submittableText)
|
||||
const handleSubmit = (message: PromptInputMessage) => {
|
||||
if (!message.text?.trim() || disabled) return
|
||||
onSubmit(message.text)
|
||||
setText('')
|
||||
}
|
||||
|
||||
const handleFileAction = (action: string) => {
|
||||
toast.info(t('Feature in development'), {
|
||||
description: action,
|
||||
})
|
||||
}
|
||||
|
||||
const handleSuggestionClick = (suggestion: string) => {
|
||||
onSubmit(suggestion)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='grid shrink-0 gap-4 px-1 md:pb-4'>
|
||||
<PromptInput groupClassName='rounded-xl' onSubmit={handleSubmit}>
|
||||
@@ -88,30 +127,113 @@ export function PlaygroundInput({
|
||||
/>
|
||||
|
||||
<PromptInputFooter className='p-2.5'>
|
||||
<PlaygroundInputControls
|
||||
disabled={disabled}
|
||||
groups={groups}
|
||||
groupValue={groupValue}
|
||||
isGenerating={isGenerating}
|
||||
isModelLoading={isModelLoading}
|
||||
models={models}
|
||||
modelValue={modelValue}
|
||||
onGroupChange={onGroupChange}
|
||||
onModelChange={onModelChange}
|
||||
onStop={onStop}
|
||||
text={text}
|
||||
tools={
|
||||
<PlaygroundInputTools
|
||||
disabled={disabled}
|
||||
hasMessages={hasMessages}
|
||||
onClearMessages={onClearMessages}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<PromptInputTools>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<PromptInputButton
|
||||
className='border font-medium'
|
||||
disabled={disabled}
|
||||
variant='outline'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PaperclipIcon size={16} />
|
||||
<span className='hidden sm:inline'>{t('Attach')}</span>
|
||||
<span className='sr-only sm:hidden'>{t('Attach')}</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start'>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleFileAction('upload-file')}
|
||||
>
|
||||
<FileIcon className='mr-2' size={16} />
|
||||
{t('Upload file')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleFileAction('upload-photo')}
|
||||
>
|
||||
<ImageIcon className='mr-2' size={16} />
|
||||
{t('Upload photo')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleFileAction('take-screenshot')}
|
||||
>
|
||||
<ScreenShareIcon className='mr-2' size={16} />
|
||||
{t('Take screenshot')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleFileAction('take-photo')}
|
||||
>
|
||||
<CameraIcon className='mr-2' size={16} />
|
||||
{t('Take photo')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<PromptInputButton
|
||||
className='border font-medium'
|
||||
disabled={disabled}
|
||||
onClick={() => toast.info(t('Search feature in development'))}
|
||||
variant='outline'
|
||||
>
|
||||
<GlobeIcon size={16} />
|
||||
<span className='hidden sm:inline'>{t('Search')}</span>
|
||||
<span className='sr-only sm:hidden'>{t('Search')}</span>
|
||||
</PromptInputButton>
|
||||
</PromptInputTools>
|
||||
|
||||
<div className='flex items-center gap-1.5 md:gap-2'>
|
||||
<ModelGroupSelector
|
||||
selectedModel={modelValue}
|
||||
models={models}
|
||||
onModelChange={onModelChange}
|
||||
selectedGroup={groupValue}
|
||||
groups={groups}
|
||||
onGroupChange={onGroupChange}
|
||||
disabled={isModelSelectDisabled || isGroupSelectDisabled}
|
||||
/>
|
||||
|
||||
{isGenerating && onStop ? (
|
||||
<PromptInputButton
|
||||
className='text-foreground font-medium'
|
||||
onClick={onStop}
|
||||
variant='secondary'
|
||||
>
|
||||
<SquareIcon className='fill-current' size={16} />
|
||||
<span className='hidden sm:inline'>{t('Stop')}</span>
|
||||
<span className='sr-only sm:hidden'>{t('Stop')}</span>
|
||||
</PromptInputButton>
|
||||
) : (
|
||||
<PromptInputButton
|
||||
className='text-foreground font-medium'
|
||||
disabled={disabled || !text.trim()}
|
||||
type='submit'
|
||||
variant='secondary'
|
||||
>
|
||||
<SendIcon size={16} />
|
||||
<span className='hidden sm:inline'>{t('Send')}</span>
|
||||
<span className='sr-only sm:hidden'>{t('Send')}</span>
|
||||
</PromptInputButton>
|
||||
)}
|
||||
</div>
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
|
||||
<PlaygroundSuggestions onSelect={onSubmit} />
|
||||
<Suggestions>
|
||||
{suggestions.map(({ icon: Icon, text, color }) => (
|
||||
<Suggestion
|
||||
className={`text-xs font-normal sm:text-sm ${
|
||||
text === 'More' ? 'hidden sm:flex' : ''
|
||||
}`}
|
||||
key={text}
|
||||
onClick={() => handleSuggestionClick(text)}
|
||||
suggestion={text}
|
||||
>
|
||||
{Icon && <Icon size={16} style={{ color }} />}
|
||||
{text}
|
||||
</Suggestion>
|
||||
))}
|
||||
</Suggestions>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
-126
@@ -1,126 +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 { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Loader } from '@/components/ai-elements/loader'
|
||||
import { MessageContent } from '@/components/ai-elements/message'
|
||||
import {
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from '@/components/ai-elements/reasoning'
|
||||
import { Response } from '@/components/ai-elements/response'
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer'
|
||||
import {
|
||||
Source,
|
||||
Sources,
|
||||
SourcesContent,
|
||||
SourcesTrigger,
|
||||
} from '@/components/ai-elements/sources'
|
||||
import { getMessageContentStyles } from '../lib/message-styles'
|
||||
import { getMessageContentState, isErrorMessage } from '../lib'
|
||||
import type { Message } from '../types'
|
||||
import { MessageError } from './message-error'
|
||||
|
||||
type PlaygroundMessageContentProps = {
|
||||
actions: ReactNode
|
||||
errorActions?: ReactNode
|
||||
message: Message
|
||||
versionContent: string
|
||||
}
|
||||
|
||||
export function PlaygroundMessageContent({
|
||||
actions,
|
||||
errorActions,
|
||||
message,
|
||||
versionContent,
|
||||
}: PlaygroundMessageContentProps) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
displayContent,
|
||||
hasReasoning,
|
||||
hasSources,
|
||||
reasoningContent,
|
||||
showLoader,
|
||||
showMessageContent,
|
||||
sources,
|
||||
} = getMessageContentState(message, versionContent)
|
||||
const isError = isErrorMessage(message)
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasSources && (
|
||||
<Sources>
|
||||
<SourcesTrigger count={sources.length} />
|
||||
<SourcesContent>
|
||||
{sources.map((source) => (
|
||||
<Source
|
||||
href={source.href}
|
||||
key={`${source.href}-${source.title}`}
|
||||
title={source.title}
|
||||
/>
|
||||
))}
|
||||
</SourcesContent>
|
||||
</Sources>
|
||||
)}
|
||||
|
||||
{hasReasoning && (
|
||||
<Reasoning
|
||||
defaultOpen={true}
|
||||
isStreaming={message.isReasoningStreaming}
|
||||
>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{reasoningContent}</ReasoningContent>
|
||||
</Reasoning>
|
||||
)}
|
||||
|
||||
{showLoader && (
|
||||
<div className='flex items-center gap-2 py-2'>
|
||||
<Loader />
|
||||
<Shimmer className='text-sm' duration={1}>
|
||||
{t('Responding...')}
|
||||
</Shimmer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<>
|
||||
<MessageError
|
||||
actions={errorActions}
|
||||
message={message}
|
||||
className='mb-2'
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isError && showMessageContent && (
|
||||
<>
|
||||
<MessageContent
|
||||
variant='flat'
|
||||
className={cn(getMessageContentStyles())}
|
||||
>
|
||||
<Response>{displayContent}</Response>
|
||||
</MessageContent>
|
||||
{actions}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
-156
@@ -1,156 +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 { useEffect, useRef, type KeyboardEvent } from 'react'
|
||||
import { Check, RotateCcw, Send, X } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { getMessageEditorState } from '../lib'
|
||||
import type { Message } from '../types'
|
||||
|
||||
type PlaygroundMessageEditorProps = {
|
||||
editText: string
|
||||
message: Message
|
||||
onCancelEdit?: (open: boolean) => void
|
||||
onEditTextChange: (text: string) => void
|
||||
onSaveEdit?: (newContent: string) => void
|
||||
onSaveEditAndSubmit?: (newContent: string) => void
|
||||
originalText: string
|
||||
}
|
||||
|
||||
export function PlaygroundMessageEditor({
|
||||
editText,
|
||||
message,
|
||||
onCancelEdit,
|
||||
onEditTextChange,
|
||||
onSaveEdit,
|
||||
onSaveEditAndSubmit,
|
||||
originalText,
|
||||
}: PlaygroundMessageEditorProps) {
|
||||
const { t } = useTranslation()
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const { canSave, hasChanged, showSaveAndSubmit } = getMessageEditorState(
|
||||
message,
|
||||
editText,
|
||||
originalText
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const handleCancel = () => {
|
||||
if (
|
||||
hasChanged &&
|
||||
!window.confirm(
|
||||
t('You have unsaved changes. Are you sure you want to leave?')
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
onCancelEdit?.(false)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
handleCancel()
|
||||
return
|
||||
}
|
||||
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
if (!canSave) return
|
||||
|
||||
if (showSaveAndSubmit) {
|
||||
onSaveEditAndSubmit?.(editText)
|
||||
} else {
|
||||
onSaveEdit?.(editText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='rounded-lg border bg-background/80 p-2 shadow-sm'>
|
||||
<Textarea
|
||||
aria-label={t('Edit')}
|
||||
className='min-h-36 resize-y font-mono text-sm leading-6 md:min-h-48'
|
||||
onChange={(event) => onEditTextChange(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={textareaRef}
|
||||
rows={8}
|
||||
value={editText}
|
||||
/>
|
||||
|
||||
<div className='mt-2 flex flex-col gap-2 md:flex-row md:items-center md:justify-between'>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{hasChanged ? t('Unsaved changes') : t('No changes')}
|
||||
</p>
|
||||
|
||||
<div className='grid gap-2 sm:flex sm:justify-end'>
|
||||
{showSaveAndSubmit && (
|
||||
<Button
|
||||
className='max-md:min-h-11'
|
||||
disabled={!canSave}
|
||||
onClick={() => onSaveEditAndSubmit?.(editText)}
|
||||
size='sm'
|
||||
>
|
||||
<Send className='size-3.5' />
|
||||
{t('Save & Submit')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className='max-md:min-h-11'
|
||||
disabled={!canSave}
|
||||
onClick={() => onSaveEdit?.(editText)}
|
||||
size='sm'
|
||||
variant={showSaveAndSubmit ? 'outline' : 'default'}
|
||||
>
|
||||
<Check className='size-3.5' />
|
||||
{t('Save')}
|
||||
</Button>
|
||||
|
||||
{hasChanged && (
|
||||
<Button
|
||||
className='max-md:min-h-11'
|
||||
onClick={() => onEditTextChange(originalText)}
|
||||
size='sm'
|
||||
variant='outline'
|
||||
>
|
||||
<RotateCcw className='size-3.5' />
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className='max-md:min-h-11'
|
||||
onClick={handleCancel}
|
||||
size='sm'
|
||||
variant='outline'
|
||||
>
|
||||
<X className='size-3.5' />
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,75 +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 {
|
||||
BarChartIcon,
|
||||
BoxIcon,
|
||||
CodeSquareIcon,
|
||||
GraduationCapIcon,
|
||||
NotepadTextIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Suggestion, Suggestions } from '@/components/ai-elements/suggestion'
|
||||
import { getSuggestionDisplayState } from '../lib'
|
||||
|
||||
type PlaygroundSuggestion = {
|
||||
icon: LucideIcon | null
|
||||
text: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
type PlaygroundSuggestionsProps = {
|
||||
onSelect: (suggestion: string) => void
|
||||
}
|
||||
|
||||
const suggestions = [
|
||||
{ icon: BarChartIcon, text: 'Analyze data', color: '#76d0eb' },
|
||||
{ icon: BoxIcon, text: 'Surprise me', color: '#76d0eb' },
|
||||
{ icon: NotepadTextIcon, text: 'Summarize text', color: '#ea8444' },
|
||||
{ icon: CodeSquareIcon, text: 'Code', color: '#6c71ff' },
|
||||
{ icon: GraduationCapIcon, text: 'Get advice', color: '#76d0eb' },
|
||||
{ icon: null, text: 'More' },
|
||||
] satisfies PlaygroundSuggestion[]
|
||||
|
||||
export function PlaygroundSuggestions({
|
||||
onSelect,
|
||||
}: PlaygroundSuggestionsProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Suggestions>
|
||||
{suggestions.map(({ icon: Icon, text, color }) => {
|
||||
const suggestion = t(text)
|
||||
const { className } = getSuggestionDisplayState(text)
|
||||
|
||||
return (
|
||||
<Suggestion
|
||||
className={className}
|
||||
key={text}
|
||||
onClick={onSelect}
|
||||
suggestion={suggestion}
|
||||
>
|
||||
{Icon && <Icon aria-hidden='true' size={16} style={{ color }} />}
|
||||
{suggestion}
|
||||
</Suggestion>
|
||||
)
|
||||
})}
|
||||
</Suggestions>
|
||||
)
|
||||
}
|
||||
@@ -20,5 +20,3 @@ export * from './use-playground-state'
|
||||
export * from './use-stream-request'
|
||||
export * from './use-chat-handler'
|
||||
export * from './use-message-action-guard'
|
||||
export * from './use-playground-conversation'
|
||||
export * from './use-playground-options'
|
||||
|
||||
+62
-58
@@ -16,21 +16,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { sendChatCompletion } from '../api'
|
||||
import { ERROR_MESSAGES } from '../constants'
|
||||
import { MESSAGE_STATUS, ERROR_MESSAGES } from '../constants'
|
||||
import {
|
||||
applyStreamingChunk,
|
||||
buildChatCompletionPayload,
|
||||
updateAssistantMessageWithError,
|
||||
updateLastAssistantMessage,
|
||||
parseRequestErrorDetails,
|
||||
applyChatCompletionResponse,
|
||||
completeAssistantMessage,
|
||||
hasChatCompletionChoice,
|
||||
isAssistantMessageFinal,
|
||||
isAssistantMessagePending,
|
||||
processStreamingContent,
|
||||
finalizeMessage,
|
||||
} from '../lib'
|
||||
import type { Message, PlaygroundConfig, ParameterEnabled } from '../types'
|
||||
import { useStreamRequest } from './use-stream-request'
|
||||
@@ -50,17 +45,33 @@ export function useChatHandler({
|
||||
onMessageUpdate,
|
||||
}: UseChatHandlerOptions) {
|
||||
const { sendStreamRequest, stopStream, isStreaming } = useStreamRequest()
|
||||
const [isRequesting, setIsRequesting] = useState(false)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const requestIdRef = useRef(0)
|
||||
|
||||
// Handle stream update
|
||||
const handleStreamUpdate = useCallback(
|
||||
(type: 'reasoning' | 'content', chunk: string) => {
|
||||
onMessageUpdate((prev) =>
|
||||
updateLastAssistantMessage(prev, (message) =>
|
||||
applyStreamingChunk(message, type, chunk)
|
||||
)
|
||||
updateLastAssistantMessage(prev, (message) => {
|
||||
if (message.status === MESSAGE_STATUS.ERROR) return message
|
||||
|
||||
if (type === 'reasoning') {
|
||||
// Direct API reasoning_content
|
||||
return {
|
||||
...message,
|
||||
reasoning: {
|
||||
content: (message.reasoning?.content || '') + chunk,
|
||||
duration: 0,
|
||||
},
|
||||
isReasoningStreaming: true,
|
||||
status: MESSAGE_STATUS.STREAMING,
|
||||
}
|
||||
}
|
||||
|
||||
// Content streaming: handle <think> tags
|
||||
return {
|
||||
...processStreamingContent(message, chunk),
|
||||
status: MESSAGE_STATUS.STREAMING,
|
||||
}
|
||||
})
|
||||
)
|
||||
},
|
||||
[onMessageUpdate]
|
||||
@@ -68,12 +79,12 @@ export function useChatHandler({
|
||||
|
||||
// Handle stream complete
|
||||
const handleStreamComplete = useCallback(() => {
|
||||
setIsRequesting(false)
|
||||
onMessageUpdate((prev) =>
|
||||
updateLastAssistantMessage(prev, (message) =>
|
||||
isAssistantMessageFinal(message)
|
||||
message.status === MESSAGE_STATUS.COMPLETE ||
|
||||
message.status === MESSAGE_STATUS.ERROR
|
||||
? message
|
||||
: completeAssistantMessage(message)
|
||||
: { ...finalizeMessage(message), status: MESSAGE_STATUS.COMPLETE }
|
||||
)
|
||||
)
|
||||
}, [onMessageUpdate])
|
||||
@@ -81,7 +92,6 @@ export function useChatHandler({
|
||||
// Handle stream error
|
||||
const handleStreamError = useCallback(
|
||||
(error: string, errorCode?: string) => {
|
||||
setIsRequesting(false)
|
||||
toast.error(error)
|
||||
onMessageUpdate((prev) =>
|
||||
updateAssistantMessageWithError(prev, error, errorCode)
|
||||
@@ -93,7 +103,6 @@ export function useChatHandler({
|
||||
// Send streaming chat request
|
||||
const sendStreamingChat = useCallback(
|
||||
(messages: Message[]) => {
|
||||
setIsRequesting(true)
|
||||
const payload = buildChatCompletionPayload(
|
||||
messages,
|
||||
config,
|
||||
@@ -124,45 +133,42 @@ export function useChatHandler({
|
||||
config,
|
||||
parameterEnabled
|
||||
)
|
||||
const requestId = requestIdRef.current + 1
|
||||
const abortController = new AbortController()
|
||||
|
||||
requestIdRef.current = requestId
|
||||
abortControllerRef.current = abortController
|
||||
|
||||
try {
|
||||
setIsRequesting(true)
|
||||
const response = await sendChatCompletion(
|
||||
payload,
|
||||
abortController.signal
|
||||
)
|
||||
if (abortController.signal.aborted) return
|
||||
|
||||
if (!hasChatCompletionChoice(response)) {
|
||||
handleStreamError(ERROR_MESSAGES.API_REQUEST_ERROR)
|
||||
return
|
||||
}
|
||||
const response = await sendChatCompletion(payload)
|
||||
const choice = response.choices?.[0]
|
||||
if (!choice) return
|
||||
|
||||
onMessageUpdate((prev) =>
|
||||
updateLastAssistantMessage(prev, (message) => {
|
||||
const updatedMessage = applyChatCompletionResponse(
|
||||
message,
|
||||
response
|
||||
)
|
||||
|
||||
return updatedMessage ?? message
|
||||
})
|
||||
updateLastAssistantMessage(prev, (message) => ({
|
||||
...finalizeMessage(
|
||||
{
|
||||
...message,
|
||||
versions: [
|
||||
{
|
||||
...message.versions[0],
|
||||
content: choice.message?.content || '',
|
||||
},
|
||||
],
|
||||
},
|
||||
choice.message?.reasoning_content
|
||||
),
|
||||
status: MESSAGE_STATUS.COMPLETE,
|
||||
}))
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
if (abortController.signal.aborted) return
|
||||
|
||||
const { errorCode, errorMessage } = parseRequestErrorDetails(error)
|
||||
handleStreamError(errorMessage, errorCode)
|
||||
} finally {
|
||||
if (requestIdRef.current === requestId) {
|
||||
abortControllerRef.current = null
|
||||
setIsRequesting(false)
|
||||
const err = error as {
|
||||
response?: {
|
||||
data?: { message?: string; error?: { code?: string } }
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
handleStreamError(
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
ERROR_MESSAGES.API_REQUEST_ERROR,
|
||||
err?.response?.data?.error?.code || undefined
|
||||
)
|
||||
}
|
||||
},
|
||||
[config, parameterEnabled, onMessageUpdate, handleStreamError]
|
||||
@@ -183,13 +189,11 @@ export function useChatHandler({
|
||||
// Stop generation
|
||||
const stopGeneration = useCallback(() => {
|
||||
stopStream()
|
||||
abortControllerRef.current?.abort()
|
||||
abortControllerRef.current = null
|
||||
setIsRequesting(false)
|
||||
onMessageUpdate((prev) =>
|
||||
updateLastAssistantMessage(prev, (message) =>
|
||||
isAssistantMessagePending(message)
|
||||
? completeAssistantMessage(message)
|
||||
message.status === MESSAGE_STATUS.LOADING ||
|
||||
message.status === MESSAGE_STATUS.STREAMING
|
||||
? { ...finalizeMessage(message), status: MESSAGE_STATUS.COMPLETE }
|
||||
: message
|
||||
)
|
||||
)
|
||||
@@ -198,6 +202,6 @@ export function useChatHandler({
|
||||
return {
|
||||
sendChat,
|
||||
stopGeneration,
|
||||
isGenerating: isStreaming || isRequesting,
|
||||
isGenerating: isStreaming,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,115 +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 { useCallback, useState } from 'react'
|
||||
import {
|
||||
appendUserMessagePair,
|
||||
applyMessageEdit,
|
||||
createRegeneratedMessages,
|
||||
removeMessageByKey,
|
||||
} from '../lib'
|
||||
import type { Message } from '../types'
|
||||
|
||||
type UsePlaygroundConversationOptions = {
|
||||
messages: Message[]
|
||||
updateMessages: (
|
||||
updater: Message[] | ((prev: Message[]) => Message[])
|
||||
) => void
|
||||
sendChat: (messages: Message[]) => void
|
||||
}
|
||||
|
||||
export function usePlaygroundConversation({
|
||||
messages,
|
||||
updateMessages,
|
||||
sendChat,
|
||||
}: UsePlaygroundConversationOptions) {
|
||||
const [editingMessageKey, setEditingMessageKey] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
(text: string) => {
|
||||
const nextMessages = appendUserMessagePair(messages, text)
|
||||
updateMessages(nextMessages)
|
||||
sendChat(nextMessages)
|
||||
},
|
||||
[messages, updateMessages, sendChat]
|
||||
)
|
||||
|
||||
const handleRegenerateMessage = useCallback(
|
||||
(message: Message) => {
|
||||
const nextMessages = createRegeneratedMessages(messages, message.key)
|
||||
if (!nextMessages) return
|
||||
|
||||
updateMessages(nextMessages)
|
||||
sendChat(nextMessages)
|
||||
},
|
||||
[messages, updateMessages, sendChat]
|
||||
)
|
||||
|
||||
const handleEditMessage = useCallback((message: Message) => {
|
||||
setEditingMessageKey(message.key)
|
||||
}, [])
|
||||
|
||||
const handleEditOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setEditingMessageKey(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const applyEdit = useCallback(
|
||||
(newContent: string, shouldSubmit: boolean) => {
|
||||
if (!editingMessageKey) return
|
||||
|
||||
const editResult = applyMessageEdit(
|
||||
messages,
|
||||
editingMessageKey,
|
||||
newContent,
|
||||
shouldSubmit
|
||||
)
|
||||
if (!editResult) return
|
||||
|
||||
setEditingMessageKey(null)
|
||||
updateMessages(editResult.messages)
|
||||
|
||||
if (editResult.shouldSend) {
|
||||
sendChat(editResult.messages)
|
||||
}
|
||||
},
|
||||
[editingMessageKey, messages, updateMessages, sendChat]
|
||||
)
|
||||
|
||||
const handleDeleteMessage = useCallback(
|
||||
(message: Message) => {
|
||||
updateMessages((previousMessages) =>
|
||||
removeMessageByKey(previousMessages, message.key)
|
||||
)
|
||||
},
|
||||
[updateMessages]
|
||||
)
|
||||
|
||||
return {
|
||||
editingMessageKey,
|
||||
handleSendMessage,
|
||||
handleRegenerateMessage,
|
||||
handleEditMessage,
|
||||
handleEditOpenChange,
|
||||
applyEdit,
|
||||
handleDeleteMessage,
|
||||
}
|
||||
}
|
||||
@@ -1,117 +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 { useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { getUserGroups, getUserModels } from '../api'
|
||||
import {
|
||||
getGroupFallback,
|
||||
getModelFallback,
|
||||
getOptionLoadErrorMessage,
|
||||
} from '../lib'
|
||||
import type { GroupOption, ModelOption, PlaygroundConfig } from '../types'
|
||||
|
||||
type UsePlaygroundOptionsParams = {
|
||||
currentGroup: string
|
||||
currentModel: string
|
||||
setGroups: (groups: GroupOption[]) => void
|
||||
setModels: (models: ModelOption[]) => void
|
||||
updateConfig: <K extends keyof PlaygroundConfig>(
|
||||
key: K,
|
||||
value: PlaygroundConfig[K]
|
||||
) => void
|
||||
}
|
||||
|
||||
export function usePlaygroundOptions({
|
||||
currentGroup,
|
||||
currentModel,
|
||||
setGroups,
|
||||
setModels,
|
||||
updateConfig,
|
||||
}: UsePlaygroundOptionsParams) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
data: modelsData,
|
||||
error: modelsError,
|
||||
isError: isModelsError,
|
||||
isLoading: isLoadingModels,
|
||||
} = useQuery({
|
||||
queryKey: ['playground-models'],
|
||||
queryFn: getUserModels,
|
||||
})
|
||||
|
||||
const {
|
||||
data: groupsData,
|
||||
error: groupsError,
|
||||
isError: isGroupsError,
|
||||
} = useQuery({
|
||||
queryKey: ['playground-groups'],
|
||||
queryFn: getUserGroups,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isModelsError) return
|
||||
|
||||
toast.error(
|
||||
getOptionLoadErrorMessage(
|
||||
modelsError,
|
||||
t('Failed to load playground models')
|
||||
)
|
||||
)
|
||||
}, [isModelsError, modelsError, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGroupsError) return
|
||||
|
||||
toast.error(
|
||||
getOptionLoadErrorMessage(
|
||||
groupsError,
|
||||
t('Failed to load playground groups')
|
||||
)
|
||||
)
|
||||
}, [isGroupsError, groupsError, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelsData) return
|
||||
|
||||
setModels(modelsData)
|
||||
const fallback = getModelFallback(modelsData, currentModel)
|
||||
|
||||
if (fallback) {
|
||||
updateConfig('model', fallback)
|
||||
}
|
||||
}, [modelsData, currentModel, setModels, updateConfig])
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupsData) return
|
||||
|
||||
setGroups(groupsData)
|
||||
const fallback = getGroupFallback(groupsData, currentGroup)
|
||||
|
||||
if (fallback) {
|
||||
updateConfig('group', fallback)
|
||||
}
|
||||
}, [groupsData, currentGroup, setGroups, updateConfig])
|
||||
|
||||
return {
|
||||
isLoadingModels,
|
||||
}
|
||||
}
|
||||
@@ -19,14 +19,12 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { useState, useCallback } from 'react'
|
||||
import { DEFAULT_CONFIG, DEFAULT_PARAMETER_ENABLED } from '../constants'
|
||||
import {
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
loadParameterEnabled,
|
||||
saveParameterEnabled,
|
||||
loadMessages,
|
||||
saveMessages,
|
||||
applyMessageStateUpdate,
|
||||
getInitialMessages,
|
||||
getInitialParameterEnabled,
|
||||
getInitialPlaygroundConfig,
|
||||
type MessageStateUpdater,
|
||||
} from '../lib'
|
||||
import type {
|
||||
Message,
|
||||
@@ -41,15 +39,21 @@ import type {
|
||||
*/
|
||||
export function usePlaygroundState() {
|
||||
// Load initial state from localStorage
|
||||
const [config, setConfig] = useState<PlaygroundConfig>(
|
||||
getInitialPlaygroundConfig
|
||||
)
|
||||
const [config, setConfig] = useState<PlaygroundConfig>(() => {
|
||||
const savedConfig = loadConfig()
|
||||
return { ...DEFAULT_CONFIG, ...savedConfig }
|
||||
})
|
||||
|
||||
const [parameterEnabled, setParameterEnabled] = useState<ParameterEnabled>(
|
||||
getInitialParameterEnabled
|
||||
() => {
|
||||
const saved = loadParameterEnabled()
|
||||
return { ...DEFAULT_PARAMETER_ENABLED, ...saved }
|
||||
}
|
||||
)
|
||||
|
||||
const [messages, setMessages] = useState<Message[]>(getInitialMessages)
|
||||
const [messages, setMessages] = useState<Message[]>(() => {
|
||||
return loadMessages() || []
|
||||
})
|
||||
|
||||
const [models, setModels] = useState<ModelOption[]>([])
|
||||
const [groups, setGroups] = useState<GroupOption[]>([])
|
||||
@@ -80,9 +84,10 @@ export function usePlaygroundState() {
|
||||
|
||||
// Update messages with automatic save
|
||||
const updateMessages = useCallback(
|
||||
(updater: MessageStateUpdater) => {
|
||||
(updater: Message[] | ((prev: Message[]) => Message[])) => {
|
||||
setMessages((prev) => {
|
||||
const newMessages = applyMessageStateUpdate(prev, updater)
|
||||
const newMessages =
|
||||
typeof updater === 'function' ? updater(prev) : updater
|
||||
saveMessages(newMessages)
|
||||
return newMessages
|
||||
})
|
||||
|
||||
+54
-39
@@ -16,18 +16,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { SSE } from 'sse.js'
|
||||
import { getCommonHeaders } from '@/lib/api'
|
||||
import { API_ENDPOINTS, ERROR_MESSAGES } from '../constants'
|
||||
import {
|
||||
getStreamReadyStateError,
|
||||
isStreamClosedReadyState,
|
||||
isStreamDoneMessage,
|
||||
parseStreamErrorDetails,
|
||||
parseStreamMessageUpdates,
|
||||
} from '../lib'
|
||||
import type { ChatCompletionRequest } from '../types'
|
||||
import type { ChatCompletionRequest, ChatCompletionChunk } from '../types'
|
||||
|
||||
/**
|
||||
* Hook for handling streaming chat completion requests
|
||||
@@ -35,17 +28,6 @@ import type { ChatCompletionRequest } from '../types'
|
||||
export function useStreamRequest() {
|
||||
const sseSourceRef = useRef<SSE | null>(null)
|
||||
const isStreamCompleteRef = useRef(false)
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
|
||||
const closeActiveStream = useCallback((source?: SSE) => {
|
||||
const streamSource = source ?? sseSourceRef.current
|
||||
streamSource?.close()
|
||||
|
||||
if (!source || sseSourceRef.current === source) {
|
||||
sseSourceRef.current = null
|
||||
setIsStreaming(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const sendStreamRequest = useCallback(
|
||||
(
|
||||
@@ -54,8 +36,6 @@ export function useStreamRequest() {
|
||||
onComplete: () => void,
|
||||
onError: (error: string, errorCode?: string) => void
|
||||
) => {
|
||||
sseSourceRef.current?.close()
|
||||
|
||||
const source = new SSE(API_ENDPOINTS.CHAT_COMPLETIONS, {
|
||||
headers: getCommonHeaders(),
|
||||
method: 'POST',
|
||||
@@ -64,28 +44,38 @@ export function useStreamRequest() {
|
||||
|
||||
sseSourceRef.current = source
|
||||
isStreamCompleteRef.current = false
|
||||
setIsStreaming(true)
|
||||
|
||||
const closeSource = () => {
|
||||
source.close()
|
||||
sseSourceRef.current = null
|
||||
}
|
||||
|
||||
const handleError = (errorMessage: string, errorCode?: string) => {
|
||||
if (!isStreamCompleteRef.current) {
|
||||
onError(errorMessage, errorCode)
|
||||
closeActiveStream(source)
|
||||
closeSource()
|
||||
}
|
||||
}
|
||||
|
||||
source.addEventListener('message', (e: MessageEvent) => {
|
||||
if (isStreamDoneMessage(e.data)) {
|
||||
if (e.data === '[DONE]') {
|
||||
isStreamCompleteRef.current = true
|
||||
closeActiveStream(source)
|
||||
closeSource()
|
||||
onComplete()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const updates = parseStreamMessageUpdates(e.data)
|
||||
const chunk: ChatCompletionChunk = JSON.parse(e.data)
|
||||
const delta = chunk.choices?.[0]?.delta
|
||||
|
||||
for (const update of updates) {
|
||||
onUpdate(update.type, update.chunk)
|
||||
if (delta) {
|
||||
if (delta.reasoning_content) {
|
||||
onUpdate('reasoning', delta.reasoning_content)
|
||||
}
|
||||
if (delta.content) {
|
||||
onUpdate('content', delta.content)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -96,10 +86,24 @@ export function useStreamRequest() {
|
||||
|
||||
source.addEventListener('error', (e: Event & { data?: string }) => {
|
||||
// Only handle errors if stream didn't complete normally
|
||||
if (!isStreamClosedReadyState(source.readyState)) {
|
||||
if (source.readyState !== 2) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('SSE Error:', e)
|
||||
const { errorCode, errorMessage } = parseStreamErrorDetails(e.data)
|
||||
let errorMessage = e.data || ERROR_MESSAGES.API_REQUEST_ERROR
|
||||
let errorCode: string | undefined
|
||||
if (e.data) {
|
||||
try {
|
||||
const parsed = JSON.parse(e.data) as {
|
||||
error?: { message?: string; code?: string }
|
||||
}
|
||||
if (parsed?.error) {
|
||||
errorMessage = parsed.error.message || errorMessage
|
||||
errorCode = parsed.error.code || undefined
|
||||
}
|
||||
} catch {
|
||||
// not JSON, use raw string
|
||||
}
|
||||
}
|
||||
handleError(errorMessage, errorCode)
|
||||
}
|
||||
})
|
||||
@@ -107,10 +111,14 @@ export function useStreamRequest() {
|
||||
source.addEventListener(
|
||||
'readystatechange',
|
||||
(e: Event & { readyState?: number }) => {
|
||||
const errorMessage = getStreamReadyStateError(e.readyState, source)
|
||||
|
||||
if (errorMessage) {
|
||||
handleError(errorMessage)
|
||||
const status = (source as unknown as { status?: number }).status
|
||||
if (
|
||||
e.readyState !== undefined &&
|
||||
e.readyState >= 2 &&
|
||||
status !== undefined &&
|
||||
status !== 200
|
||||
) {
|
||||
handleError(`HTTP ${status}: ${ERROR_MESSAGES.CONNECTION_CLOSED}`)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -121,19 +129,26 @@ export function useStreamRequest() {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to start SSE stream:', error)
|
||||
onError(ERROR_MESSAGES.STREAM_START_ERROR)
|
||||
closeActiveStream(source)
|
||||
sseSourceRef.current = null
|
||||
}
|
||||
},
|
||||
[closeActiveStream]
|
||||
[]
|
||||
)
|
||||
|
||||
const stopStream = useCallback(() => {
|
||||
closeActiveStream()
|
||||
}, [closeActiveStream])
|
||||
if (sseSourceRef.current) {
|
||||
sseSourceRef.current.close()
|
||||
sseSourceRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
const isStreaming = sseSourceRef.current !== null
|
||||
|
||||
return {
|
||||
sendStreamRequest,
|
||||
stopStream,
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
isStreaming,
|
||||
}
|
||||
}
|
||||
|
||||
+148
-34
@@ -16,16 +16,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { getUserModels, getUserGroups } from './api'
|
||||
import { PlaygroundChat } from './components/playground-chat'
|
||||
import { PlaygroundInput } from './components/playground-input'
|
||||
import {
|
||||
useChatHandler,
|
||||
usePlaygroundConversation,
|
||||
usePlaygroundOptions,
|
||||
usePlaygroundState,
|
||||
} from './hooks'
|
||||
import { usePlaygroundState, useChatHandler } from './hooks'
|
||||
import { createUserMessage, createLoadingAssistantMessage } from './lib'
|
||||
import type { Message as MessageType } from './types'
|
||||
|
||||
export function Playground() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
config,
|
||||
parameterEnabled,
|
||||
@@ -36,7 +39,6 @@ export function Playground() {
|
||||
setModels,
|
||||
setGroups,
|
||||
updateConfig,
|
||||
clearMessages,
|
||||
} = usePlaygroundState()
|
||||
|
||||
const { sendChat, stopGeneration, isGenerating } = useChatHandler({
|
||||
@@ -45,43 +47,157 @@ export function Playground() {
|
||||
onMessageUpdate: updateMessages,
|
||||
})
|
||||
|
||||
const {
|
||||
editingMessageKey,
|
||||
handleSendMessage,
|
||||
handleRegenerateMessage,
|
||||
handleEditMessage,
|
||||
handleEditOpenChange,
|
||||
applyEdit,
|
||||
handleDeleteMessage,
|
||||
} = usePlaygroundConversation({
|
||||
messages,
|
||||
updateMessages,
|
||||
sendChat,
|
||||
// Edit dialog state
|
||||
const [editingMessageKey, setEditingMessageKey] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
|
||||
// Load models
|
||||
const { data: modelsData, isLoading: isLoadingModels } = useQuery({
|
||||
queryKey: ['playground-models'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await getUserModels()
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('Failed to load playground models')
|
||||
)
|
||||
return []
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const handleClearMessages = () => {
|
||||
handleEditOpenChange(false)
|
||||
clearMessages()
|
||||
// Load groups
|
||||
const { data: groupsData } = useQuery({
|
||||
queryKey: ['playground-groups'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await getUserGroups()
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('Failed to load playground groups')
|
||||
)
|
||||
return []
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Update models when data changes
|
||||
useEffect(() => {
|
||||
if (!modelsData) return
|
||||
|
||||
setModels(modelsData)
|
||||
|
||||
// Set default model if current model is not available
|
||||
const isCurrentModelValid = modelsData.some((m) => m.value === config.model)
|
||||
if (modelsData.length > 0 && !isCurrentModelValid) {
|
||||
updateConfig('model', modelsData[0].value)
|
||||
}
|
||||
}, [modelsData, config.model, setModels, updateConfig])
|
||||
|
||||
// Update groups when data changes
|
||||
useEffect(() => {
|
||||
if (!groupsData) return
|
||||
|
||||
setGroups(groupsData)
|
||||
|
||||
const hasCurrentGroup = groupsData.some((g) => g.value === config.group)
|
||||
if (!hasCurrentGroup && groupsData.length > 0) {
|
||||
const fallback =
|
||||
groupsData.find((g) => g.value === 'default')?.value ??
|
||||
groupsData[0].value
|
||||
updateConfig('group', fallback)
|
||||
}
|
||||
}, [groupsData, setGroups, config.group, updateConfig])
|
||||
|
||||
const handleSendMessage = (text: string) => {
|
||||
const userMessage = createUserMessage(text)
|
||||
const assistantMessage = createLoadingAssistantMessage()
|
||||
|
||||
const newMessages = [...messages, userMessage, assistantMessage]
|
||||
updateMessages(newMessages)
|
||||
|
||||
// Send chat request
|
||||
sendChat(newMessages)
|
||||
}
|
||||
|
||||
const { isLoadingModels } = usePlaygroundOptions({
|
||||
currentGroup: config.group,
|
||||
currentModel: config.model,
|
||||
setGroups,
|
||||
setModels,
|
||||
updateConfig,
|
||||
})
|
||||
const handleCopyMessage = (message: MessageType) => {
|
||||
// Copy is handled in MessageActions component
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Message copied:', message.key)
|
||||
}
|
||||
|
||||
const handleRegenerateMessage = (message: MessageType) => {
|
||||
// Find the message index and regenerate from there
|
||||
const messageIndex = messages.findIndex((m) => m.key === message.key)
|
||||
if (messageIndex === -1) return
|
||||
|
||||
// Remove messages after this one and regenerate
|
||||
const messagesUpToHere = messages.slice(0, messageIndex)
|
||||
const loadingMessage = createLoadingAssistantMessage()
|
||||
const newMessages = [...messagesUpToHere, loadingMessage]
|
||||
|
||||
updateMessages(newMessages)
|
||||
sendChat(newMessages)
|
||||
}
|
||||
|
||||
const handleEditMessage = useCallback((message: MessageType) => {
|
||||
setEditingMessageKey(message.key)
|
||||
}, [])
|
||||
|
||||
const handleEditOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) setEditingMessageKey(null)
|
||||
}, [])
|
||||
|
||||
// Apply edit and optionally re-submit from the edited user message
|
||||
const applyEdit = useCallback(
|
||||
(newContent: string, submit: boolean) => {
|
||||
if (!editingMessageKey) return
|
||||
const index = messages.findIndex((m) => m.key === editingMessageKey)
|
||||
if (index === -1) return
|
||||
|
||||
const updated = messages.map((m) =>
|
||||
m.key === editingMessageKey
|
||||
? { ...m, versions: [{ ...m.versions[0], content: newContent }] }
|
||||
: m
|
||||
)
|
||||
|
||||
setEditingMessageKey(null)
|
||||
|
||||
if (!submit || updated[index].from !== 'user') {
|
||||
updateMessages(updated)
|
||||
return
|
||||
}
|
||||
|
||||
const toSubmit = [
|
||||
...updated.slice(0, index + 1),
|
||||
createLoadingAssistantMessage(),
|
||||
]
|
||||
updateMessages(toSubmit)
|
||||
sendChat(toSubmit)
|
||||
},
|
||||
[editingMessageKey, messages, updateMessages, sendChat]
|
||||
)
|
||||
|
||||
const handleDeleteMessage = (message: MessageType) => {
|
||||
const newMessages = messages.filter((m) => m.key !== message.key)
|
||||
updateMessages(newMessages)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative flex size-full min-h-0 flex-col overflow-hidden'>
|
||||
<div className='relative flex size-full flex-col overflow-hidden'>
|
||||
{/* Full-width scroll container: scrolling works even over side whitespace */}
|
||||
<div className='flex min-h-0 flex-1 flex-col overflow-hidden'>
|
||||
<div className='flex flex-1 flex-col overflow-hidden'>
|
||||
<PlaygroundChat
|
||||
messages={messages}
|
||||
onCopyMessage={handleCopyMessage}
|
||||
onRegenerateMessage={handleRegenerateMessage}
|
||||
onEditMessage={handleEditMessage}
|
||||
onDeleteMessage={handleDeleteMessage}
|
||||
onSelectPrompt={handleSendMessage}
|
||||
isGenerating={isGenerating}
|
||||
editingKey={editingMessageKey}
|
||||
onCancelEdit={handleEditOpenChange}
|
||||
@@ -101,11 +217,9 @@ export function Playground() {
|
||||
modelValue={config.model}
|
||||
models={models}
|
||||
onGroupChange={(value) => updateConfig('group', value)}
|
||||
onClearMessages={handleClearMessages}
|
||||
onModelChange={(value) => updateConfig('model', value)}
|
||||
onStop={stopGeneration}
|
||||
onSubmit={handleSendMessage}
|
||||
hasMessages={messages.length > 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,142 +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 { Message } from '../types'
|
||||
import { MESSAGE_ROLES } from '../constants'
|
||||
import {
|
||||
createLoadingAssistantMessage,
|
||||
createUserMessage,
|
||||
getMessageContent,
|
||||
updateCurrentVersionContent,
|
||||
} from './message-utils'
|
||||
|
||||
type ApplyMessageEditResult = {
|
||||
messages: Message[]
|
||||
shouldSend: boolean
|
||||
}
|
||||
|
||||
type ChatMessageRenderState = {
|
||||
alwaysShowActions: boolean
|
||||
content: string
|
||||
isEditing: boolean
|
||||
}
|
||||
|
||||
export function appendUserMessagePair(
|
||||
messages: Message[],
|
||||
content: string
|
||||
): Message[] {
|
||||
return [
|
||||
...messages,
|
||||
createUserMessage(content),
|
||||
createLoadingAssistantMessage(),
|
||||
]
|
||||
}
|
||||
|
||||
export function createRegeneratedMessages(
|
||||
messages: Message[],
|
||||
messageKey: string
|
||||
): Message[] | null {
|
||||
const messageIndex = messages.findIndex((message) => message.key === messageKey)
|
||||
|
||||
if (messageIndex === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return [...messages.slice(0, messageIndex), createLoadingAssistantMessage()]
|
||||
}
|
||||
|
||||
export function removeMessageByKey(
|
||||
messages: Message[],
|
||||
messageKey: string
|
||||
): Message[] {
|
||||
return messages.filter((message) => message.key !== messageKey)
|
||||
}
|
||||
|
||||
export function getPreviousUserMessage(
|
||||
messages: Message[],
|
||||
beforeIndex: number
|
||||
): Message | null {
|
||||
for (let index = beforeIndex - 1; index >= 0; index--) {
|
||||
if (messages[index].from === MESSAGE_ROLES.USER) {
|
||||
return messages[index]
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function applyMessageEdit(
|
||||
messages: Message[],
|
||||
messageKey: string,
|
||||
content: string,
|
||||
shouldSubmit: boolean
|
||||
): ApplyMessageEditResult | null {
|
||||
const messageIndex = messages.findIndex((message) => message.key === messageKey)
|
||||
|
||||
if (messageIndex === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const updatedMessages = messages.map((message) =>
|
||||
message.key === messageKey
|
||||
? updateCurrentVersionContent(message, content)
|
||||
: message
|
||||
)
|
||||
|
||||
if (
|
||||
!shouldSubmit ||
|
||||
updatedMessages[messageIndex].from !== MESSAGE_ROLES.USER
|
||||
) {
|
||||
return { messages: updatedMessages, shouldSend: false }
|
||||
}
|
||||
|
||||
return {
|
||||
messages: [
|
||||
...updatedMessages.slice(0, messageIndex + 1),
|
||||
createLoadingAssistantMessage(),
|
||||
],
|
||||
shouldSend: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function getEditingMessageContent(
|
||||
messages: Message[],
|
||||
editingKey?: string | null
|
||||
): string {
|
||||
if (!editingKey) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const message = messages.find((item) => item.key === editingKey)
|
||||
return message ? getMessageContent(message) : ''
|
||||
}
|
||||
|
||||
export function getChatMessageRenderState(
|
||||
messages: Message[],
|
||||
message: Message,
|
||||
messageIndex: number,
|
||||
editingKey?: string | null
|
||||
): ChatMessageRenderState {
|
||||
return {
|
||||
alwaysShowActions:
|
||||
messageIndex === messages.length - 1 &&
|
||||
message.from === MESSAGE_ROLES.ASSISTANT,
|
||||
content: getMessageContent(message),
|
||||
isEditing: editingKey === message.key,
|
||||
}
|
||||
}
|
||||
@@ -17,21 +17,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export * from './message-utils'
|
||||
export * from './input-control-utils'
|
||||
export * from './input-tool-utils'
|
||||
export * from './message-action-utils'
|
||||
export * from './message-content-utils'
|
||||
export * from './message-editor-utils'
|
||||
export * from './message-error-utils'
|
||||
export * from './message-reasoning-utils'
|
||||
export * from './message-streaming-utils'
|
||||
export * from './message-update-utils'
|
||||
export * from './payload-builder'
|
||||
export * from './storage'
|
||||
export * from './message-styles'
|
||||
export * from './stream-utils'
|
||||
export * from './request-error-utils'
|
||||
export * from './conversation-message-utils'
|
||||
export * from './playground-state-utils'
|
||||
export * from './playground-option-utils'
|
||||
export * from './suggestion-utils'
|
||||
|
||||
@@ -1,67 +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 { GroupOption, ModelOption } from '../types'
|
||||
|
||||
type InputControlStateOptions = {
|
||||
disabled?: boolean
|
||||
groups: GroupOption[]
|
||||
hasStopHandler: boolean
|
||||
isGenerating?: boolean
|
||||
isModelLoading?: boolean
|
||||
models: ModelOption[]
|
||||
text: string
|
||||
}
|
||||
|
||||
type InputControlState = {
|
||||
canSubmit: boolean
|
||||
isSelectorDisabled: boolean
|
||||
shouldShowStop: boolean
|
||||
}
|
||||
|
||||
type SubmittableInputMessage = {
|
||||
text?: string | null
|
||||
}
|
||||
|
||||
export function getSubmittableInputText(
|
||||
message: SubmittableInputMessage,
|
||||
disabled?: boolean
|
||||
): string | null {
|
||||
if (disabled || !message.text?.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return message.text
|
||||
}
|
||||
|
||||
export function getInputControlState({
|
||||
disabled,
|
||||
groups,
|
||||
hasStopHandler,
|
||||
isGenerating,
|
||||
isModelLoading,
|
||||
models,
|
||||
text,
|
||||
}: InputControlStateOptions): InputControlState {
|
||||
return {
|
||||
canSubmit: !disabled && text.trim().length > 0,
|
||||
isSelectorDisabled:
|
||||
disabled || isModelLoading || models.length === 0 || groups.length === 0,
|
||||
shouldShowStop: Boolean(isGenerating && hasStopHandler),
|
||||
}
|
||||
}
|
||||
@@ -1,60 +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 {
|
||||
CameraIcon,
|
||||
FileIcon,
|
||||
ImageIcon,
|
||||
ScreenShareIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
type AttachmentAction = {
|
||||
action: string
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
}
|
||||
|
||||
type InputToolNotice = {
|
||||
description?: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export const ATTACHMENT_ACTIONS = [
|
||||
{ action: 'upload-file', icon: FileIcon, label: 'Upload file' },
|
||||
{ action: 'upload-photo', icon: ImageIcon, label: 'Upload photo' },
|
||||
{
|
||||
action: 'take-screenshot',
|
||||
icon: ScreenShareIcon,
|
||||
label: 'Take screenshot',
|
||||
},
|
||||
{ action: 'take-photo', icon: CameraIcon, label: 'Take photo' },
|
||||
] satisfies AttachmentAction[]
|
||||
|
||||
export function getAttachmentActionNotice(action: string): InputToolNotice {
|
||||
return {
|
||||
description: action,
|
||||
title: 'Feature in development',
|
||||
}
|
||||
}
|
||||
|
||||
export function getSearchActionNotice(): InputToolNotice {
|
||||
return {
|
||||
title: 'Search feature in development',
|
||||
}
|
||||
}
|
||||
@@ -1,45 +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 { MESSAGE_ROLES, MESSAGE_STATUS } from '../constants'
|
||||
import type { Message } from '../types'
|
||||
import { getMessageContent, hasMessageContent } from './message-utils'
|
||||
|
||||
type MessageActionState = {
|
||||
content: string
|
||||
hasContent: boolean
|
||||
isAssistant: boolean
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export function getMessageActionState(message: Message): MessageActionState {
|
||||
return {
|
||||
content: getMessageContent(message),
|
||||
hasContent: hasMessageContent(message),
|
||||
isAssistant: message.from === MESSAGE_ROLES.ASSISTANT,
|
||||
isLoading:
|
||||
message.status === MESSAGE_STATUS.LOADING ||
|
||||
message.status === MESSAGE_STATUS.STREAMING,
|
||||
}
|
||||
}
|
||||
|
||||
export function getMessageActionsVisibilityClass(alwaysVisible: boolean): string {
|
||||
return alwaysVisible
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100 max-md:opacity-100'
|
||||
}
|
||||
@@ -1,105 +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 { MESSAGE_ROLES, MESSAGE_STATUS } from '../constants'
|
||||
import type { Message } from '../types'
|
||||
import { parseThinkTags } from './message-reasoning-utils'
|
||||
|
||||
type MessageContentStateBase = {
|
||||
displayContent: string
|
||||
hasSources: boolean
|
||||
isAssistant: boolean
|
||||
showLoader: boolean
|
||||
showMessageContent: boolean
|
||||
sources: NonNullable<Message['sources']>
|
||||
}
|
||||
|
||||
type MessageContentState = MessageContentStateBase &
|
||||
(
|
||||
| {
|
||||
hasReasoning: true
|
||||
reasoningContent: string
|
||||
}
|
||||
| {
|
||||
hasReasoning: false
|
||||
reasoningContent: undefined
|
||||
}
|
||||
)
|
||||
|
||||
function shouldShowMessageLoader(
|
||||
message: Message,
|
||||
isAssistant: boolean,
|
||||
versionContent: string
|
||||
): boolean {
|
||||
return (
|
||||
isAssistant &&
|
||||
!message.isReasoningStreaming &&
|
||||
(message.status === MESSAGE_STATUS.LOADING ||
|
||||
(message.status === MESSAGE_STATUS.STREAMING && !versionContent))
|
||||
)
|
||||
}
|
||||
|
||||
function shouldShowMessageContent(
|
||||
message: Message,
|
||||
versionContent: string
|
||||
): boolean {
|
||||
return (
|
||||
(message.from === MESSAGE_ROLES.USER || !message.isReasoningStreaming) &&
|
||||
versionContent.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
export function getMessageContentState(
|
||||
message: Message,
|
||||
versionContent: string
|
||||
): MessageContentState {
|
||||
const isAssistant = message.from === MESSAGE_ROLES.ASSISTANT
|
||||
const sources = message.sources ?? []
|
||||
const reasoningContent = isAssistant ? message.reasoning?.content : undefined
|
||||
const showLoader = shouldShowMessageLoader(
|
||||
message,
|
||||
isAssistant,
|
||||
versionContent
|
||||
)
|
||||
const showMessageContent = shouldShowMessageContent(message, versionContent)
|
||||
|
||||
const baseState: MessageContentStateBase = {
|
||||
displayContent: isAssistant
|
||||
? parseThinkTags(versionContent).visibleContent
|
||||
: versionContent,
|
||||
hasSources: sources.length > 0,
|
||||
isAssistant,
|
||||
showLoader,
|
||||
showMessageContent,
|
||||
sources,
|
||||
}
|
||||
|
||||
if (reasoningContent) {
|
||||
return {
|
||||
...baseState,
|
||||
hasReasoning: true,
|
||||
reasoningContent,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...baseState,
|
||||
hasReasoning: false,
|
||||
reasoningContent: undefined,
|
||||
}
|
||||
}
|
||||
@@ -1,41 +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 { MESSAGE_ROLES } from '../constants'
|
||||
import type { Message } from '../types'
|
||||
|
||||
type MessageEditorState = {
|
||||
canSave: boolean
|
||||
hasChanged: boolean
|
||||
showSaveAndSubmit: boolean
|
||||
}
|
||||
|
||||
export function getMessageEditorState(
|
||||
message: Message,
|
||||
editText: string,
|
||||
originalText: string
|
||||
): MessageEditorState {
|
||||
const hasText = editText.trim().length > 0
|
||||
const hasChanged = editText !== originalText
|
||||
|
||||
return {
|
||||
canSave: hasText && hasChanged,
|
||||
hasChanged,
|
||||
showSaveAndSubmit: message.from === MESSAGE_ROLES.USER,
|
||||
}
|
||||
}
|
||||
@@ -1,59 +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 { MESSAGE_STATUS } from '../constants'
|
||||
import type { Message } from '../types'
|
||||
import { getMessageContent } from './message-utils'
|
||||
|
||||
export const MODEL_PRICING_SETTINGS_PATH =
|
||||
'/system-settings/billing/model-pricing'
|
||||
|
||||
const MODEL_PRICE_ERROR_CODE = 'model_price_error'
|
||||
const FALLBACK_ERROR_CONTENT = 'An unknown error occurred'
|
||||
|
||||
type MessageErrorState = {
|
||||
content: string
|
||||
kind: 'generic' | 'model-price'
|
||||
showSettingsLink: boolean
|
||||
}
|
||||
|
||||
export function isAdminRole(role?: number | null): boolean {
|
||||
return role != null && role >= 10
|
||||
}
|
||||
|
||||
export function isErrorMessage(message: Message): boolean {
|
||||
return message.status === MESSAGE_STATUS.ERROR
|
||||
}
|
||||
|
||||
export function getMessageErrorState(
|
||||
message: Message,
|
||||
isAdmin: boolean
|
||||
): MessageErrorState | null {
|
||||
if (!isErrorMessage(message)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const content = getMessageContent(message) || FALLBACK_ERROR_CONTENT
|
||||
const isModelPriceError = message.errorCode === MODEL_PRICE_ERROR_CODE
|
||||
|
||||
return {
|
||||
content,
|
||||
kind: isModelPriceError ? 'model-price' : 'generic',
|
||||
showSettingsLink: isModelPriceError && isAdmin,
|
||||
}
|
||||
}
|
||||
@@ -1,71 +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
|
||||
*/
|
||||
|
||||
interface ParsedThinkTags {
|
||||
visibleContent: string
|
||||
reasoning: string
|
||||
hasUnclosedTag: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse content to separate thinking from visible text.
|
||||
* Handles both complete and incomplete <think> tags.
|
||||
*/
|
||||
export function parseThinkTags(content: string): ParsedThinkTags {
|
||||
if (!content.includes('<think>')) {
|
||||
return { visibleContent: content, reasoning: '', hasUnclosedTag: false }
|
||||
}
|
||||
|
||||
const visibleParts: string[] = []
|
||||
const reasoningParts: string[] = []
|
||||
let currentPos = 0
|
||||
let hasUnclosedTag = false
|
||||
|
||||
while (true) {
|
||||
const openPos = content.indexOf('<think>', currentPos)
|
||||
|
||||
if (openPos === -1) {
|
||||
if (currentPos < content.length) {
|
||||
visibleParts.push(content.substring(currentPos))
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (openPos > currentPos) {
|
||||
visibleParts.push(content.substring(currentPos, openPos))
|
||||
}
|
||||
|
||||
const closePos = content.indexOf('</think>', openPos + 7)
|
||||
|
||||
if (closePos === -1) {
|
||||
reasoningParts.push(content.substring(openPos + 7))
|
||||
hasUnclosedTag = true
|
||||
break
|
||||
}
|
||||
|
||||
reasoningParts.push(content.substring(openPos + 7, closePos))
|
||||
currentPos = closePos + 8
|
||||
}
|
||||
|
||||
return {
|
||||
visibleContent: visibleParts.join('').trim(),
|
||||
reasoning: reasoningParts.join('\n\n').trim(),
|
||||
hasUnclosedTag,
|
||||
}
|
||||
}
|
||||
@@ -1,208 +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 { ERROR_MESSAGES, MESSAGE_ROLES, MESSAGE_STATUS } from '../constants'
|
||||
import type { ChatCompletionResponse, Message } from '../types'
|
||||
import {
|
||||
getCurrentVersion,
|
||||
hasMessageContent,
|
||||
updateCurrentVersionContent,
|
||||
} from './message-utils'
|
||||
import { parseThinkTags } from './message-reasoning-utils'
|
||||
|
||||
/**
|
||||
* Process content chunk during streaming.
|
||||
* Separates <think> reasoning from visible content in real-time.
|
||||
* Note: versions[0].content keeps the full raw content with tags during streaming.
|
||||
*/
|
||||
export function processStreamingContent(
|
||||
message: Message,
|
||||
contentChunk?: string
|
||||
): Message {
|
||||
const currentVersion = getCurrentVersion(message)
|
||||
const fullContent = contentChunk
|
||||
? currentVersion.content + contentChunk
|
||||
: currentVersion.content
|
||||
|
||||
const { reasoning, hasUnclosedTag } = parseThinkTags(fullContent)
|
||||
const finalReasoning = reasoning
|
||||
? { content: reasoning, duration: 0 }
|
||||
: message.reasoning
|
||||
|
||||
return {
|
||||
...updateCurrentVersionContent(message, fullContent),
|
||||
reasoning: finalReasoning,
|
||||
isReasoningStreaming: hasUnclosedTag,
|
||||
}
|
||||
}
|
||||
|
||||
export type StreamChunkType = 'reasoning' | 'content'
|
||||
|
||||
export function applyStreamingChunk(
|
||||
message: Message,
|
||||
type: StreamChunkType,
|
||||
chunk: string
|
||||
): Message {
|
||||
if (message.status === MESSAGE_STATUS.ERROR) {
|
||||
return message
|
||||
}
|
||||
|
||||
if (type === 'reasoning') {
|
||||
return {
|
||||
...message,
|
||||
reasoning: {
|
||||
content: (message.reasoning?.content || '') + chunk,
|
||||
duration: 0,
|
||||
},
|
||||
isReasoningStreaming: true,
|
||||
status: MESSAGE_STATUS.STREAMING,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...processStreamingContent(message, chunk),
|
||||
status: MESSAGE_STATUS.STREAMING,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize message after streaming completes.
|
||||
* Cleans content and consolidates reasoning from all sources.
|
||||
*/
|
||||
export function finalizeMessage(
|
||||
message: Message,
|
||||
apiReasoningContent?: string
|
||||
): Message {
|
||||
const currentVersion = getCurrentVersion(message)
|
||||
const { visibleContent, reasoning } = parseThinkTags(currentVersion.content)
|
||||
const finalReasoning =
|
||||
apiReasoningContent || message.reasoning?.content || reasoning || ''
|
||||
|
||||
return {
|
||||
...updateCurrentVersionContent(message, visibleContent),
|
||||
reasoning: finalReasoning
|
||||
? { content: finalReasoning, duration: message.reasoning?.duration || 0 }
|
||||
: undefined,
|
||||
isReasoningStreaming: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function completeAssistantMessage(message: Message): Message {
|
||||
return {
|
||||
...finalizeMessage(message),
|
||||
status: MESSAGE_STATUS.COMPLETE,
|
||||
}
|
||||
}
|
||||
|
||||
export function isAssistantMessageFinal(message: Message): boolean {
|
||||
return (
|
||||
message.status === MESSAGE_STATUS.COMPLETE ||
|
||||
message.status === MESSAGE_STATUS.ERROR
|
||||
)
|
||||
}
|
||||
|
||||
export function isAssistantMessagePending(message: Message): boolean {
|
||||
return (
|
||||
message.status === MESSAGE_STATUS.LOADING ||
|
||||
message.status === MESSAGE_STATUS.STREAMING
|
||||
)
|
||||
}
|
||||
|
||||
export function isPendingAssistantMessage(message?: Message): boolean {
|
||||
return Boolean(
|
||||
message?.from === MESSAGE_ROLES.ASSISTANT &&
|
||||
isAssistantMessagePending(message)
|
||||
)
|
||||
}
|
||||
|
||||
type ChatCompletionChoice = ChatCompletionResponse['choices'][number]
|
||||
|
||||
export function hasChatCompletionChoice(
|
||||
response: ChatCompletionResponse
|
||||
): boolean {
|
||||
return Boolean(response.choices?.[0])
|
||||
}
|
||||
|
||||
export function applyChatCompletionChoice(
|
||||
message: Message,
|
||||
choice: ChatCompletionChoice
|
||||
): Message {
|
||||
return {
|
||||
...finalizeMessage(
|
||||
updateCurrentVersionContent(message, choice.message?.content || ''),
|
||||
choice.message?.reasoning_content
|
||||
),
|
||||
status: MESSAGE_STATUS.COMPLETE,
|
||||
}
|
||||
}
|
||||
|
||||
export function applyChatCompletionResponse(
|
||||
message: Message,
|
||||
response: ChatCompletionResponse
|
||||
): Message | null {
|
||||
const choice = response.choices?.[0]
|
||||
|
||||
if (!choice) {
|
||||
return null
|
||||
}
|
||||
|
||||
return applyChatCompletionChoice(message, choice)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize messages loaded from storage.
|
||||
* Converts stuck loading/streaming messages to stable state.
|
||||
*/
|
||||
export function sanitizeMessagesOnLoad(messages: Message[]): Message[] {
|
||||
let targetIndex = -1
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i]
|
||||
|
||||
if (isPendingAssistantMessage(message)) {
|
||||
targetIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (targetIndex === -1) return messages
|
||||
|
||||
const finalized = finalizeMessage(messages[targetIndex])
|
||||
const hasContent = hasMessageContent(finalized)
|
||||
const hasReasoning = finalized.reasoning?.content?.trim()
|
||||
|
||||
const sanitized: Message =
|
||||
hasContent || hasReasoning
|
||||
? {
|
||||
...finalized,
|
||||
status: MESSAGE_STATUS.COMPLETE,
|
||||
isReasoningStreaming: false,
|
||||
}
|
||||
: {
|
||||
...updateCurrentVersionContent(
|
||||
finalized,
|
||||
`${ERROR_MESSAGES.API_REQUEST_ERROR}: ${ERROR_MESSAGES.INTERRUPTED}`
|
||||
),
|
||||
status: MESSAGE_STATUS.ERROR,
|
||||
isReasoningStreaming: false,
|
||||
}
|
||||
|
||||
const result = [...messages]
|
||||
result[targetIndex] = sanitized
|
||||
return result
|
||||
}
|
||||
+10
-24
@@ -22,39 +22,25 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export function getMessageContentStyles() {
|
||||
return [
|
||||
// Assistant content reads like a document column; user bubble stays compact.
|
||||
// Assistant content fills the row; user bubble auto-width
|
||||
'group-[.is-assistant]:w-full',
|
||||
'group-[.is-assistant]:max-w-[78ch]',
|
||||
'group-[.is-assistant]:max-w-none',
|
||||
'group-[.is-user]:w-fit',
|
||||
|
||||
// User bubble: compact surface that stays calm in both light and dark themes.
|
||||
'group-[.is-user]:rounded-2xl',
|
||||
'group-[.is-user]:rounded-br-md',
|
||||
'group-[.is-user]:border',
|
||||
'group-[.is-user]:border-border/70',
|
||||
'group-[.is-user]:bg-muted/70',
|
||||
'group-[.is-user]:px-4',
|
||||
'group-[.is-user]:py-2.5',
|
||||
// User bubble: rounded and themed background
|
||||
'group-[.is-user]:text-foreground',
|
||||
'group-[.is-user]:shadow-sm',
|
||||
'group-[.is-user]:shadow-black/5',
|
||||
|
||||
// Assistant response: flat reading surface using the active UI font axis.
|
||||
'group-[.is-user]:bg-secondary',
|
||||
'dark:group-[.is-user]:bg-muted',
|
||||
'group-[.is-user]:rounded-3xl',
|
||||
// Assistant bubble: flat serif style (one-sided style)
|
||||
'group-[.is-assistant]:text-foreground',
|
||||
'group-[.is-assistant]:bg-transparent',
|
||||
'group-[.is-assistant]:p-0',
|
||||
'group-[.is-assistant]:rounded-none',
|
||||
'group-[.is-assistant]:overflow-visible',
|
||||
'group-[.is-assistant]:[font-family:var(--font-body)]',
|
||||
'group-[.is-assistant]:text-foreground/90',
|
||||
|
||||
'group-[.is-assistant]:font-serif',
|
||||
// Preferred readable widths and wrapping
|
||||
'text-[0.95rem]',
|
||||
'leading-6',
|
||||
'leading-relaxed',
|
||||
'break-words',
|
||||
'whitespace-pre-wrap',
|
||||
'sm:text-[0.975rem]',
|
||||
'sm:leading-7',
|
||||
|
||||
// Cap user bubble width so it does not look like a banner
|
||||
'group-[.is-user]:max-w-[85%]',
|
||||
'sm:group-[.is-user]:max-w-[62ch]',
|
||||
|
||||
@@ -1,61 +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 { ERROR_MESSAGES, MESSAGE_ROLES, MESSAGE_STATUS } from '../constants'
|
||||
import type { Message } from '../types'
|
||||
import { updateCurrentVersionContent } from './message-utils'
|
||||
|
||||
/**
|
||||
* Update the last assistant message with an error.
|
||||
*/
|
||||
export function updateAssistantMessageWithError(
|
||||
messages: Message[],
|
||||
errorMessage: string,
|
||||
errorCode?: string
|
||||
): Message[] {
|
||||
return updateLastAssistantMessage(messages, (message) => {
|
||||
const updatedMessage = updateCurrentVersionContent(
|
||||
message,
|
||||
`${ERROR_MESSAGES.API_REQUEST_ERROR}: ${errorMessage}`
|
||||
)
|
||||
|
||||
return {
|
||||
...updatedMessage,
|
||||
status: MESSAGE_STATUS.ERROR,
|
||||
isReasoningStreaming: false,
|
||||
errorCode: errorCode || null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the most recent assistant message, preserving the array when absent.
|
||||
*/
|
||||
export function updateLastAssistantMessage(
|
||||
messages: Message[],
|
||||
updater: (message: Message) => Message
|
||||
): Message[] {
|
||||
if (messages.length === 0) return messages
|
||||
|
||||
const last = messages[messages.length - 1]
|
||||
if (!last || last.from !== MESSAGE_ROLES.ASSISTANT) return messages
|
||||
|
||||
const updated = [...messages]
|
||||
updated[updated.length - 1] = updater(last)
|
||||
return updated
|
||||
}
|
||||
+206
-18
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { nanoid } from 'nanoid'
|
||||
import { MESSAGE_ROLES, MESSAGE_STATUS } from '../constants'
|
||||
import { MESSAGE_ROLES, MESSAGE_STATUS, ERROR_MESSAGES } from '../constants'
|
||||
import type {
|
||||
Message,
|
||||
MessageVersion,
|
||||
@@ -42,20 +42,6 @@ export function getCurrentVersion(message: Message): MessageVersion {
|
||||
return message.versions[0] || { id: 'default', content: '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get displayable content from the current message version.
|
||||
*/
|
||||
export function getMessageContent(message: Message): string {
|
||||
return getCurrentVersion(message).content
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a message has non-empty content in its current version.
|
||||
*/
|
||||
export function hasMessageContent(message: Message): boolean {
|
||||
return getMessageContent(message).trim() !== ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current version content in message
|
||||
*/
|
||||
@@ -158,10 +144,212 @@ export function formatMessageForAPI(message: Message): ChatCompletionMessage {
|
||||
export function isValidMessage(message: Message): boolean {
|
||||
if (!message || !message.from || !message.versions.length) return false
|
||||
|
||||
const content = message.versions[0]?.content
|
||||
if (content === undefined) return false
|
||||
|
||||
// Exclude empty assistant messages (loading/streaming placeholders)
|
||||
if (message.from === MESSAGE_ROLES.ASSISTANT && !hasMessageContent(message)) {
|
||||
return false
|
||||
}
|
||||
if (message.from === 'assistant' && !content.trim()) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse content to separate thinking from visible text
|
||||
* Handles both complete and incomplete <think> tags
|
||||
*/
|
||||
export function parseThinkTags(content: string): {
|
||||
visibleContent: string
|
||||
reasoning: string
|
||||
hasUnclosedTag: boolean
|
||||
} {
|
||||
if (!content.includes('<think>')) {
|
||||
return { visibleContent: content, reasoning: '', hasUnclosedTag: false }
|
||||
}
|
||||
|
||||
const visibleParts: string[] = []
|
||||
const reasoningParts: string[] = []
|
||||
let currentPos = 0
|
||||
let hasUnclosed = false
|
||||
|
||||
while (true) {
|
||||
// Find next <think> tag
|
||||
const openPos = content.indexOf('<think>', currentPos)
|
||||
|
||||
if (openPos === -1) {
|
||||
// No more think tags, add remaining content
|
||||
if (currentPos < content.length) {
|
||||
visibleParts.push(content.substring(currentPos))
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Add visible content before this tag
|
||||
if (openPos > currentPos) {
|
||||
visibleParts.push(content.substring(currentPos, openPos))
|
||||
}
|
||||
|
||||
// Look for matching </think> tag
|
||||
const closePos = content.indexOf('</think>', openPos + 7)
|
||||
|
||||
if (closePos === -1) {
|
||||
// Unclosed tag: rest is reasoning buffer
|
||||
reasoningParts.push(content.substring(openPos + 7))
|
||||
hasUnclosed = true
|
||||
break
|
||||
}
|
||||
|
||||
// Extract reasoning content between tags
|
||||
reasoningParts.push(content.substring(openPos + 7, closePos))
|
||||
currentPos = closePos + 8
|
||||
}
|
||||
|
||||
return {
|
||||
visibleContent: visibleParts.join('').trim(),
|
||||
reasoning: reasoningParts.join('\n\n').trim(),
|
||||
hasUnclosedTag: hasUnclosed,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the last assistant message with an error
|
||||
* @param messages - Current messages array
|
||||
* @param errorMessage - Error message to display
|
||||
* @returns Updated messages array
|
||||
*/
|
||||
export function updateAssistantMessageWithError(
|
||||
messages: Message[],
|
||||
errorMessage: string,
|
||||
errorCode?: string
|
||||
): Message[] {
|
||||
return updateLastAssistantMessage(messages, (message) => {
|
||||
const updatedMessage = updateCurrentVersionContent(
|
||||
message,
|
||||
`${ERROR_MESSAGES.API_REQUEST_ERROR}: ${errorMessage}`
|
||||
)
|
||||
return {
|
||||
...updatedMessage,
|
||||
status: MESSAGE_STATUS.ERROR,
|
||||
isReasoningStreaming: false,
|
||||
errorCode: errorCode || null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to update the last assistant message
|
||||
* @param messages - Current messages array
|
||||
* @param updater - Function to update the message
|
||||
* @returns Updated messages array or original if no assistant message found
|
||||
*/
|
||||
export function updateLastAssistantMessage(
|
||||
messages: Message[],
|
||||
updater: (message: Message) => Message
|
||||
): Message[] {
|
||||
if (messages.length === 0) return messages
|
||||
const last = messages[messages.length - 1]
|
||||
if (!last || last.from !== MESSAGE_ROLES.ASSISTANT) return messages
|
||||
|
||||
const updated = [...messages]
|
||||
updated[updated.length - 1] = updater(last)
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Process content chunk during streaming
|
||||
* Separates <think> reasoning from visible content in real-time
|
||||
* Note: versions[0].content keeps the full raw content (with tags) during streaming
|
||||
*/
|
||||
export function processStreamingContent(
|
||||
message: Message,
|
||||
contentChunk?: string
|
||||
): Message {
|
||||
const currentVersion = getCurrentVersion(message)
|
||||
const fullContent = contentChunk
|
||||
? currentVersion.content + contentChunk
|
||||
: currentVersion.content
|
||||
|
||||
const { reasoning, hasUnclosedTag } = parseThinkTags(fullContent)
|
||||
|
||||
// Preserve existing reasoning if no think tags found (e.g., from API reasoning_content)
|
||||
const finalReasoning = reasoning
|
||||
? { content: reasoning, duration: 0 }
|
||||
: message.reasoning
|
||||
|
||||
return {
|
||||
...updateCurrentVersionContent(message, fullContent),
|
||||
reasoning: finalReasoning,
|
||||
isReasoningStreaming: hasUnclosedTag,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize message after streaming completes
|
||||
* Cleans content and consolidates reasoning from all sources
|
||||
*/
|
||||
export function finalizeMessage(
|
||||
message: Message,
|
||||
apiReasoningContent?: string
|
||||
): Message {
|
||||
const currentVersion = getCurrentVersion(message)
|
||||
const { visibleContent, reasoning } = parseThinkTags(currentVersion.content)
|
||||
|
||||
// Priority:
|
||||
// 1. API reasoning_content passed as parameter (non-streaming response)
|
||||
// 2. Existing message.reasoning (from streaming reasoning_content)
|
||||
// 3. Extracted think tags from content
|
||||
const finalReasoning =
|
||||
apiReasoningContent || message.reasoning?.content || reasoning || ''
|
||||
|
||||
return {
|
||||
...updateCurrentVersionContent(message, visibleContent),
|
||||
reasoning: finalReasoning
|
||||
? { content: finalReasoning, duration: message.reasoning?.duration || 0 }
|
||||
: undefined,
|
||||
isReasoningStreaming: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize messages loaded from storage
|
||||
* Converts stuck loading/streaming messages to stable state
|
||||
*/
|
||||
export function sanitizeMessagesOnLoad(messages: Message[]): Message[] {
|
||||
let targetIndex = -1
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]
|
||||
if (
|
||||
m?.from === MESSAGE_ROLES.ASSISTANT &&
|
||||
(m?.status === MESSAGE_STATUS.LOADING ||
|
||||
m?.status === MESSAGE_STATUS.STREAMING)
|
||||
) {
|
||||
targetIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (targetIndex === -1) return messages
|
||||
|
||||
const finalized = finalizeMessage(messages[targetIndex])
|
||||
const hasContent = finalized.versions?.[0]?.content?.trim()
|
||||
const hasReasoning = finalized.reasoning?.content?.trim()
|
||||
|
||||
const sanitized: Message =
|
||||
hasContent || hasReasoning
|
||||
? {
|
||||
...finalized,
|
||||
status: MESSAGE_STATUS.COMPLETE,
|
||||
isReasoningStreaming: false,
|
||||
}
|
||||
: {
|
||||
...updateCurrentVersionContent(
|
||||
finalized,
|
||||
`${ERROR_MESSAGES.API_REQUEST_ERROR}: ${ERROR_MESSAGES.INTERRUPTED}`
|
||||
),
|
||||
status: MESSAGE_STATUS.ERROR,
|
||||
isReasoningStreaming: false,
|
||||
}
|
||||
|
||||
const result = [...messages]
|
||||
result[targetIndex] = sanitized
|
||||
return result
|
||||
}
|
||||
|
||||
+17
-22
@@ -44,29 +44,24 @@ export function buildChatCompletionPayload(
|
||||
stream: config.stream,
|
||||
}
|
||||
|
||||
if (parameterEnabled.temperature) {
|
||||
payload.temperature = config.temperature
|
||||
}
|
||||
// Add enabled parameters
|
||||
const parameterKeys: Array<keyof ParameterEnabled> = [
|
||||
'temperature',
|
||||
'top_p',
|
||||
'max_tokens',
|
||||
'frequency_penalty',
|
||||
'presence_penalty',
|
||||
'seed',
|
||||
]
|
||||
|
||||
if (parameterEnabled.top_p) {
|
||||
payload.top_p = config.top_p
|
||||
}
|
||||
|
||||
if (parameterEnabled.max_tokens) {
|
||||
payload.max_tokens = config.max_tokens
|
||||
}
|
||||
|
||||
if (parameterEnabled.frequency_penalty) {
|
||||
payload.frequency_penalty = config.frequency_penalty
|
||||
}
|
||||
|
||||
if (parameterEnabled.presence_penalty) {
|
||||
payload.presence_penalty = config.presence_penalty
|
||||
}
|
||||
|
||||
if (parameterEnabled.seed && config.seed !== null) {
|
||||
payload.seed = config.seed
|
||||
}
|
||||
parameterKeys.forEach((key) => {
|
||||
if (parameterEnabled[key]) {
|
||||
const value = config[key as keyof PlaygroundConfig]
|
||||
if (value !== undefined && value !== null) {
|
||||
;(payload as unknown as Record<string, unknown>)[key] = value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -1,54 +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 { GroupOption, ModelOption } from '../types'
|
||||
|
||||
export function getModelFallback(
|
||||
models: ModelOption[],
|
||||
currentModel: string
|
||||
): string | null {
|
||||
const hasCurrentModel = models.some((model) => model.value === currentModel)
|
||||
|
||||
if (hasCurrentModel || models.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return models[0].value
|
||||
}
|
||||
|
||||
export function getGroupFallback(
|
||||
groups: GroupOption[],
|
||||
currentGroup: string
|
||||
): string | null {
|
||||
const hasCurrentGroup = groups.some((group) => group.value === currentGroup)
|
||||
|
||||
if (hasCurrentGroup || groups.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
groups.find((group) => group.value === 'default')?.value ?? groups[0].value
|
||||
)
|
||||
}
|
||||
|
||||
export function getOptionLoadErrorMessage(
|
||||
error: unknown,
|
||||
fallbackMessage: string
|
||||
): string {
|
||||
return error instanceof Error ? error.message : fallbackMessage
|
||||
}
|
||||
@@ -1,44 +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 { DEFAULT_CONFIG, DEFAULT_PARAMETER_ENABLED } from '../constants'
|
||||
import type { Message, ParameterEnabled, PlaygroundConfig } from '../types'
|
||||
import { loadConfig, loadMessages, loadParameterEnabled } from './storage'
|
||||
|
||||
export type MessageStateUpdater =
|
||||
| Message[]
|
||||
| ((previousMessages: Message[]) => Message[])
|
||||
|
||||
export function getInitialPlaygroundConfig(): PlaygroundConfig {
|
||||
return { ...DEFAULT_CONFIG, ...loadConfig() }
|
||||
}
|
||||
|
||||
export function getInitialParameterEnabled(): ParameterEnabled {
|
||||
return { ...DEFAULT_PARAMETER_ENABLED, ...loadParameterEnabled() }
|
||||
}
|
||||
|
||||
export function getInitialMessages(): Message[] {
|
||||
return loadMessages() || []
|
||||
}
|
||||
|
||||
export function applyMessageStateUpdate(
|
||||
previousMessages: Message[],
|
||||
updater: MessageStateUpdater
|
||||
): Message[] {
|
||||
return typeof updater === 'function' ? updater(previousMessages) : updater
|
||||
}
|
||||
@@ -1,48 +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 { ERROR_MESSAGES } from '../constants'
|
||||
|
||||
type RequestErrorLike = {
|
||||
message?: string
|
||||
response?: {
|
||||
data?: {
|
||||
error?: {
|
||||
code?: string
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type RequestErrorDetails = {
|
||||
errorCode?: string
|
||||
errorMessage: string
|
||||
}
|
||||
|
||||
export function parseRequestErrorDetails(error: unknown): RequestErrorDetails {
|
||||
const requestError = error as RequestErrorLike
|
||||
|
||||
return {
|
||||
errorCode: requestError?.response?.data?.error?.code || undefined,
|
||||
errorMessage:
|
||||
requestError?.response?.data?.message ||
|
||||
requestError?.message ||
|
||||
ERROR_MESSAGES.API_REQUEST_ERROR,
|
||||
}
|
||||
}
|
||||
@@ -1,81 +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 { z } from 'zod'
|
||||
|
||||
export const STORAGE_VERSION = 1
|
||||
export const MAX_STORED_MESSAGES = 100
|
||||
|
||||
export const playgroundConfigSchema = z.object({
|
||||
model: z.string().optional(),
|
||||
group: z.string().optional(),
|
||||
temperature: z.number().finite().optional(),
|
||||
top_p: z.number().finite().optional(),
|
||||
max_tokens: z.number().finite().optional(),
|
||||
frequency_penalty: z.number().finite().optional(),
|
||||
presence_penalty: z.number().finite().optional(),
|
||||
seed: z.number().finite().nullable().optional(),
|
||||
stream: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const parameterEnabledSchema = z.object({
|
||||
temperature: z.boolean().optional(),
|
||||
top_p: z.boolean().optional(),
|
||||
max_tokens: z.boolean().optional(),
|
||||
frequency_penalty: z.boolean().optional(),
|
||||
presence_penalty: z.boolean().optional(),
|
||||
seed: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const messageRoleSchema = z.enum(['user', 'assistant', 'system'])
|
||||
const messageStatusSchema = z.enum([
|
||||
'loading',
|
||||
'streaming',
|
||||
'complete',
|
||||
'error',
|
||||
])
|
||||
|
||||
const messageVersionSchema = z.object({
|
||||
id: z.string(),
|
||||
content: z.string(),
|
||||
})
|
||||
|
||||
const sourceSchema = z.object({
|
||||
href: z.string(),
|
||||
title: z.string(),
|
||||
})
|
||||
|
||||
const reasoningSchema = z.object({
|
||||
content: z.string(),
|
||||
duration: z.number().finite(),
|
||||
})
|
||||
|
||||
const messageSchema = z.object({
|
||||
key: z.string(),
|
||||
from: messageRoleSchema,
|
||||
versions: z.array(messageVersionSchema).min(1),
|
||||
sources: z.array(sourceSchema).optional(),
|
||||
reasoning: reasoningSchema.optional(),
|
||||
isReasoningStreaming: z.boolean().optional(),
|
||||
isReasoningComplete: z.boolean().optional(),
|
||||
isContentComplete: z.boolean().optional(),
|
||||
status: messageStatusSchema.optional(),
|
||||
errorCode: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
export const messagesSchema = z.array(messageSchema)
|
||||
+27
-75
@@ -18,65 +18,17 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { STORAGE_KEYS } from '../constants'
|
||||
import type { PlaygroundConfig, ParameterEnabled, Message } from '../types'
|
||||
import { sanitizeMessagesOnLoad } from './message-streaming-utils'
|
||||
import {
|
||||
MAX_STORED_MESSAGES,
|
||||
STORAGE_VERSION,
|
||||
messagesSchema,
|
||||
parameterEnabledSchema,
|
||||
playgroundConfigSchema,
|
||||
} from './storage-schema'
|
||||
|
||||
type StoredEnvelope<T> = {
|
||||
version: number
|
||||
data: T
|
||||
}
|
||||
|
||||
function readStoredValue(key: string): unknown | null {
|
||||
const saved = localStorage.getItem(key)
|
||||
if (!saved) return null
|
||||
|
||||
return JSON.parse(saved) as unknown
|
||||
}
|
||||
|
||||
function unwrapStoredValue(value: unknown): unknown {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value
|
||||
}
|
||||
|
||||
if ('version' in value && 'data' in value) {
|
||||
return (value as StoredEnvelope<unknown>).data
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function writeStoredValue<T>(key: string, data: T): void {
|
||||
const payload: StoredEnvelope<T> = {
|
||||
version: STORAGE_VERSION,
|
||||
data,
|
||||
}
|
||||
|
||||
localStorage.setItem(key, JSON.stringify(payload))
|
||||
}
|
||||
|
||||
function trimMessages(messages: Message[]): Message[] {
|
||||
if (messages.length <= MAX_STORED_MESSAGES) {
|
||||
return messages
|
||||
}
|
||||
|
||||
return messages.slice(-MAX_STORED_MESSAGES)
|
||||
}
|
||||
import { sanitizeMessagesOnLoad } from './message-utils'
|
||||
|
||||
/**
|
||||
* Load playground config from localStorage
|
||||
*/
|
||||
export function loadConfig(): Partial<PlaygroundConfig> {
|
||||
try {
|
||||
const saved = readStoredValue(STORAGE_KEYS.CONFIG)
|
||||
if (!saved) return {}
|
||||
|
||||
return playgroundConfigSchema.parse(unwrapStoredValue(saved))
|
||||
const saved = localStorage.getItem(STORAGE_KEYS.CONFIG)
|
||||
if (saved) {
|
||||
return JSON.parse(saved)
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to load config:', error)
|
||||
@@ -89,8 +41,7 @@ export function loadConfig(): Partial<PlaygroundConfig> {
|
||||
*/
|
||||
export function saveConfig(config: Partial<PlaygroundConfig>): void {
|
||||
try {
|
||||
const parsed = playgroundConfigSchema.parse(config)
|
||||
writeStoredValue(STORAGE_KEYS.CONFIG, parsed)
|
||||
localStorage.setItem(STORAGE_KEYS.CONFIG, JSON.stringify(config))
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to save config:', error)
|
||||
@@ -102,10 +53,10 @@ export function saveConfig(config: Partial<PlaygroundConfig>): void {
|
||||
*/
|
||||
export function loadParameterEnabled(): Partial<ParameterEnabled> {
|
||||
try {
|
||||
const saved = readStoredValue(STORAGE_KEYS.PARAMETER_ENABLED)
|
||||
if (!saved) return {}
|
||||
|
||||
return parameterEnabledSchema.parse(unwrapStoredValue(saved))
|
||||
const saved = localStorage.getItem(STORAGE_KEYS.PARAMETER_ENABLED)
|
||||
if (saved) {
|
||||
return JSON.parse(saved)
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to load parameter enabled:', error)
|
||||
@@ -120,8 +71,10 @@ export function saveParameterEnabled(
|
||||
parameterEnabled: Partial<ParameterEnabled>
|
||||
): void {
|
||||
try {
|
||||
const parsed = parameterEnabledSchema.parse(parameterEnabled)
|
||||
writeStoredValue(STORAGE_KEYS.PARAMETER_ENABLED, parsed)
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.PARAMETER_ENABLED,
|
||||
JSON.stringify(parameterEnabled)
|
||||
)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to save parameter enabled:', error)
|
||||
@@ -133,18 +86,19 @@ export function saveParameterEnabled(
|
||||
*/
|
||||
export function loadMessages(): Message[] | null {
|
||||
try {
|
||||
const saved = readStoredValue(STORAGE_KEYS.MESSAGES)
|
||||
if (!saved) return null
|
||||
|
||||
const parsed = messagesSchema.parse(unwrapStoredValue(saved)) as Message[]
|
||||
const trimmed = trimMessages(parsed)
|
||||
const sanitized = sanitizeMessagesOnLoad(trimmed)
|
||||
|
||||
if (sanitized !== parsed || trimmed !== parsed) {
|
||||
saveMessages(sanitized)
|
||||
const saved = localStorage.getItem(STORAGE_KEYS.MESSAGES)
|
||||
if (saved) {
|
||||
const parsed: unknown = JSON.parse(saved)
|
||||
if (!Array.isArray(parsed)) {
|
||||
return null
|
||||
}
|
||||
const sanitized = sanitizeMessagesOnLoad(parsed as Message[])
|
||||
// Persist sanitized result to avoid re-sanitizing on subsequent loads
|
||||
if (sanitized !== parsed) {
|
||||
saveMessages(sanitized)
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
return sanitized
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to load messages:', error)
|
||||
@@ -157,9 +111,7 @@ export function loadMessages(): Message[] | null {
|
||||
*/
|
||||
export function saveMessages(messages: Message[]): void {
|
||||
try {
|
||||
const trimmed = trimMessages(messages)
|
||||
const parsed = messagesSchema.parse(trimmed) as Message[]
|
||||
writeStoredValue(STORAGE_KEYS.MESSAGES, parsed)
|
||||
localStorage.setItem(STORAGE_KEYS.MESSAGES, JSON.stringify(messages))
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to save messages:', error)
|
||||
|
||||
@@ -1,112 +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 { ERROR_MESSAGES } from '../constants'
|
||||
import type { ChatCompletionChunk } from '../types'
|
||||
|
||||
const STREAM_DONE_MESSAGE = '[DONE]'
|
||||
const STREAM_CLOSED_READY_STATE = 2
|
||||
|
||||
export type StreamUpdateType = 'reasoning' | 'content'
|
||||
|
||||
export type StreamMessageUpdate = {
|
||||
type: StreamUpdateType
|
||||
chunk: string
|
||||
}
|
||||
|
||||
type StreamErrorPayload = {
|
||||
error?: {
|
||||
code?: string
|
||||
message?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type StreamErrorDetails = {
|
||||
errorCode?: string
|
||||
errorMessage: string
|
||||
}
|
||||
|
||||
export function parseStreamErrorDetails(data?: string): StreamErrorDetails {
|
||||
const fallbackMessage = data || ERROR_MESSAGES.API_REQUEST_ERROR
|
||||
|
||||
if (!data) {
|
||||
return { errorMessage: fallbackMessage }
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data) as StreamErrorPayload
|
||||
|
||||
if (!parsed?.error) {
|
||||
return { errorMessage: fallbackMessage }
|
||||
}
|
||||
|
||||
return {
|
||||
errorCode: parsed.error.code || undefined,
|
||||
errorMessage: parsed.error.message || fallbackMessage,
|
||||
}
|
||||
} catch {
|
||||
return { errorMessage: fallbackMessage }
|
||||
}
|
||||
}
|
||||
|
||||
export function parseStreamMessageUpdates(data: string): StreamMessageUpdate[] {
|
||||
const chunk = JSON.parse(data) as ChatCompletionChunk
|
||||
const delta = chunk.choices?.[0]?.delta
|
||||
|
||||
if (!delta) {
|
||||
return []
|
||||
}
|
||||
|
||||
const updates: StreamMessageUpdate[] = []
|
||||
|
||||
if (delta.reasoning_content) {
|
||||
updates.push({ type: 'reasoning', chunk: delta.reasoning_content })
|
||||
}
|
||||
|
||||
if (delta.content) {
|
||||
updates.push({ type: 'content', chunk: delta.content })
|
||||
}
|
||||
|
||||
return updates
|
||||
}
|
||||
|
||||
export function isStreamDoneMessage(data: string): boolean {
|
||||
return data === STREAM_DONE_MESSAGE
|
||||
}
|
||||
|
||||
export function isStreamClosedReadyState(readyState?: number): boolean {
|
||||
return readyState === STREAM_CLOSED_READY_STATE
|
||||
}
|
||||
|
||||
export function getStreamReadyStateError(
|
||||
eventReadyState: number | undefined,
|
||||
source: unknown
|
||||
): string | null {
|
||||
const status = (source as { status?: number }).status
|
||||
|
||||
if (
|
||||
eventReadyState !== undefined &&
|
||||
eventReadyState >= STREAM_CLOSED_READY_STATE &&
|
||||
status !== undefined &&
|
||||
status !== 200
|
||||
) {
|
||||
return `HTTP ${status}: ${ERROR_MESSAGES.CONNECTION_CLOSED}`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,36 +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
|
||||
*/
|
||||
const MORE_SUGGESTION_TEXT = 'More'
|
||||
const SUGGESTION_CLASS_NAME = 'text-xs font-normal sm:text-sm'
|
||||
const MOBILE_HIDDEN_SUGGESTION_CLASS_NAME = `${SUGGESTION_CLASS_NAME} hidden sm:flex`
|
||||
|
||||
type SuggestionDisplayState = {
|
||||
className: string
|
||||
}
|
||||
|
||||
export function getSuggestionDisplayState(
|
||||
text: string
|
||||
): SuggestionDisplayState {
|
||||
return {
|
||||
className:
|
||||
text === MORE_SUGGESTION_TEXT
|
||||
? MOBILE_HIDDEN_SUGGESTION_CLASS_NAME
|
||||
: SUGGESTION_CLASS_NAME,
|
||||
}
|
||||
}
|
||||
@@ -56,8 +56,9 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
|
||||
const tags = parseTags(props.model.tags)
|
||||
const groups = props.model.enable_groups || []
|
||||
const endpoints = props.model.supported_endpoint_types || []
|
||||
const vendorIcon = props.model.vendor_icon
|
||||
? getLobeIcon(props.model.vendor_icon, 28)
|
||||
const modelIconKey = props.model.icon || props.model.vendor_icon
|
||||
const modelIcon = modelIconKey
|
||||
? getLobeIcon(modelIconKey, 28)
|
||||
: null
|
||||
const initial = props.model.model_name?.charAt(0).toUpperCase() || '?'
|
||||
const isDynamicPricing =
|
||||
@@ -97,7 +98,7 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
|
||||
<div className='flex items-start justify-between gap-2.5 sm:gap-3'>
|
||||
<div className='flex min-w-0 items-start gap-2.5 sm:gap-3'>
|
||||
<div className='bg-muted/40 flex size-9 shrink-0 items-center justify-center rounded-lg sm:size-10 sm:rounded-xl'>
|
||||
{vendorIcon || (
|
||||
{modelIcon || (
|
||||
<span className='text-muted-foreground text-sm font-bold'>
|
||||
{initial}
|
||||
</span>
|
||||
|
||||
@@ -268,8 +268,9 @@ function OverviewSummaryGrid(props: { model: PricingModel }) {
|
||||
function ModelHeader(props: { model: PricingModel }) {
|
||||
const { t } = useTranslation()
|
||||
const model = props.model
|
||||
const vendorIcon = model.vendor_icon
|
||||
? getLobeIcon(model.vendor_icon, 20)
|
||||
const modelIconKey = model.icon || model.vendor_icon
|
||||
const modelIcon = modelIconKey
|
||||
? getLobeIcon(modelIconKey, 20)
|
||||
: null
|
||||
const description = model.description || model.vendor_description || null
|
||||
const tags = parseTags(model.tags)
|
||||
@@ -281,7 +282,7 @@ function ModelHeader(props: { model: PricingModel }) {
|
||||
return (
|
||||
<header className='pb-4'>
|
||||
<div className='flex items-center gap-2.5'>
|
||||
{vendorIcon}
|
||||
{modelIcon}
|
||||
<h1 className='font-mono text-xl font-bold tracking-tight sm:text-2xl'>
|
||||
{model.model_name}
|
||||
</h1>
|
||||
|
||||
@@ -106,13 +106,14 @@ export function usePricingColumns(
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const model = row.original
|
||||
const vendorIcon = model.vendor_icon
|
||||
? getLobeIcon(model.vendor_icon, 14)
|
||||
const modelIconKey = model.icon || model.vendor_icon
|
||||
const modelIcon = modelIconKey
|
||||
? getLobeIcon(modelIconKey, 14)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className='flex min-w-[200px] items-center gap-2'>
|
||||
{vendorIcon}
|
||||
{modelIcon}
|
||||
<span className='truncate font-mono text-sm font-medium'>
|
||||
{model.model_name}
|
||||
</span>
|
||||
|
||||
+1
@@ -31,6 +31,7 @@ export type PricingModel = {
|
||||
id: number
|
||||
model_name: string
|
||||
description?: string
|
||||
icon?: string
|
||||
vendor_id?: number
|
||||
vendor_name?: string
|
||||
vendor_icon?: string
|
||||
|
||||
+20
-3
@@ -111,6 +111,7 @@ export function SubscriptionPurchaseDialog(props: Props) {
|
||||
Math.ceil(Number(plan.price_amount || 0) * quotaPerUnit)
|
||||
)
|
||||
const userQuota = Math.max(0, Number(props.userQuota || 0))
|
||||
const allowBalancePay = plan.allow_balance_pay !== false
|
||||
const insufficientBalance = userQuota < balanceCost
|
||||
const limitReached =
|
||||
(props.purchaseLimit || 0) > 0 &&
|
||||
@@ -232,6 +233,10 @@ export function SubscriptionPurchaseDialog(props: Props) {
|
||||
}
|
||||
|
||||
const handlePayBalance = async () => {
|
||||
if (!allowBalancePay) {
|
||||
toast.error(t('This plan does not allow balance redemption'))
|
||||
return
|
||||
}
|
||||
setPaying(true)
|
||||
try {
|
||||
const res = await paySubscriptionBalance({ plan_id: plan.id })
|
||||
@@ -332,15 +337,27 @@ export function SubscriptionPurchaseDialog(props: Props) {
|
||||
<span className='text-muted-foreground'>{t('Available')}</span>
|
||||
<span>{formatQuota(userQuota)}</span>
|
||||
</div>
|
||||
{insufficientBalance && (
|
||||
{!allowBalancePay ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertDescription>{t('Insufficient balance')}</AlertDescription>
|
||||
<AlertDescription>
|
||||
{t('This plan does not allow balance redemption')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
insufficientBalance && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertDescription>
|
||||
{t('Insufficient balance')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
)}
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={handlePayBalance}
|
||||
disabled={paying || limitReached || insufficientBalance}
|
||||
disabled={
|
||||
paying || limitReached || !allowBalancePay || insufficientBalance
|
||||
}
|
||||
>
|
||||
{t('Pay with Balance')}
|
||||
</Button>
|
||||
|
||||
+18
@@ -461,6 +461,24 @@ export function SubscriptionsMutateDrawer({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='allow_balance_pay'
|
||||
render={({ field }) => (
|
||||
<FormItem className={sideDrawerSwitchItemClassName()}>
|
||||
<FormLabel className='!mt-0'>
|
||||
{t('Allow balance redemption')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SideDrawerSection>
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ export function getPlanFormSchema(t: TFunction) {
|
||||
quota_reset_custom_seconds: z.coerce.number().min(0).optional(),
|
||||
enabled: z.boolean(),
|
||||
sort_order: z.coerce.number(),
|
||||
allow_balance_pay: z.boolean(),
|
||||
max_purchase_per_user: z.coerce.number().min(0),
|
||||
total_amount: z.coerce.number().min(0),
|
||||
upgrade_group: z.string().optional(),
|
||||
@@ -61,6 +62,7 @@ export const PLAN_FORM_DEFAULTS: PlanFormValues = {
|
||||
quota_reset_custom_seconds: 0,
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
allow_balance_pay: true,
|
||||
max_purchase_per_user: 0,
|
||||
total_amount: 0,
|
||||
upgrade_group: '',
|
||||
@@ -81,6 +83,7 @@ export function planToFormValues(plan: SubscriptionPlan): PlanFormValues {
|
||||
quota_reset_custom_seconds: Number(plan.quota_reset_custom_seconds || 0),
|
||||
enabled: plan.enabled !== false,
|
||||
sort_order: Number(plan.sort_order || 0),
|
||||
allow_balance_pay: plan.allow_balance_pay !== false,
|
||||
max_purchase_per_user: Number(plan.max_purchase_per_user || 0),
|
||||
total_amount: quotaUnitsToDollars(Number(plan.total_amount || 0)),
|
||||
upgrade_group: plan.upgrade_group || '',
|
||||
|
||||
@@ -35,6 +35,7 @@ export const subscriptionPlanSchema = z.object({
|
||||
quota_reset_custom_seconds: z.number().optional(),
|
||||
enabled: z.boolean(),
|
||||
sort_order: z.number(),
|
||||
allow_balance_pay: z.boolean().optional().default(true),
|
||||
max_purchase_per_user: z.number(),
|
||||
total_amount: z.number(),
|
||||
upgrade_group: z.string().optional(),
|
||||
|
||||
+55
-46
@@ -16,7 +16,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { flexRender, type Cell, type Table } from '@tanstack/react-table'
|
||||
import {
|
||||
flexRender,
|
||||
type Cell,
|
||||
type Table,
|
||||
} from '@tanstack/react-table'
|
||||
import { Database } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
@@ -29,20 +33,14 @@ import {
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
dotColorMap,
|
||||
textColorMap,
|
||||
type StatusVariant,
|
||||
} from '@/components/status-badge'
|
||||
import { dotColorMap, textColorMap, type StatusVariant } from '@/components/status-badge'
|
||||
import type { LogCategory } from '../types'
|
||||
import { LOG_TYPE_ENUM } from '../constants'
|
||||
import { getLogTypeConfig } from '../lib/utils'
|
||||
import type { LogCategory } from '../types'
|
||||
|
||||
const logTypeRowTint: Record<number, string> = {
|
||||
[LOG_TYPE_ENUM.ERROR]:
|
||||
'bg-rose-50/40 dark:bg-rose-950/20 border-rose-200/50 dark:border-rose-900/30',
|
||||
[LOG_TYPE_ENUM.REFUND]:
|
||||
'bg-blue-50/30 dark:bg-blue-950/15 border-blue-200/50 dark:border-blue-900/30',
|
||||
[LOG_TYPE_ENUM.ERROR]: 'bg-rose-50/40 dark:bg-rose-950/20 border-rose-200/50 dark:border-rose-900/30',
|
||||
[LOG_TYPE_ENUM.REFUND]: 'bg-blue-50/30 dark:bg-blue-950/15 border-blue-200/50 dark:border-blue-900/30',
|
||||
}
|
||||
|
||||
interface UsageLogsMobileListProps<TData> {
|
||||
@@ -55,11 +53,11 @@ interface UsageLogsMobileListProps<TData> {
|
||||
|
||||
function UsageLogsMobileSkeleton() {
|
||||
return (
|
||||
<div className='border-border/50 bg-card overflow-hidden rounded-lg border'>
|
||||
<div className='overflow-hidden rounded-lg border border-border/50 bg-card'>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='border-border/40 space-y-2.5 border-b p-3 last:border-b-0'
|
||||
className='space-y-2.5 border-b border-border/40 p-3 last:border-b-0'
|
||||
>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<Skeleton className='h-5 w-40 rounded-md' />
|
||||
@@ -95,7 +93,7 @@ function CompactCell<TData>({
|
||||
className={cn(
|
||||
'min-w-0 overflow-hidden leading-tight [&_button]:max-w-full [&_span]:max-w-full',
|
||||
primaryOnly &&
|
||||
'[&_.flex-col]:min-w-0 [&_.flex-col>*:not(:first-child)]:hidden',
|
||||
'[&_.flex-col>*:not(:first-child)]:hidden [&_.flex-col]:min-w-0',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -125,7 +123,10 @@ function SummaryField<TData>({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('bg-muted/20 min-w-0 rounded-md px-2 py-1.5', className)}
|
||||
className={cn(
|
||||
'min-w-0 rounded-md bg-muted/20 px-2 py-1.5',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className='text-muted-foreground mb-1 text-[11px] leading-none font-medium select-none'>
|
||||
{label}
|
||||
@@ -173,19 +174,6 @@ function MobileLogTimeStatus({
|
||||
)
|
||||
}
|
||||
|
||||
function getCellOriginalField<TData>(
|
||||
cell: Cell<TData, unknown> | undefined,
|
||||
field: string
|
||||
): unknown {
|
||||
const original = cell?.row.original
|
||||
|
||||
if (!original || typeof original !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return (original as Record<string, unknown>)[field]
|
||||
}
|
||||
|
||||
function CommonLogsCard<TData>({
|
||||
cells,
|
||||
}: {
|
||||
@@ -195,7 +183,6 @@ function CommonLogsCard<TData>({
|
||||
|
||||
const modelCell = cells.get('model_name')
|
||||
const quotaCell = cells.get('quota')
|
||||
const createdAtCell = cells.get('created_at')
|
||||
|
||||
return (
|
||||
<div className='space-y-2.5'>
|
||||
@@ -208,13 +195,13 @@ function CommonLogsCard<TData>({
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)] gap-1.5'>
|
||||
<div className='bg-muted/20 min-w-0 rounded-md px-2 py-1.5'>
|
||||
<div className='min-w-0 rounded-md bg-muted/20 px-2 py-1.5'>
|
||||
<div className='text-muted-foreground mb-1 text-[11px] leading-none font-medium select-none'>
|
||||
{t('Time')}
|
||||
</div>
|
||||
<MobileLogTimeStatus
|
||||
createdAt={getCellOriginalField(createdAtCell, 'created_at')}
|
||||
type={getCellOriginalField(createdAtCell, 'type')}
|
||||
createdAt={cells.get('created_at')?.row.original?.created_at}
|
||||
type={cells.get('created_at')?.row.original?.type}
|
||||
/>
|
||||
</div>
|
||||
<SummaryField
|
||||
@@ -267,8 +254,15 @@ function TaskLogsCard<TData>({
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-1.5'>
|
||||
<SummaryField label={t('Submit Time')} cell={submitTimeCell} />
|
||||
<SummaryField label={t('User')} cell={cells.get('user')} primaryOnly />
|
||||
<SummaryField
|
||||
label={t('Submit Time')}
|
||||
cell={submitTimeCell}
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('User')}
|
||||
cell={cells.get('user')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Result')}
|
||||
cell={cells.get('fail_reason')}
|
||||
@@ -298,19 +292,28 @@ function DrawingLogsCard<TData>({
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-1.5'>
|
||||
<SummaryField label={t('Submit Time')} cell={submitTimeCell} />
|
||||
<SummaryField
|
||||
label={t('Submit Time')}
|
||||
cell={submitTimeCell}
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Channel')}
|
||||
cell={cells.get('channel')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField label={t('Task ID')} cell={cells.get('mj_id')} />
|
||||
<SummaryField
|
||||
label={t('Task ID')}
|
||||
cell={cells.get('mj_id')}
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Duration')}
|
||||
cell={cells.get('duration')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField label={t('Image')} cell={cells.get('image_url')} />
|
||||
<SummaryField
|
||||
label={t('Image')}
|
||||
cell={cells.get('image_url')}
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Prompt')}
|
||||
cell={cells.get('prompt')}
|
||||
@@ -348,11 +351,11 @@ export function UsageLogsMobileList<TData>({
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
return (
|
||||
<div className='rounded-lg border p-6'>
|
||||
<Empty className='border-none p-0'>
|
||||
<div className="rounded-lg border p-6">
|
||||
<Empty className="border-none p-0">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
<Database className='size-6' />
|
||||
<EmptyMedia variant="icon">
|
||||
<Database className="size-6" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{resolvedEmptyTitle}</EmptyTitle>
|
||||
<EmptyDescription>{resolvedEmptyDescription}</EmptyDescription>
|
||||
@@ -363,7 +366,7 @@ export function UsageLogsMobileList<TData>({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='border-border/50 bg-card overflow-hidden rounded-lg border'>
|
||||
<div className='overflow-hidden rounded-lg border border-border/50 bg-card'>
|
||||
{rows.map((row) => {
|
||||
const cells = new Map(
|
||||
row.getVisibleCells().map((cell) => [cell.column.id, cell])
|
||||
@@ -378,13 +381,19 @@ export function UsageLogsMobileList<TData>({
|
||||
<div
|
||||
key={row.id}
|
||||
className={cn(
|
||||
'border-border/40 border-b border-l-2 border-l-transparent p-3 transition-colors last:border-b-0',
|
||||
'border-l-2 border-l-transparent border-b border-border/40 p-3 transition-colors last:border-b-0',
|
||||
tintClass
|
||||
)}
|
||||
>
|
||||
{logCategory === 'common' && <CommonLogsCard cells={cells} />}
|
||||
{logCategory === 'task' && <TaskLogsCard cells={cells} />}
|
||||
{logCategory === 'drawing' && <DrawingLogsCard cells={cells} />}
|
||||
{logCategory === 'common' && (
|
||||
<CommonLogsCard cells={cells} />
|
||||
)}
|
||||
{logCategory === 'task' && (
|
||||
<TaskLogsCard cells={cells} />
|
||||
)}
|
||||
{logCategory === 'drawing' && (
|
||||
<DrawingLogsCard cells={cells} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
Vendored
+2
-12
@@ -253,7 +253,6 @@
|
||||
"All conditions must match before this tier is used.": "All conditions must match before this tier is used.",
|
||||
"All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "All edits are overwrite operations. Leave fields empty to keep current values unchanged.",
|
||||
"All files exceed the maximum size.": "All files exceed the maximum size.",
|
||||
"All playground messages saved in this browser will be removed. This cannot be undone.": "All playground messages saved in this browser will be removed. This cannot be undone.",
|
||||
"All Groups": "All Groups",
|
||||
"All Models": "All Models",
|
||||
"All models in use are properly configured.": "All models in use are properly configured.",
|
||||
@@ -269,6 +268,7 @@
|
||||
"All-time": "All-time",
|
||||
"Allocated Memory": "Allocated Memory",
|
||||
"Allow accountFilter parameter": "Allow accountFilter parameter",
|
||||
"Allow balance redemption": "Allow balance redemption",
|
||||
"Allow Claude beta query passthrough": "Allow Claude beta query passthrough",
|
||||
"Allow clients to query configured ratios via `/api/ratio`.": "Allow clients to query configured ratios via `/api/ratio`.",
|
||||
"Allow HTTP image requests": "Allow HTTP image requests",
|
||||
@@ -312,7 +312,6 @@
|
||||
"Amount options must be a JSON array": "Amount options must be a JSON array",
|
||||
"Amount to pay:": "Amount to pay:",
|
||||
"An unexpected error occurred": "An unexpected error occurred",
|
||||
"Analyze data": "Analyze data",
|
||||
"and": "and",
|
||||
"Announcement added. Click \"Save Settings\" to apply.": "Announcement added. Click \"Save Settings\" to apply.",
|
||||
"Announcement content": "Announcement content",
|
||||
@@ -727,8 +726,6 @@
|
||||
"Clear All Cache": "Clear All Cache",
|
||||
"Clear all filters": "Clear all filters",
|
||||
"Clear cache for this rule": "Clear cache for this rule",
|
||||
"Clear chat history": "Clear chat history",
|
||||
"Clear chat history?": "Clear chat history?",
|
||||
"Clear filters": "Clear filters",
|
||||
"Clear Mapping": "Clear Mapping",
|
||||
"Clear mode flags in prompts": "Clear mode flags in prompts",
|
||||
@@ -921,7 +918,6 @@
|
||||
"Continue with Telegram": "Continue with Telegram",
|
||||
"Continue with WeChat": "Continue with WeChat",
|
||||
"Contract review, compliance, summarisation": "Contract review, compliance, summarisation",
|
||||
"Conversation cleared": "Conversation cleared",
|
||||
"Control which models are exposed and which groups may use them.": "Control which models are exposed and which groups may use them.",
|
||||
"Controls how much the model thinks before answering": "Controls how much the model thinks before answering",
|
||||
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Controls whether user verification (biometrics/PIN) is required during Passkey flows.",
|
||||
@@ -1837,7 +1833,6 @@
|
||||
"Generating...": "Generating...",
|
||||
"Generation quality preset": "Generation quality preset",
|
||||
"Generic cache": "Generic cache",
|
||||
"Get advice": "Get advice",
|
||||
"Get notified when balance falls below this value": "Get notified when balance falls below this value",
|
||||
"Get one here": "Get one here",
|
||||
"Get started": "Get started",
|
||||
@@ -3358,7 +3353,6 @@
|
||||
"Resets in:": "Resets in:",
|
||||
"Resolve Conflicts": "Resolve Conflicts",
|
||||
"Resource Configuration": "Resource Configuration",
|
||||
"Responding...": "Responding...",
|
||||
"Response": "Response",
|
||||
"Response Time": "Response Time",
|
||||
"Responses API Version": "Responses API Version",
|
||||
@@ -3429,7 +3423,6 @@
|
||||
"Sampling temperature; lower is more deterministic": "Sampling temperature; lower is more deterministic",
|
||||
"Sandbox mode": "Sandbox mode",
|
||||
"Save": "Save",
|
||||
"Save & Submit": "Save & Submit",
|
||||
"Save all settings": "Save all settings",
|
||||
"Save Backup Codes": "Save Backup Codes",
|
||||
"Save changes": "Save changes",
|
||||
@@ -3717,7 +3710,6 @@
|
||||
"Standard": "Standard",
|
||||
"Standard price": "Standard price",
|
||||
"Start": "Start",
|
||||
"Start a playground chat": "Start a playground chat",
|
||||
"Start a conversation to see messages here": "Start a conversation to see messages here",
|
||||
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.",
|
||||
"Start for free with generous limits. No credit card required.": "Start for free with generous limits. No credit card required.",
|
||||
@@ -3792,7 +3784,6 @@
|
||||
"Successfully enabled {{count}} model(s)": "Successfully enabled {{count}} model(s)",
|
||||
"Suffix": "Suffix",
|
||||
"Suffix Match": "Suffix Match",
|
||||
"Summarize text": "Summarize text",
|
||||
"SunoAPI": "SunoAPI",
|
||||
"Sunset Glow": "Sunset Glow",
|
||||
"Super Admin": "Super Admin",
|
||||
@@ -3807,7 +3798,6 @@
|
||||
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.",
|
||||
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.",
|
||||
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.",
|
||||
"Surprise me": "Surprise me",
|
||||
"Sustained tokens per second": "Sustained tokens per second",
|
||||
"Swap Face": "Swap Face",
|
||||
"Switch affinity on success": "Switch affinity on success",
|
||||
@@ -3894,7 +3884,6 @@
|
||||
"Test Model": "Test Model",
|
||||
"Test models and prompts from the browser": "Test models and prompts from the browser",
|
||||
"Test selected models": "Test selected models",
|
||||
"Test a model with a starter prompt, or write your own request below.": "Test a model with a starter prompt, or write your own request below.",
|
||||
"Testing all enabled channels started. Please refresh to see results.": "Testing all enabled channels started. Please refresh to see results.",
|
||||
"Testing...": "Testing...",
|
||||
"Text": "Text",
|
||||
@@ -3963,6 +3952,7 @@
|
||||
"This model is not available in any group, or no group pricing information is configured.": "This model is not available in any group, or no group pricing information is configured.",
|
||||
"This month": "This month",
|
||||
"This page has not been created yet.": "This page has not been created yet.",
|
||||
"This plan does not allow balance redemption": "This plan does not allow balance redemption",
|
||||
"This project must be used in compliance with the": "This project must be used in compliance with the",
|
||||
"This record was written by a pre-upgrade instance and lacks audit info. Upgrade the instance to record server IP, callback IP, payment method and system version.": "This record was written by a pre-upgrade instance and lacks audit info. Upgrade the instance to record server IP, callback IP, payment method and system version.",
|
||||
"This site currently has {{count}} models enabled": "This site currently has {{count}} models enabled",
|
||||
|
||||
Vendored
+2
-12
@@ -253,7 +253,6 @@
|
||||
"All conditions must match before this tier is used.": "Toutes les conditions doivent correspondre avant que ce palier soit utilisé.",
|
||||
"All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "Toutes les modifications sont des opérations d'écrasement. Laissez les champs vides pour conserver les valeurs actuelles inchangées.",
|
||||
"All files exceed the maximum size.": "Tous les fichiers dépassent la taille maximale.",
|
||||
"All playground messages saved in this browser will be removed. This cannot be undone.": "Tous les messages du Playground enregistrés dans ce navigateur seront supprimés. Cette action est irréversible.",
|
||||
"All Groups": "Tous les groupes",
|
||||
"All Models": "Tous les modèles",
|
||||
"All models in use are properly configured.": "Tous les modèles utilisés sont correctement configurés.",
|
||||
@@ -269,6 +268,7 @@
|
||||
"All-time": "Tous temps",
|
||||
"Allocated Memory": "Mémoire allouée",
|
||||
"Allow accountFilter parameter": "Autoriser le paramètre accountFilter",
|
||||
"Allow balance redemption": "Autoriser le paiement avec le solde",
|
||||
"Allow Claude beta query passthrough": "Autoriser le passage des requêtes bêta Claude",
|
||||
"Allow clients to query configured ratios via `/api/ratio`.": "Autoriser les clients à interroger les ratios configurés via `/api/ratio`.",
|
||||
"Allow HTTP image requests": "Autoriser les requêtes d'images HTTP",
|
||||
@@ -312,7 +312,6 @@
|
||||
"Amount options must be a JSON array": "Les options de montant doivent être un tableau JSON",
|
||||
"Amount to pay:": "Montant à payer :",
|
||||
"An unexpected error occurred": "Une erreur inattendue est survenue",
|
||||
"Analyze data": "Analyser les données",
|
||||
"and": "et",
|
||||
"Announcement added. Click \"Save Settings\" to apply.": "Annonce ajoutée. Cliquez sur \"Enregistrer les paramètres\" pour appliquer.",
|
||||
"Announcement content": "Contenu de l'annonce",
|
||||
@@ -727,8 +726,6 @@
|
||||
"Clear All Cache": "Vider tout le cache",
|
||||
"Clear all filters": "Effacer tous les filtres",
|
||||
"Clear cache for this rule": "Vider le cache de cette règle",
|
||||
"Clear chat history": "Effacer l'historique du chat",
|
||||
"Clear chat history?": "Effacer l'historique du chat ?",
|
||||
"Clear filters": "Effacer les filtres",
|
||||
"Clear Mapping": "Effacer le mappage",
|
||||
"Clear mode flags in prompts": "Effacer les indicateurs de mode dans les prompts",
|
||||
@@ -921,7 +918,6 @@
|
||||
"Continue with Telegram": "Continuer avec Telegram",
|
||||
"Continue with WeChat": "Continuer avec WeChat",
|
||||
"Contract review, compliance, summarisation": "Revue de contrats, conformité, résumé",
|
||||
"Conversation cleared": "Conversation effacée",
|
||||
"Control which models are exposed and which groups may use them.": "Contrôlez les modèles exposés et les groupes autorisés à les utiliser.",
|
||||
"Controls how much the model thinks before answering": "Contrôle la quantité de raisonnement avant la réponse",
|
||||
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Contrôle si la vérification de l'utilisateur (biométrie/PIN) est requise lors des flux de Passkey.",
|
||||
@@ -1837,7 +1833,6 @@
|
||||
"Generating...": "Génération...",
|
||||
"Generation quality preset": "Préréglage de qualité de génération",
|
||||
"Generic cache": "Cache générique",
|
||||
"Get advice": "Obtenir des conseils",
|
||||
"Get notified when balance falls below this value": "Recevoir une notification lorsque le solde tombe en dessous de cette valeur",
|
||||
"Get one here": "Obtenir ici",
|
||||
"Get started": "Commencer",
|
||||
@@ -3358,7 +3353,6 @@
|
||||
"Resets in:": "Réinitialise dans :",
|
||||
"Resolve Conflicts": "Résoudre les conflits",
|
||||
"Resource Configuration": "Configuration des ressources",
|
||||
"Responding...": "Réponse en cours...",
|
||||
"Response": "Réponse",
|
||||
"Response Time": "Temps de réponse",
|
||||
"Responses API Version": "Version de l'API des réponses",
|
||||
@@ -3429,7 +3423,6 @@
|
||||
"Sampling temperature; lower is more deterministic": "Température d'échantillonnage ; plus c'est bas, plus c'est déterministe",
|
||||
"Sandbox mode": "Mode sandbox",
|
||||
"Save": "Enregistrer",
|
||||
"Save & Submit": "Enregistrer et envoyer",
|
||||
"Save all settings": "Enregistrer tous les paramètres",
|
||||
"Save Backup Codes": "Sauvegarder les codes de secours",
|
||||
"Save changes": "Enregistrer les modifications",
|
||||
@@ -3717,7 +3710,6 @@
|
||||
"Standard": "Standard",
|
||||
"Standard price": "Prix standard",
|
||||
"Start": "Début",
|
||||
"Start a playground chat": "Démarrer une conversation dans le playground",
|
||||
"Start a conversation to see messages here": "Démarrez une conversation pour voir les messages ici",
|
||||
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "Commencez à encaisser des paiements dans le monde entier sans créer de société. Conçu pour les développeurs indépendants, les entrepreneurs individuels OPC et les startups. Waffo Pancake agit comme Merchant of Record et prend en charge la conformité liée à l’encaissement mondial : taxes à la consommation, facturation, gestion des abonnements, remboursements et rétrofacturations. Les développeurs solo peuvent lancer rapidement leur produit et rester concentrés sur celui-ci plutôt que sur la conformité. Intégration en quelques minutes, d’une seule invite à une intégration complète.",
|
||||
"Start for free with generous limits. No credit card required.": "Commencez gratuitement avec des limites généreuses. Aucune carte de crédit requise.",
|
||||
@@ -3792,7 +3784,6 @@
|
||||
"Successfully enabled {{count}} model(s)": "{{count}} modèle(s) activé(s) avec succès",
|
||||
"Suffix": "Suffixe",
|
||||
"Suffix Match": "Correspondance de suffixe",
|
||||
"Summarize text": "Résumer le texte",
|
||||
"SunoAPI": "SunoAPI",
|
||||
"Sunset Glow": "Lueur du couchant",
|
||||
"Super Admin": "Super Administrateur",
|
||||
@@ -3807,7 +3798,6 @@
|
||||
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "Prend en charge le balisage HTML ou l'intégration d'iframe. Entrez le code HTML directement, ou fournissez une URL complète pour l'intégrer automatiquement en tant qu'iframe.",
|
||||
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "Prend en charge la configuration en un clic et s'adapte parfaitement à la configuration multi-protocole NewAPI.",
|
||||
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "Prend en charge PNG, JPG, SVG ou WebP. Taille recommandée : 128×128 ou moins.",
|
||||
"Surprise me": "Surprends-moi",
|
||||
"Sustained tokens per second": "Jetons par seconde soutenus",
|
||||
"Swap Face": "Échanger le visage",
|
||||
"Switch affinity on success": "Changer l'affinité en cas de succès",
|
||||
@@ -3894,7 +3884,6 @@
|
||||
"Test Model": "Tester le modèle",
|
||||
"Test models and prompts from the browser": "Tester les modèles et les prompts depuis le navigateur",
|
||||
"Test selected models": "Tester les modèles sélectionnés",
|
||||
"Test a model with a starter prompt, or write your own request below.": "Testez un modèle avec un prompt de départ, ou rédigez votre propre demande ci-dessous.",
|
||||
"Testing all enabled channels started. Please refresh to see results.": "Test de tous les canaux activés démarré. Veuillez actualiser pour voir les résultats.",
|
||||
"Testing...": "Test en cours...",
|
||||
"Text": "Texte",
|
||||
@@ -3963,6 +3952,7 @@
|
||||
"This model is not available in any group, or no group pricing information is configured.": "Ce modèle n'est disponible dans aucun groupe, ou aucune information de tarification de groupe n'est configurée.",
|
||||
"This month": "Ce mois-ci",
|
||||
"This page has not been created yet.": "Cette page n'a pas encore été créée.",
|
||||
"This plan does not allow balance redemption": "Ce forfait ne permet pas le paiement avec le solde",
|
||||
"This project must be used in compliance with the": "Ce projet doit être utilisé conformément aux",
|
||||
"This record was written by a pre-upgrade instance and lacks audit info. Upgrade the instance to record server IP, callback IP, payment method and system version.": "Cet enregistrement provient d’une instance avant la mise à niveau et n’inclut pas d’audits. Mettez à jour l’instance pour enregistrer l’IP du serveur, l’IP de callback, le moyen de paiement et la version du système.",
|
||||
"This site currently has {{count}} models enabled": "Ce site compte actuellement {{count}} modèles activés",
|
||||
|
||||
Vendored
+2
-12
@@ -253,7 +253,6 @@
|
||||
"All conditions must match before this tier is used.": "この段階を使用するには、すべての条件に一致する必要があります。",
|
||||
"All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "すべての編集は上書き操作です。現在の値を変更しないままにするには、フィールドを空のままにしてください。",
|
||||
"All files exceed the maximum size.": "すべてのファイルが最大サイズを超えています。",
|
||||
"All playground messages saved in this browser will be removed. This cannot be undone.": "このブラウザに保存されたすべての Playground メッセージが削除されます。この操作は元に戻せません。",
|
||||
"All Groups": "すべてのグループ",
|
||||
"All Models": "すべてのモデル",
|
||||
"All models in use are properly configured.": "使用中のすべてのモデルが適切に構成されています。",
|
||||
@@ -269,6 +268,7 @@
|
||||
"All-time": "全期間",
|
||||
"Allocated Memory": "割り当て済みメモリ",
|
||||
"Allow accountFilter parameter": "accountFilter パラメータを許可",
|
||||
"Allow balance redemption": "残高での交換を許可",
|
||||
"Allow Claude beta query passthrough": "Claude ベータクエリのパススルーを許可",
|
||||
"Allow clients to query configured ratios via `/api/ratio`.": "クライアントが `/api/ratio` 経由で設定された比率を照会できるようにします。",
|
||||
"Allow HTTP image requests": "HTTP画像リクエストを許可",
|
||||
@@ -312,7 +312,6 @@
|
||||
"Amount options must be a JSON array": "金額オプションは JSON 配列でなければなりません",
|
||||
"Amount to pay:": "支払い金額:",
|
||||
"An unexpected error occurred": "予期せぬエラーが発生しました",
|
||||
"Analyze data": "データを分析",
|
||||
"and": "および",
|
||||
"Announcement added. Click \"Save Settings\" to apply.": "お知らせが追加されました。\"設定を保存\" をクリックして適用してください。",
|
||||
"Announcement content": "お知らせの内容",
|
||||
@@ -727,8 +726,6 @@
|
||||
"Clear All Cache": "全キャッシュをクリア",
|
||||
"Clear all filters": "すべてのフィルターをクリア",
|
||||
"Clear cache for this rule": "このルールのキャッシュをクリア",
|
||||
"Clear chat history": "チャット履歴を消去",
|
||||
"Clear chat history?": "チャット履歴を消去しますか?",
|
||||
"Clear filters": "フィルターをクリア",
|
||||
"Clear Mapping": "マッピングをクリア",
|
||||
"Clear mode flags in prompts": "プロンプト内のモードフラグをクリア",
|
||||
@@ -921,7 +918,6 @@
|
||||
"Continue with Telegram": "Telegram で続行",
|
||||
"Continue with WeChat": "WeChat で続行",
|
||||
"Contract review, compliance, summarisation": "契約レビュー・コンプライアンス・要約",
|
||||
"Conversation cleared": "会話を消去しました",
|
||||
"Control which models are exposed and which groups may use them.": "公開するモデルと、それらを利用できるグループを制御します。",
|
||||
"Controls how much the model thinks before answering": "モデルが回答前に考える深さを制御します",
|
||||
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Passkeyフロー中にユーザー認証(生体認証/PIN)が必要かどうかを制御します。",
|
||||
@@ -1837,7 +1833,6 @@
|
||||
"Generating...": "生成中...",
|
||||
"Generation quality preset": "生成品質プリセット",
|
||||
"Generic cache": "汎用キャッシュ",
|
||||
"Get advice": "アドバイスを得る",
|
||||
"Get notified when balance falls below this value": "残高がこの値を下回ったときに通知を受け取る",
|
||||
"Get one here": "こちらから取得",
|
||||
"Get started": "はじめる",
|
||||
@@ -3358,7 +3353,6 @@
|
||||
"Resets in:": "リセットまで:",
|
||||
"Resolve Conflicts": "競合を解決",
|
||||
"Resource Configuration": "リソース設定",
|
||||
"Responding...": "応答中...",
|
||||
"Response": "レスポンス",
|
||||
"Response Time": "応答時間",
|
||||
"Responses API Version": "応答APIバージョン",
|
||||
@@ -3429,7 +3423,6 @@
|
||||
"Sampling temperature; lower is more deterministic": "サンプリング温度。低いほど決定論的になります",
|
||||
"Sandbox mode": "サンドボックスモード",
|
||||
"Save": "保存",
|
||||
"Save & Submit": "保存して送信",
|
||||
"Save all settings": "すべての設定を保存",
|
||||
"Save Backup Codes": "バックアップコードを保存",
|
||||
"Save changes": "変更を保存",
|
||||
@@ -3717,7 +3710,6 @@
|
||||
"Standard": "標準",
|
||||
"Standard price": "標準価格",
|
||||
"Start": "開始",
|
||||
"Start a playground chat": "Playground でチャットを開始",
|
||||
"Start a conversation to see messages here": "会話を開始すると、ここにメッセージが表示されます",
|
||||
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "法人を設立せずに世界中で決済を受け付けられます。個人開発者、OPC 個人事業主、スタートアップ向けに設計されています。Waffo Pancake は Merchant of Record として、消費税、請求書、サブスクリプション管理、返金、チャージバックなど、グローバル決済のコンプライアンス負担を引き受けます。個人開発者はコンプライアンスではなくプロダクトに集中しながら素早くローンチできます。数分でオンボーディングし、1 つのプロンプトから完全な統合まで進められます。",
|
||||
"Start for free with generous limits. No credit card required.": "豊富な無料枠で始められます。クレジットカードは不要です。",
|
||||
@@ -3792,7 +3784,6 @@
|
||||
"Successfully enabled {{count}} model(s)": "{{count}} 個のモデルを有効にしました",
|
||||
"Suffix": "サフィックス",
|
||||
"Suffix Match": "サフィックス一致",
|
||||
"Summarize text": "テキストを要約",
|
||||
"SunoAPI": "SunoAPI",
|
||||
"Sunset Glow": "サンセットグロウ",
|
||||
"Super Admin": "スーパー管理者",
|
||||
@@ -3807,7 +3798,6 @@
|
||||
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "HTMLマークアップまたはiframe埋め込みをサポートします。HTMLコードを直接入力するか、完全なURLを提供してiframeとして自動的に埋め込みます。",
|
||||
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "ワンクリック設定をサポートし、NewAPIマルチプロトコル設定に完全に適応します。",
|
||||
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "PNG、JPG、SVG、WebPに対応。推奨サイズ: 128×128以下。",
|
||||
"Surprise me": "おまかせ",
|
||||
"Sustained tokens per second": "持続的な毎秒トークン数",
|
||||
"Swap Face": "顔入れ替え",
|
||||
"Switch affinity on success": "成功時にアフィニティを切替",
|
||||
@@ -3894,7 +3884,6 @@
|
||||
"Test Model": "モデルをテスト",
|
||||
"Test models and prompts from the browser": "ブラウザでモデルとプロンプトをテスト",
|
||||
"Test selected models": "選択したモデルをテスト",
|
||||
"Test a model with a starter prompt, or write your own request below.": "スタータープロンプトでモデルをテストするか、下に独自のリクエストを入力してください。",
|
||||
"Testing all enabled channels started. Please refresh to see results.": "有効な全チャネルのテストを開始しました。結果を確認するにはページを更新してください。",
|
||||
"Testing...": "テスト中...",
|
||||
"Text": "テキスト",
|
||||
@@ -3963,6 +3952,7 @@
|
||||
"This model is not available in any group, or no group pricing information is configured.": "このモデルはどのグループでも利用できないか、グループの料金情報が設定されていません。",
|
||||
"This month": "今月",
|
||||
"This page has not been created yet.": "このページはまだ作成されていません。",
|
||||
"This plan does not allow balance redemption": "このプランでは残高での交換は許可されていません",
|
||||
"This project must be used in compliance with the": "このプロジェクトは、以下を遵守して使用する必要があります",
|
||||
"This record was written by a pre-upgrade instance and lacks audit info. Upgrade the instance to record server IP, callback IP, payment method and system version.": "古いバージョンのインスタンスがこの記録を書き込み、監査情報がありません。最新に更新し、サーバーIP・コールバックIP・支払方法・OSバージョンの記録を有効にしてください。",
|
||||
"This site currently has {{count}} models enabled": "このサイトでは現在 {{count}} 個のモデルが有効です",
|
||||
|
||||
Vendored
+2
-12
@@ -253,7 +253,6 @@
|
||||
"All conditions must match before this tier is used.": "Все условия должны совпасть, прежде чем будет использован этот уровень.",
|
||||
"All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "Все изменения являются операциями перезаписи. Оставьте поля пустыми, чтобы сохранить текущие значения без изменений.",
|
||||
"All files exceed the maximum size.": "Все файлы превышают максимальный размер.",
|
||||
"All playground messages saved in this browser will be removed. This cannot be undone.": "Все сообщения Playground, сохраненные в этом браузере, будут удалены. Это действие нельзя отменить.",
|
||||
"All Groups": "Все группы",
|
||||
"All Models": "Все модели",
|
||||
"All models in use are properly configured.": "Все используемые модели настроены правильно.",
|
||||
@@ -269,6 +268,7 @@
|
||||
"All-time": "За всё время",
|
||||
"Allocated Memory": "Выделенная память",
|
||||
"Allow accountFilter parameter": "Разрешить параметр accountFilter",
|
||||
"Allow balance redemption": "Разрешить оплату балансом",
|
||||
"Allow Claude beta query passthrough": "Разрешить проброс бета-запросов Claude",
|
||||
"Allow clients to query configured ratios via `/api/ratio`.": "Разрешить клиентам запрашивать настроенные соотношения через `/api/ratio`.",
|
||||
"Allow HTTP image requests": "Разрешить HTTP-запросы изображений",
|
||||
@@ -312,7 +312,6 @@
|
||||
"Amount options must be a JSON array": "Варианты сумм должны быть JSON-массивом",
|
||||
"Amount to pay:": "Сумма к оплате:",
|
||||
"An unexpected error occurred": "Произошла непредвиденная ошибка",
|
||||
"Analyze data": "Анализировать данные",
|
||||
"and": "и",
|
||||
"Announcement added. Click \"Save Settings\" to apply.": "Объявление добавлено. Нажмите \"Сохранить настройки\", чтобы применить.",
|
||||
"Announcement content": "Содержимое объявления",
|
||||
@@ -727,8 +726,6 @@
|
||||
"Clear All Cache": "Очистить весь кэш",
|
||||
"Clear all filters": "Очистить все фильтры",
|
||||
"Clear cache for this rule": "Очистить кэш этого правила",
|
||||
"Clear chat history": "Очистить историю чата",
|
||||
"Clear chat history?": "Очистить историю чата?",
|
||||
"Clear filters": "Очистить фильтры",
|
||||
"Clear Mapping": "Очистить сопоставление",
|
||||
"Clear mode flags in prompts": "Очистить флаги режимов в промптах",
|
||||
@@ -921,7 +918,6 @@
|
||||
"Continue with Telegram": "Продолжить с Telegram",
|
||||
"Continue with WeChat": "Продолжить с WeChat",
|
||||
"Contract review, compliance, summarisation": "Анализ контрактов, комплаенс, резюме",
|
||||
"Conversation cleared": "Диалог очищен",
|
||||
"Control which models are exposed and which groups may use them.": "Управляйте тем, какие модели доступны и какие группы могут их использовать.",
|
||||
"Controls how much the model thinks before answering": "Регулирует глубину размышлений модели перед ответом",
|
||||
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Определяет, требуется ли проверка пользователя (биометрия/PIN) во время процессов Passkey.",
|
||||
@@ -1837,7 +1833,6 @@
|
||||
"Generating...": "Создание...",
|
||||
"Generation quality preset": "Пресет качества генерации",
|
||||
"Generic cache": "Общий кэш",
|
||||
"Get advice": "Получить совет",
|
||||
"Get notified when balance falls below this value": "Получать уведомления, когда баланс опускается ниже этого значения",
|
||||
"Get one here": "Получить здесь",
|
||||
"Get started": "Начало работы",
|
||||
@@ -3358,7 +3353,6 @@
|
||||
"Resets in:": "Сброс через:",
|
||||
"Resolve Conflicts": "Разрешить конфликты",
|
||||
"Resource Configuration": "Конфигурация ресурсов",
|
||||
"Responding...": "Отвечаем...",
|
||||
"Response": "Ответ",
|
||||
"Response Time": "Время ответа",
|
||||
"Responses API Version": "Версия API ответов",
|
||||
@@ -3429,7 +3423,6 @@
|
||||
"Sampling temperature; lower is more deterministic": "Температура сэмплирования; чем ниже, тем детерминированнее",
|
||||
"Sandbox mode": "Режим песочницы",
|
||||
"Save": "Сохранить",
|
||||
"Save & Submit": "Сохранить и отправить",
|
||||
"Save all settings": "Сохранить все настройки",
|
||||
"Save Backup Codes": "Сохранить резервные коды",
|
||||
"Save changes": "Сохранить изменения",
|
||||
@@ -3717,7 +3710,6 @@
|
||||
"Standard": "Стандартный",
|
||||
"Standard price": "Стандартная цена",
|
||||
"Start": "Начало",
|
||||
"Start a playground chat": "Начните чат в Playground",
|
||||
"Start a conversation to see messages here": "Начните разговор, чтобы увидеть сообщения здесь",
|
||||
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "Начните принимать платежи по всему миру без регистрации компании. Подходит для независимых разработчиков, индивидуальных предпринимателей OPC и стартапов. Waffo Pancake выступает как Merchant of Record и берет на себя комплаенс глобального приема платежей: потребительские налоги, выставление счетов, управление подписками, возвраты и чарджбеки. Одиночные разработчики могут быстро запуститься и сосредоточиться на продукте, а не на комплаенсе. Подключение за минуты — от одного запроса до полной интеграции.",
|
||||
"Start for free with generous limits. No credit card required.": "Начните бесплатно с щедрыми лимитами. Кредитная карта не требуется.",
|
||||
@@ -3792,7 +3784,6 @@
|
||||
"Successfully enabled {{count}} model(s)": "Успешно включено {{count}} моделей",
|
||||
"Suffix": "Суффикс",
|
||||
"Suffix Match": "Совпадение по суффиксу",
|
||||
"Summarize text": "Кратко изложить текст",
|
||||
"SunoAPI": "SunoAPI",
|
||||
"Sunset Glow": "Закатное сияние",
|
||||
"Super Admin": "Суперадмин",
|
||||
@@ -3807,7 +3798,6 @@
|
||||
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "Поддерживает HTML-разметку или встраивание iframe. Введите HTML-код напрямую или укажите полный URL для автоматического встраивания в виде iframe.",
|
||||
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "Поддерживает настройку в один клик и идеально адаптируется к многопротокольной конфигурации NewAPI.",
|
||||
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "Поддерживаются PNG, JPG, SVG или WebP. Рекомендуемый размер: 128×128 или меньше.",
|
||||
"Surprise me": "Удиви меня",
|
||||
"Sustained tokens per second": "Устойчивая скорость токенов в секунду",
|
||||
"Swap Face": "Замена лица",
|
||||
"Switch affinity on success": "Переключить привязку при успехе",
|
||||
@@ -3894,7 +3884,6 @@
|
||||
"Test Model": "Проверить модель",
|
||||
"Test models and prompts from the browser": "Тестируйте модели и промпты в браузере",
|
||||
"Test selected models": "Проверить выбранные модели",
|
||||
"Test a model with a starter prompt, or write your own request below.": "Проверьте модель с начальным промптом или напишите собственный запрос ниже.",
|
||||
"Testing all enabled channels started. Please refresh to see results.": "Тестирование всех включенных каналов начато. Пожалуйста, обновите страницу, чтобы увидеть результаты.",
|
||||
"Testing...": "Тестирование...",
|
||||
"Text": "Текст",
|
||||
@@ -3963,6 +3952,7 @@
|
||||
"This model is not available in any group, or no group pricing information is configured.": "Эта модель недоступна ни в одной группе, или информация о ценах для групп не настроена.",
|
||||
"This month": "В этом месяце",
|
||||
"This page has not been created yet.": "Эта страница еще не создана.",
|
||||
"This plan does not allow balance redemption": "Этот план не разрешает оплату балансом",
|
||||
"This project must be used in compliance with the": "Этот проект должен использоваться в соответствии с",
|
||||
"This record was written by a pre-upgrade instance and lacks audit info. Upgrade the instance to record server IP, callback IP, payment method and system version.": "Запись создана экземпляром до обновления и не содержит сведений аудита. Обновите экземпляр, чтобы фиксировать IP сервера, IP callback, способ оплаты и версию ОС.",
|
||||
"This site currently has {{count}} models enabled": "На этом сайте сейчас включено моделей: {{count}}",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user