Compare commits

...

17 Commits

Author SHA1 Message Date
QuentinHsu 6f415428d3 perf(web): improve frontend table rendering and pinned columns/UI table (#5405)
* refactor(web): centralize data table implementation

- route all TanStack table setup through a shared data-table hook to remove repeated state and row model wiring.
- move table rendering, static table wrappers, empty states, and primitive exports behind the data-table module.
- update feature tables and configuration editors to share the same table UX while preserving their existing workflows.

* refactor(web): trim data table public API

- remove unused data-table exports and dead static table helper types.
- keep internal table header, skeleton, empty state, and faceted filter helpers private to the data-table module.
- route feature imports through the data-table barrel to avoid subpath coupling.

* refactor(web): unify table rendering components

- centralize static table headers, bodies, empty states, and shared class names behind the data-table package.
- migrate settings, pricing, channel, key, subscription, and model tables to the shared table APIs.
- remove data-table exports for low-level table primitives so feature code uses one supported abstraction.

* perf(web): keep list tables fixed within page content

- make shared data table pages fill available height and scroll row data inside the table body.
- add a fixed content layout mode so selected list pages avoid page-level scrolling.
- apply the fixed table behavior to keys, logs, channels, models, users, redemptions, and subscriptions.

* perf(web): refine table pagination controls

- show total row counts instead of redundant page range text.
- tighten visible page buttons so pagination fits constrained table widths.
- align pagination controls and tune text hierarchy for clearer scanning.

* perf(web): stabilize model pricing table columns

- keep model pricing columns at fixed widths so headers do not collapse in narrow layouts.
- truncate long model names and pricing summaries within their cells instead of squeezing adjacent columns.

* refactor(web): simplify data table rendering internals

- split table body rendering into focused helpers for loading, empty, and row states.
- extract static table row and cell class resolution to reduce branching in the main component.
- reuse a single pagination page-size option list to avoid duplicated constants.

* perf(pricing): reduce dynamic pricing table render work

- reuse dynamic pricing field metadata instead of rebuilding it inside table columns.
- precompute formatted dynamic prices per tier and group to avoid repeated entry mapping for each cell.
- simplify select option construction in related dialogs while preserving the same choices.

* refactor(web): streamline pricing table rendering

- reuse translated endpoint select options between trigger data and menu items.
- precompute dynamic pricing maps per group so table cells only resolve formatted values.
- add local dynamic pricing type aliases to keep helper signatures readable.

* refactor(web): merge pricing table imports

* refactor(web): merge upstream ratio table imports

* refactor(web): merge channel selector table imports

* refactor(web): simplify tiered pricing select items

* refactor(web): reuse model ratio row state

* refactor(web): rely on table view row defaults

* refactor(web): reuse pagination state values

* refactor(web): hoist pagination size select items

* refactor(web): clarify static table body rows

* refactor(web): extract table page pagination rendering

* fix(web): remove direct hast type dependency

- rely on Shiki transformer contextual typing for line nodes.
- allow frontend typecheck to pass without an undeclared hast package.

* refactor(web): trim data table hook return API

- return only the TanStack table instance from useDataTable.
- keep internal state handling private because callers do not consume it directly.

* refactor(web): keep static table empty row private

- stop exporting the internal StaticDataTableEmptyRow helper.
- keep the public static table API focused on the table component and column type.

* refactor(web): hide data table view props from barrel

* refactor(web): remove stale long text lint override

* fix(web): keep pinned table columns opaque

- apply pinned column background classes after custom column classes.
- use an opaque hover background so scrolled content cannot show through fixed cells.

* refactor(data-table): organize shared table components

- group table primitives, page composition, toolbar controls, static tables, and hooks by responsibility.
- split shared view types, row rendering, header rendering, and pinned-column styling out of the main table view.
- keep the public data-table barrel stable while documenting the new ownership boundaries.

* fix(web): stabilize split table column sizing

- derive default colgroup widths from visible columns when split headers or header sizing are enabled.
- apply a fixed table layout with computed minimum width so header and body columns stay aligned.
- keep split-header containers from leaking horizontal overflow and avoid extra pinned-column borders.

* fix(web): set stable table utility column widths

- assign fixed widths to selection columns so shared colgroup sizing keeps checkbox cells compact.
- size id columns in redemption and user tables to keep split headers aligned with body rows.

* fix(web): align model metadata icon cells

- render compact provider avatars in the metadata icon column instead of wide wordmarks.
- position icons in a fixed-size wrapper so they line up with the existing icon header alignment.

* fix(status-badge): hide status dot by default

* fix(web): prevent user invite info overlap

- give the invite info and created-at columns explicit widths so table sizing reserves enough space.
- allow invite badges to wrap within the cell instead of spilling into adjacent columns.

* perf(data-table): cache pinned column class resolution

- reuse the pinned column lookup while table props stay stable to reduce repeated per-render work.
- share the resolved column class handler across unified and split-header table layouts.
- localize page-number screen reader labels so pagination remains accessible in every locale.

* refactor(data-table): tighten static table modes

- make StaticDataTable distinguish data-driven and children-only usage through explicit prop shapes.
- remove unsupported columns-without-data fallback after confirming no repository callers rely on it.
- default manual table modes away from unused local row models to reduce repeated table work.

* fix(data-table): make pinned edit column opaque

- use an opaque muted background for the active action column so sticky cells do not reveal scrolled content underneath.

* fix(data-table): prevent narrow column overlap

- apply stable header sizing to remaining desktop data table pages so constrained layouts scroll instead of compressing cells.
- add explicit widths for key, quota, badge, and timestamp columns that contain fixed-format content.
- constrain masked values and timestamp cells with truncation to keep content inside its assigned column.

* fix(table): align table cell content with headers

- remove extra inline padding from masked table text buttons so values start at the cell edge.
- tag status badges and offset leading badges inside table cells to match header text alignment.

* fix(table): prevent admin list column overflow

- widen redemption and subscription table columns so masked codes, timestamps, and localized headers fit.
- localize subscription ID headers and add Received amount translations across supported locales.

* fix(provider-badge): unify provider icon spacing

- add a shared provider badge component for icon and status label layout.
- reuse it in channel type and model vendor columns so OpenAI icons align consistently.
2026-06-11 02:36:41 +08:00
CaIon 59a93cf5c7 fix(openai): align image streaming relay governance
Route OpenAI image streaming through shared stream handling, split image/realtime/usage helpers for maintainability, and include the related image request and rate limit updates.
2026-06-10 17:47:37 +08:00
Benson Yan 867d8acfc3 fix: normalize kimi k2.6 temperature (#5390) 2026-06-10 17:19:57 +08:00
Q.A.zh 30d3a3a5f7 perf(web): add debounce channel search and skip during IME composition (#5393) 2026-06-10 17:18:51 +08:00
gaoren002 d2576ddcd3 fix(openai): support streaming image relay and image edit for images API (#4608)
* fix(openai): support streaming image relay

* fix(openai): keep image edit multipart body reusable

* test(openai): cover image stream usage details

* test(openai): cover image edit fallback stream field

* fix(openai): wrap image json fallback as stream

* fix(relay): support OpenAI image streaming

* fix(openai): record image stream upstream error events

* fix(openai): harden image stream relay

* fix(openai): return image JSON errors

* fix(relay): reset stream status per scanner run

* fix(relay): drop upstream credit passthrough

* fix(openai): keep image errors minimal

* fix(openai): keep image error status from response

---------

Co-authored-by: CaIon <i@caion.me>
2026-06-08 18:36:17 +08:00
同語 4ca47ee236 fix: support six-decimal steps in model pricing editor (#5332)
Merge pull request #5332 from yyhhyyyyyy/fix/model-pricing-six-decimal-step
2026-06-06 23:22:37 +08:00
同語 16dd7237c0 fix: align mobile usage log cost badge (#5161)
Merge pull request #5161 from yyhhyyyyyy/fix/mobile-usage-log-cost-alignment
2026-06-06 23:19:07 +08:00
同語 1915344838 fix: respect theme for multiselect combobox popover (#5328)
Merge pull request #5328 from yyhhyyyyyy/fix/multiselect-popover-theme
2026-06-06 23:18:04 +08:00
同語 15ff8e0268 chore(web): improve frontend dialog layout and sizing (#5346)
Merge pull request #5346 from QuantumNous/perf/ui-dialog
2026-06-06 23:16:53 +08:00
同語 a1c82841b5 chore(web): simplify public page hero copy (#5339)
Merge pull request #5339 from QuantumNous/perf/compact-display
2026-06-06 23:15:05 +08:00
QuentinHsu 2eaa943d9f perf(web): improve dialog sizing and footer layout
- migrate frontend dialogs to the shared footer API so actions stay separated from scrollable body content.
- tune dialog dimensions for model analytics, prefill groups, billing history, channel model sync, and related workflows.
- update channel terminology and dialog action translations across supported locales.
2026-06-06 21:49:33 +08:00
QuentinHsu 7a5348caa3 feat(web): add shared dialog wrapper
- introduce a reusable dialog component for consistent header, body, and footer layout.
- support per-dialog sizing, trigger rendering, initial focus, and close button controls.
- preserve base dialog open and close motion classes while allowing content-specific styling.
2026-06-06 18:47:10 +08:00
QuentinHsu f5753a2b31 perf(web): simplify public page hero copy 2026-06-06 15:49:38 +08:00
yyhhyyyyyy e8c36762fd fix: support six-decimal steps in model pricing editor 2026-06-05 17:24:33 +08:00
yyhhyyyyyy e2dbd02cbb Merge remote-tracking branch 'upstream/main' into fix/mobile-usage-log-cost-alignment
# Conflicts:
#	web/default/src/features/usage-logs/components/usage-logs-mobile-card.tsx
2026-06-05 14:11:55 +08:00
yyhhyyyyyy c8d3768087 fix: respect theme for multiselect combobox popover 2026-06-05 14:02:26 +08:00
yyhhyyyyyy 979aeceb5c fix: align mobile usage log cost badge 2026-05-28 19:17:47 +08:00
178 changed files with 13159 additions and 11936 deletions
+2 -2
View File
@@ -112,11 +112,11 @@ func InitEnv() {
// Initialize rate limit variables
GlobalApiRateLimitEnable = GetEnvOrDefaultBool("GLOBAL_API_RATE_LIMIT_ENABLE", true)
GlobalApiRateLimitNum = GetEnvOrDefault("GLOBAL_API_RATE_LIMIT", 180)
GlobalApiRateLimitNum = GetEnvOrDefault("GLOBAL_API_RATE_LIMIT", 360)
GlobalApiRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_API_RATE_LIMIT_DURATION", 180))
GlobalWebRateLimitEnable = GetEnvOrDefaultBool("GLOBAL_WEB_RATE_LIMIT_ENABLE", true)
GlobalWebRateLimitNum = GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT", 60)
GlobalWebRateLimitNum = GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT", 120)
GlobalWebRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT_DURATION", 180))
CriticalRateLimitEnable = GetEnvOrDefaultBool("CRITICAL_RATE_LIMIT_ENABLE", true)
+2 -2
View File
@@ -26,7 +26,7 @@ type ImageRequest struct {
OutputFormat json.RawMessage `json:"output_format,omitempty"`
OutputCompression json.RawMessage `json:"output_compression,omitempty"`
PartialImages json.RawMessage `json:"partial_images,omitempty"`
// Stream bool `json:"stream,omitempty"`
Stream *bool `json:"stream,omitempty"`
Images json.RawMessage `json:"images,omitempty"`
Mask json.RawMessage `json:"mask,omitempty"`
InputFidelity json.RawMessage `json:"input_fidelity,omitempty"`
@@ -163,7 +163,7 @@ func (i *ImageRequest) GetTokenCountMeta() *types.TokenCountMeta {
}
func (i *ImageRequest) IsStream(c *gin.Context) bool {
return false
return i.Stream != nil && *i.Stream
}
func (i *ImageRequest) SetModelName(modelName string) {
+16
View File
@@ -5,7 +5,9 @@ import (
"fmt"
"io"
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
channelconstant "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/relay/channel"
@@ -79,9 +81,23 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
}
func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
if request.Temperature != nil && isTemperatureOneOnlyModel(getUpstreamModelName(info, request.Model)) && *request.Temperature != 1.0 {
request.Temperature = common.GetPointer[float64](1.0)
}
return request, nil
}
func getUpstreamModelName(info *relaycommon.RelayInfo, fallback string) string {
if info != nil && info.ChannelMeta != nil && info.UpstreamModelName != "" {
return info.UpstreamModelName
}
return fallback
}
func isTemperatureOneOnlyModel(model string) bool {
return strings.EqualFold(model, "kimi-k2.6")
}
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
// TODO implement me
return nil, errors.New("not implemented")
+68
View File
@@ -0,0 +1,68 @@
package moonshot
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/stretchr/testify/require"
)
func TestConvertOpenAIRequestKimiK26UsesOnlyAllowedTemperature(t *testing.T) {
request := &dto.GeneralOpenAIRequest{
Model: "kimi-k2.6",
Temperature: common.GetPointer[float64](0.7),
}
info := &relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "kimi-k2.6",
},
}
converted, err := (&Adaptor{}).ConvertOpenAIRequest(nil, info, request)
require.NoError(t, err)
convertedRequest, ok := converted.(*dto.GeneralOpenAIRequest)
require.True(t, ok)
require.NotNil(t, convertedRequest.Temperature)
require.Equal(t, 1.0, *convertedRequest.Temperature)
}
func TestConvertOpenAIRequestKimiK26KeepsOmittedTemperatureOmitted(t *testing.T) {
request := &dto.GeneralOpenAIRequest{
Model: "kimi-k2.6",
}
info := &relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "kimi-k2.6",
},
}
converted, err := (&Adaptor{}).ConvertOpenAIRequest(nil, info, request)
require.NoError(t, err)
convertedRequest, ok := converted.(*dto.GeneralOpenAIRequest)
require.True(t, ok)
require.Nil(t, convertedRequest.Temperature)
}
func TestConvertOpenAIRequestOtherMoonshotModelKeepsTemperature(t *testing.T) {
request := &dto.GeneralOpenAIRequest{
Model: "kimi-k2.5",
Temperature: common.GetPointer[float64](0.7),
}
info := &relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "kimi-k2.5",
},
}
converted, err := (&Adaptor{}).ConvertOpenAIRequest(nil, info, request)
require.NoError(t, err)
convertedRequest, ok := converted.(*dto.GeneralOpenAIRequest)
require.True(t, ok)
require.NotNil(t, convertedRequest.Temperature)
require.Equal(t, 0.7, *convertedRequest.Temperature)
}
+12 -4
View File
@@ -9,6 +9,7 @@ import (
"mime/multipart"
"net/http"
"net/textproto"
"net/url"
"path/filepath"
"strings"
@@ -439,10 +440,13 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
// 使用已解析的 multipart 表单,避免重复解析
mf := c.Request.MultipartForm
if mf == nil {
if _, err := c.MultipartForm(); err != nil {
return nil, errors.New("failed to parse multipart form")
form, err := common.ParseMultipartFormReusable(c)
if err != nil {
return nil, fmt.Errorf("failed to parse multipart form: %w", err)
}
mf = c.Request.MultipartForm
c.Request.MultipartForm = form
c.Request.PostForm = url.Values(form.Value)
mf = form
}
// 写入所有非文件字段
@@ -625,7 +629,11 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
case relayconstant.RelayModeAudioTranscription:
err, usage = OpenaiSTTHandler(c, resp, info, a.ResponseFormat)
case relayconstant.RelayModeImagesGenerations, relayconstant.RelayModeImagesEdits:
usage, err = OpenaiHandlerWithUsage(c, info, resp)
if info.IsStream {
usage, err = OpenaiImageStreamHandler(c, info, resp)
} else {
usage, err = OpenaiImageHandler(c, info, resp)
}
case relayconstant.RelayModeRerank:
usage, err = common_handler.RerankHandler(c, info, resp)
case relayconstant.RelayModeResponses:
+98
View File
@@ -0,0 +1,98 @@
package openai
import (
"bytes"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// TestConvertImageEditRequestMultipart verifies that ConvertImageRequest
// re-serializes multipart image edit requests with all fields (including
// stream) and the file intact, both when the form was already parsed and when
// it must be re-parsed from the reusable body.
func TestConvertImageEditRequestMultipart(t *testing.T) {
gin.SetMode(gin.TestMode)
newMultipartContext := func(t *testing.T, prompt string) *gin.Context {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
require.NoError(t, writer.WriteField("model", "gpt-image-1"))
require.NoError(t, writer.WriteField("prompt", prompt))
require.NoError(t, writer.WriteField("stream", "true"))
require.NoError(t, writer.WriteField("partial_images", "3"))
part, err := writer.CreateFormFile("image", "input.png")
require.NoError(t, err)
_, err = part.Write([]byte("fake image"))
require.NoError(t, err)
require.NoError(t, writer.Close())
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body)
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
return c
}
convertAndReplay := func(t *testing.T, c *gin.Context, prompt string) {
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeImagesEdits,
}
request := dto.ImageRequest{
Model: "gpt-image-1",
Prompt: prompt,
Stream: common.GetPointer(true),
}
converted, err := (&Adaptor{}).ConvertImageRequest(c, info, request)
require.NoError(t, err)
convertedBody, ok := converted.(*bytes.Buffer)
require.True(t, ok)
replayedRequest := httptest.NewRequest(http.MethodPost, "/v1/images/edits", bytes.NewReader(convertedBody.Bytes()))
replayedRequest.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
require.NoError(t, replayedRequest.ParseMultipartForm(32<<20))
require.Equal(t, "gpt-image-1", replayedRequest.PostForm.Get("model"))
require.Equal(t, prompt, replayedRequest.PostForm.Get("prompt"))
require.Equal(t, "true", replayedRequest.PostForm.Get("stream"))
require.Equal(t, "3", replayedRequest.PostForm.Get("partial_images"))
require.Len(t, replayedRequest.MultipartForm.File["image"], 1)
file, err := replayedRequest.MultipartForm.File["image"][0].Open()
require.NoError(t, err)
defer file.Close()
fileBytes, err := io.ReadAll(file)
require.NoError(t, err)
require.Equal(t, []byte("fake image"), fileBytes)
}
t.Run("with pre-parsed form", func(t *testing.T) {
prompt := "edit this image"
c := newMultipartContext(t, prompt)
require.NoError(t, c.Request.ParseMultipartForm(32<<20))
convertAndReplay(t, c, prompt)
})
t.Run("re-parses reusable body when form is missing", func(t *testing.T) {
prompt := "edit without pre-parsed form"
c := newMultipartContext(t, prompt)
storage, err := common.GetBodyStorage(c)
require.NoError(t, err)
c.Request.Body = io.NopCloser(storage)
c.Request.MultipartForm = nil
c.Request.PostForm = nil
convertAndReplay(t, c, prompt)
})
}
+173
View File
@@ -0,0 +1,173 @@
package openai
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/QuantumNous/new-api/constant"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func newImageTestContext(t *testing.T, body, contentType string, isStream bool) (*gin.Context, *httptest.ResponseRecorder, *http.Response, *relaycommon.RelayInfo) {
t.Helper()
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{"Content-Type": []string{contentType}},
}
info := &relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{},
IsStream: isStream,
}
return c, recorder, resp, info
}
// TestOpenaiImageStreamHandlerForwardsSSEAndUsage covers the core SSE path:
// chunks are forwarded with rebuilt event lines, usage is extracted and
// normalized (input_tokens -> prompt_tokens with details), and [DONE] is
// re-emitted to the client.
func TestOpenaiImageStreamHandlerForwardsSSEAndUsage(t *testing.T) {
oldMode := gin.Mode()
gin.SetMode(gin.TestMode)
t.Cleanup(func() { gin.SetMode(oldMode) })
oldTimeout := constant.StreamingTimeout
constant.StreamingTimeout = 30
t.Cleanup(func() { constant.StreamingTimeout = oldTimeout })
body := strings.Join([]string{
`event: image_generation.partial_image`,
`data: {"type":"image_generation.partial_image","b64_json":"partial"}`,
``,
`data: {"usage":{"input_tokens":3,"output_tokens":4,"total_tokens":7,"input_tokens_details":{"image_tokens":2,"text_tokens":1}}}`,
``,
`data: [DONE]`,
``,
}, "\n")
c, recorder, resp, info := newImageTestContext(t, body, "text/event-stream", true)
usage, err := OpenaiImageStreamHandler(c, info, resp)
require.Nil(t, err)
require.Equal(t, 3, usage.PromptTokens)
require.Equal(t, 4, usage.CompletionTokens)
require.Equal(t, 7, usage.TotalTokens)
require.Equal(t, 2, usage.PromptTokensDetails.ImageTokens)
require.Equal(t, 1, usage.PromptTokensDetails.TextTokens)
require.Contains(t, recorder.Body.String(), `event: image_generation.partial_image`)
require.Contains(t, recorder.Body.String(), `data: {"type":"image_generation.partial_image","b64_json":"partial"}`)
require.Contains(t, recorder.Body.String(), `data: {"usage":{"input_tokens":3,"output_tokens":4,"total_tokens":7,"input_tokens_details":{"image_tokens":2,"text_tokens":1}}}`)
require.Contains(t, recorder.Body.String(), `data: [DONE]`)
require.Equal(t, "text/event-stream", recorder.Header().Get("Content-Type"))
}
// TestOpenaiImageStreamHandlerWrapsJSONResponse covers the non-SSE fallback:
// a JSON upstream response is wrapped into pseudo-SSE completed events.
func TestOpenaiImageStreamHandlerWrapsJSONResponse(t *testing.T) {
oldMode := gin.Mode()
gin.SetMode(gin.TestMode)
t.Cleanup(func() { gin.SetMode(oldMode) })
body := `{"created":1710000000,"data":[{"b64_json":"final","revised_prompt":"draw a cat"}],"usage":{"input_tokens":3,"output_tokens":4,"total_tokens":7,"input_tokens_details":{"image_tokens":2,"text_tokens":1}}}`
c, recorder, resp, info := newImageTestContext(t, body, "application/json", true)
usage, err := OpenaiImageStreamHandler(c, info, resp)
require.Nil(t, err)
require.Equal(t, 3, usage.PromptTokens)
require.Equal(t, 4, usage.CompletionTokens)
require.Equal(t, 7, usage.TotalTokens)
require.Equal(t, 2, usage.PromptTokensDetails.ImageTokens)
require.Equal(t, 1, usage.PromptTokensDetails.TextTokens)
require.Equal(t, "text/event-stream", recorder.Header().Get("Content-Type"))
require.Empty(t, recorder.Header().Get("Content-Length"))
require.Contains(t, recorder.Body.String(), `event: image_generation.completed`)
require.Contains(t, recorder.Body.String(), `"type":"image_generation.completed"`)
require.Contains(t, recorder.Body.String(), `"b64_json":"final"`)
require.Contains(t, recorder.Body.String(), `"revised_prompt":"draw a cat"`)
require.Contains(t, recorder.Body.String(), `data: [DONE]`)
}
// TestOpenaiImageHandlersReturnJSONError covers JSON error responses for both
// entry points: the non-streaming handler and the stream handler's non-SSE
// fallback. Neither must leak the error body to the client.
func TestOpenaiImageHandlersReturnJSONError(t *testing.T) {
oldMode := gin.Mode()
gin.SetMode(gin.TestMode)
t.Cleanup(func() { gin.SetMode(oldMode) })
body := `{"error":{"message":"content moderation failed","type":"upstream_error","code":"content_moderation_failed","status":502}}`
t.Run("non-streaming handler", func(t *testing.T) {
c, recorder, resp, info := newImageTestContext(t, body, "application/json", false)
usage, err := OpenaiImageHandler(c, info, resp)
require.Nil(t, usage)
require.NotNil(t, err)
require.Equal(t, http.StatusOK, err.StatusCode)
oaiError := err.ToOpenAIError()
require.Equal(t, "content moderation failed", oaiError.Message)
require.Equal(t, "upstream_error", oaiError.Type)
require.Equal(t, "content_moderation_failed", oaiError.Code)
require.Empty(t, recorder.Body.String())
})
t.Run("stream handler JSON fallback", func(t *testing.T) {
c, recorder, resp, info := newImageTestContext(t, body, "application/json", true)
usage, err := OpenaiImageStreamHandler(c, info, resp)
require.Nil(t, usage)
require.NotNil(t, err)
require.Equal(t, http.StatusOK, err.StatusCode)
require.Equal(t, "content moderation failed", err.ToOpenAIError().Message)
require.Empty(t, recorder.Body.String())
})
}
// TestOpenaiImageStreamHandlerRecordsUpstreamErrorEvent verifies that an error
// event inside the SSE stream is recorded as a soft error while the payload is
// still forwarded to the client.
func TestOpenaiImageStreamHandlerRecordsUpstreamErrorEvent(t *testing.T) {
oldMode := gin.Mode()
gin.SetMode(gin.TestMode)
t.Cleanup(func() { gin.SetMode(oldMode) })
oldTimeout := constant.StreamingTimeout
constant.StreamingTimeout = 30
t.Cleanup(func() { constant.StreamingTimeout = oldTimeout })
body := strings.Join([]string{
`event: image_generation.partial_image`,
`data: {"type":"image_generation.partial_image","b64_json":"partial"}`,
``,
`event: error`,
`data: {"type":"upstream_error","error":{"message":"stream error: stream ID 77; INTERNAL_ERROR; received from peer"}}`,
``,
}, "\n")
c, recorder, resp, info := newImageTestContext(t, body, "text/event-stream", true)
usage, err := OpenaiImageStreamHandler(c, info, resp)
require.Nil(t, err)
require.NotNil(t, usage)
require.NotNil(t, info.StreamStatus)
require.Equal(t, relaycommon.StreamEndReasonEOF, info.StreamStatus.EndReason)
require.True(t, info.StreamStatus.HasErrors())
require.Equal(t, 1, info.StreamStatus.TotalErrorCount())
require.Contains(t, info.StreamStatus.Errors[0].Message, "INTERNAL_ERROR")
// The scanner strips the upstream "event: error" line; the event name is
// rebuilt from the JSON "type" field (upstream_error). The error message
// is still forwarded in the data: payload (stream ID 77).
require.Contains(t, recorder.Body.String(), `event: upstream_error`)
require.Contains(t, recorder.Body.String(), `stream ID 77`)
}
-421
View File
@@ -14,12 +14,9 @@ import (
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/types"
"github.com/bytedance/gopkg/util/gopool"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
func sendStreamData(c *gin.Context, info *relaycommon.RelayInfo, data string, forceFormat bool, thinkToContent bool) error {
@@ -293,421 +290,3 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
return &simpleResponse.Usage, nil
}
func streamTTSResponse(c *gin.Context, resp *http.Response) {
c.Writer.WriteHeaderNow()
flusher, ok := c.Writer.(http.Flusher)
if !ok {
logger.LogWarn(c, "streaming not supported")
_, err := io.Copy(c.Writer, resp.Body)
if err != nil {
logger.LogWarn(c, err.Error())
}
return
}
buffer := make([]byte, 4096)
for {
n, err := resp.Body.Read(buffer)
//logger.LogInfo(c, fmt.Sprintf("streamTTSResponse read %d bytes", n))
if n > 0 {
if _, writeErr := c.Writer.Write(buffer[:n]); writeErr != nil {
logger.LogError(c, writeErr.Error())
break
}
flusher.Flush()
}
if err != nil {
if err != io.EOF {
logger.LogError(c, err.Error())
}
break
}
}
}
func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.RealtimeUsage) {
if info == nil || info.ClientWs == nil || info.TargetWs == nil {
return types.NewError(fmt.Errorf("invalid websocket connection"), types.ErrorCodeBadResponse), nil
}
info.IsStream = true
clientConn := info.ClientWs
targetConn := info.TargetWs
clientClosed := make(chan struct{})
targetClosed := make(chan struct{})
sendChan := make(chan []byte, 100)
receiveChan := make(chan []byte, 100)
errChan := make(chan error, 2)
usage := &dto.RealtimeUsage{}
localUsage := &dto.RealtimeUsage{}
sumUsage := &dto.RealtimeUsage{}
gopool.Go(func() {
defer func() {
if r := recover(); r != nil {
errChan <- fmt.Errorf("panic in client reader: %v", r)
}
}()
for {
select {
case <-c.Done():
return
default:
_, message, err := clientConn.ReadMessage()
if err != nil {
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
errChan <- fmt.Errorf("error reading from client: %v", err)
}
close(clientClosed)
return
}
realtimeEvent := &dto.RealtimeEvent{}
err = common.Unmarshal(message, realtimeEvent)
if err != nil {
errChan <- fmt.Errorf("error unmarshalling message: %v", err)
return
}
if realtimeEvent.Type == dto.RealtimeEventTypeSessionUpdate {
if realtimeEvent.Session != nil {
if realtimeEvent.Session.Tools != nil {
info.RealtimeTools = realtimeEvent.Session.Tools
}
}
}
textToken, audioToken, err := service.CountTokenRealtime(info, *realtimeEvent, info.UpstreamModelName)
if err != nil {
errChan <- fmt.Errorf("error counting text token: %v", err)
return
}
logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
localUsage.TotalTokens += textToken + audioToken
localUsage.InputTokens += textToken + audioToken
localUsage.InputTokenDetails.TextTokens += textToken
localUsage.InputTokenDetails.AudioTokens += audioToken
err = helper.WssString(c, targetConn, string(message))
if err != nil {
errChan <- fmt.Errorf("error writing to target: %v", err)
return
}
select {
case sendChan <- message:
default:
}
}
}
})
gopool.Go(func() {
defer func() {
if r := recover(); r != nil {
errChan <- fmt.Errorf("panic in target reader: %v", r)
}
}()
for {
select {
case <-c.Done():
return
default:
_, message, err := targetConn.ReadMessage()
if err != nil {
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
errChan <- fmt.Errorf("error reading from target: %v", err)
}
close(targetClosed)
return
}
info.SetFirstResponseTime()
realtimeEvent := &dto.RealtimeEvent{}
err = common.Unmarshal(message, realtimeEvent)
if err != nil {
errChan <- fmt.Errorf("error unmarshalling message: %v", err)
return
}
if realtimeEvent.Type == dto.RealtimeEventTypeResponseDone {
realtimeUsage := realtimeEvent.Response.Usage
if realtimeUsage != nil {
usage.TotalTokens += realtimeUsage.TotalTokens
usage.InputTokens += realtimeUsage.InputTokens
usage.OutputTokens += realtimeUsage.OutputTokens
usage.InputTokenDetails.AudioTokens += realtimeUsage.InputTokenDetails.AudioTokens
usage.InputTokenDetails.CachedTokens += realtimeUsage.InputTokenDetails.CachedTokens
usage.InputTokenDetails.TextTokens += realtimeUsage.InputTokenDetails.TextTokens
usage.OutputTokenDetails.AudioTokens += realtimeUsage.OutputTokenDetails.AudioTokens
usage.OutputTokenDetails.TextTokens += realtimeUsage.OutputTokenDetails.TextTokens
err := preConsumeUsage(c, info, usage, sumUsage)
if err != nil {
errChan <- fmt.Errorf("error consume usage: %v", err)
return
}
// 本次计费完成,清除
usage = &dto.RealtimeUsage{}
localUsage = &dto.RealtimeUsage{}
} else {
textToken, audioToken, err := service.CountTokenRealtime(info, *realtimeEvent, info.UpstreamModelName)
if err != nil {
errChan <- fmt.Errorf("error counting text token: %v", err)
return
}
logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
localUsage.TotalTokens += textToken + audioToken
info.IsFirstRequest = false
localUsage.InputTokens += textToken + audioToken
localUsage.InputTokenDetails.TextTokens += textToken
localUsage.InputTokenDetails.AudioTokens += audioToken
err = preConsumeUsage(c, info, localUsage, sumUsage)
if err != nil {
errChan <- fmt.Errorf("error consume usage: %v", err)
return
}
// 本次计费完成,清除
localUsage = &dto.RealtimeUsage{}
// print now usage
}
logger.LogInfo(c, fmt.Sprintf("realtime streaming sumUsage: %v", sumUsage))
logger.LogInfo(c, fmt.Sprintf("realtime streaming localUsage: %v", localUsage))
logger.LogInfo(c, fmt.Sprintf("realtime streaming localUsage: %v", localUsage))
} else if realtimeEvent.Type == dto.RealtimeEventTypeSessionUpdated || realtimeEvent.Type == dto.RealtimeEventTypeSessionCreated {
realtimeSession := realtimeEvent.Session
if realtimeSession != nil {
// update audio format
info.InputAudioFormat = common.GetStringIfEmpty(realtimeSession.InputAudioFormat, info.InputAudioFormat)
info.OutputAudioFormat = common.GetStringIfEmpty(realtimeSession.OutputAudioFormat, info.OutputAudioFormat)
}
} else {
textToken, audioToken, err := service.CountTokenRealtime(info, *realtimeEvent, info.UpstreamModelName)
if err != nil {
errChan <- fmt.Errorf("error counting text token: %v", err)
return
}
logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
localUsage.TotalTokens += textToken + audioToken
localUsage.OutputTokens += textToken + audioToken
localUsage.OutputTokenDetails.TextTokens += textToken
localUsage.OutputTokenDetails.AudioTokens += audioToken
}
err = helper.WssString(c, clientConn, string(message))
if err != nil {
errChan <- fmt.Errorf("error writing to client: %v", err)
return
}
select {
case receiveChan <- message:
default:
}
}
}
})
select {
case <-clientClosed:
case <-targetClosed:
case err := <-errChan:
//return service.OpenAIErrorWrapper(err, "realtime_error", http.StatusInternalServerError), nil
logger.LogError(c, "realtime error: "+err.Error())
case <-c.Done():
}
if usage.TotalTokens != 0 {
_ = preConsumeUsage(c, info, usage, sumUsage)
}
if localUsage.TotalTokens != 0 {
_ = preConsumeUsage(c, info, localUsage, sumUsage)
}
// check usage total tokens, if 0, use local usage
return nil, sumUsage
}
func preConsumeUsage(ctx *gin.Context, info *relaycommon.RelayInfo, usage *dto.RealtimeUsage, totalUsage *dto.RealtimeUsage) error {
if usage == nil || totalUsage == nil {
return fmt.Errorf("invalid usage pointer")
}
totalUsage.TotalTokens += usage.TotalTokens
totalUsage.InputTokens += usage.InputTokens
totalUsage.OutputTokens += usage.OutputTokens
totalUsage.InputTokenDetails.CachedTokens += usage.InputTokenDetails.CachedTokens
totalUsage.InputTokenDetails.TextTokens += usage.InputTokenDetails.TextTokens
totalUsage.InputTokenDetails.AudioTokens += usage.InputTokenDetails.AudioTokens
totalUsage.OutputTokenDetails.TextTokens += usage.OutputTokenDetails.TextTokens
totalUsage.OutputTokenDetails.AudioTokens += usage.OutputTokenDetails.AudioTokens
// clear usage
err := service.PreWssConsumeQuota(ctx, info, usage)
return err
}
func OpenaiHandlerWithUsage(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
defer service.CloseResponseBodyGracefully(resp)
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
}
var usageResp dto.SimpleResponse
err = common.Unmarshal(responseBody, &usageResp)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
// 写入新的 response body
service.IOCopyBytesGracefully(c, resp, responseBody)
// Once we've written to the client, we should not return errors anymore
// because the upstream has already consumed resources and returned content
// We should still perform billing even if parsing fails
// format
if usageResp.InputTokens > 0 {
usageResp.PromptTokens += usageResp.InputTokens
}
if usageResp.OutputTokens > 0 {
usageResp.CompletionTokens += usageResp.OutputTokens
}
if usageResp.InputTokensDetails != nil {
usageResp.PromptTokensDetails.ImageTokens += usageResp.InputTokensDetails.ImageTokens
usageResp.PromptTokensDetails.TextTokens += usageResp.InputTokensDetails.TextTokens
}
applyUsagePostProcessing(info, &usageResp.Usage, responseBody)
return &usageResp.Usage, nil
}
func applyUsagePostProcessing(info *relaycommon.RelayInfo, usage *dto.Usage, responseBody []byte) {
if info == nil || usage == nil {
return
}
switch info.ChannelType {
case constant.ChannelTypeDeepSeek:
if usage.PromptTokensDetails.CachedTokens == 0 && usage.PromptCacheHitTokens != 0 {
usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens
}
case constant.ChannelTypeZhipu_v4:
// 智普的cached_tokens在标准位置: usage.prompt_tokens_details.cached_tokens
if usage.PromptTokensDetails.CachedTokens == 0 {
if usage.InputTokensDetails != nil && usage.InputTokensDetails.CachedTokens > 0 {
usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens
} else if cachedTokens, ok := extractCachedTokensFromBody(responseBody); ok {
usage.PromptTokensDetails.CachedTokens = cachedTokens
} else if usage.PromptCacheHitTokens > 0 {
usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens
}
}
case constant.ChannelTypeMoonshot:
// Moonshot的cached_tokens在非标准位置: choices[].usage.cached_tokens
if usage.PromptTokensDetails.CachedTokens == 0 {
if usage.InputTokensDetails != nil && usage.InputTokensDetails.CachedTokens > 0 {
usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens
} else if cachedTokens, ok := extractMoonshotCachedTokensFromBody(responseBody); ok {
usage.PromptTokensDetails.CachedTokens = cachedTokens
} else if cachedTokens, ok := extractCachedTokensFromBody(responseBody); ok {
usage.PromptTokensDetails.CachedTokens = cachedTokens
} else if usage.PromptCacheHitTokens > 0 {
usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens
}
}
case constant.ChannelTypeOpenAI:
if usage.PromptTokensDetails.CachedTokens == 0 {
if cachedTokens, ok := extractLlamaCachedTokensFromBody(responseBody); ok {
usage.PromptTokensDetails.CachedTokens = cachedTokens
}
}
}
}
func extractCachedTokensFromBody(body []byte) (int, bool) {
if len(body) == 0 {
return 0, false
}
var payload struct {
Usage struct {
PromptTokensDetails struct {
CachedTokens *int `json:"cached_tokens"`
} `json:"prompt_tokens_details"`
CachedTokens *int `json:"cached_tokens"`
PromptCacheHitTokens *int `json:"prompt_cache_hit_tokens"`
} `json:"usage"`
}
if err := common.Unmarshal(body, &payload); err != nil {
return 0, false
}
if payload.Usage.PromptTokensDetails.CachedTokens != nil {
return *payload.Usage.PromptTokensDetails.CachedTokens, true
}
if payload.Usage.CachedTokens != nil {
return *payload.Usage.CachedTokens, true
}
if payload.Usage.PromptCacheHitTokens != nil {
return *payload.Usage.PromptCacheHitTokens, true
}
return 0, false
}
// extractMoonshotCachedTokensFromBody 从Moonshot的非标准位置提取cached_tokens
// Moonshot的流式响应格式: {"choices":[{"usage":{"cached_tokens":111}}]}
func extractMoonshotCachedTokensFromBody(body []byte) (int, bool) {
if len(body) == 0 {
return 0, false
}
var payload struct {
Choices []struct {
Usage struct {
CachedTokens *int `json:"cached_tokens"`
} `json:"usage"`
} `json:"choices"`
}
if err := common.Unmarshal(body, &payload); err != nil {
return 0, false
}
// 遍历choices查找cached_tokens
for _, choice := range payload.Choices {
if choice.Usage.CachedTokens != nil && *choice.Usage.CachedTokens > 0 {
return *choice.Usage.CachedTokens, true
}
}
return 0, false
}
// extractLlamaCachedTokensFromBody 从llama.cpp的非标准位置提取cache_n
func extractLlamaCachedTokensFromBody(body []byte) (int, bool) {
if len(body) == 0 {
return 0, false
}
var payload struct {
Timings struct {
CachedTokens *int `json:"cache_n"`
} `json:"timings"`
}
if err := common.Unmarshal(body, &payload); err != nil {
return 0, false
}
if payload.Timings.CachedTokens == nil {
return 0, false
}
return *payload.Timings.CachedTokens, true
}
+287
View File
@@ -0,0 +1,287 @@
package openai
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
)
// OpenaiImageHandler handles non-streaming OpenAI image responses
// (generations/edits), returning the parsed usage for billing.
func OpenaiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
defer service.CloseResponseBodyGracefully(resp)
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
}
var usageResp dto.SimpleResponse
err = common.Unmarshal(responseBody, &usageResp)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if oaiError := usageResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
}
// 写入新的 response body
service.IOCopyBytesGracefully(c, resp, responseBody)
normalizeOpenAIUsage(&usageResp.Usage)
applyUsagePostProcessing(info, &usageResp.Usage, responseBody)
return &usageResp.Usage, nil
}
// normalizeOpenAIUsage maps the OpenAI Images usage shape (input_tokens /
// output_tokens / input_tokens_details) onto the canonical prompt/completion
// fields. It is used only on the OpenAI image relay paths (generations/edits,
// streaming and non-streaming): the image API never returns prompt_tokens /
// completion_tokens, so the overwrite (=) semantics here are equivalent to the
// previous additive (+=) behavior while avoiding any future double-counting if
// both field sets are ever populated. Do not reuse this on chat/embedding paths
// without revisiting the overwrite semantics.
func normalizeOpenAIUsage(usage *dto.Usage) {
if usage == nil {
return
}
if usage.InputTokens != 0 {
usage.PromptTokens = usage.InputTokens
}
if usage.OutputTokens != 0 {
usage.CompletionTokens = usage.OutputTokens
}
if usage.InputTokensDetails != nil {
usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens
usage.PromptTokensDetails.CachedCreationTokens = usage.InputTokensDetails.CachedCreationTokens
usage.PromptTokensDetails.ImageTokens = usage.InputTokensDetails.ImageTokens
usage.PromptTokensDetails.TextTokens = usage.InputTokensDetails.TextTokens
usage.PromptTokensDetails.AudioTokens = usage.InputTokensDetails.AudioTokens
}
if usage.TotalTokens == 0 {
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
}
}
func OpenaiImageStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
if resp == nil || resp.Body == nil {
logger.LogError(c, "invalid image stream response")
return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
contentType := strings.ToLower(resp.Header.Get("Content-Type"))
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return OpenaiImageHandler(c, info, resp)
}
if !strings.Contains(contentType, "text/event-stream") {
return OpenaiImageJSONAsStreamHandler(c, info, resp)
}
// Reuse the shared streaming engine (helper.StreamScannerHandler) so the
// image streaming path gets the same ping keepalive, streaming-timeout
// watchdog, client-disconnect detection, panic recovery and goroutine
// cleanup as every other relay stream. The scanner delivers only the
// "data:" payload, so the SSE "event:" line is rebuilt from the JSON "type"
// field (real OpenAI image events keep event == type).
usage := &dto.Usage{}
var lastStreamData []byte
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
raw := common.StringToByteSlice(data)
lastStreamData = raw
if isOpenAIImageStreamErrorEvent(raw) {
// Record the error as a soft error; the scanner drives the final
// EndReason. HasErrors() flags the failure for logging/handling.
sr.Error(fmt.Errorf("%s", extractOpenAIImageStreamErrorMessage(raw)))
}
var usageResp dto.SimpleResponse
if err := common.Unmarshal(raw, &usageResp); err == nil {
normalizeOpenAIUsage(&usageResp.Usage)
if service.ValidUsage(&usageResp.Usage) {
usage = &usageResp.Usage
}
}
writeOpenaiImageStreamChunk(c, raw)
})
// StreamScannerHandler consumes the upstream [DONE]; re-emit it so the
// client still receives a terminal data: [DONE].
if info != nil && info.StreamStatus != nil && info.StreamStatus.EndReason == relaycommon.StreamEndReasonDone {
helper.Done(c)
}
applyUsagePostProcessing(info, usage, lastStreamData)
return usage, nil
}
// writeOpenaiImageStreamChunk rebuilds the SSE frame for an image stream chunk:
// it emits an "event:" line derived from the JSON "type" field (when present)
// followed by the verbatim "data:" payload, mirroring helper.ResponseChunkData.
func writeOpenaiImageStreamChunk(c *gin.Context, data []byte) {
var payload struct {
Type string `json:"type"`
}
_ = common.Unmarshal(data, &payload)
if eventName := strings.TrimSpace(payload.Type); eventName != "" {
c.Render(-1, common.CustomEvent{Data: fmt.Sprintf("event: %s\n", eventName)})
}
c.Render(-1, common.CustomEvent{Data: "data: " + string(data)})
_ = helper.FlushWriter(c)
}
// isOpenAIImageStreamErrorEvent detects upstream error chunks by JSON content
// only ("type" of error/upstream_error, or a non-empty "error" field). The SSE
// "event:" line is not available here: StreamScannerHandler delivers only the
// "data:" payload. A payload carrying just a "message" key is deliberately NOT
// treated as an error to avoid false positives.
func isOpenAIImageStreamErrorEvent(data []byte) bool {
if !json.Valid(data) {
return false
}
var payload struct {
Type string `json:"type"`
Error json.RawMessage `json:"error"`
}
if err := common.Unmarshal(data, &payload); err != nil {
return false
}
payloadType := strings.ToLower(strings.TrimSpace(payload.Type))
return payloadType == "error" || payloadType == "upstream_error" || len(payload.Error) > 0
}
func extractOpenAIImageStreamErrorMessage(data []byte) string {
if len(data) == 0 || !json.Valid(data) {
return "upstream image stream returned error event"
}
var payload struct {
Message string `json:"message"`
Error json.RawMessage `json:"error"`
}
if err := common.Unmarshal(data, &payload); err != nil {
return "upstream image stream returned error event"
}
if msg := strings.TrimSpace(payload.Message); msg != "" {
return msg
}
if len(payload.Error) > 0 {
var nested struct {
Message string `json:"message"`
}
if err := common.Unmarshal(payload.Error, &nested); err == nil {
if msg := strings.TrimSpace(nested.Message); msg != "" {
return msg
}
}
if msg := strings.TrimSpace(common.JsonRawMessageToString(payload.Error)); msg != "" {
return msg
}
}
return "upstream image stream returned error event"
}
func OpenaiImageJSONAsStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
defer service.CloseResponseBodyGracefully(resp)
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
}
var imageResp dto.ImageResponse
if err := common.Unmarshal(responseBody, &imageResp); err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
var usageResp dto.SimpleResponse
_ = common.Unmarshal(responseBody, &usageResp)
if oaiError := usageResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
}
normalizeOpenAIUsage(&usageResp.Usage)
applyUsagePostProcessing(info, &usageResp.Usage, responseBody)
helper.SetEventStreamHeaders(c)
c.Status(http.StatusOK)
created := imageResp.Created
if created == 0 {
created = time.Now().Unix()
}
if info != nil {
info.SetFirstResponseTime()
}
for _, image := range imageResp.Data {
payload := map[string]any{
"type": "image_generation.completed",
"created_at": created,
}
if image.Url != "" {
payload["url"] = image.Url
}
if image.B64Json != "" {
payload["b64_json"] = image.B64Json
}
if image.RevisedPrompt != "" {
payload["revised_prompt"] = image.RevisedPrompt
}
if service.ValidUsage(&usageResp.Usage) {
payload["usage"] = usageResp.Usage
}
if err := writeOpenaiImageStreamPayload(c, "image_generation.completed", payload); err != nil {
if info != nil && info.StreamStatus != nil {
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonClientGone, err)
}
return &usageResp.Usage, nil
}
}
if err := writeOpenaiImageStreamDone(c); err != nil {
if info != nil && info.StreamStatus != nil {
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonClientGone, err)
}
return &usageResp.Usage, nil
}
if info != nil {
info.ReceivedResponseCount += len(imageResp.Data)
if info.StreamStatus == nil {
info.StreamStatus = relaycommon.NewStreamStatus()
}
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonDone, nil)
}
return &usageResp.Usage, nil
}
func writeOpenaiImageStreamPayload(c *gin.Context, eventName string, payload any) error {
data, err := common.Marshal(payload)
if err != nil {
return err
}
if eventName != "" {
if _, err := fmt.Fprintf(c.Writer, "event: %s\n", eventName); err != nil {
return err
}
}
if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", data); err != nil {
return err
}
return helper.FlushWriter(c)
}
func writeOpenaiImageStreamDone(c *gin.Context) error {
if _, err := fmt.Fprint(c.Writer, "data: [DONE]\n\n"); err != nil {
return err
}
return helper.FlushWriter(c)
}
+242
View File
@@ -0,0 +1,242 @@
package openai
import (
"fmt"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/types"
"github.com/bytedance/gopkg/util/gopool"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.RealtimeUsage) {
if info == nil || info.ClientWs == nil || info.TargetWs == nil {
return types.NewError(fmt.Errorf("invalid websocket connection"), types.ErrorCodeBadResponse), nil
}
info.IsStream = true
clientConn := info.ClientWs
targetConn := info.TargetWs
clientClosed := make(chan struct{})
targetClosed := make(chan struct{})
sendChan := make(chan []byte, 100)
receiveChan := make(chan []byte, 100)
errChan := make(chan error, 2)
usage := &dto.RealtimeUsage{}
localUsage := &dto.RealtimeUsage{}
sumUsage := &dto.RealtimeUsage{}
gopool.Go(func() {
defer func() {
if r := recover(); r != nil {
errChan <- fmt.Errorf("panic in client reader: %v", r)
}
}()
for {
select {
case <-c.Done():
return
default:
_, message, err := clientConn.ReadMessage()
if err != nil {
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
errChan <- fmt.Errorf("error reading from client: %v", err)
}
close(clientClosed)
return
}
realtimeEvent := &dto.RealtimeEvent{}
err = common.Unmarshal(message, realtimeEvent)
if err != nil {
errChan <- fmt.Errorf("error unmarshalling message: %v", err)
return
}
if realtimeEvent.Type == dto.RealtimeEventTypeSessionUpdate {
if realtimeEvent.Session != nil {
if realtimeEvent.Session.Tools != nil {
info.RealtimeTools = realtimeEvent.Session.Tools
}
}
}
textToken, audioToken, err := service.CountTokenRealtime(info, *realtimeEvent, info.UpstreamModelName)
if err != nil {
errChan <- fmt.Errorf("error counting text token: %v", err)
return
}
logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
localUsage.TotalTokens += textToken + audioToken
localUsage.InputTokens += textToken + audioToken
localUsage.InputTokenDetails.TextTokens += textToken
localUsage.InputTokenDetails.AudioTokens += audioToken
err = helper.WssString(c, targetConn, string(message))
if err != nil {
errChan <- fmt.Errorf("error writing to target: %v", err)
return
}
select {
case sendChan <- message:
default:
}
}
}
})
gopool.Go(func() {
defer func() {
if r := recover(); r != nil {
errChan <- fmt.Errorf("panic in target reader: %v", r)
}
}()
for {
select {
case <-c.Done():
return
default:
_, message, err := targetConn.ReadMessage()
if err != nil {
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
errChan <- fmt.Errorf("error reading from target: %v", err)
}
close(targetClosed)
return
}
info.SetFirstResponseTime()
realtimeEvent := &dto.RealtimeEvent{}
err = common.Unmarshal(message, realtimeEvent)
if err != nil {
errChan <- fmt.Errorf("error unmarshalling message: %v", err)
return
}
if realtimeEvent.Type == dto.RealtimeEventTypeResponseDone {
realtimeUsage := realtimeEvent.Response.Usage
if realtimeUsage != nil {
usage.TotalTokens += realtimeUsage.TotalTokens
usage.InputTokens += realtimeUsage.InputTokens
usage.OutputTokens += realtimeUsage.OutputTokens
usage.InputTokenDetails.AudioTokens += realtimeUsage.InputTokenDetails.AudioTokens
usage.InputTokenDetails.CachedTokens += realtimeUsage.InputTokenDetails.CachedTokens
usage.InputTokenDetails.TextTokens += realtimeUsage.InputTokenDetails.TextTokens
usage.OutputTokenDetails.AudioTokens += realtimeUsage.OutputTokenDetails.AudioTokens
usage.OutputTokenDetails.TextTokens += realtimeUsage.OutputTokenDetails.TextTokens
err := preConsumeUsage(c, info, usage, sumUsage)
if err != nil {
errChan <- fmt.Errorf("error consume usage: %v", err)
return
}
// 本次计费完成,清除
usage = &dto.RealtimeUsage{}
localUsage = &dto.RealtimeUsage{}
} else {
textToken, audioToken, err := service.CountTokenRealtime(info, *realtimeEvent, info.UpstreamModelName)
if err != nil {
errChan <- fmt.Errorf("error counting text token: %v", err)
return
}
logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
localUsage.TotalTokens += textToken + audioToken
info.IsFirstRequest = false
localUsage.InputTokens += textToken + audioToken
localUsage.InputTokenDetails.TextTokens += textToken
localUsage.InputTokenDetails.AudioTokens += audioToken
err = preConsumeUsage(c, info, localUsage, sumUsage)
if err != nil {
errChan <- fmt.Errorf("error consume usage: %v", err)
return
}
// 本次计费完成,清除
localUsage = &dto.RealtimeUsage{}
// print now usage
}
logger.LogInfo(c, fmt.Sprintf("realtime streaming sumUsage: %v", sumUsage))
logger.LogInfo(c, fmt.Sprintf("realtime streaming localUsage: %v", localUsage))
logger.LogInfo(c, fmt.Sprintf("realtime streaming localUsage: %v", localUsage))
} else if realtimeEvent.Type == dto.RealtimeEventTypeSessionUpdated || realtimeEvent.Type == dto.RealtimeEventTypeSessionCreated {
realtimeSession := realtimeEvent.Session
if realtimeSession != nil {
// update audio format
info.InputAudioFormat = common.GetStringIfEmpty(realtimeSession.InputAudioFormat, info.InputAudioFormat)
info.OutputAudioFormat = common.GetStringIfEmpty(realtimeSession.OutputAudioFormat, info.OutputAudioFormat)
}
} else {
textToken, audioToken, err := service.CountTokenRealtime(info, *realtimeEvent, info.UpstreamModelName)
if err != nil {
errChan <- fmt.Errorf("error counting text token: %v", err)
return
}
logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
localUsage.TotalTokens += textToken + audioToken
localUsage.OutputTokens += textToken + audioToken
localUsage.OutputTokenDetails.TextTokens += textToken
localUsage.OutputTokenDetails.AudioTokens += audioToken
}
err = helper.WssString(c, clientConn, string(message))
if err != nil {
errChan <- fmt.Errorf("error writing to client: %v", err)
return
}
select {
case receiveChan <- message:
default:
}
}
}
})
select {
case <-clientClosed:
case <-targetClosed:
case err := <-errChan:
//return service.OpenAIErrorWrapper(err, "realtime_error", http.StatusInternalServerError), nil
logger.LogError(c, "realtime error: "+err.Error())
case <-c.Done():
}
if usage.TotalTokens != 0 {
_ = preConsumeUsage(c, info, usage, sumUsage)
}
if localUsage.TotalTokens != 0 {
_ = preConsumeUsage(c, info, localUsage, sumUsage)
}
// check usage total tokens, if 0, use local usage
return nil, sumUsage
}
func preConsumeUsage(ctx *gin.Context, info *relaycommon.RelayInfo, usage *dto.RealtimeUsage, totalUsage *dto.RealtimeUsage) error {
if usage == nil || totalUsage == nil {
return fmt.Errorf("invalid usage pointer")
}
totalUsage.TotalTokens += usage.TotalTokens
totalUsage.InputTokens += usage.InputTokens
totalUsage.OutputTokens += usage.OutputTokens
totalUsage.InputTokenDetails.CachedTokens += usage.InputTokenDetails.CachedTokens
totalUsage.InputTokenDetails.TextTokens += usage.InputTokenDetails.TextTokens
totalUsage.InputTokenDetails.AudioTokens += usage.InputTokenDetails.AudioTokens
totalUsage.OutputTokenDetails.TextTokens += usage.OutputTokenDetails.TextTokens
totalUsage.OutputTokenDetails.AudioTokens += usage.OutputTokenDetails.AudioTokens
// clear usage
err := service.PreWssConsumeQuota(ctx, info, usage)
return err
}
+133
View File
@@ -0,0 +1,133 @@
package openai
import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
)
func applyUsagePostProcessing(info *relaycommon.RelayInfo, usage *dto.Usage, responseBody []byte) {
if info == nil || usage == nil {
return
}
switch info.ChannelType {
case constant.ChannelTypeDeepSeek:
if usage.PromptTokensDetails.CachedTokens == 0 && usage.PromptCacheHitTokens != 0 {
usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens
}
case constant.ChannelTypeZhipu_v4:
// 智普的cached_tokens在标准位置: usage.prompt_tokens_details.cached_tokens
if usage.PromptTokensDetails.CachedTokens == 0 {
if usage.InputTokensDetails != nil && usage.InputTokensDetails.CachedTokens > 0 {
usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens
} else if cachedTokens, ok := extractCachedTokensFromBody(responseBody); ok {
usage.PromptTokensDetails.CachedTokens = cachedTokens
} else if usage.PromptCacheHitTokens > 0 {
usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens
}
}
case constant.ChannelTypeMoonshot:
// Moonshot的cached_tokens在非标准位置: choices[].usage.cached_tokens
if usage.PromptTokensDetails.CachedTokens == 0 {
if usage.InputTokensDetails != nil && usage.InputTokensDetails.CachedTokens > 0 {
usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens
} else if cachedTokens, ok := extractMoonshotCachedTokensFromBody(responseBody); ok {
usage.PromptTokensDetails.CachedTokens = cachedTokens
} else if cachedTokens, ok := extractCachedTokensFromBody(responseBody); ok {
usage.PromptTokensDetails.CachedTokens = cachedTokens
} else if usage.PromptCacheHitTokens > 0 {
usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens
}
}
case constant.ChannelTypeOpenAI:
if usage.PromptTokensDetails.CachedTokens == 0 {
if cachedTokens, ok := extractLlamaCachedTokensFromBody(responseBody); ok {
usage.PromptTokensDetails.CachedTokens = cachedTokens
}
}
}
}
func extractCachedTokensFromBody(body []byte) (int, bool) {
if len(body) == 0 {
return 0, false
}
var payload struct {
Usage struct {
PromptTokensDetails struct {
CachedTokens *int `json:"cached_tokens"`
} `json:"prompt_tokens_details"`
CachedTokens *int `json:"cached_tokens"`
PromptCacheHitTokens *int `json:"prompt_cache_hit_tokens"`
} `json:"usage"`
}
if err := common.Unmarshal(body, &payload); err != nil {
return 0, false
}
if payload.Usage.PromptTokensDetails.CachedTokens != nil {
return *payload.Usage.PromptTokensDetails.CachedTokens, true
}
if payload.Usage.CachedTokens != nil {
return *payload.Usage.CachedTokens, true
}
if payload.Usage.PromptCacheHitTokens != nil {
return *payload.Usage.PromptCacheHitTokens, true
}
return 0, false
}
// extractMoonshotCachedTokensFromBody 从Moonshot的非标准位置提取cached_tokens
// Moonshot的流式响应格式: {"choices":[{"usage":{"cached_tokens":111}}]}
func extractMoonshotCachedTokensFromBody(body []byte) (int, bool) {
if len(body) == 0 {
return 0, false
}
var payload struct {
Choices []struct {
Usage struct {
CachedTokens *int `json:"cached_tokens"`
} `json:"usage"`
} `json:"choices"`
}
if err := common.Unmarshal(body, &payload); err != nil {
return 0, false
}
// 遍历choices查找cached_tokens
for _, choice := range payload.Choices {
if choice.Usage.CachedTokens != nil && *choice.Usage.CachedTokens > 0 {
return *choice.Usage.CachedTokens, true
}
}
return 0, false
}
// extractLlamaCachedTokensFromBody 从llama.cpp的非标准位置提取cache_n
func extractLlamaCachedTokensFromBody(body []byte) (int, bool) {
if len(body) == 0 {
return 0, false
}
var payload struct {
Timings struct {
CachedTokens *int `json:"cache_n"`
} `json:"timings"`
}
if err := common.Unmarshal(body, &payload); err != nil {
return 0, false
}
if payload.Timings.CachedTokens == nil {
return 0, false
}
return *payload.Timings.CachedTokens, true
}
+1 -1
View File
@@ -114,7 +114,7 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
switch info.RelayMode {
case constant.RelayModeImagesGenerations, constant.RelayModeImagesEdits:
usage, err = openai.OpenaiHandlerWithUsage(c, info, resp)
usage, err = openai.OpenaiImageHandler(c, info, resp)
case constant.RelayModeResponses:
if info.IsStream {
usage, err = openai.OaiResponsesStreamHandler(c, info, resp)
+71
View File
@@ -0,0 +1,71 @@
package helper
import (
"bytes"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/QuantumNous/new-api/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// TestGetAndValidOpenAIImageRequestMultipartStream verifies multipart image
// edit parsing: the stream field is parsed and validated, and the request body
// stays replayable for the upstream request.
func TestGetAndValidOpenAIImageRequestMultipartStream(t *testing.T) {
gin.SetMode(gin.TestMode)
newContext := func(t *testing.T, streamValue string, withImage bool) (*gin.Context, string) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
require.NoError(t, writer.WriteField("model", "gpt-image-1"))
require.NoError(t, writer.WriteField("prompt", "edit this image"))
require.NoError(t, writer.WriteField("stream", streamValue))
if withImage {
part, err := writer.CreateFormFile("image", "input.png")
require.NoError(t, err)
_, err = part.Write([]byte("fake image"))
require.NoError(t, err)
}
require.NoError(t, writer.Close())
originalBody := body.String()
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body)
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
return c, originalBody
}
t.Run("valid stream value keeps body replayable", func(t *testing.T) {
c, originalBody := newContext(t, "true", true)
req, err := GetAndValidOpenAIImageRequest(c, relayconstant.RelayModeImagesEdits)
require.NoError(t, err)
require.NotNil(t, req.Stream)
require.True(t, *req.Stream)
require.True(t, req.IsStream(c))
bodyAfterValidation, err := io.ReadAll(c.Request.Body)
require.NoError(t, err)
require.Equal(t, originalBody, string(bodyAfterValidation))
form, err := common.ParseMultipartFormReusable(c)
require.NoError(t, err)
require.Equal(t, "true", url.Values(form.Value).Get("stream"))
require.Len(t, form.File["image"], 1)
})
t.Run("invalid stream value is rejected", func(t *testing.T) {
c, _ := newContext(t, "notabool", false)
_, err := GetAndValidOpenAIImageRequest(c, relayconstant.RelayModeImagesEdits)
require.Error(t, err)
require.Contains(t, err.Error(), "invalid stream value")
})
}
+1 -1
View File
@@ -23,7 +23,7 @@ import (
const (
InitialScannerBufferSize = 64 << 10 // 64KB (64*1024)
DefaultMaxScannerBufferSize = 64 << 20 // 64MB (64*1024*1024) default SSE buffer size
DefaultMaxScannerBufferSize = 128 << 20 // 64MB (64*1024*1024) default SSE buffer size
DefaultPingInterval = 10 * time.Second
)
+2 -2
View File
@@ -631,7 +631,7 @@ func TestStreamScannerHandler_StreamStatus_InitializedIfNil(t *testing.T) {
assert.NotNil(t, info.StreamStatus)
}
func TestStreamScannerHandler_StreamStatus_PreInitialized(t *testing.T) {
func TestStreamScannerHandler_StreamStatus_ReplacesPreInitialized(t *testing.T) {
t.Parallel()
body := buildSSEBody(5)
@@ -643,7 +643,7 @@ func TestStreamScannerHandler_StreamStatus_PreInitialized(t *testing.T) {
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {})
assert.Equal(t, relaycommon.StreamEndReasonDone, info.StreamStatus.EndReason)
assert.Equal(t, 1, info.StreamStatus.TotalErrorCount())
assert.Equal(t, 0, info.StreamStatus.TotalErrorCount())
}
func TestStreamScannerHandler_PingInterleavesWithSlowUpstream(t *testing.T) {
+13 -2
View File
@@ -4,6 +4,8 @@ import (
"errors"
"fmt"
"math"
"net/url"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
@@ -144,16 +146,25 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq
switch relayMode {
case relayconstant.RelayModeImagesEdits:
if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
_, err := c.MultipartForm()
form, err := common.ParseMultipartFormReusable(c)
if err != nil {
return nil, fmt.Errorf("failed to parse image edit form request: %w", err)
}
formData := c.Request.PostForm
formData := url.Values(form.Value)
c.Request.MultipartForm = form
c.Request.PostForm = formData
imageRequest.Prompt = formData.Get("prompt")
imageRequest.Model = formData.Get("model")
imageRequest.N = common.GetPointer(uint(common.String2Int(formData.Get("n"))))
imageRequest.Quality = formData.Get("quality")
imageRequest.Size = formData.Get("size")
if streamValue := strings.TrimSpace(formData.Get("stream")); streamValue != "" {
stream, err := strconv.ParseBool(streamValue)
if err != nil {
return nil, fmt.Errorf("invalid stream value: %w", err)
}
imageRequest.Stream = common.GetPointer(stream)
}
if imageValue := formData.Get("image"); imageValue != "" {
imageRequest.Image, _ = common.Marshal(imageValue)
}
+1 -2
View File
@@ -27,7 +27,6 @@ import {
useEffect,
useState,
} from 'react'
import type { Element } from 'hast'
import { CheckIcon, CopyIcon } from 'lucide-react'
import {
type BundledLanguage,
@@ -53,7 +52,7 @@ const CodeBlockContext = createContext<CodeBlockContextType>({
const lineNumberTransformer: ShikiTransformer = {
name: 'line-numbers',
line(node: Element, line: number) {
line(node, line) {
node.children.unshift({
type: 'element',
tagName: 'span',
+17
View File
@@ -0,0 +1,17 @@
# Data Table Components
This package keeps a stable public API through `index.ts`; feature code should
continue importing from `@/components/data-table`.
- `core/`: TanStack table rendering primitives, headers, rows, pagination,
loading, empty states, and pinned-column behavior.
- `layout/`: responsive page-level composition that combines toolbar, desktop
table, mobile list, bulk actions, and pagination placement.
- `toolbar/`: filter/search/view-option controls and selection action toolbar.
- `static/`: lightweight table rendering for local/static arrays that do not
need TanStack state.
- `hooks/`: table state and filter hooks.
Keep feature-specific columns, actions, and dialogs inside their feature
folders. Shared table code belongs here only when it is reusable across more
than one feature.
@@ -0,0 +1,73 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { cn } from '@/lib/utils'
import type { DataTableColumnClassName, DataTablePinnedColumn } from './types'
export function getResolvedColumnClassName(
getColumnClassName?: DataTableColumnClassName,
pinnedColumns?: DataTablePinnedColumn[]
): DataTableColumnClassName {
return getResolvedColumnClassNameFromMap(
getColumnClassName,
getPinnedColumnMap(pinnedColumns)
)
}
export function getResolvedColumnClassNameFromMap(
getColumnClassName?: DataTableColumnClassName,
pinnedColumnById?: Map<string, DataTablePinnedColumn>
): DataTableColumnClassName {
return (columnId, kind) => {
const customClassName = getColumnClassName?.(columnId, kind)
const pinnedColumn = pinnedColumnById?.get(columnId)
if (!pinnedColumn) return customClassName
return cn(customClassName, getPinnedColumnClassName(pinnedColumn, kind))
}
}
export function getPinnedColumnMap(pinnedColumns?: DataTablePinnedColumn[]) {
if (!pinnedColumns?.length) return undefined
return new Map(pinnedColumns.map((column) => [column.columnId, column]))
}
function getPinnedColumnClassName(
pinnedColumn: DataTablePinnedColumn,
kind: 'header' | 'cell'
) {
const edgeClassName =
pinnedColumn.side === 'left'
? 'shadow-[8px_0_10px_-10px_hsl(var(--foreground))]'
: 'shadow-[-8px_0_10px_-10px_hsl(var(--foreground))]'
return cn(
'sticky whitespace-nowrap',
pinnedColumn.side === 'left' ? 'left-0' : 'right-0',
edgeClassName,
kind === 'header'
? 'bg-background z-30'
: 'bg-background z-10 group-hover:bg-muted group-data-[state=selected]:bg-muted',
pinnedColumn.className,
kind === 'header'
? pinnedColumn.headerClassName
: pinnedColumn.cellClassName
)
}
@@ -0,0 +1,33 @@
/*
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 { Table as TanstackTable } from '@tanstack/react-table'
export function DataTableColgroup<TData>({
table,
}: {
table: TanstackTable<TData>
}) {
return (
<colgroup>
{table.getVisibleLeafColumns().map((column) => (
<col key={column.id} style={{ width: column.getSize() }} />
))}
</colgroup>
)
}
@@ -0,0 +1,61 @@
/*
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 { flexRender, type Table as TanstackTable } from '@tanstack/react-table'
import { TableHead, TableHeader, TableRow } from '@/components/ui/table'
import type { DataTableColumnClassName } from './types'
type DataTableHeaderProps<TData> = {
table: TanstackTable<TData>
applyHeaderSize?: boolean
className?: string
rowClassName?: string
getColumnClassName?: DataTableColumnClassName
}
export function DataTableHeader<TData>({
table,
applyHeaderSize,
className,
rowClassName,
getColumnClassName,
}: DataTableHeaderProps<TData>) {
return (
<TableHeader className={className}>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className={rowClassName}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
colSpan={header.colSpan}
className={getColumnClassName?.(header.column.id, 'header')}
style={applyHeaderSize ? { width: header.getSize() } : undefined}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
)
}
@@ -0,0 +1,52 @@
/*
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 * as React from 'react'
import { flexRender, type Row } from '@tanstack/react-table'
import { TableCell, TableRow } from '@/components/ui/table'
import type { DataTableColumnClassName } from './types'
type DataTableRowProps<TData> = {
row: Row<TData>
className?: string
getColumnClassName?: DataTableColumnClassName
} & Omit<React.ComponentProps<typeof TableRow>, 'children'>
export function DataTableRow<TData>({
row,
className,
getColumnClassName,
...rowProps
}: DataTableRowProps<TData>) {
return (
<TableRow
data-state={row.getIsSelected() ? 'selected' : undefined}
className={className}
{...rowProps}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={getColumnClassName?.(cell.column.id, 'cell')}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}
@@ -0,0 +1,310 @@
/*
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 * as React from 'react'
import { type Row } from '@tanstack/react-table'
import { cn } from '@/lib/utils'
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table'
import {
getPinnedColumnMap,
getResolvedColumnClassNameFromMap,
} from './column-pinning'
import { DataTableColgroup } from './data-table-colgroup'
import { DataTableHeader } from './data-table-header'
import { DataTableRow } from './data-table-row'
import { TableEmpty } from './table-empty'
import { getTableSizeStyle } from './table-sizing'
import { TableSkeleton } from './table-skeleton'
import type {
DataTableColumnClassName,
DataTablePinnedColumn,
DataTableViewProps,
} from './types'
export type {
DataTableColumnClassName,
DataTablePinnedColumn,
DataTableRenderRowHelpers,
DataTableViewProps,
} from './types'
export { DataTableRow } from './data-table-row'
export function DataTableView<TData>(props: DataTableViewProps<TData>) {
const rows = props.rows ?? props.table.getRowModel().rows
const colSpan = props.table.getVisibleLeafColumns().length
const columnClassName = useResolvedColumnClassName(
props.getColumnClassName,
props.pinnedColumns
)
return (
<div
className={cn(
'overflow-hidden rounded-lg border',
props.containerClassName
)}
{...props.containerProps}
>
{props.splitHeader ? (
<SplitHeaderTableView
props={props}
rows={rows}
colSpan={colSpan}
getColumnClassName={columnClassName}
/>
) : (
<UnifiedTableView
props={props}
rows={rows}
colSpan={colSpan}
getColumnClassName={columnClassName}
/>
)}
</div>
)
}
function UnifiedTableView<TData>({
props,
rows,
colSpan,
getColumnClassName,
}: {
props: DataTableViewProps<TData>
rows: Row<TData>[]
colSpan: number
getColumnClassName: DataTableColumnClassName
}) {
const tableSizing = getTableSizing(props)
return (
<div className={props.tableContainerClassName}>
<Table className={props.tableClassName} style={tableSizing.style}>
{tableSizing.colgroup}
<DataTableHeader
table={props.table}
applyHeaderSize={props.applyHeaderSize}
className={props.tableHeaderClassName}
rowClassName={props.tableHeaderRowClassName}
getColumnClassName={getColumnClassName}
/>
{renderTableBody(props, rows, colSpan, getColumnClassName)}
</Table>
</div>
)
}
function SplitHeaderTableView<TData>({
props,
rows,
colSpan,
getColumnClassName,
}: {
props: DataTableViewProps<TData>
rows: Row<TData>[]
colSpan: number
getColumnClassName: DataTableColumnClassName
}) {
const headerHostRef = React.useRef<HTMLDivElement>(null)
const bodyHostRef = React.useRef<HTMLDivElement>(null)
const tableSizing = getTableSizing(props)
React.useEffect(() => {
const headerScroller = headerHostRef.current?.querySelector<HTMLElement>(
'[data-slot=table-container]'
)
const bodyScroller = bodyHostRef.current?.querySelector<HTMLElement>(
'[data-slot=table-container]'
)
if (!headerScroller || !bodyScroller) return
const syncHeaderScroll = () => {
headerScroller.scrollLeft = bodyScroller.scrollLeft
}
syncHeaderScroll()
bodyScroller.addEventListener('scroll', syncHeaderScroll, { passive: true })
return () => {
bodyScroller.removeEventListener('scroll', syncHeaderScroll)
}
}, [rows.length, props.tableClassName, props.colgroup])
return (
<div
className={cn(
'flex h-full min-h-0 flex-col',
props.tableContainerClassName
)}
>
<div
className={cn(
'flex min-h-0 flex-1 flex-col overflow-hidden',
props.splitHeaderScrollClassName
)}
>
<div
ref={headerHostRef}
className='[scrollbar-gutter:stable] overflow-hidden [&_[data-slot=table-container]]:overflow-x-hidden'
>
<Table className={props.tableClassName} style={tableSizing.style}>
{tableSizing.colgroup}
<DataTableHeader
table={props.table}
applyHeaderSize={props.applyHeaderSize}
className={props.tableHeaderClassName}
rowClassName={props.tableHeaderRowClassName}
getColumnClassName={getColumnClassName}
/>
</Table>
</div>
<div
ref={bodyHostRef}
className={cn(
'min-h-0 flex-1 [scrollbar-gutter:stable] overflow-y-auto',
props.bodyContainerClassName
)}
>
<Table className={props.tableClassName} style={tableSizing.style}>
{tableSizing.colgroup}
{renderTableBody(props, rows, colSpan, getColumnClassName)}
</Table>
</div>
</div>
</div>
)
}
function useResolvedColumnClassName(
getColumnClassName?: DataTableColumnClassName,
pinnedColumns?: DataTablePinnedColumn[]
) {
const pinnedColumnById = React.useMemo(
() => getPinnedColumnMap(pinnedColumns),
[pinnedColumns]
)
return React.useMemo(
() =>
getResolvedColumnClassNameFromMap(getColumnClassName, pinnedColumnById),
[getColumnClassName, pinnedColumnById]
)
}
function getTableSizing<TData>(props: DataTableViewProps<TData>): {
colgroup?: React.ReactNode
style?: React.CSSProperties
} {
if (props.colgroup) {
return { colgroup: props.colgroup }
}
if (!props.splitHeader && !props.applyHeaderSize) {
return {}
}
return {
colgroup: <DataTableColgroup table={props.table} />,
style: getTableSizeStyle(props.table),
}
}
function renderTableBody<TData>(
props: DataTableViewProps<TData>,
rows: Row<TData>[],
colSpan: number,
getColumnClassName: DataTableColumnClassName
) {
return (
<TableBody className={props.tableBodyClassName}>
{renderTableBodyContent(props, rows, colSpan, getColumnClassName)}
</TableBody>
)
}
function renderTableBodyContent<TData>(
props: DataTableViewProps<TData>,
rows: Row<TData>[],
colSpan: number,
getColumnClassName: DataTableColumnClassName
) {
if (props.isLoading) {
return (
<TableSkeleton
table={props.table}
keyPrefix={props.skeletonKeyPrefix}
rowHeight={props.skeletonRowHeight}
/>
)
}
if (rows.length === 0) {
return renderEmptyState(props, colSpan)
}
return rows.map((row) =>
props.renderRow
? props.renderRow(row, {
getCellClassName: (columnId, className) =>
cn(getColumnClassName(columnId, 'cell'), className),
})
: renderDefaultRow(props, row, getColumnClassName)
)
}
function renderEmptyState<TData>(
props: DataTableViewProps<TData>,
colSpan: number
) {
if (props.emptyContent) {
return (
<TableRow>
<TableCell colSpan={colSpan} className={props.emptyCellClassName}>
{props.emptyContent}
</TableCell>
</TableRow>
)
}
return (
<TableEmpty
colSpan={colSpan}
title={props.emptyTitle}
description={props.emptyDescription}
icon={props.emptyIcon}
>
{props.emptyAction}
</TableEmpty>
)
}
function renderDefaultRow<TData>(
props: DataTableViewProps<TData>,
row: Row<TData>,
getColumnClassName: DataTableColumnClassName
) {
return (
<DataTableRow
key={row.id}
row={row}
className={cn(props.tableBodyRowClassName, props.getRowClassName?.(row))}
getColumnClassName={getColumnClassName}
/>
)
}
@@ -39,48 +39,55 @@ type DataTablePaginationProps<TData> = {
table: Table<TData>
}
const PAGE_SIZE_OPTIONS = [10, 20, 30, 40, 50, 100] as const
const PAGE_SIZE_SELECT_ITEMS = PAGE_SIZE_OPTIONS.map((pageSize) => ({
value: `${pageSize}`,
label: pageSize,
}))
export function DataTablePagination<TData>({
table,
}: DataTablePaginationProps<TData>) {
const { t } = useTranslation()
const currentPage = table.getState().pagination.pageIndex + 1
const pagination = table.getState().pagination
const currentPage = pagination.pageIndex + 1
const pageSize = pagination.pageSize
const totalPages = table.getPageCount()
const totalRows = table.getRowCount()
const pageNumbers = getPageNumbers(currentPage, totalPages)
return (
<div
className={cn(
'flex items-center justify-between overflow-clip',
'@max-2xl/content:flex-col-reverse @max-2xl/content:gap-2 sm:@max-2xl/content:gap-4'
'@container/pagination flex min-w-0 items-center justify-end overflow-clip'
)}
style={{ overflowClipMargin: 1 }}
>
<div className='flex w-full items-center justify-between gap-2'>
<div className='flex min-w-0 items-center text-xs font-medium whitespace-nowrap sm:min-w-[130px] sm:text-sm @2xl/content:hidden'>
{t('Page {{current}} of {{total}}', {
current: currentPage,
total: totalPages,
})}
<div className='flex min-w-0 shrink-0 items-center gap-2 @xl/pagination:gap-3'>
<div className='flex shrink-0 items-baseline gap-1.5 text-xs font-medium whitespace-nowrap sm:text-sm'>
<span className='text-muted-foreground/80'>{t('Total:')}</span>
<span className='text-foreground tabular-nums'>
{totalRows.toLocaleString()}
</span>
</div>
<div className='flex items-center gap-2 @max-2xl/content:flex-row-reverse'>
<div className='flex shrink-0 items-center gap-1.5 @lg/pagination:gap-2'>
<p className='text-muted-foreground/80 hidden text-sm font-medium whitespace-nowrap @2xl/pagination:block'>
{t('Rows per page')}
</p>
<Select
items={[
...[10, 20, 30, 40, 50, 100].map((pageSize) => ({
value: `${pageSize}`,
label: pageSize,
})),
]}
value={`${table.getState().pagination.pageSize}`}
items={PAGE_SIZE_SELECT_ITEMS}
value={`${pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className='h-8 w-[64px] sm:w-[70px]'>
<SelectValue placeholder={table.getState().pagination.pageSize} />
<SelectTrigger className='text-foreground h-8 w-[64px] font-medium tabular-nums sm:w-[70px]'>
<SelectValue placeholder={pageSize} />
</SelectTrigger>
<SelectContent side='top' alignItemWithTrigger={false}>
<SelectGroup>
{[10, 20, 30, 40, 50, 100].map((pageSize) => (
{PAGE_SIZE_OPTIONS.map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
@@ -88,23 +95,12 @@ export function DataTablePagination<TData>({
</SelectGroup>
</SelectContent>
</Select>
<p className='hidden text-sm font-medium sm:block'>
{t('Rows per page')}
</p>
</div>
</div>
<div className='flex items-center sm:space-x-6 lg:space-x-8'>
<div className='flex min-w-[130px] items-center text-sm font-medium whitespace-nowrap @max-3xl/content:hidden'>
{t('Page {{current}} of {{total}}', {
current: currentPage,
total: totalPages,
})}
</div>
<div className='flex items-center space-x-1.5 sm:space-x-2'>
<div className='flex min-w-0 shrink-0 items-center gap-1 @lg/pagination:gap-1.5 @xl/pagination:gap-2'>
<Button
variant='outline'
className='size-8 p-0 @max-md/content:hidden'
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0 @max-lg/pagination:hidden'
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
@@ -113,7 +109,7 @@ export function DataTablePagination<TData>({
</Button>
<Button
variant='outline'
className='size-8 p-0'
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0'
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
@@ -121,18 +117,26 @@ export function DataTablePagination<TData>({
<ChevronLeftIcon className='h-4 w-4' />
</Button>
{/* Page number buttons */}
{pageNumbers.map((pageNumber, index) => (
<div key={`${pageNumber}-${index}`} className='flex items-center'>
{pageNumber === '...' ? (
<span className='text-muted-foreground px-1 text-sm'>...</span>
<span className='text-muted-foreground/60 px-0.5 text-sm @lg/pagination:px-1'>
...
</span>
) : (
<Button
variant={currentPage === pageNumber ? 'default' : 'outline'}
className='h-8 min-w-8 px-2'
className={cn(
'h-8 min-w-8 px-2 tabular-nums',
currentPage === pageNumber
? 'font-semibold'
: 'text-muted-foreground hover:text-foreground'
)}
onClick={() => table.setPageIndex((pageNumber as number) - 1)}
>
<span className='sr-only'>Go to page {pageNumber}</span>
<span className='sr-only'>
{t('Go to page {{page}}', { page: pageNumber })}
</span>
{pageNumber}
</Button>
)}
@@ -141,7 +145,7 @@ export function DataTablePagination<TData>({
<Button
variant='outline'
className='size-8 p-0'
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0'
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
@@ -150,7 +154,7 @@ export function DataTablePagination<TData>({
</Button>
<Button
variant='outline'
className='size-8 p-0 @max-md/content:hidden'
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0 @max-lg/pagination:hidden'
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
@@ -0,0 +1,30 @@
/*
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 * as React from 'react'
import type { Table as TanstackTable } from '@tanstack/react-table'
export function getTableSizeStyle<TData>(
table: TanstackTable<TData>
): React.CSSProperties {
const width = table
.getVisibleLeafColumns()
.reduce((total, column) => total + column.getSize(), 0)
return { minWidth: width, tableLayout: 'fixed', width: '100%' }
}
+71
View File
@@ -0,0 +1,71 @@
/*
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 * as React from 'react'
import type { Row, Table as TanstackTable } from '@tanstack/react-table'
export type DataTableColumnClassName = (
columnId: string,
kind: 'header' | 'cell'
) => string | undefined
export type DataTablePinnedColumn = {
columnId: string
side: 'left' | 'right'
className?: string
headerClassName?: string
cellClassName?: string
}
export type DataTableRenderRowHelpers = {
getCellClassName: (columnId: string, className?: string) => string | undefined
}
export type DataTableViewProps<TData> = {
table: TanstackTable<TData>
isLoading?: boolean
rows?: Row<TData>[]
emptyTitle?: string
emptyDescription?: string
emptyIcon?: React.ReactNode
emptyAction?: React.ReactNode
emptyContent?: React.ReactNode
emptyCellClassName?: string
skeletonKeyPrefix?: string
skeletonRowHeight?: string
renderRow?: (
row: Row<TData>,
helpers: DataTableRenderRowHelpers
) => React.ReactNode
getRowClassName?: (row: Row<TData>) => string | undefined
getColumnClassName?: DataTableColumnClassName
pinnedColumns?: DataTablePinnedColumn[]
applyHeaderSize?: boolean
tableClassName?: string
tableHeaderClassName?: string
tableHeaderRowClassName?: string
tableBodyClassName?: string
tableBodyRowClassName?: string
splitHeader?: boolean
splitHeaderScrollClassName?: string
bodyContainerClassName?: string
containerClassName?: string
containerProps?: Omit<React.ComponentProps<'div'>, 'className' | 'children'>
tableContainerClassName?: string
colgroup?: React.ReactNode
}
@@ -0,0 +1,234 @@
/*
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 * as React from 'react'
import {
type ColumnDef,
type ColumnFiltersState,
type ExpandedState,
type OnChangeFn,
type PaginationState,
type RowSelectionState,
type SortingState,
type TableOptions,
type Updater,
type VisibilityState,
getCoreRowModel,
getExpandedRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table'
type DataTableFeatureOptions<TData> = Pick<
TableOptions<TData>,
| 'enableRowSelection'
| 'getRowId'
| 'getSubRows'
| 'globalFilterFn'
| 'autoResetPageIndex'
| 'manualFiltering'
| 'manualPagination'
| 'manualSorting'
>
type DataTableStateOptions = {
initialSorting?: SortingState
sorting?: SortingState
onSortingChange?: OnChangeFn<SortingState>
initialColumnVisibility?: VisibilityState
columnVisibility?: VisibilityState
onColumnVisibilityChange?: OnChangeFn<VisibilityState>
initialRowSelection?: RowSelectionState
rowSelection?: RowSelectionState
onRowSelectionChange?: OnChangeFn<RowSelectionState>
initialExpanded?: ExpandedState
expanded?: ExpandedState
onExpandedChange?: OnChangeFn<ExpandedState>
columnFilters?: ColumnFiltersState
onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>
globalFilter?: string
onGlobalFilterChange?: OnChangeFn<string>
initialPagination?: PaginationState
pagination?: PaginationState
onPaginationChange?: OnChangeFn<PaginationState>
}
type DataTableRowModelOptions = {
withFilteredRowModel?: boolean
withPaginationRowModel?: boolean
withSortedRowModel?: boolean
withFacetedRowModel?: boolean
withExpandedRowModel?: boolean
}
type UseDataTableOptions<TData> = DataTableFeatureOptions<TData> &
DataTableStateOptions &
DataTableRowModelOptions & {
data: TData[]
columns: ColumnDef<TData, unknown>[]
totalCount?: number
pageCount?: number
ensurePageInRange?: (pageCount: number) => void
}
function resolveUpdater<TValue>(
updater: Updater<TValue>,
previous: TValue
): TValue {
return typeof updater === 'function'
? (updater as (old: TValue) => TValue)(previous)
: updater
}
function useControllableTableState<TValue>(
controlledValue: TValue | undefined,
defaultValue: TValue,
onChange: OnChangeFn<TValue> | undefined
): [TValue, OnChangeFn<TValue>] {
const [uncontrolledValue, setUncontrolledValue] =
React.useState<TValue>(defaultValue)
const value = controlledValue ?? uncontrolledValue
const setValue = React.useCallback<OnChangeFn<TValue>>(
(updater) => {
if (controlledValue === undefined) {
setUncontrolledValue((previous) => resolveUpdater(updater, previous))
}
onChange?.(updater)
},
[controlledValue, onChange]
)
return [value, setValue]
}
export function useDataTable<TData>(options: UseDataTableOptions<TData>) {
const {
data,
columns,
totalCount,
pageCount: explicitPageCount,
ensurePageInRange,
manualFiltering,
manualPagination,
manualSorting,
initialSorting = [],
initialColumnVisibility = {},
initialRowSelection = {},
initialExpanded = {},
initialPagination = { pageIndex: 0, pageSize: 20 },
withFilteredRowModel = !manualFiltering,
withPaginationRowModel = !manualPagination,
withSortedRowModel = !manualSorting,
withFacetedRowModel = !manualFiltering,
withExpandedRowModel = false,
} = options
const [sorting, onSortingChange] = useControllableTableState(
options.sorting,
initialSorting,
options.onSortingChange
)
const [columnVisibility, onColumnVisibilityChange] =
useControllableTableState(
options.columnVisibility,
initialColumnVisibility,
options.onColumnVisibilityChange
)
const [rowSelection, onRowSelectionChange] = useControllableTableState(
options.rowSelection,
initialRowSelection,
options.onRowSelectionChange
)
const [expanded, onExpandedChange] = useControllableTableState(
options.expanded,
initialExpanded,
options.onExpandedChange
)
const [pagination, onPaginationChange] = useControllableTableState(
options.pagination,
initialPagination,
options.onPaginationChange
)
const resolvedPageCount =
explicitPageCount ??
(totalCount !== undefined
? Math.ceil(totalCount / pagination.pageSize)
: undefined)
const table = useReactTable({
data,
columns,
rowCount: totalCount,
pageCount: resolvedPageCount,
state: {
sorting,
columnVisibility,
rowSelection,
expanded,
columnFilters: options.columnFilters,
globalFilter: options.globalFilter,
pagination,
},
enableRowSelection: options.enableRowSelection,
getRowId: options.getRowId,
getSubRows: options.getSubRows,
globalFilterFn: options.globalFilterFn,
autoResetPageIndex: options.autoResetPageIndex,
manualFiltering,
manualPagination,
manualSorting,
onSortingChange,
onColumnVisibilityChange,
onRowSelectionChange,
onExpandedChange,
onColumnFiltersChange: options.onColumnFiltersChange,
onGlobalFilterChange: options.onGlobalFilterChange,
onPaginationChange,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: withFilteredRowModel
? getFilteredRowModel()
: undefined,
getPaginationRowModel: withPaginationRowModel
? getPaginationRowModel()
: undefined,
getSortedRowModel: withSortedRowModel ? getSortedRowModel() : undefined,
getFacetedRowModel: withFacetedRowModel ? getFacetedRowModel() : undefined,
getFacetedUniqueValues: withFacetedRowModel
? getFacetedUniqueValues()
: undefined,
getExpandedRowModel: withExpandedRowModel
? getExpandedRowModel()
: undefined,
})
const actualPageCount = table.getPageCount()
React.useEffect(() => {
ensurePageInRange?.(actualPageCount)
}, [actualPageCount, ensurePageInRange])
return {
table,
}
}
@@ -0,0 +1,110 @@
/*
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 * as React from 'react'
import type { ColumnFiltersState, OnChangeFn } from '@tanstack/react-table'
import { useDebounce } from '@/hooks/use-debounce'
type UseDebouncedColumnFilterOptions = {
columnFilters: ColumnFiltersState
columnId: string
onColumnFiltersChange: OnChangeFn<ColumnFiltersState>
delay?: number
}
export function useDebouncedColumnFilter({
columnFilters,
columnId,
onColumnFiltersChange,
delay = 500,
}: UseDebouncedColumnFilterOptions) {
const value =
(columnFilters.find((filter) => filter.id === columnId)?.value as
| string
| undefined) ?? ''
const [inputValue, setInputValue] = React.useState(value)
const [pendingValue, setPendingValue] = React.useState(value)
const isComposingRef = React.useRef(false)
const debouncedValue = useDebounce(pendingValue, delay)
React.useEffect(() => {
// Keep the input aligned when URL state changes outside the local field.
if (!isComposingRef.current) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setInputValue(value)
}
// eslint-disable-next-line react-hooks/set-state-in-effect
setPendingValue(value)
}, [value])
React.useEffect(() => {
if (debouncedValue === value) return
onColumnFiltersChange((previous) => {
const filters = previous.filter((filter) => filter.id !== columnId)
return debouncedValue
? [...filters, { id: columnId, value: debouncedValue }]
: filters
})
}, [columnId, debouncedValue, onColumnFiltersChange, value])
const updateInputValue = React.useCallback((nextValue: string) => {
setInputValue(nextValue)
if (!isComposingRef.current) {
setPendingValue(nextValue)
}
}, [])
const handleChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
updateInputValue(event.target.value)
},
[updateInputValue]
)
const handleCompositionStart = React.useCallback(() => {
isComposingRef.current = true
}, [])
const handleCompositionEnd = React.useCallback(
(event: React.CompositionEvent<HTMLInputElement>) => {
isComposingRef.current = false
const nextValue = event.currentTarget.value
setInputValue(nextValue)
setPendingValue(nextValue)
},
[]
)
const resetInput = React.useCallback(() => {
isComposingRef.current = false
setInputValue('')
setPendingValue('')
}, [])
return {
value,
inputValue,
setInputValue: updateInputValue,
onChange: handleChange,
onCompositionStart: handleCompositionStart,
onCompositionEnd: handleCompositionEnd,
resetInput,
}
}
+24 -10
View File
@@ -16,16 +16,30 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
export { DataTablePagination } from './pagination'
export { DataTableColumnHeader } from './column-header'
export { DataTableFacetedFilter } from './faceted-filter'
export { DataTableViewOptions } from './view-options'
export { DataTableToolbar } from './toolbar'
export { DataTableBulkActions } from './bulk-actions'
export { TableSkeleton } from './table-skeleton'
export { TableEmpty } from './table-empty'
export { MobileCardList } from './mobile-card-list'
export { DataTablePage, type DataTablePageProps } from './data-table-page'
export { DataTablePagination } from './core/pagination'
export { DataTableColumnHeader } from './core/column-header'
export { DataTableViewOptions } from './toolbar/view-options'
export { DataTableToolbar } from './toolbar/toolbar'
export { DataTableBulkActions } from './toolbar/bulk-actions'
export {
StaticDataTable,
type StaticDataTableColumn,
} from './static/static-data-table'
export { staticDataTableClassNames } from './static/static-data-table-classnames'
export {
DataTableRow,
DataTableView,
type DataTableColumnClassName,
type DataTablePinnedColumn,
type DataTableRenderRowHelpers,
} from './core/data-table-view'
export { MobileCardList } from './layout/mobile-card-list'
export {
DataTablePage,
type DataTablePageProps,
} from './layout/data-table-page'
export { useDataTable } from './hooks/use-data-table'
export { useDebouncedColumnFilter } from './hooks/use-debounced-column-filter'
export const DISABLED_ROW_DESKTOP =
'bg-muted/85 hover:bg-muted [&>td:first-child]:border-l-muted-foreground/35 [&>td:first-child]:border-l-4 [&>td:first-child]:pl-1'
@@ -18,27 +18,22 @@ For commercial licensing, please contact support@quantumnous.com
*/
import * as React from 'react'
import {
flexRender,
type ColumnDef,
type Row,
type Table as TanstackTable,
} from '@tanstack/react-table'
import { useMediaQuery } from '@/hooks'
import { cn } from '@/lib/utils'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { PageFooterPortal } from '@/components/layout'
import {
DataTableView,
type DataTableColumnClassName,
type DataTablePinnedColumn,
type DataTableRenderRowHelpers,
} from '../core/data-table-view'
import { DataTablePagination } from '../core/pagination'
import { DataTableToolbar } from '../toolbar/toolbar'
import { MobileCardList } from './mobile-card-list'
import { DataTablePagination } from './pagination'
import { TableEmpty } from './table-empty'
import { TableSkeleton } from './table-skeleton'
import { DataTableToolbar } from './toolbar'
/**
* Pass-through configuration for the default {@link DataTableToolbar}.
@@ -145,7 +140,22 @@ export type DataTablePageProps<TData> = {
* Custom desktop row renderer replaces the default `<TableRow>`/`<TableCell>` mapping.
* Use for expanded rows, aggregate rows, click-on-row navigation, etc.
*/
renderRow?: (row: Row<TData>) => React.ReactNode
renderRow?: (
row: Row<TData>,
helpers: DataTableRenderRowHelpers
) => React.ReactNode
/**
* Desktop column className resolver. Use for semantic alignment/spacing only;
* fixed-column behavior should be configured with `pinnedColumns`.
*/
getColumnClassName?: DataTableColumnClassName
/**
* Fixed desktop columns. The shared table component owns sticky position,
* layering, shadows, and row-state backgrounds.
*/
pinnedColumns?: DataTablePinnedColumn[]
/**
* Apply explicit column widths from `header.getSize()` to `<TableHead>`.
@@ -182,6 +192,12 @@ export type DataTablePageProps<TData> = {
*/
className?: string
/**
* Make the desktop table consume the available page height and scroll inside
* the table body while keeping the header fixed. Defaults to `true`.
*/
fixedHeight?: boolean
/**
* Desktop table container className (the bordered scroll wrapper).
*/
@@ -189,7 +205,8 @@ export type DataTablePageProps<TData> = {
/**
* Desktop `<TableHeader>` className override.
* Useful for sticky headers (`'sticky top-0 z-10 bg-muted/30'`) on long lists.
* Use for header color/spacing overrides. Fixed-height pages keep the header
* outside the scrollable body automatically.
*/
tableHeaderClassName?: string
}
@@ -222,10 +239,18 @@ export function DataTablePage<TData>(props: DataTablePageProps<TData>) {
const toolbarNode = renderToolbar(props)
const mobileNode = renderMobile(props, showMobile)
const desktopNode = renderDesktop(props, showMobile)
const paginationNode = renderPagination(props)
return (
<>
<div className={cn('space-y-2.5 sm:space-y-3', props.className)}>
<div
className={cn(
props.fixedHeight !== false
? 'flex h-full min-h-0 flex-col gap-2.5 sm:gap-3'
: 'space-y-2.5 sm:space-y-3',
props.className
)}
>
{toolbarNode}
{mobileNode}
{desktopNode}
@@ -236,16 +261,7 @@ export function DataTablePage<TData>(props: DataTablePageProps<TData>) {
handle its own visibility, we just gate it to non-mobile. */}
{!showMobile && props.bulkActions}
{props.showPagination !== false &&
(props.paginationInFooter !== false ? (
<PageFooterPortal>
<DataTablePagination table={props.table} />
</PageFooterPortal>
) : (
<div className='pt-2'>
<DataTablePagination table={props.table} />
</div>
))}
{paginationNode}
</>
)
}
@@ -265,12 +281,25 @@ function renderToolbar<TData>(
return null
}
function renderPagination<TData>(
props: DataTablePageProps<TData>
): React.ReactNode {
if (props.showPagination === false) return null
const pagination = <DataTablePagination table={props.table} />
return props.paginationInFooter !== false ? (
<PageFooterPortal>{pagination}</PageFooterPortal>
) : (
<div className='pt-2'>{pagination}</div>
)
}
function renderMobile<TData>(
props: DataTablePageProps<TData>,
showMobile: boolean
): React.ReactNode {
if (!showMobile) return null
if (props.mobile !== undefined) return props.mobile
const ownGetRowClassName = props.getRowClassName
const mobileGetRowClassName =
@@ -278,8 +307,7 @@ function renderMobile<TData>(
(ownGetRowClassName
? (row: Row<TData>) => ownGetRowClassName(row, { isMobile: true })
: undefined)
return (
const mobileContent = props.mobile ?? (
<MobileCardList
table={props.table}
isLoading={props.isLoading}
@@ -289,6 +317,8 @@ function renderMobile<TData>(
getRowClassName={mobileGetRowClassName}
/>
)
return <div className='min-h-0 flex-1 overflow-y-auto'>{mobileContent}</div>
}
function renderDesktop<TData>(
@@ -297,94 +327,37 @@ function renderDesktop<TData>(
): React.ReactNode {
if (showMobile) return null
const rows = props.table.getRowModel().rows
const isFetchingOnly = props.isFetching && !props.isLoading
const fixedHeight = props.fixedHeight !== false
return (
<div
className={cn(
'overflow-hidden rounded-lg border transition-opacity duration-150',
<DataTableView
table={props.table}
isLoading={props.isLoading}
emptyTitle={props.emptyTitle}
emptyDescription={props.emptyDescription}
emptyIcon={props.emptyIcon}
emptyAction={props.emptyAction}
skeletonKeyPrefix={props.skeletonKeyPrefix}
renderRow={props.renderRow}
applyHeaderSize={props.applyHeaderSize}
splitHeader={fixedHeight}
tableContainerClassName={fixedHeight ? 'h-full min-h-0' : undefined}
tableHeaderClassName={cn(
fixedHeight && 'bg-muted/30',
props.tableHeaderClassName
)}
getColumnClassName={props.getColumnClassName}
pinnedColumns={props.pinnedColumns}
containerClassName={cn(
fixedHeight && 'min-h-0 flex-1',
'transition-opacity duration-150',
isFetchingOnly && 'pointer-events-none opacity-60',
props.tableClassName
)}
>
<Table>
<TableHeader className={props.tableHeaderClassName}>
{props.table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
colSpan={header.colSpan}
style={
props.applyHeaderSize
? { width: header.getSize() }
: undefined
getRowClassName={(row) =>
props.getRowClassName?.(row, { isMobile: false })
}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{props.isLoading ? (
<TableSkeleton
table={props.table}
keyPrefix={props.skeletonKeyPrefix}
/>
) : rows.length === 0 ? (
<TableEmpty
colSpan={props.columns.length}
title={props.emptyTitle}
description={props.emptyDescription}
icon={props.emptyIcon}
>
{props.emptyAction}
</TableEmpty>
) : (
rows.map((row) => {
if (props.renderRow) {
return props.renderRow(row)
}
return (
<DefaultRow
key={row.id}
row={row}
className={props.getRowClassName?.(row, { isMobile: false })}
/>
)
})
)}
</TableBody>
</Table>
</div>
)
}
function DefaultRow<TData>({
row,
className,
}: {
row: Row<TData>
className?: string
}) {
return (
<TableRow
data-state={row.getIsSelected() && 'selected'}
className={className}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}
@@ -0,0 +1,46 @@
/*
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
*/
export const staticDataTableClassNames = {
container: 'overflow-hidden rounded-md border',
sectionContainer: 'border-border/60 rounded-lg',
embeddedContainer: 'rounded-none border-0',
compactTable: 'text-sm',
compactHeaderRow: 'hover:bg-transparent',
mutedHeaderRow: 'bg-muted/30 hover:bg-muted/30',
compactHeaderCell:
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase',
compactHeaderCellRight:
'text-muted-foreground py-2 text-right text-[10px] font-medium tracking-wider uppercase',
compactCell: 'py-2.5',
compactTopCell: 'py-2.5 align-top',
compactTopNumericCell: 'py-2.5 text-right align-top font-mono',
compactMutedCell: 'text-muted-foreground py-2.5',
compactMutedCodeCell: 'text-muted-foreground py-2.5 font-mono',
compactNumericCell: 'py-2.5 text-right font-mono',
compactMutedNumericCell: 'text-muted-foreground py-2.5 text-right font-mono',
topCell: 'py-2 align-top',
topMutedCell: 'text-muted-foreground py-2 align-top',
codeCell: 'font-mono text-sm',
mutedCell: 'text-muted-foreground text-sm',
mutedCodeCell: 'text-muted-foreground font-mono text-sm',
topNumericCell: 'py-2 text-right font-mono',
mediumCell: 'font-medium',
actionHeaderCell: 'text-right',
actionCell: 'text-right',
} as const
@@ -0,0 +1,206 @@
/*
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 * as React from 'react'
import { cn } from '@/lib/utils'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { staticDataTableClassNames } from './static-data-table-classnames'
type StaticDataTableBaseProps = {
className?: string
tableClassName?: string
containerProps?: Omit<React.ComponentProps<'div'>, 'className' | 'children'>
tableProps?: Omit<
React.ComponentProps<typeof Table>,
'className' | 'children'
>
}
type StaticDataTableDataProps<TData = unknown> = StaticDataTableBaseProps & {
columns: StaticDataTableColumn<TData>[]
data: TData[]
getRowKey?: (row: TData, index: number) => React.Key
getRowClassName?: (row: TData, index: number) => string | undefined
renderRow?: (row: TData, index: number) => React.ReactNode
empty?: boolean
emptyContent?: React.ReactNode
emptyClassName?: string
headerRowClassName?: string
}
type StaticDataTableChildrenProps = StaticDataTableBaseProps & {
children: React.ReactNode
columns?: never
data?: never
}
type StaticDataTableProps<TData = unknown> =
| StaticDataTableDataProps<TData>
| StaticDataTableChildrenProps
export type StaticDataTableColumn<TData = unknown> = {
id: string
header: React.ReactNode
className?: string
cellClassName?: string | ((row: TData, index: number) => string | undefined)
cell?: (row: TData, index: number) => React.ReactNode
}
export function StaticDataTable<TData = unknown>(
props: StaticDataTableProps<TData>
) {
const { className, tableClassName, containerProps, tableProps } = props
return (
<div
className={cn(staticDataTableClassNames.container, className)}
{...containerProps}
>
<Table className={tableClassName} {...tableProps}>
{props.columns !== undefined ? (
<StaticDataTableWithColumns {...props} />
) : (
props.children
)}
</Table>
</div>
)
}
function StaticDataTableWithColumns<TData>({
columns,
data,
getRowKey,
getRowClassName,
renderRow,
empty,
emptyContent,
emptyClassName,
headerRowClassName,
}: StaticDataTableDataProps<TData>) {
const isEmpty = empty ?? (data !== undefined && data.length === 0)
const bodyRows = data.map((row, index) => (
<StaticDataTableRow
key={getRowKey?.(row, index) ?? index}
row={row}
index={index}
columns={columns}
getRowClassName={getRowClassName}
renderRow={renderRow}
/>
))
return (
<>
<TableHeader>
<TableRow className={headerRowClassName}>
{columns.map((column) => (
<TableHead key={column.id} className={column.className}>
{column.header}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{isEmpty ? (
<StaticDataTableEmptyRow
colSpan={columns.length}
className={emptyClassName}
>
{emptyContent}
</StaticDataTableEmptyRow>
) : (
bodyRows
)}
</TableBody>
</>
)
}
type StaticDataTableRowProps<TData> = Required<
Pick<StaticDataTableDataProps<TData>, 'columns'>
> &
Pick<StaticDataTableDataProps<TData>, 'getRowClassName' | 'renderRow'> & {
row: TData
index: number
}
function StaticDataTableRow<TData>({
row,
index,
columns,
getRowClassName,
renderRow,
}: StaticDataTableRowProps<TData>) {
if (renderRow) {
return <>{renderRow(row, index)}</>
}
return (
<TableRow className={getRowClassName?.(row, index)}>
{columns.map((column) => (
<TableCell
key={column.id}
className={getStaticCellClassName(column, row, index)}
>
{column.cell?.(row, index)}
</TableCell>
))}
</TableRow>
)
}
function getStaticCellClassName<TData>(
column: StaticDataTableColumn<TData>,
row: TData,
index: number
) {
return typeof column.cellClassName === 'function'
? column.cellClassName(row, index)
: column.cellClassName
}
type StaticDataTableEmptyRowProps = {
colSpan: number
children: React.ReactNode
className?: string
}
function StaticDataTableEmptyRow({
colSpan,
children,
className,
}: StaticDataTableEmptyRowProps) {
return (
<TableRow>
<TableCell
colSpan={colSpan}
className={cn('h-24 text-center', className)}
>
{children}
</TableCell>
</TableRow>
)
}
@@ -21,6 +21,7 @@ import { useState, type ReactNode } from 'react'
import { type Table } from '@tanstack/react-table'
import { ChevronDown, Loader2, X as Cross2Icon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useDebounce } from '@/hooks'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -46,6 +47,10 @@ export type DataTableToolbarProps<TData> = {
* Placeholder for the default search input. Defaults to `t('Filter...')`.
*/
searchPlaceholder?: string
/**
* Delay committing the default search input. Defaults to immediate updates.
*/
searchDebounceMs?: number
/**
* Column id to filter on. When provided, the search input filters
* a specific column. When omitted, the search input updates the
@@ -136,6 +141,8 @@ export type DataTableToolbarProps<TData> = {
export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
const { t } = useTranslation()
const [expanded, setExpanded] = useState(false)
const isSearchComposingRef = React.useRef(false)
const lastCommittedSearchValueRef = React.useRef('')
const filters = props.filters ?? []
const hasExpandable = props.expandable != null
@@ -147,26 +154,109 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
!!props.hasAdditionalFilters
const placeholder = props.searchPlaceholder ?? t('Filter...')
const currentSearchValue = props.searchKey
? ((props.table.getColumn(props.searchKey)?.getFilterValue() as string) ??
'')
: ((props.table.getState().globalFilter as string | undefined) ?? '')
const [searchValue, setSearchValue] = useState(currentSearchValue)
const [pendingSearchValue, setPendingSearchValue] =
useState(currentSearchValue)
const searchDebounceMs = Math.max(0, props.searchDebounceMs ?? 0)
const debouncedSearchValue = useDebounce(
pendingSearchValue,
searchDebounceMs
)
React.useEffect(() => {
lastCommittedSearchValueRef.current = currentSearchValue
if (!isSearchComposingRef.current) {
setSearchValue(currentSearchValue)
}
setPendingSearchValue(currentSearchValue)
}, [currentSearchValue])
const commitSearchValue = React.useCallback(
(value: string) => {
if (value === lastCommittedSearchValueRef.current) {
return
}
lastCommittedSearchValueRef.current = value
if (props.searchKey) {
props.table.getColumn(props.searchKey)?.setFilterValue(value)
return
}
props.table.setGlobalFilter(value)
},
[props.searchKey, props.table]
)
React.useEffect(() => {
if (
searchDebounceMs <= 0 ||
isSearchComposingRef.current ||
debouncedSearchValue !== pendingSearchValue
) {
return
}
commitSearchValue(debouncedSearchValue)
}, [
commitSearchValue,
debouncedSearchValue,
pendingSearchValue,
searchDebounceMs,
])
const queueSearchValue = (value: string) => {
setPendingSearchValue(value)
if (searchDebounceMs <= 0) {
commitSearchValue(value)
}
}
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value
setSearchValue(value)
if (!isSearchComposingRef.current) {
queueSearchValue(value)
}
}
const handleSearchCompositionStart = () => {
isSearchComposingRef.current = true
}
const handleSearchCompositionEnd = (
event: React.CompositionEvent<HTMLInputElement>
) => {
isSearchComposingRef.current = false
const value = event.currentTarget.value
setSearchValue(value)
queueSearchValue(value)
}
const searchInput = props.searchKey ? (
<Input
placeholder={placeholder}
value={
(props.table.getColumn(props.searchKey)?.getFilterValue() as string) ??
''
}
onChange={(event) =>
props.table
.getColumn(props.searchKey!)
?.setFilterValue(event.target.value)
}
value={searchValue}
onChange={handleSearchChange}
onCompositionStart={handleSearchCompositionStart}
onCompositionEnd={handleSearchCompositionEnd}
className='w-full sm:w-[200px] lg:w-[240px]'
/>
) : (
<Input
placeholder={placeholder}
value={props.table.getState().globalFilter ?? ''}
onChange={(event) => props.table.setGlobalFilter(event.target.value)}
value={searchValue}
onChange={handleSearchChange}
onCompositionStart={handleSearchCompositionStart}
onCompositionEnd={handleSearchCompositionEnd}
className='w-full sm:w-[200px] lg:w-[240px]'
/>
)
@@ -186,6 +276,10 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
})
const handleReset = () => {
isSearchComposingRef.current = false
setSearchValue('')
setPendingSearchValue('')
lastCommittedSearchValueRef.current = ''
props.table.resetColumnFilters()
props.table.setGlobalFilter('')
props.onReset?.()
+127
View File
@@ -0,0 +1,127 @@
/*
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 * as React from 'react'
import { cn } from '@/lib/utils'
import {
Dialog as DialogRoot,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
type DialogProps = React.ComponentProps<typeof DialogRoot> & {
title: React.ReactNode
description?: React.ReactNode
children: React.ReactNode
trigger?: React.ReactElement
footer?: React.ReactNode
contentHeight?: React.CSSProperties['height']
contentClassName?: string
headerClassName?: string
titleClassName?: string
descriptionClassName?: string
bodyClassName?: string
footerClassName?: string
initialFocus?: boolean
showCloseButton?: boolean
}
const dialogContentMotionClassName =
'data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 duration-100'
export function Dialog({
title,
description,
children,
trigger,
footer,
contentHeight = 'auto',
contentClassName,
headerClassName,
titleClassName,
descriptionClassName,
bodyClassName,
footerClassName,
initialFocus,
showCloseButton,
...dialogProps
}: DialogProps) {
return (
<DialogRoot {...dialogProps}>
{trigger ? <DialogTrigger render={trigger} /> : null}
<DialogContent
className={cn(
'flex max-h-[calc(100vh-2rem)] w-full flex-col gap-4 overflow-hidden p-4 sm:max-w-2xl sm:p-6',
contentClassName,
dialogContentMotionClassName
)}
initialFocus={initialFocus}
showCloseButton={showCloseButton}
style={
{
'--dialog-content-height': contentHeight,
} as React.CSSProperties
}
>
<DialogHeader
className={cn('flex-shrink-0 text-start', headerClassName)}
>
<DialogTitle className={titleClassName}>{title}</DialogTitle>
{description ? (
<DialogDescription className={descriptionClassName}>
{description}
</DialogDescription>
) : null}
</DialogHeader>
<div
className={cn(
'-mx-1 min-h-0 overflow-x-hidden overflow-y-auto overscroll-contain',
'h-[var(--dialog-content-height)] max-h-[calc(100vh-14rem)]'
)}
>
<div
className={cn(
'min-w-0 px-1 py-1',
'[&_form]:overflow-x-visible',
'[&_[data-slot=scroll-area-viewport]]:px-1 [&_[data-slot=scroll-area-viewport]]:py-1',
bodyClassName
)}
>
{children}
</div>
</div>
{footer ? (
<DialogFooter
className={cn(
'flex-shrink-0 gap-2 sm:-mx-6 sm:-mb-6 sm:justify-end sm:p-6',
footerClassName
)}
>
{footer}
</DialogFooter>
) : null}
</DialogContent>
</DialogRoot>
)
}
@@ -25,15 +25,8 @@ import { useNotifications } from '@/hooks/use-notifications'
import { useSystemConfig } from '@/hooks/use-system-config'
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
import { Dialog } from '@/components/dialog'
import { LanguageSwitcher } from '@/components/language-switcher'
import { NotificationPopover } from '@/components/notification-popover'
import { ProfileDropdown } from '@/components/profile-dropdown'
@@ -427,28 +420,26 @@ export function PublicHeader(props: PublicHeaderProps) {
closeAuthPrompt()
}
}}
>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Sign in required')}</DialogTitle>
<DialogDescription>
{t('Please sign in to view {{module}}.', {
title={t('Sign in required')}
description={t('Please sign in to view {{module}}.', {
module: authPromptTarget?.title || '',
})}
</DialogDescription>
</DialogHeader>
contentClassName='sm:max-w-md'
contentHeight='auto'
footer={
<>
<Button variant='outline' onClick={closeAuthPrompt}>
{t('Cancel')}
</Button>
<Button onClick={navigateToSignIn}>{t('Sign in now')}</Button>
</>
}
>
<div className='bg-muted/40 text-muted-foreground rounded-lg px-3 py-2 text-sm'>
{t('Redirecting to sign in in {{seconds}} seconds.', {
seconds: authPromptSecondsLeft,
})}
</div>
<DialogFooter>
<Button variant='outline' onClick={closeAuthPrompt}>
{t('Cancel')}
</Button>
<Button onClick={navigateToSignIn}>{t('Sign in now')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
@@ -50,6 +50,7 @@ SectionPageLayoutBreadcrumb.displayName = 'SectionPageLayout.Breadcrumb'
export type SectionPageLayoutProps = {
children: ReactNode
fixedContent?: boolean
}
export function SectionPageLayout(props: SectionPageLayoutProps) {
@@ -95,7 +96,13 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
</div>
</div>
<div className='min-h-0 flex-1 overflow-auto px-3 pt-1 pb-3 sm:px-4 sm:pt-1.5 sm:pb-4'>
<div
className={
props.fixedContent
? 'min-h-0 flex-1 overflow-hidden px-3 pt-1 pb-3 sm:px-4 sm:pt-1.5 sm:pb-4'
: 'min-h-0 flex-1 overflow-auto px-3 pt-1 pb-3 sm:px-4 sm:pt-1.5 sm:pb-4'
}
>
{content}
</div>
-1
View File
@@ -46,7 +46,6 @@ export function LongText({
useEffect(() => {
if (checkOverflow(ref.current)) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsOverflown(true)
return
}
+7 -3
View File
@@ -42,14 +42,18 @@ interface MaskedValueDisplayProps {
*/
export function MaskedValueDisplay(props: MaskedValueDisplayProps) {
return (
<div className='flex items-center'>
<div className='flex max-w-full min-w-0 items-center'>
<Popover>
<PopoverTrigger
render={
<Button variant='ghost' size='sm' className='h-7 font-mono' />
<Button
variant='ghost'
size='sm'
className='h-7 max-w-full min-w-0 justify-start truncate px-0 font-mono hover:bg-transparent aria-expanded:bg-transparent'
/>
}
>
{props.maskedValue}
<span className='truncate'>{props.maskedValue}</span>
</PopoverTrigger>
<PopoverContent
className='w-auto max-w-[min(90vw,28rem)]'
+44
View File
@@ -0,0 +1,44 @@
/*
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 { getLobeIcon } from '@/lib/lobe-icon'
import { cn } from '@/lib/utils'
import { StatusBadge, type StatusBadgeProps } from './status-badge'
type ProviderBadgeProps = Omit<StatusBadgeProps, 'children' | 'label'> & {
iconKey?: string | null
iconSize?: number
label: string
}
export function ProviderBadge({
className,
iconKey,
iconSize = 14,
label,
...badgeProps
}: ProviderBadgeProps) {
const icon = iconKey ? getLobeIcon(iconKey, iconSize) : null
return (
<div className={cn('flex items-center gap-1.5', className)}>
{icon}
<StatusBadge label={label} autoColor={label} size='sm' {...badgeProps} />
</div>
)
}
+2 -1
View File
@@ -103,7 +103,7 @@ export function StatusBadge({
variant,
size = 'sm',
pulse = false,
showDot = true,
showDot = false,
copyable = true,
copyText,
autoColor,
@@ -130,6 +130,7 @@ export function StatusBadge({
return (
<span
data-slot='status-badge'
className={cn(
'inline-flex w-fit max-w-full shrink-0 items-center rounded-4xl font-medium tracking-normal whitespace-nowrap transition-colors',
sizeMap[size ?? 'sm'],
+1 -1
View File
@@ -180,7 +180,7 @@ function ComboboxContent({
data-slot='combobox-content'
data-chips={!!anchor}
className={cn(
'dark group/combobox-content bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg shadow-md ring-1 duration-100 data-[chips=true]:min-w-(--anchor-width) *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none',
'group/combobox-content bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg shadow-md ring-1 duration-100 data-[chips=true]:min-w-(--anchor-width) *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none',
className
)}
{...props}
+1 -1
View File
@@ -103,7 +103,7 @@ function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
<td
data-slot='table-cell'
className={cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0',
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>*:has(>[data-slot=status-badge]:first-child):first-child]:-ml-1.5 [&>[data-slot=status-badge]:first-child]:-ml-1.5',
className
)}
{...props}
@@ -20,16 +20,9 @@ import { useMemo } from 'react'
import { ShieldCheck, KeyRound, Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Dialog } from '@/components/dialog'
import type {
SecureVerificationState,
VerificationMethod,
@@ -91,23 +84,45 @@ export function SecureVerificationDialog({
(activeMethod === '2fa' && (!state.code.trim() || state.code.length < 6))
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className='top-[8vh] max-w-[calc(100%-1.5rem)] translate-y-0 gap-0 overflow-hidden border-none p-0 shadow-xl sm:top-1/2 sm:max-w-md sm:translate-y-[-50%] sm:rounded-xl'
showCloseButton={!state.loading}
>
<div className='bg-background flex max-h-[calc(100dvh-2rem)] flex-col'>
<DialogHeader className='border-b px-6 py-5 text-left'>
<DialogTitle className='flex items-center gap-2 text-lg font-semibold'>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
<ShieldCheck className='text-primary h-5 w-5' />
{title}
</DialogTitle>
<DialogDescription className='text-left'>
{description}
</DialogDescription>
</DialogHeader>
<div className='flex-1 overflow-y-auto px-6 py-5'>
</>
}
description={description}
contentClassName='top-[8vh] max-w-[calc(100%-1.5rem)] translate-y-0 overflow-hidden border-none shadow-xl sm:top-1/2 sm:max-w-md sm:translate-y-[-50%] sm:rounded-xl'
headerClassName='border-b pb-4 text-left'
titleClassName='flex items-center gap-2 text-lg font-semibold'
descriptionClassName='text-left'
contentHeight='auto'
bodyClassName='px-1 py-1'
showCloseButton={!state.loading}
footerClassName='bg-muted/30 border-t px-6 py-4 sm:flex-row sm:justify-end'
footer={
<>
<Button
type='button'
variant='outline'
disabled={state.loading}
onClick={onCancel}
>
{t('Cancel')}
</Button>
<Button
type='button'
onClick={handleVerify}
disabled={availableTabs.length === 0 || verifyDisabled}
>
{state.loading && <Loader2 className='h-4 w-4 animate-spin' />}
{t('Verify')}
</Button>
</>
}
>
{availableTabs.length === 0 ? (
<div className='grid place-items-center gap-4 text-center'>
<div className='bg-muted flex h-16 w-16 items-center justify-center rounded-2xl'>
@@ -122,16 +137,12 @@ export function SecureVerificationDialog({
) : (
<Tabs
value={activeMethod ?? availableTabs[0]}
onValueChange={(value) =>
onMethodChange(value as VerificationMethod)
}
onValueChange={(value) => onMethodChange(value as VerificationMethod)}
className='gap-4'
>
<TabsList>
{methods.has2FA && (
<TabsTrigger value='2fa'>
{t('Authenticator code')}
</TabsTrigger>
<TabsTrigger value='2fa'>{t('Authenticator code')}</TabsTrigger>
)}
{methods.hasPasskey && methods.passkeySupported && (
<TabsTrigger value='passkey'>{t('Passkey')}</TabsTrigger>
@@ -185,28 +196,6 @@ export function SecureVerificationDialog({
</TabsContent>
</Tabs>
)}
</div>
<DialogFooter className='bg-muted/30 border-t px-6 py-4 sm:flex-row sm:justify-end'>
<Button
type='button'
variant='outline'
disabled={state.loading}
onClick={onCancel}
>
{t('Cancel')}
</Button>
<Button
type='button'
onClick={handleVerify}
disabled={availableTabs.length === 0 || verifyDisabled}
>
{state.loading && <Loader2 className='h-4 w-4 animate-spin' />}
{t('Verify')}
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
)
}
@@ -32,14 +32,6 @@ import {
import { cn } from '@/lib/utils'
import { useStatus } from '@/hooks/use-status'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -50,6 +42,7 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { PasswordInput } from '@/components/password-input'
import { Turnstile } from '@/components/turnstile'
import { login, wechatLoginByCode } from '@/features/auth/api'
@@ -414,43 +407,16 @@ export function UserAuthForm({
<Dialog
open={isWeChatDialogOpen}
onOpenChange={handleWeChatDialogChange}
>
<DialogContent className='max-w-sm'>
<DialogHeader className='text-left'>
<DialogTitle>{t('WeChat sign in')}</DialogTitle>
<DialogDescription>
{t(
title={t('WeChat sign in')}
description={t(
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
)}
</DialogDescription>
</DialogHeader>
{wechatQrCodeUrl ? (
<div className='flex justify-center'>
<img
src={wechatQrCodeUrl}
alt={t('WeChat login QR code')}
className='h-40 w-40 rounded-md border object-contain'
/>
</div>
) : (
<p className='text-muted-foreground text-sm'>
{t('QR code is not configured. Please contact support.')}
</p>
)}
<div className='grid gap-2'>
<Label htmlFor='wechat-code'>{t('Verification code')}</Label>
<Input
id='wechat-code'
placeholder={t('Enter the verification code')}
value={wechatCode}
onChange={(event) => setWeChatCode(event.target.value)}
autoComplete='one-time-code'
/>
</div>
<DialogFooter>
contentClassName='max-w-sm'
headerClassName='text-left'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
@@ -474,8 +440,32 @@ export function UserAuthForm({
) : null}
{t('Confirm')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
{wechatQrCodeUrl ? (
<div className='flex justify-center'>
<img
src={wechatQrCodeUrl}
alt={t('WeChat login QR code')}
className='h-40 w-40 rounded-md border object-contain'
/>
</div>
) : (
<p className='text-muted-foreground text-sm'>
{t('QR code is not configured. Please contact support.')}
</p>
)}
<div className='grid gap-2'>
<Label htmlFor='wechat-code'>{t('Verification code')}</Label>
<Input
id='wechat-code'
placeholder={t('Enter the verification code')}
value={wechatCode}
onChange={(event) => setWeChatCode(event.target.value)}
autoComplete='one-time-code'
/>
</div>
</Dialog>
)}
</Form>
@@ -26,14 +26,6 @@ import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { useStatus } from '@/hooks/use-status'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -44,6 +36,7 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { PasswordInput } from '@/components/password-input'
import { Turnstile } from '@/components/turnstile'
import { register, wechatLoginByCode } from '@/features/auth/api'
@@ -387,43 +380,16 @@ export function SignUpForm({
<Dialog
open={isWeChatDialogOpen}
onOpenChange={handleWeChatDialogChange}
>
<DialogContent className='max-w-sm'>
<DialogHeader className='text-left'>
<DialogTitle>{t('WeChat sign in')}</DialogTitle>
<DialogDescription>
{t(
title={t('WeChat sign in')}
description={t(
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
)}
</DialogDescription>
</DialogHeader>
{wechatQrCodeUrl ? (
<div className='flex justify-center'>
<img
src={wechatQrCodeUrl}
alt={t('WeChat login QR code')}
className='h-40 w-40 rounded-md border object-contain'
/>
</div>
) : (
<p className='text-muted-foreground text-sm'>
{t('QR code is not configured. Please contact support.')}
</p>
)}
<div className='grid gap-2'>
<Label htmlFor='wechat-code'>{t('Verification code')}</Label>
<Input
id='wechat-code'
placeholder={t('Enter the verification code')}
value={wechatCode}
onChange={(event) => setWeChatCode(event.target.value)}
autoComplete='one-time-code'
/>
</div>
<DialogFooter>
contentClassName='max-w-sm'
headerClassName='text-left'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
@@ -447,8 +413,32 @@ export function SignUpForm({
) : null}
{t('Confirm')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
{wechatQrCodeUrl ? (
<div className='flex justify-center'>
<img
src={wechatQrCodeUrl}
alt={t('WeChat login QR code')}
className='h-40 w-40 rounded-md border object-contain'
/>
</div>
) : (
<p className='text-muted-foreground text-sm'>
{t('QR code is not configured. Please contact support.')}
</p>
)}
<div className='grid gap-2'>
<Label htmlFor='wechat-code'>{t('Verification code')}</Label>
<Input
id='wechat-code'
placeholder={t('Enter the verification code')}
value={wechatCode}
onChange={(event) => setWeChatCode(event.target.value)}
autoComplete='one-time-code'
/>
</div>
</Dialog>
)}
</Form>
@@ -35,7 +35,6 @@ import {
formatTimestampToDate,
formatQuota as formatQuotaValue,
} from '@/lib/format'
import { getLobeIcon } from '@/lib/lobe-icon'
import { truncateText } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
@@ -46,8 +45,9 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { DataTableColumnHeader } from '@/components/data-table/column-header'
import { DataTableColumnHeader } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge'
import { ProviderBadge } from '@/components/provider-badge'
import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import { TruncatedText } from '@/components/truncated-text'
@@ -623,7 +623,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
const typeNameKey = getChannelTypeLabel(type)
const typeName = t(typeNameKey)
const iconName = getChannelTypeIcon(type)
const icon = getLobeIcon(`${iconName}.Color`, 14)
const channel = row.original as Channel
const isMultiKey = isMultiKeyChannel(channel)
const multiKeyMode = channel.channel_info?.multi_key_mode ?? 'random'
@@ -657,16 +656,12 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
</Tooltip>
</TooltipProvider>
)}
<StatusBadge
autoColor={typeName}
size='sm'
<ProviderBadge
iconKey={iconName}
label={typeName}
copyable={false}
showDot={false}
className='gap-1 pl-1'
>
{icon}
<span className='truncate'>{typeName}</span>
</StatusBadge>
/>
{isIonet && (
<TooltipProvider delay={100}>
<Tooltip>
@@ -16,20 +16,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useMemo, useEffect } from 'react'
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import {
getCoreRowModel,
useReactTable,
getExpandedRowModel,
type OnChangeFn,
type SortingState,
type VisibilityState,
type ExpandedState,
type Row,
} from '@tanstack/react-table'
import { useDebounce, useMediaQuery } from '@/hooks'
import { useMediaQuery } from '@/hooks'
import { useTranslation } from 'react-i18next'
import { getLobeIcon } from '@/lib/lobe-icon'
import { useTableUrlState } from '@/hooks/use-table-url-state'
@@ -38,6 +33,8 @@ import {
DISABLED_ROW_DESKTOP,
DISABLED_ROW_MOBILE,
DataTablePage,
useDebouncedColumnFilter,
useDataTable,
} from '@/components/data-table'
import { getChannels, searchChannels, getGroups } from '../api'
import {
@@ -81,12 +78,6 @@ export function ChannelsTable() {
// Table state
const [sorting, setSorting] = useState<SortingState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
models: false,
tag: false,
})
const [rowSelection, setRowSelection] = useState({})
const [expanded, setExpanded] = useState<ExpandedState>({})
// URL state management
const {
@@ -116,35 +107,24 @@ export function ChannelsTable() {
// Extract filters from column filters
const statusFilter =
(columnFilters.find((f) => f.id === 'status')?.value as string[]) || []
const typeFilter =
(columnFilters.find((f) => f.id === 'type')?.value as string[]) || []
const typeFilter = useMemo(
() => (columnFilters.find((f) => f.id === 'type')?.value as string[]) || [],
[columnFilters]
)
const groupFilter =
(columnFilters.find((f) => f.id === 'group')?.value as string[]) || []
const modelFilterFromUrl =
(columnFilters.find((f) => f.id === 'model')?.value as string) || ''
// Local state for immediate input feedback
const [modelFilterInput, setModelFilterInput] = useState(modelFilterFromUrl)
const debouncedModelFilter = useDebounce(modelFilterInput, 500)
// Sync local input with URL when URL changes (e.g., from back/forward navigation)
useEffect(() => {
setModelFilterInput(modelFilterFromUrl)
}, [modelFilterFromUrl])
// Update URL when debounced value changes
useEffect(() => {
if (debouncedModelFilter !== modelFilterFromUrl) {
onColumnFiltersChange((prev) => {
const filtered = prev.filter((f) => f.id !== 'model')
return debouncedModelFilter
? [...filtered, { id: 'model', value: debouncedModelFilter }]
: filtered
const {
value: modelFilter,
inputValue: modelFilterInput,
onChange: onModelFilterInputChange,
onCompositionStart: onModelFilterCompositionStart,
onCompositionEnd: onModelFilterCompositionEnd,
resetInput: resetModelFilterInput,
} = useDebouncedColumnFilter({
columnFilters,
columnId: 'model',
onColumnFiltersChange,
})
}
}, [debouncedModelFilter, modelFilterFromUrl, onColumnFiltersChange])
const modelFilter = modelFilterFromUrl
// Determine whether to use search or regular list API
const shouldSearch = Boolean(globalFilter?.trim() || modelFilter.trim())
@@ -279,41 +259,31 @@ export function ChannelsTable() {
const columns = useChannelsColumns()
// React Table instance
const table = useReactTable({
const { table } = useDataTable({
data: channels,
columns,
pageCount: Math.ceil(totalCount / pagination.pageSize),
state: {
totalCount,
sorting,
columnFilters,
columnVisibility,
rowSelection,
pagination,
expanded,
globalFilter,
initialColumnVisibility: {
models: false,
tag: false,
},
columnFilters,
pagination,
globalFilter,
enableRowSelection: (row: Row<Channel>) => !isTagAggregateRow(row.original),
onRowSelectionChange: setRowSelection,
onSortingChange: handleSortingChange,
onColumnFiltersChange,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange,
onExpandedChange: setExpanded,
onGlobalFilterChange,
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getSubRows: (row: Channel & { children?: Channel[] }) => row.children,
manualPagination: true,
manualSorting: true,
manualFiltering: true,
withExpandedRowModel: true,
ensurePageInRange,
})
// Ensure page is in range when total count changes
const pageCount = table.getPageCount()
useEffect(() => {
ensurePageInRange(pageCount)
}, [pageCount, ensurePageInRange])
// Prepare filter options from existing channel types only.
const typeFilterOptions = useMemo(() => {
const counts = typeCounts || {}
@@ -385,11 +355,17 @@ export function ChannelsTable() {
applyHeaderSize
toolbarProps={{
searchPlaceholder: t('Filter by name, ID, or key...'),
searchDebounceMs: 500,
onReset: () => {
resetModelFilterInput()
},
additionalSearch: (
<Input
placeholder={t('Filter by model...')}
value={modelFilterInput}
onChange={(e) => setModelFilterInput(e.target.value)}
onChange={onModelFilterInputChange}
onCompositionStart={onModelFilterCompositionStart}
onCompositionEnd={onModelFilterCompositionEnd}
className='w-full sm:w-[150px] lg:w-[180px]'
/>
),
@@ -22,14 +22,6 @@ import { type Table } from '@tanstack/react-table'
import { Power, PowerOff, Tag, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
@@ -38,6 +30,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import {
handleBatchDelete,
handleBatchDisable,
@@ -188,16 +181,34 @@ export function DataTableBulkActions<TData>({
</BulkActionsToolbar>
{/* Set Tag Dialog */}
<Dialog open={showTagDialog} onOpenChange={setShowTagDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Set Tag')}</DialogTitle>
<DialogDescription>
{t('Set a tag for')} {selectedIds.length}{' '}
<Dialog
open={showTagDialog}
onOpenChange={setShowTagDialog}
title={t('Set Tag')}
description={
<>
{t('Set a tag for')}
{selectedIds.length}{' '}
{t('selected channel(s). Leave empty to remove tag.')}
</DialogDescription>
</DialogHeader>
</>
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
variant='outline'
onClick={() => {
setShowTagDialog(false)
setTagValue('')
}}
>
{t('Cancel')}
</Button>
<Button onClick={handleSetTag}>{t('Set Tag')}</Button>
</>
}
>
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='tag'>{t('Tag')}</Label>
@@ -209,34 +220,23 @@ export function DataTableBulkActions<TData>({
/>
</div>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={() => {
setShowTagDialog(false)
setTagValue('')
}}
>
{t('Cancel')}
</Button>
<Button onClick={handleSetTag}>{t('Set Tag')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Delete Channels?')}</DialogTitle>
<DialogDescription>
{t('Are you sure you want to delete')} {selectedIds.length}{' '}
<Dialog
open={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
title={t('Delete Channels?')}
description={
<>
{t('Are you sure you want to delete')}
{selectedIds.length}{' '}
{t('channel(s)? This action cannot be undone.')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
</>
}
contentHeight='auto'
footer={
<>
<Button
variant='outline'
onClick={() => setShowDeleteConfirm(false)}
@@ -246,8 +246,10 @@ export function DataTableBulkActions<TData>({
<Button variant='destructive' onClick={handleDeleteAll}>
{t('Delete')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
{' '}
</Dialog>
</>
)
@@ -24,14 +24,7 @@ import { toast } from 'sonner'
import { formatCurrencyFromUSD } from '@/lib/currency'
import { formatTimestampToDate } from '@/lib/format'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Dialog } from '@/components/dialog'
import { getCodexUsage, updateChannelBalance } from '../../api'
import { channelsQueryKeys } from '../../lib'
import { useChannels } from '../channels-provider'
@@ -161,15 +154,26 @@ export function BalanceQueryDialog({
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Query Balance')}</DialogTitle>
<DialogDescription>
{t('Update balance for:')} <strong>{currentRow.name}</strong>
</DialogDescription>
</DialogHeader>
<Dialog
open={open}
onOpenChange={handleClose}
title={t('Query Balance')}
description={
<>
{t('Update balance for:')}
<strong>{currentRow.name}</strong>
</>
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={handleClose} disabled={isQuerying}>
{t('Close')}
</Button>
</>
}
>
<div className='space-y-4 py-4'>
{/* Current Balance Display */}
<div className='bg-muted/50 rounded-lg border p-4'>
@@ -184,9 +188,7 @@ export function BalanceQueryDialog({
</div>
<div className='text-muted-foreground mt-2 text-xs'>
{t('Last updated:')}{' '}
{formatDate(
balanceUpdatedTime ?? currentRow.balance_updated_time
)}
{formatDate(balanceUpdatedTime ?? currentRow.balance_updated_time)}
</div>
</div>
@@ -201,13 +203,6 @@ export function BalanceQueryDialog({
{isQuerying ? t('Querying...') : t('Update Balance')}
</Button>
</div>
<DialogFooter>
<Button variant='outline' onClick={handleClose} disabled={isQuerying}>
{t('Close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -21,10 +21,6 @@ import {
type ColumnDef,
type RowSelectionState,
type Table as TanStackTable,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table'
import { Check, Copy, Info, Loader2, Settings } from 'lucide-react'
import { useTranslation } from 'react-i18next'
@@ -33,14 +29,6 @@ import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { useIsMobile } from '@/hooks/use-mobile'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
@@ -60,21 +48,18 @@ import {
SheetTitle,
} from '@/components/ui/sheet'
import { Switch } from '@/components/ui/switch'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { DataTablePagination } from '@/components/data-table/pagination'
import {
DataTableBulkActions as BulkActionsToolbar,
DataTablePagination,
DataTableView,
useDataTable,
} from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import {
sideDrawerContentClassName,
sideDrawerFooterClassName,
@@ -207,7 +192,7 @@ function getTestTableColumnClass(columnId: string) {
case 'status':
return 'w-70 min-w-70 max-w-70 whitespace-normal'
case 'actions':
return 'bg-popover sticky right-0 z-20 w-24 min-w-24 border-l shadow-[-8px_0_8px_-8px_rgb(0_0_0_/_0.2)] whitespace-nowrap sm:w-28 sm:min-w-28'
return 'bg-popover w-24 min-w-24 whitespace-nowrap sm:w-28 sm:min-w-28'
default:
return undefined
}
@@ -234,6 +219,14 @@ export function ChannelTestDialog({
pageIndex: 0,
pageSize: 10,
})
const endpointSelectItems = useMemo(
() =>
endpointTypeOptions.map((option) => ({
value: option.value,
label: t(option.label),
})),
[t]
)
const resetState = useCallback(() => {
setEndpointType('auto')
@@ -509,18 +502,17 @@ export function ChannelTestDialog({
]
)
const table = useReactTable({
const { table } = useDataTable({
data: tableData,
columns,
state: {
rowSelection,
pagination,
},
enableRowSelection: true,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
withFilteredRowModel: false,
withSortedRowModel: false,
withFacetedRowModel: false,
})
if (!currentRow) {
@@ -529,26 +521,33 @@ export function ChannelTestDialog({
return (
<>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className='max-h-[90vh] overflow-hidden sm:max-w-3xl'>
<DialogHeader>
<DialogTitle>{t('Test Channel Connection')}</DialogTitle>
<DialogDescription>
{t('Test connectivity for:')} <strong>{currentRow.name}</strong>
</DialogDescription>
</DialogHeader>
<Dialog
open={open}
onOpenChange={handleClose}
title={t('Test Channel Connection')}
description={
<>
{t('Test connectivity for:')}
<strong>{currentRow.name}</strong>
</>
}
contentClassName='max-h-[90vh] overflow-hidden sm:max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={handleClose}>
{t('Close')}
</Button>
</>
}
>
<div className='max-h-[78vh] space-y-4 overflow-y-auto py-4 pr-1'>
<div className='grid gap-4 md:grid-cols-2'>
<div className='grid gap-2'>
<Label htmlFor='endpoint-type'>{t('Endpoint Type')}</Label>
<Select
items={[
...endpointTypeOptions.map((option) => {
const itemValue = option.value
return { value: itemValue, label: t(option.label) }
}),
]}
items={endpointSelectItems}
value={endpointType}
onValueChange={(v) => v !== null && setEndpointType(v)}
>
@@ -557,14 +556,11 @@ export function ChannelTestDialog({
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{endpointTypeOptions.map((option) => {
const itemValue = option.value
return (
<SelectItem key={itemValue} value={itemValue}>
{t(option.label)}
{endpointSelectItems.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
)
})}
))}
</SelectGroup>
</SelectContent>
</Select>
@@ -610,80 +606,41 @@ export function ChannelTestDialog({
</div>
<div className='space-y-3'>
<div
className='overflow-hidden rounded-md border'
role='region'
aria-label={t('Channel models')}
>
<div className='max-h-90 overflow-auto **:data-[slot=table-container]:overflow-visible'>
<Table className='w-max min-w-full table-auto'>
<DataTableView
table={table}
containerClassName='rounded-md'
containerProps={{
role: 'region',
'aria-label': t('Channel models'),
}}
tableContainerClassName='max-h-90 overflow-auto **:data-[slot=table-container]:overflow-visible'
tableClassName='w-max min-w-full table-auto'
pinnedColumns={[
{
columnId: 'actions',
side: 'right',
className: 'w-24 min-w-24 sm:w-28 sm:min-w-28',
cellClassName: 'bg-popover',
},
]}
colgroup={
<colgroup>
<col className='w-10 min-w-10' />
<col className='w-auto' />
<col className='w-70' />
<col className='w-24 sm:w-28' />
</colgroup>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className={getTestTableColumnClass(
header.column.id
)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() ? 'selected' : undefined
}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={getTestTableColumnClass(
cell.column.id
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={table.getVisibleLeafColumns().length}
className='text-muted-foreground h-16 text-center text-sm'
>
{models.length
? 'No models matched your search.'
: 'This channel has no configured models.'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
getColumnClassName={(columnId) =>
getTestTableColumnClass(columnId)
}
emptyContent={
models.length
? t('No models matched your search.')
: t('This channel has no configured models.')
}
emptyCellClassName='text-muted-foreground h-16 text-center text-sm'
/>
<DataTablePagination table={table} />
</div>
@@ -695,13 +652,6 @@ export function ChannelTestDialog({
/>
</div>
</div>
<DialogFooter>
<Button variant='outline' onClick={handleClose}>
{t('Close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<FailureDetailsSheet
details={failureDetails}
@@ -24,15 +24,8 @@ import { tryPrettyJson } from '@/lib/utils'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog'
import { completeCodexOAuth, startCodexOAuth } from '../../api'
type CodexOAuthDialogProps = {
@@ -129,17 +122,35 @@ export function CodexOAuthDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-2xl'>
<DialogHeader>
<DialogTitle>{t('Codex Authorization')}</DialogTitle>
<DialogDescription>
{t(
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Codex Authorization')}
description={t(
'Generate a Codex OAuth credential and paste it into the channel key field.'
)}
</DialogDescription>
</DialogHeader>
contentClassName='sm:max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
disabled={state.isStarting || state.isCompleting}
>
{t('Cancel')}
</Button>
<Button onClick={handleComplete} disabled={!canComplete}>
{state.isCompleting && (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
)}
{state.isCompleting ? t('Generating...') : t('Generate credential')}
</Button>
</>
}
>
<div className='space-y-4'>
<Alert>
<AlertDescription>
@@ -199,24 +210,6 @@ export function CodexOAuthDialog({
</div>
</div>
</div>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
disabled={state.isStarting || state.isCompleting}
>
{t('Cancel')}
</Button>
<Button onClick={handleComplete} disabled={!canComplete}>
{state.isCompleting && (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
)}
{state.isCompleting ? t('Generating...') : t('Generate credential')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -31,16 +31,9 @@ import { useTranslation } from 'react-i18next'
import dayjs from '@/lib/dayjs'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Progress } from '@/components/ui/progress'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Dialog } from '@/components/dialog'
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
type CodexRateLimitWindow = {
@@ -414,18 +407,33 @@ export function CodexUsageDialog({
}, [response])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-3xl'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
{t('Codex Account & Usage')}
</DialogTitle>
<DialogDescription>
{t('Channel:')} <strong>{channelName || '-'}</strong>{' '}
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Codex Account & Usage')}
description={
<>
{t('Channel:')}
<strong>{channelName || '-'}</strong>{' '}
{channelId ? `(#${channelId})` : ''}
</DialogDescription>
</DialogHeader>
</>
}
contentClassName='sm:max-w-3xl'
titleClassName='flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Close')}
</Button>
</>
}
>
<div className='space-y-4'>
{errorMessage && (
<div className='rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400'>
@@ -583,17 +591,6 @@ export function CodexUsageDialog({
)}
</div>
</div>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -22,16 +22,9 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { handleCopyChannel } from '../../lib'
import { useChannels } from '../channels-provider'
@@ -74,15 +67,34 @@ export function CopyChannelDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Copy Channel')}</DialogTitle>
<DialogDescription>
{t('Create a copy of:')} <strong>{currentRow.name}</strong>
</DialogDescription>
</DialogHeader>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Copy Channel')}
description={
<>
{t('Create a copy of:')}
<strong>{currentRow.name}</strong>
</>
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
disabled={isCopying}
>
{t('Cancel')}
</Button>
<Button onClick={handleCopy} disabled={isCopying}>
{isCopying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isCopying ? t('Copying...') : t('Copy Channel')}
</Button>
</>
}
>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label htmlFor='suffix'>{t('Name Suffix')}</Label>
@@ -111,21 +123,6 @@ export function CopyChannelDialog({
</Label>
</div>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
disabled={isCopying}
>
{t('Cancel')}
</Button>
<Button onClick={handleCopy} disabled={isCopying}>
{isCopying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isCopying ? 'Copying...' : 'Copy Channel'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -22,14 +22,6 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
@@ -43,6 +35,7 @@ import {
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { GroupBadge } from '@/components/group-badge'
import { StatusBadge } from '@/components/status-badge'
import {
@@ -222,19 +215,33 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
if (!currentTag) return null
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className='max-h-[90vh] max-w-2xl'>
<DialogHeader>
<DialogTitle>
{t('Edit Tag:')} {currentTag}
</DialogTitle>
<DialogDescription>
{t(
<Dialog
open={open}
onOpenChange={handleClose}
title={
<>
{t('Edit Tag:')}
{currentTag}
</>
}
description={t(
'Batch edit all channels with this tag. Leave fields empty to keep current values.'
)}
</DialogDescription>
</DialogHeader>
contentClassName='max-h-[90vh] max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={handleClose}>
{t('Cancel')}
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{t('Save Changes')}
</Button>
</>
}
>
<ScrollArea className='max-h-[60vh] pr-4'>
<div className='space-y-6'>
{/* Tag Name */}
@@ -430,17 +437,6 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
</div>
</div>
</ScrollArea>
<DialogFooter>
<Button variant='outline' onClick={handleClose}>
{t('Cancel')}
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{t('Save Changes')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -28,14 +28,6 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
@@ -44,6 +36,7 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { Dialog } from '@/components/dialog'
import { fetchUpstreamModels, updateChannel } from '../../api'
import {
channelsQueryKeys,
@@ -365,28 +358,46 @@ export function FetchModelsDialog({
)
}
const showFooterActions =
!!(activeChannel || customFetcher) &&
!isFetching &&
(fetchedModels.length > 0 || removedModels.length > 0)
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className='max-w-3xl'>
<DialogHeader>
<DialogTitle>{t('Fetch Models')}</DialogTitle>
<DialogDescription>
{activeChannel ? (
<Dialog
open={open}
onOpenChange={handleClose}
title={t('Fetch Models')}
description={
activeChannel ? (
<>
{t('Fetch available models for:')}{' '}
<strong>{activeChannel.name}</strong>
{t('Channel:')} <strong>{activeChannel.name}</strong>
</>
) : channelName ? (
<>
{t('Fetch available models for:')}{' '}
<strong>{channelName}</strong>
{t('Channel:')} <strong>{channelName}</strong>
</>
) : (
t('Fetch available models from upstream')
)}
</DialogDescription>
</DialogHeader>
)
}
contentClassName='max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
showFooterActions ? (
<>
<Button variant='outline' onClick={handleClose} disabled={isSaving}>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isSaving ? t('Saving...') : t('Save Models')}
</Button>
</>
) : null
}
>
{!activeChannel && !customFetcher ? (
<div className='text-muted-foreground py-8 text-center'>
{t('No channel selected')}
@@ -459,8 +470,7 @@ export function FetchModelsDialog({
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(newModelsByCategory).map(
([category, models]) =>
renderModelCategory(category, models)
([category, models]) => renderModelCategory(category, models)
)}
</TabsContent>
@@ -469,8 +479,7 @@ export function FetchModelsDialog({
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(existingModelsByCategory).map(
([category, models]) =>
renderModelCategory(category, models)
([category, models]) => renderModelCategory(category, models)
)}
</TabsContent>
@@ -494,23 +503,8 @@ export function FetchModelsDialog({
{t('{{n}} model(s) selected', { n: selectedModels.length })}
</div>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={handleClose}
disabled={isSaving}
>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isSaving ? t('Saving...') : t('Save Models')}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
)
}
@@ -22,13 +22,6 @@ import { Loader2, RefreshCw, Trash2, Power, PowerOff } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
@@ -38,15 +31,9 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StaticDataTable } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import {
getMultiKeyStatus,
@@ -228,10 +215,11 @@ export function MultiKeyManageDialog({
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='flex max-h-[90vh] max-w-5xl flex-col'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
{t('Multi-Key Management')}
<StatusBadge
label={currentRow.name}
@@ -249,12 +237,16 @@ export function MultiKeyManageDialog({
copyable={false}
/>
)}
</DialogTitle>
<DialogDescription>
{t('Manage multi-key status and configuration for this channel')}
</DialogDescription>
</DialogHeader>
</>
}
description={t(
'Manage multi-key status and configuration for this channel'
)}
contentClassName='flex max-h-[90vh] max-w-5xl flex-col'
titleClassName='flex items-center gap-2'
contentHeight='min(72vh, 720px)'
bodyClassName='space-y-4'
>
<div className='flex min-h-0 flex-1 flex-col space-y-4 overflow-hidden'>
{/* Statistics */}
<div className='grid shrink-0 grid-cols-3 gap-3'>
@@ -339,9 +331,7 @@ export function MultiKeyManageDialog({
<Button
variant='destructive'
size='sm'
onClick={() =>
setConfirmAction({ type: 'delete-disabled' })
}
onClick={() => setConfirmAction({ type: 'delete-disabled' })}
>
<Trash2 className='mr-2 h-4 w-4' />
{t('Delete Auto-Disabled')}
@@ -361,48 +351,53 @@ export function MultiKeyManageDialog({
{t('No keys found')}
</div>
) : (
<div className='min-w-[800px]'>
<Table>
<TableHeader>
<TableRow>
<TableHead className='w-20'>{t('Index')}</TableHead>
<TableHead className='w-32'>{t('Status')}</TableHead>
<TableHead className='min-w-[200px]'>
{t('Disabled Reason')}
</TableHead>
<TableHead className='w-44'>
{t('Disabled Time')}
</TableHead>
<TableHead className='w-44 text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{keys.map((key) => (
<TableRow key={key.index}>
<TableCell className='font-mono text-sm'>
#{key.index + 1}
</TableCell>
<TableCell>{renderStatusBadge(key.status)}</TableCell>
<TableCell className='max-w-xs truncate text-sm'>
{key.reason || '-'}
</TableCell>
<TableCell className='text-muted-foreground text-sm'>
{formatKeyTimestamp(key.disabled_time)}
</TableCell>
<TableCell>
<StaticDataTable
className='rounded-none border-0'
tableClassName='min-w-[800px]'
data={keys}
getRowKey={(key) => key.index}
columns={[
{
id: 'index',
header: t('Index'),
className: 'w-20',
cellClassName: 'font-mono text-sm',
cell: (key) => `#${key.index + 1}`,
},
{
id: 'status',
header: t('Status'),
className: 'w-32',
cell: (key) => renderStatusBadge(key.status),
},
{
id: 'reason',
header: t('Disabled Reason'),
className: 'min-w-[200px]',
cellClassName: 'max-w-xs truncate text-sm',
cell: (key) => key.reason || '-',
},
{
id: 'disabled-time',
header: t('Disabled Time'),
className: 'w-44',
cellClassName: 'text-muted-foreground text-sm',
cell: (key) => formatKeyTimestamp(key.disabled_time),
},
{
id: 'actions',
header: t('Actions'),
className: 'w-44 text-right',
cell: (key) => (
<MultiKeyTableRowActions
keyIndex={key.index}
status={key.status}
onAction={setConfirmAction}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
),
},
]}
/>
)}
</div>
@@ -436,7 +431,6 @@ export function MultiKeyManageDialog({
</div>
)}
</div>
</DialogContent>
</Dialog>
{/* Confirmation Dialog */}
@@ -34,18 +34,11 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator'
import { Dialog } from '@/components/dialog'
import {
deleteOllamaModel,
fetchModels as fetchModelsFromEndpoint,
@@ -375,21 +368,30 @@ export function OllamaModelsDialog({
if (!open) return null
return (
<Dialog open={open} onOpenChange={close}>
<DialogContent className='max-h-[90vh] overflow-hidden sm:max-w-3xl'>
<DialogHeader>
<DialogTitle>{t('Ollama Models')}</DialogTitle>
<DialogDescription>
<Dialog
open={open}
onOpenChange={close}
title={t('Ollama Models')}
description={
<>
{t('Manage local models for:')} <strong>{currentRow?.name}</strong>
</DialogDescription>
</DialogHeader>
</>
}
contentClassName='sm:max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<Button variant='outline' onClick={close}>
{t('Close')}
</Button>
}
>
{!isOllamaChannel ? (
<div className='text-muted-foreground py-8 text-center'>
{t('This channel is not an Ollama channel.')}
</div>
) : (
<div className='max-h-[78vh] space-y-4 overflow-y-auto py-2 pr-1'>
<div className='space-y-4 py-2 pr-1'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
<div className='flex-1 space-y-2'>
<Label htmlFor='ollama-pull'>{t('Pull model')}</Label>
@@ -521,9 +523,7 @@ export function OllamaModelsDialog({
<div className='flex min-w-0 items-start gap-3'>
<Checkbox
checked={checked}
onCheckedChange={(v) =>
toggleSelected(m.id, !!v)
}
onCheckedChange={(v) => toggleSelected(m.id, !!v)}
aria-label={`Select model ${m.id}`}
/>
<div className='min-w-0'>
@@ -566,13 +566,6 @@ export function OllamaModelsDialog({
</div>
)}
<DialogFooter>
<Button variant='outline' onClick={close}>
{t('Close')}
</Button>
</DialogFooter>
</DialogContent>
<AlertDialog
open={deleteOpen}
onOpenChange={(v) => {
@@ -43,14 +43,6 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
@@ -63,6 +55,7 @@ import {
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
// ---------------------------------------------------------------------------
// Types
@@ -1701,17 +1694,33 @@ export function ParamOverrideEditorDialog(
// ---------------------------------------------------------------------------
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='flex max-h-[90vh] flex-col gap-0 p-0 sm:max-w-5xl'>
<DialogHeader className='border-b px-6 py-4'>
<DialogTitle>{t('Parameter Override')}</DialogTitle>
<DialogDescription>
{t(
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={t('Parameter Override')}
description={t(
'Create request parameter override rules with a visual editor or raw JSON.'
)}
</DialogDescription>
</DialogHeader>
contentClassName='flex max-h-[90vh] flex-col gap-0 p-0 sm:max-w-5xl'
headerClassName='border-b px-6 py-4'
footerClassName='border-t px-6 py-4'
contentHeight='min(72vh, 720px)'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => props.onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='button' onClick={handleSave}>
{t('Save')}
</Button>
</>
}
>
{/* Toolbar */}
<div className='bg-muted/30 border-b px-4 py-3'>
<div className='flex flex-wrap items-center gap-2'>
@@ -1791,7 +1800,6 @@ export function ParamOverrideEditorDialog(
</Button>
</div>
</div>
{/* Content */}
<div className='min-h-0 flex-1 overflow-hidden'>
{editMode === 'visual' ? (
@@ -1885,15 +1893,11 @@ export function ParamOverrideEditorDialog(
role='button'
tabIndex={0}
draggable={operations.length > 1}
onClick={() =>
setSelectedOperationId(operation.id)
}
onClick={() => setSelectedOperationId(operation.id)}
onDragStart={(e) =>
handleDragStart(e, operation.id)
}
onDragOver={(e) =>
handleDragOver(e, operation.id)
}
onDragOver={(e) => handleDragOver(e, operation.id)}
onDrop={(e) => handleDrop(e, operation.id)}
onDragEnd={resetDragState}
onKeyDown={(e: KeyboardEvent) => {
@@ -1948,9 +1952,7 @@ export function ParamOverrideEditorDialog(
<span
className={cn(
'mt-1 inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium',
getModeTagTailwind(
operation.mode || 'set'
)
getModeTagTailwind(operation.mode || 'set')
)}
>
{t(
@@ -2038,9 +2040,7 @@ export function ParamOverrideEditorDialog(
className='font-mono text-xs'
/>
<p className='text-muted-foreground mt-2 text-xs'>
{t(
'Edit JSON text directly. Format will be validated on save.'
)}
{t('Edit JSON text directly. Format will be validated on save.')}
</p>
{jsonError && (
<p className='text-destructive mt-1 text-xs'>{jsonError}</p>
@@ -2048,21 +2048,7 @@ export function ParamOverrideEditorDialog(
</div>
)}
</div>
{/* Footer */}
<DialogFooter className='border-t px-6 py-4'>
<Button
type='button'
variant='outline'
onClick={() => props.onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='button' onClick={handleSave}>
{t('Save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -21,16 +21,9 @@ import { AlertTriangle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
interface StatusCodeRiskDialogProps {
open: boolean
@@ -84,18 +77,35 @@ export function StatusCodeRiskDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-w-lg'>
<DialogHeader>
<DialogTitle className='text-destructive flex items-center gap-2'>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
<AlertTriangle className='h-5 w-5' />
{t('High-risk operation confirmation')}
</DialogTitle>
<DialogDescription>
{t('High-risk status code retry risk disclaimer')}
</DialogDescription>
</DialogHeader>
</>
}
description={t('High-risk status code retry risk disclaimer')}
contentClassName='max-w-lg'
titleClassName='text-destructive flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={handleCancel}>
{t('Cancel')}
</Button>
<Button
variant='destructive'
disabled={!canConfirm}
onClick={handleConfirm}
>
{t('I confirm enabling high-risk retry')}
</Button>
</>
}
>
<div className='space-y-4'>
{detailItems.length > 0 && (
<div className='border-destructive/30 bg-destructive/5 rounded-lg border p-3'>
@@ -149,20 +159,6 @@ export function StatusCodeRiskDialog({
)}
</div>
</div>
<DialogFooter>
<Button variant='outline' onClick={handleCancel}>
{t('Cancel')}
</Button>
<Button
variant='destructive'
disabled={!canConfirm}
onClick={handleConfirm}
>
{t('I confirm enabling high-risk retry')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -23,18 +23,11 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { MultiSelect } from '@/components/multi-select'
import {
getTagModels,
@@ -190,15 +183,35 @@ export function TagBatchEditDialog({
if (!currentTag) return null
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className='max-h-[90vh] max-w-2xl overflow-y-auto'>
<DialogHeader>
<DialogTitle>{t('Batch Edit by Tag')}</DialogTitle>
<DialogDescription>
{t('Edit all channels with tag:')} <strong>{currentTag}</strong>
</DialogDescription>
</DialogHeader>
<Dialog
open={open}
onOpenChange={handleClose}
title={t('Batch Edit by Tag')}
description={
<>
{t('Edit all channels with tag:')}
<strong>{currentTag}</strong>
</>
}
contentClassName='max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
!isLoading ? (
<>
<Button variant='outline' onClick={handleClose} disabled={isSaving}>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{isSaving ? t('Saving...') : t('Save Changes')}
</Button>
</>
) : null
}
>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
@@ -272,9 +285,7 @@ export function TagBatchEditDialog({
options={groupOptions}
selected={groups}
onChange={setGroups}
placeholder={t(
'Select groups (leave empty to keep current)'
)}
placeholder={t('Select groups (leave empty to keep current)')}
/>
)}
<p className='text-muted-foreground text-xs'>
@@ -282,23 +293,8 @@ export function TagBatchEditDialog({
</p>
</div>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={handleClose}
disabled={isSaving}
>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isSaving ? t('Saving...') : t('Save Changes')}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
)
}
@@ -21,17 +21,11 @@ import { Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
interface UpstreamUpdateDialogProps {
@@ -120,18 +114,36 @@ export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
return (
<>
<Dialog open={props.open} onOpenChange={(v) => !v && props.onCancel()}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Upstream Model Updates')}</DialogTitle>
</DialogHeader>
<Dialog
open={props.open}
onOpenChange={(v) => !v && props.onCancel()}
title={t('Upstream Model Updates')}
contentClassName='sm:max-w-lg'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={props.onCancel}>
{t('Cancel')}
</Button>
<Button
onClick={handleConfirm}
disabled={
props.confirmLoading ||
(props.addModels.length === 0 &&
props.removeModels.length === 0)
}
>
{t('Confirm')}
</Button>
</>
}
>
<p className='text-muted-foreground text-sm'>
{t(
'Select models to process. Unselected "add" models will be ignored.'
)}
</p>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as 'add' | 'remove')}
@@ -139,21 +151,13 @@ export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='add' className='gap-1'>
{t('Add Models')}
<StatusBadge
variant='neutral'
className='ml-1'
copyable={false}
>
<StatusBadge variant='neutral' className='ml-1' copyable={false}>
{selectedAdd.size}/{props.addModels.length}
</StatusBadge>
</TabsTrigger>
<TabsTrigger value='remove' className='gap-1'>
{t('Remove Models')}
<StatusBadge
variant='neutral'
className='ml-1'
copyable={false}
>
<StatusBadge variant='neutral' className='ml-1' copyable={false}>
{selectedRemove.size}/{props.removeModels.length}
</StatusBadge>
</TabsTrigger>
@@ -248,11 +252,7 @@ export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
<Checkbox
checked={selectedRemove.has(model)}
onCheckedChange={() =>
toggleModel(
model,
selectedRemove,
setSelectedRemove
)
toggleModel(model, selectedRemove, setSelectedRemove)
}
/>
<span className='truncate text-sm'>{model}</span>
@@ -269,23 +269,6 @@ export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
</ScrollArea>
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant='outline' onClick={props.onCancel}>
{t('Cancel')}
</Button>
<Button
onClick={handleConfirm}
disabled={
props.confirmLoading ||
(props.addModels.length === 0 &&
props.removeModels.length === 0)
}
>
{t('Confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
+1 -1
View File
@@ -27,7 +27,7 @@ export function Channels() {
const { t } = useTranslation()
return (
<ChannelsProvider>
<SectionPageLayout>
<SectionPageLayout fixedContent>
<SectionPageLayout.Title>{t('Channels')}</SectionPageLayout.Title>
<SectionPageLayout.Actions>
<ChannelsPrimaryButtons />
@@ -21,15 +21,6 @@ import { Save, Settings2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { TimeGranularity } from '@/lib/time'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import {
Select,
@@ -39,6 +30,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Dialog } from '@/components/dialog'
import {
CONSUMPTION_DISTRIBUTION_CHART_OPTIONS,
MODEL_ANALYTICS_CHART_OPTIONS,
@@ -74,23 +66,28 @@ export function ModelsChartPreferences(props: ModelsChartPreferencesProps) {
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger render={<Button variant='outline' size='sm' />}>
<Dialog
open={open}
onOpenChange={handleOpenChange}
trigger={
<Button variant='outline' size='sm'>
<Settings2 className='mr-2 h-4 w-4' />
{t('Preferences')}
</DialogTrigger>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Dashboard Preferences')}</DialogTitle>
<DialogDescription>
{t(
'Choose the default charts, range, and time granularity for model analytics.'
)}
</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-2'>
<div className='grid gap-2'>
</Button>
}
title={t('Model Analytics Defaults')}
description={t('Set default ranges and charts for model analytics.')}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='grid gap-3'
footer={
<Button onClick={handleSave} type='button'>
<Save className='mr-2 h-4 w-4' />
{t('Save Preferences')}
</Button>
}
>
<div className='grid gap-1.5'>
<Label htmlFor='default-time-range'>{t('Default range')}</Label>
<Select
items={[
@@ -121,8 +118,7 @@ export function ModelsChartPreferences(props: ModelsChartPreferencesProps) {
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<div className='grid gap-1.5'>
<Label htmlFor='default-time-granularity'>
{t('Default time granularity')}
</Label>
@@ -155,8 +151,7 @@ export function ModelsChartPreferences(props: ModelsChartPreferencesProps) {
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<div className='grid gap-1.5'>
<Label htmlFor='consumption-distribution-chart'>
{t('Default consumption chart')}
</Label>
@@ -190,8 +185,7 @@ export function ModelsChartPreferences(props: ModelsChartPreferencesProps) {
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<div className='grid gap-1.5'>
<Label htmlFor='model-analytics-chart'>
{t('Default model call chart')}
</Label>
@@ -224,15 +218,6 @@ export function ModelsChartPreferences(props: ModelsChartPreferencesProps) {
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button onClick={handleSave} type='button'>
<Save className='mr-2 h-4 w-4' />
{t('Save Preferences')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -23,15 +23,6 @@ import { useAuthStore } from '@/stores/auth-store'
import { getRollingDateRange, type TimeGranularity } from '@/lib/time'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
@@ -44,6 +35,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { DateTimePicker } from '@/components/datetime-picker'
import { Dialog } from '@/components/dialog'
import {
TIME_GRANULARITY_OPTIONS,
TIME_RANGE_PRESETS,
@@ -144,23 +136,35 @@ export function ModelsFilter(props: ModelsFilterProps) {
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger render={<Button variant='outline' size='sm' />}>
<Dialog
open={open}
onOpenChange={handleOpenChange}
trigger={
<Button variant='outline' size='sm'>
<Filter className='mr-2 h-4 w-4' />
{t('Filter')}
</DialogTrigger>
<DialogContent className='flex max-h-[calc(100dvh-2rem)] flex-col max-sm:h-dvh max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Filter Dashboard Models')}</DialogTitle>
<DialogDescription>
{t(
'Set filters to customize your dashboard statistics and charts.'
)}
</DialogDescription>
</DialogHeader>
<ScrollArea className='flex-1 pr-3 sm:pr-4'>
<div className='grid gap-3 py-3 sm:gap-4 sm:py-4'>
</Button>
}
title={t('Model Analytics Filters')}
description={t('Filter the model analytics view by time range and user.')}
contentClassName='max-sm:h-dvh max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-lg'
contentHeight='min(48vh, 460px)'
footerClassName='grid grid-cols-2 gap-2 sm:flex'
footer={
<>
<Button onClick={handleReset} variant='outline' type='button'>
<RotateCcw className='mr-2 h-4 w-4' />
{t('Reset')}
</Button>
<Button onClick={handleApply} type='submit'>
<Search className='mr-2 h-4 w-4' />
{t('Apply Filters')}
</Button>
</>
}
>
<ScrollArea className='h-full pr-3 sm:pr-4'>
<div className='grid gap-2.5 py-2'>
{/* Quick time range selection */}
<div className='grid gap-2'>
<Label className='flex items-center gap-2'>
@@ -173,9 +177,7 @@ export function ModelsFilter(props: ModelsFilterProps) {
key={range.days}
type='button'
size='sm'
variant={
selectedRange === range.days ? 'default' : 'outline'
}
variant={selectedRange === range.days ? 'default' : 'outline'}
onClick={() => handleQuickRange(range.days)}
className={cn(
'flex-1',
@@ -192,7 +194,7 @@ export function ModelsFilter(props: ModelsFilterProps) {
<SectionDivider label={t('Custom Time Range')} />
{/* Custom time range */}
<div className='grid gap-3 sm:gap-4'>
<div className='grid gap-2.5'>
<div className='grid gap-2'>
<Label htmlFor='start_timestamp'>{t('Start Time')}</Label>
<DateTimePicker
@@ -265,18 +267,6 @@ export function ModelsFilter(props: ModelsFilterProps) {
)}
</div>
</ScrollArea>
<DialogFooter className='grid grid-cols-2 gap-2 sm:flex'>
<Button onClick={handleReset} variant='outline' type='button'>
<RotateCcw className='mr-2 h-4 w-4' />
{t('Reset')}
</Button>
<Button onClick={handleApply} type='submit'>
<Search className='mr-2 h-4 w-4' />
{t('Apply Filters')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -18,15 +18,9 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import { formatDateTimeObject } from '@/lib/time'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Markdown } from '@/components/ui/markdown'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Dialog } from '@/components/dialog'
interface AnnouncementDetailModalProps {
open: boolean
@@ -47,18 +41,20 @@ export function AnnouncementDetailModal({
}: AnnouncementDetailModalProps) {
const { t } = useTranslation()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Announcement Details')}</DialogTitle>
{announcement?.publishDate && (
<DialogDescription>
{t('Published:')}{' '}
{formatDateTimeObject(new Date(announcement.publishDate))}
</DialogDescription>
)}
</DialogHeader>
<ScrollArea className='max-h-[60vh] pr-4'>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Announcement Details')}
description={
announcement?.publishDate
? `${t('Published:')} ${formatDateTimeObject(new Date(announcement.publishDate))}`
: undefined
}
contentClassName='sm:max-w-lg'
contentHeight='auto'
bodyClassName='space-y-4'
>
<ScrollArea className='max-h-[min(58vh,520px)] pr-4'>
<div className='space-y-4'>
{announcement?.content && (
<div>
@@ -78,7 +74,6 @@ export function AnnouncementDetailModal({
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
)
}
@@ -76,18 +76,18 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
}, [resolvedFullKey, resolveRealKey, apiKey.id, markKeyCopied, t])
return (
<div className='flex items-center'>
<div className='flex max-w-full min-w-0 items-center'>
<Popover open={popoverOpen} onOpenChange={handlePopoverOpen}>
<PopoverTrigger
render={
<Button
variant='ghost'
size='sm'
className='text-muted-foreground h-7 font-mono text-xs'
className='text-muted-foreground h-7 max-w-full min-w-0 justify-start truncate px-0 font-mono text-xs hover:bg-transparent aria-expanded:bg-transparent'
/>
}
>
{maskedKey}
<span className='truncate'>{maskedKey}</span>
</PopoverTrigger>
<PopoverContent
className='w-auto max-w-[min(90vw,28rem)]'
@@ -92,6 +92,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
),
enableSorting: false,
enableHiding: false,
size: 40,
meta: { label: t('Select') },
},
{
@@ -104,6 +105,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
{row.getValue('name')}
</div>
),
size: 180,
meta: { label: t('Name'), mobileTitle: true },
},
{
@@ -123,6 +125,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
)
},
filterFn: (row, id, value) => value.includes(String(row.getValue(id))),
size: 120,
meta: { label: t('Status'), mobileBadge: true },
},
{
@@ -131,6 +134,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
header: t('API Key'),
cell: ({ row }) => <ApiKeyCell apiKey={row.original} />,
enableSorting: false,
size: 260,
meta: { label: t('API Key') },
},
{
@@ -189,6 +193,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
</Tooltip>
)
},
size: 170,
meta: { label: t('Quota') },
},
{
@@ -230,6 +235,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
}
return <GroupBadge group={group} ratio={ratio} />
},
size: 160,
meta: { label: t('Group'), mobileHidden: true },
},
{
@@ -240,6 +246,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
),
cell: ({ row }) => <ModelLimitsCell apiKey={row.original} />,
enableSorting: false,
size: 160,
meta: { label: t('Models'), mobileHidden: true },
},
{
@@ -250,6 +257,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
),
cell: ({ row }) => <IpRestrictionsCell apiKey={row.original} />,
enableSorting: false,
size: 160,
meta: { label: t('IP Restriction'), mobileHidden: true },
},
{
@@ -258,10 +266,11 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
<DataTableColumnHeader column={column} title={t('Created')} />
),
cell: ({ row }) => (
<span className='text-muted-foreground font-mono text-xs tabular-nums'>
<span className='text-muted-foreground block truncate font-mono text-xs tabular-nums'>
{formatTimestampToDate(row.getValue('created_time'))}
</span>
),
size: 180,
meta: { label: t('Created'), mobileHidden: true },
},
{
@@ -275,11 +284,12 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
return <span className='text-muted-foreground text-xs'>-</span>
}
return (
<span className='text-muted-foreground font-mono text-xs tabular-nums'>
<span className='text-muted-foreground block truncate font-mono text-xs tabular-nums'>
{formatTimestampToDate(accessedTime)}
</span>
)
},
size: 180,
meta: { label: t('Last Used'), mobileHidden: true },
},
{
@@ -302,7 +312,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
return (
<span
className={cn(
'font-mono text-xs tabular-nums',
'block truncate font-mono text-xs tabular-nums',
isExpired ? 'text-destructive' : 'text-muted-foreground'
)}
>
@@ -310,6 +320,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
</span>
)
},
size: 180,
meta: { label: t('Expires'), mobileHidden: true },
},
{
+17 -59
View File
@@ -16,21 +16,9 @@ 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 { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import {
type SortingState,
type VisibilityState,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table'
import { useDebounce } from '@/hooks'
import { type Table as TanstackTable } from '@tanstack/react-table'
import { Database } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -50,6 +38,8 @@ import {
DISABLED_ROW_DESKTOP,
DISABLED_ROW_MOBILE,
DataTablePage,
useDebouncedColumnFilter,
useDataTable,
} from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge'
import { getApiKeys, searchApiKeys } from '../api'
@@ -99,7 +89,7 @@ function ApiKeysMobileList({
table,
isLoading,
}: {
table: ReturnType<typeof useReactTable<ApiKey>>
table: TanstackTable<ApiKey>
isLoading: boolean
}) {
const { t } = useTranslation()
@@ -192,9 +182,6 @@ export function ApiKeysTable() {
const { t } = useTranslation()
const { refreshTrigger } = useApiKeys()
const columns = useApiKeysColumns()
const [rowSelection, setRowSelection] = useState({})
const [sorting, setSorting] = useState<SortingState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const {
globalFilter,
@@ -215,27 +202,15 @@ export function ApiKeysTable() {
],
})
const tokenFilterFromUrl =
(columnFilters.find((f) => f.id === '_tokenSearch')?.value as string) || ''
const [tokenFilterInput, setTokenFilterInput] = useState(tokenFilterFromUrl)
const debouncedTokenFilter = useDebounce(tokenFilterInput, 500)
useEffect(() => {
setTokenFilterInput(tokenFilterFromUrl)
}, [tokenFilterFromUrl])
useEffect(() => {
if (debouncedTokenFilter !== tokenFilterFromUrl) {
onColumnFiltersChange((prev) => {
const filtered = prev.filter((f) => f.id !== '_tokenSearch')
return debouncedTokenFilter
? [...filtered, { id: '_tokenSearch', value: debouncedTokenFilter }]
: filtered
const {
value: tokenFilter,
inputValue: tokenFilterInput,
setInputValue: setTokenFilterInput,
} = useDebouncedColumnFilter({
columnFilters,
columnId: '_tokenSearch',
onColumnFiltersChange,
})
}
}, [debouncedTokenFilter, tokenFilterFromUrl, onColumnFiltersChange])
const tokenFilter = tokenFilterFromUrl
const shouldSearch = Boolean(globalFilter?.trim() || tokenFilter.trim())
// Fetch data with React Query
@@ -284,40 +259,22 @@ export function ApiKeysTable() {
const apiKeys = data?.items || []
const table = useReactTable({
const { table } = useDataTable({
data: apiKeys,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
enableRowSelection: true,
columnFilters,
globalFilter,
pagination,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility,
globalFilterFn: () => true,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
onPaginationChange,
onGlobalFilterChange,
onColumnFiltersChange,
manualPagination: true,
pageCount: Math.ceil((data?.total || 0) / pagination.pageSize),
totalCount: data?.total || 0,
ensurePageInRange,
})
const pageCount = table.getPageCount()
useEffect(() => {
ensurePageInRange(pageCount)
}, [pageCount, ensurePageInRange])
return (
<DataTablePage
table={table}
@@ -329,6 +286,7 @@ export function ApiKeysTable() {
'No API keys available. Create your first API key to get started.'
)}
skeletonKeyPrefix='api-keys-skeleton'
applyHeaderSize
toolbarProps={{
searchPlaceholder: t('Filter by name...'),
additionalSearch: (
@@ -23,15 +23,9 @@ import { toast } from 'sonner'
import { getUserModels } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { ComboboxInput } from '@/components/ui/combobox-input'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Dialog } from '@/components/dialog'
const APP_CONFIGS = {
claude: {
@@ -151,12 +145,22 @@ export function CCSwitchDialog(props: Props) {
}
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Import to CC Switch')}</DialogTitle>
</DialogHeader>
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={t('Import to CC Switch')}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={() => props.onOpenChange(false)}>
{t('Cancel')}
</Button>
<Button onClick={handleSubmit}>{t('Open CC Switch')}</Button>
</>
}
>
<div className='space-y-4'>
<div className='space-y-2'>
<Label>{t('Application')}</Label>
@@ -213,14 +217,6 @@ export function CCSwitchDialog(props: Props) {
</div>
))}
</div>
<DialogFooter>
<Button variant='outline' onClick={() => props.onOpenChange(false)}>
{t('Cancel')}
</Button>
<Button onClick={handleSubmit}>{t('Open CC Switch')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+1 -1
View File
@@ -27,7 +27,7 @@ export function ApiKeys() {
const { t } = useTranslation()
return (
<ApiKeysProvider>
<SectionPageLayout>
<SectionPageLayout fixedContent>
<SectionPageLayout.Title>{t('API Keys')}</SectionPageLayout.Title>
<SectionPageLayout.Actions>
<ApiKeysPrimaryButtons />
@@ -24,20 +24,13 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { copyToClipboard } from '@/lib/copy-to-clipboard'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import {
handleBatchEnableModels,
handleBatchDisableModels,
@@ -187,19 +180,17 @@ export function DataTableBulkActions<TData>({
</BulkActionsToolbar>
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Delete Models?')}</DialogTitle>
<DialogDescription>
{t(
<Dialog
open={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
title={t('Delete Models?')}
description={t(
'Are you sure you want to delete {{count}} model(s)? This action cannot be undone.',
{ count: selectedIds.length }
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
contentHeight='auto'
footer={
<>
<Button
variant='outline'
onClick={() => setShowDeleteConfirm(false)}
@@ -209,8 +200,10 @@ export function DataTableBulkActions<TData>({
<Button variant='destructive' onClick={handleDeleteAll}>
{t('Delete')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
{' '}
</Dialog>
</>
)
@@ -21,7 +21,7 @@ import { Eye, Info, Pencil, Settings2, Timer, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { formatTimestampToDate } from '@/lib/format'
import { Button } from '@/components/ui/button'
import { DataTableColumnHeader } from '@/components/data-table/column-header'
import { DataTableColumnHeader } from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import { getDeploymentStatusConfig } from '../constants'
@@ -16,14 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import {
getCoreRowModel,
useReactTable,
type VisibilityState,
} from '@tanstack/react-table'
import { useMediaQuery } from '@/hooks'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -38,7 +33,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { DataTablePage } from '@/components/data-table'
import { DataTablePage, useDataTable } from '@/components/data-table'
import { deleteDeployment, listDeployments, searchDeployments } from '../api'
import { getDeploymentStatusOptions } from '../constants'
import { deploymentsQueryKeys } from '../lib'
@@ -167,8 +162,6 @@ export function DeploymentsTable() {
}
}
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const columns = useDeploymentsColumns({
onViewLogs: (id) => {
setLogsDeploymentId(id)
@@ -197,30 +190,22 @@ export function DeploymentsTable() {
},
})
const table = useReactTable({
const { table } = useDataTable({
data: deployments,
columns,
pageCount: Math.ceil(totalCount / pagination.pageSize),
state: {
totalCount,
columnFilters,
columnVisibility,
pagination,
globalFilter,
},
onColumnFiltersChange,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange,
onGlobalFilterChange,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualFiltering: true,
withSortedRowModel: false,
ensurePageInRange,
})
const pageCount = table.getPageCount()
useEffect(() => {
ensurePageInRange(pageCount)
}, [ensurePageInRange, pageCount])
const statusFilterOptions = useMemo(() => {
return [...getDeploymentStatusOptions(t)].map((opt) => ({
label: opt.label,
@@ -17,14 +17,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Dialog } from '@/components/dialog'
type DescriptionDialogProps = {
open: boolean
@@ -41,13 +35,15 @@ export function DescriptionDialog({
}: DescriptionDialogProps) {
const { t } = useTranslation()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-w-2xl'>
<DialogHeader>
<DialogTitle>{modelName}</DialogTitle>
<DialogDescription>{t('Model Description')}</DialogDescription>
</DialogHeader>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={modelName}
description={t('Model Description')}
contentClassName='max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
>
<ScrollArea className='max-h-96'>
<div className='space-y-2 pr-4'>
<p className='text-foreground text-sm leading-relaxed break-words whitespace-pre-wrap'>
@@ -55,7 +51,6 @@ export function DescriptionDialog({
</p>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
)
}
@@ -22,15 +22,9 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import { Dialog } from '@/components/dialog'
import { estimatePrice, extendDeployment, getDeployment } from '../../api'
import { deploymentsQueryKeys } from '../../lib'
@@ -164,12 +158,28 @@ export function ExtendDeploymentDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Extend deployment')}</DialogTitle>
</DialogHeader>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Extend deployment')}
contentClassName='sm:max-w-lg'
footerClassName='mt-4'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{t('Cancel')}
</Button>
<Button onClick={() => void onSubmit()} disabled={!canSubmit}>
{isSubmitting ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{t('Extend')}
</Button>
</>
}
>
{isLoadingDetails ? (
<div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
@@ -218,19 +228,6 @@ export function ExtendDeploymentDialog({
</div>
</div>
)}
<DialogFooter className='mt-4'>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{t('Cancel')}
</Button>
<Button onClick={() => void onSubmit()} disabled={!canSubmit}>
{isSubmitting ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{t('Extend')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -22,13 +22,6 @@ import { ChevronLeft, ChevronRight, Loader2, Plus, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useIsMobile } from '@/hooks/use-mobile'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Empty,
EmptyDescription,
@@ -37,6 +30,7 @@ import {
EmptyTitle,
} from '@/components/ui/empty'
import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { getMissingModels } from '../../api'
import { DEFAULT_PAGE_SIZE } from '../../constants'
@@ -115,18 +109,19 @@ export function MissingModelsDialog({
const showPagination = totalItems > pageSize
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className='flex max-h-[85vh] max-w-2xl flex-col gap-3 p-4'
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Missing Models')}
description={t(
'Models that are being used but not configured in the system'
)}
contentClassName='flex max-h-[85vh] max-w-2xl flex-col gap-3 p-4'
headerClassName='flex-shrink-0 text-start'
contentHeight='min(74vh, 760px)'
bodyClassName='space-y-4'
initialFocus={!isMobile}
>
<DialogHeader className='flex-shrink-0 text-start'>
<DialogTitle>{t('Missing Models')}</DialogTitle>
<DialogDescription>
{t('Models that are being used but not configured in the system')}
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='h-8 w-8 animate-spin' />
@@ -142,8 +137,7 @@ export function MissingModelsDialog({
<div className='flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto'>
<div className='flex flex-shrink-0 items-center justify-between gap-3'>
<div className='text-muted-foreground text-sm whitespace-nowrap'>
{t('Showing')} {displayStart}-{displayEnd} {t('of')}{' '}
{totalItems}
{t('Showing')} {displayStart}-{displayEnd} {t('of')} {totalItems}
</div>
<div className='relative w-48'>
<Search className='text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
@@ -225,9 +219,7 @@ export function MissingModelsDialog({
size='icon'
className='h-8 w-8'
onClick={() =>
setCurrentPage((prev) =>
Math.min(totalPages, prev + 1)
)
setCurrentPage((prev) => Math.min(totalPages, prev + 1))
}
disabled={currentPage === totalPages}
aria-label={t('Next page')}
@@ -241,7 +233,6 @@ export function MissingModelsDialog({
)}
</div>
)}
</DialogContent>
</Dialog>
)
}
@@ -25,7 +25,6 @@ import {
Plus,
RefreshCcw,
Trash2,
X,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -40,14 +39,6 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Empty,
EmptyDescription,
@@ -55,15 +46,9 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StaticDataTable } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import { deletePrefillGroup, getPrefillGroups } from '../../api'
@@ -172,51 +157,31 @@ export function PrefillGroupManagementDialog({
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className='prefill-dialog-content !top-4 !flex !-translate-y-0 !flex-col !gap-0 !border-none !bg-transparent !p-0 !shadow-none sm:!top-1/2 sm:!-translate-y-1/2'
style={{ maxWidth: 'min(100vw, 64rem)' }}
>
<div
className={cn(
'prefill-dialog-panel border-border/70 bg-background flex max-h-[calc(100dvh-1.5rem)] flex-col overflow-hidden border shadow-2xl',
isMobile ? 'rounded-none' : 'rounded-2xl'
)}
>
<div
className={cn(
'relative flex flex-col gap-3 border-b px-4 py-4 sm:px-6 sm:py-5',
isMobile && 'pt-[calc(env(safe-area-inset-top,0px)+1rem)]'
)}
>
<DialogHeader className='max-w-3xl gap-3 pr-12 text-start sm:pr-0'>
<DialogTitle className='flex flex-wrap items-center gap-2 text-xl'>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
<Layers3 className='text-foreground/80 h-5 w-5' />
{t('Prefill Group Management')}
</DialogTitle>
<DialogDescription className='text-base leading-relaxed sm:text-sm'>
{t(
</>
}
description={t(
'Create reusable bundles of models, tags, endpoints, and user groups to speed up configuration elsewhere in the console.'
)}
</DialogDescription>
</DialogHeader>
<DialogClose
render={
<Button
variant='ghost'
size='icon'
className='text-muted-foreground hover:text-foreground absolute top-4 right-4 border border-transparent sm:top-5 sm:right-6'
/>
}
contentClassName={cn(
'w-[calc(100vw-2rem)] sm:max-w-[52rem]',
isMobile && 'max-w-none rounded-none'
)}
titleClassName='flex flex-wrap items-center gap-2 text-lg'
descriptionClassName='text-sm leading-relaxed'
contentHeight='auto'
bodyClassName={cn(
'space-y-3',
isMobile && 'pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]'
)}
>
<span className='sr-only'>{t('Close dialog')}</span>
<X className='h-4 w-4' />
</DialogClose>
</div>
<div className='flex flex-wrap items-center gap-3 border-b px-4 py-3 text-sm sm:px-6'>
<div className='bg-muted/30 flex flex-wrap items-center justify-between gap-3 rounded-md border p-2 text-sm'>
<div className='flex flex-wrap items-center gap-2'>
<Button size='sm' onClick={onCreateGroup}>
<Plus className='mr-2 h-4 w-4' />
@@ -243,14 +208,7 @@ export function PrefillGroupManagementDialog({
/>
</div>
<div
className={cn(
'flex flex-1 flex-col overflow-hidden px-4 py-4 sm:px-6 sm:py-6',
isMobile && 'pb-[calc(env(safe-area-inset-bottom,0px)+1.5rem)]'
)}
>
<div className='flex-1 overflow-y-auto'>
<div className='flex flex-col gap-4'>
<div className='flex flex-col gap-3'>
{error && (
<Alert variant='destructive'>
<AlertTitle>{t('Unable to load groups')}</AlertTitle>
@@ -262,14 +220,14 @@ export function PrefillGroupManagementDialog({
)}
{isLoading ? (
<div className='flex flex-col items-center justify-center gap-2 py-16 text-center'>
<div className='flex flex-col items-center justify-center gap-2 py-12 text-center'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
<p className='text-muted-foreground text-sm'>
{t('Fetching prefill groups...')}
</p>
</div>
) : normalizedGroups.length === 0 ? (
<Empty className='border border-dashed'>
<Empty className='border border-dashed py-10'>
<EmptyMedia variant='icon'>
<Layers3 className='h-6 w-6' />
</EmptyMedia>
@@ -288,7 +246,7 @@ export function PrefillGroupManagementDialog({
</EmptyDescription>
</Empty>
) : isMobile ? (
<div className='space-y-4'>
<div className='space-y-3'>
{normalizedGroups.map(({ group, meta, parsedItems }) => (
<Card key={group.id} className='border-border/60'>
<CardHeader className='flex flex-row items-start justify-between gap-4'>
@@ -301,9 +259,7 @@ export function PrefillGroupManagementDialog({
copyable={false}
>
{meta.label}
<span className='text-muted-foreground/30'>
·
</span>
<span className='text-muted-foreground/30'>·</span>
<span className='text-muted-foreground font-mono'>
#{group.id}
</span>
@@ -381,31 +337,19 @@ export function PrefillGroupManagementDialog({
))}
</div>
) : (
<div className='rounded-md border'>
<div className='w-full overflow-x-auto'>
<Table className='min-w-[720px]'>
<TableHeader>
<TableRow>
<TableHead>{t('Group')}</TableHead>
<TableHead>{t('Type')}</TableHead>
<TableHead className='min-w-[280px]'>
{t('Items')}
</TableHead>
<TableHead className='w-[120px] text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{normalizedGroups.map(
({ group, meta, parsedItems }) => (
<TableRow key={group.id}>
<TableCell className='align-top whitespace-normal'>
<StaticDataTable
tableClassName='min-w-[680px]'
data={normalizedGroups}
getRowKey={({ group }) => group.id}
columns={[
{
id: 'group',
header: t('Group'),
cellClassName: 'align-top whitespace-normal',
cell: ({ group }) => (
<div className='flex flex-col gap-1'>
<div className='flex flex-wrap items-center gap-2'>
<span className='font-medium'>
{group.name}
</span>
<span className='font-medium'>{group.name}</span>
<TableId value={group.id} />
</div>
{group.description ? (
@@ -418,22 +362,32 @@ export function PrefillGroupManagementDialog({
</p>
)}
</div>
</TableCell>
<TableCell className='align-top'>
),
},
{
id: 'type',
header: t('Type'),
cellClassName: 'align-top',
cell: ({ meta }) => (
<StatusBadge
label={meta.label}
variant={meta.badge}
size='sm'
copyable={false}
/>
</TableCell>
<TableCell className='align-top whitespace-normal'>
),
},
{
id: 'items',
header: t('Items'),
className: 'min-w-[240px]',
cellClassName: 'align-top whitespace-normal',
cell: ({ group, parsedItems }) => (
<>
<div className='flex flex-wrap gap-2'>
{parsedItems.length > 0 ? (
<>
{parsedItems
.slice(0, 6)
.map((item) => (
{parsedItems.slice(0, 6).map((item) => (
<StatusBadge
key={item}
label={item}
@@ -462,8 +416,15 @@ export function PrefillGroupManagementDialog({
{parsedItems.length} item
{parsedItems.length === 1 ? '' : 's'}
</div>
</TableCell>
<TableCell className='align-top'>
</>
),
},
{
id: 'actions',
header: t('Actions'),
className: 'w-[120px] text-right',
cellClassName: 'align-top',
cell: ({ group }) => (
<div className='flex justify-end gap-2'>
<Button
size='icon'
@@ -471,9 +432,7 @@ export function PrefillGroupManagementDialog({
onClick={() => onEditGroup(group)}
>
<Pencil className='h-4 w-4' />
<span className='sr-only'>
Edit group
</span>
<span className='sr-only'>Edit group</span>
</Button>
<Button
size='icon'
@@ -482,25 +441,15 @@ export function PrefillGroupManagementDialog({
onClick={() => handleDeleteClick(group)}
>
<Trash2 className='h-4 w-4' />
<span className='sr-only'>
Delete group
</span>
<span className='sr-only'>Delete group</span>
</Button>
</div>
</TableCell>
</TableRow>
)
)}
</TableBody>
</Table>
</div>
</div>
),
},
]}
/>
)}
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
<ConfirmDialog
@@ -22,14 +22,8 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog'
import { checkClusterNameAvailability, updateDeploymentName } from '../../api'
import { deploymentsQueryKeys } from '../../lib'
@@ -111,12 +105,28 @@ export function RenameDeploymentDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Rename deployment')}</DialogTitle>
</DialogHeader>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Rename deployment')}
contentClassName='sm:max-w-lg'
footerClassName='mt-4'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{t('Cancel')}
</Button>
<Button onClick={() => void onSubmit()} disabled={!canSubmit}>
{isSubmitting ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{t('Rename')}
</Button>
</>
}
>
<div className='space-y-2'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}:{' '}
@@ -130,19 +140,6 @@ export function RenameDeploymentDialog({
/>
<div className='text-muted-foreground text-xs'>{helper}</div>
</div>
<DialogFooter className='mt-4'>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{t('Cancel')}
</Button>
<Button onClick={() => void onSubmit()} disabled={!canSubmit}>
{isSubmitting ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{t('Rename')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -24,16 +24,9 @@ import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { useIsMobile } from '@/hooks/use-mobile'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { syncUpstream, previewUpstreamDiff } from '../../api'
import { getSyncLocaleOptions, getSyncSourceOptions } from '../../constants'
@@ -125,19 +118,31 @@ export function SyncWizardDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className='flex max-h-[90vh] w-full flex-col gap-4 p-4 sm:max-w-2xl sm:p-6'
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Sync Upstream Models')}
description={t('Synchronize models and vendors from an upstream source')}
initialFocus={!isMobile}
contentHeight='auto'
bodyClassName='flex flex-col gap-6'
footer={
<>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
disabled={isSyncing}
>
{t('Cancel')}
</Button>
<Button onClick={handleSync} disabled={isSyncing}>
{isSyncing && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
<RefreshCw className='mr-2 h-4 w-4' />
{isSyncing ? t('Syncing...') : t('Sync Now')}
</Button>
</>
}
>
<DialogHeader className='flex-shrink-0 text-start'>
<DialogTitle>{t('Sync Upstream Models')}</DialogTitle>
<DialogDescription>
{t('Synchronize models and vendors from an upstream source')}
</DialogDescription>
</DialogHeader>
<div className='flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto'>
<div className='space-y-3'>
<div>
<Label className='text-base'>{t('Select Sync Source')}</Label>
@@ -233,23 +238,6 @@ export function SyncWizardDialog({
)}
</p>
</div>
</div>
<DialogFooter className='flex-shrink-0 gap-2 sm:justify-end'>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
disabled={isSyncing}
>
{t('Cancel')}
</Button>
<Button onClick={handleSync} disabled={isSyncing}>
{isSyncing && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
<RefreshCw className='mr-2 h-4 w-4' />
{isSyncing ? 'Syncing...' : 'Sync Now'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -30,13 +30,6 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -47,6 +40,7 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { getDeployment, updateDeployment } from '../../api'
import { deploymentsQueryKeys } from '../../lib'
@@ -64,6 +58,8 @@ const schema = z.object({
type Values = z.input<typeof schema>
const UPDATE_CONFIG_FORM_ID = 'update-config-form'
function normalizeJsonObject(input?: string) {
if (!input || !input.trim()) return undefined
const parsed = JSON.parse(input)
@@ -212,12 +208,37 @@ export function UpdateConfigDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-h-[calc(100dvh-2rem)] overflow-hidden max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-3xl'>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={title}
contentClassName='max-h-[calc(100dvh-2rem)] overflow-hidden max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
isLoading ? null : (
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button
type='submit'
form={UPDATE_CONFIG_FORM_ID}
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{t('Update')}
</Button>
</>
)
}
>
{isLoading ? (
<div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
@@ -226,6 +247,7 @@ export function UpdateConfigDialog({
<div className='max-h-[calc(100dvh-8.5rem)] overflow-y-auto py-2 pr-1 sm:max-h-[72vh]'>
<Form {...form}>
<form
id={UPDATE_CONFIG_FORM_ID}
onSubmit={form.handleSubmit(onSubmit)}
autoComplete='off'
className='space-y-4'
@@ -238,10 +260,7 @@ export function UpdateConfigDialog({
<FormItem>
<FormLabel>{t('Image')}</FormLabel>
<FormControl>
<Input
placeholder='ollama/ollama:latest'
{...field}
/>
<Input placeholder='ollama/ollama:latest' {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -286,9 +305,7 @@ export function UpdateConfigDialog({
name='entrypoint'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Entrypoint (space separated)')}
</FormLabel>
<FormLabel>{t('Entrypoint (space separated)')}</FormLabel>
<FormControl>
<Input placeholder='bash -lc' {...field} />
</FormControl>
@@ -394,9 +411,7 @@ export function UpdateConfigDialog({
name='secret_env_json'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Secret env (JSON object)')}
</FormLabel>
<FormLabel>{t('Secret env (JSON object)')}</FormLabel>
<FormControl>
<Textarea
className='min-h-40 font-mono text-xs'
@@ -411,27 +426,10 @@ export function UpdateConfigDialog({
</div>
</CollapsibleContent>
</Collapsible>
<DialogFooter className='grid grid-cols-2 gap-2 pt-2 sm:flex'>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='submit' disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{t('Update')}
</Button>
</DialogFooter>
</form>
</Form>
</div>
)}
</DialogContent>
</Dialog>
)
}
@@ -18,13 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useEffect, useMemo, useState, useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import {
flexRender,
getCoreRowModel,
useReactTable,
type ColumnDef,
type RowSelectionState,
} from '@tanstack/react-table'
import { type ColumnDef, type RowSelectionState } from '@tanstack/react-table'
import {
Search,
Info,
@@ -37,14 +31,6 @@ import { toast } from 'sonner'
import { useIsMobile } from '@/hooks/use-mobile'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
Popover,
@@ -59,14 +45,8 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { DataTableView, useDataTable } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { applyUpstreamOverwrite } from '../../api'
import { modelsQueryKeys, vendorsQueryKeys } from '../../lib'
@@ -85,6 +65,8 @@ const FIELD_LABELS: Record<string, string> = {
enable_groups: 'Enable Groups',
}
const PAGE_SIZE_OPTIONS = [5, 10, 20, 50] as const
const formatValue = (value: unknown) => {
if (value === null || value === undefined) return '—'
if (typeof value === 'string') return value || '—'
@@ -348,16 +330,17 @@ export function UpstreamConflictDialog({
]
}, [isMobile])
const table = useReactTable({
const { table } = useDataTable({
data: conflictRows,
columns,
state: {
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
withFilteredRowModel: false,
withPaginationRowModel: false,
withSortedRowModel: false,
withFacetedRowModel: false,
})
const totalSelectedFields = table.getSelectedRowModel().rows.length
@@ -453,21 +436,46 @@ export function UpstreamConflictDialog({
}
onOpenChange(nextOpen)
}}
>
<DialogContent
className='flex max-h-[90vh] w-full flex-col gap-4 p-4 sm:max-w-5xl sm:p-6'
initialFocus={!isMobile}
>
<div className='flex min-h-0 flex-1 flex-col gap-4 overflow-hidden'>
<DialogHeader className='flex-shrink-0 text-start'>
<DialogTitle>{t('Resolve Conflicts')}</DialogTitle>
<DialogDescription>
{t(
title={t('Resolve Conflicts')}
description={t(
'Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.'
)}
</DialogDescription>
</DialogHeader>
contentClassName='w-full sm:max-w-5xl'
contentHeight='min(72vh, 720px)'
bodyClassName='flex flex-col gap-4'
initialFocus={!isMobile}
footerClassName='sm:justify-between'
footer={
<div className='flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<div className='text-muted-foreground flex flex-1 items-start gap-2 text-xs'>
<Info className='h-4 w-4 flex-shrink-0' />
<span>
{t(
'Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.'
)}
</span>
</div>
<div className='flex flex-col gap-2 sm:flex-row sm:justify-end'>
<Button
variant='outline'
onClick={() => {
setUpstreamConflicts([])
onOpenChange(false)
}}
>
{t('Cancel')}
</Button>
<Button
onClick={handleApplyOverwrite}
disabled={isSubmitting || !hasSelection}
>
{isSubmitting ? t('Applying...') : t('Apply Overwrite')}
</Button>
</div>
</div>
}
>
<div className='flex min-h-0 flex-1 flex-col gap-4'>
{!hasConflicts ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center rounded-md border border-dashed p-8 text-center text-sm'>
{t('No conflict entries available.')}
@@ -518,43 +526,14 @@ export function UpstreamConflictDialog({
) : (
<div className='flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border'>
<div className='flex-1 overflow-auto'>
<div className={isMobile ? 'min-w-full' : 'min-w-[720px]'}>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{paginatedRows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
<DataTableView
table={table}
rows={paginatedRows}
containerClassName='border-0'
tableContainerClassName={
isMobile ? 'min-w-full' : 'min-w-[720px]'
}
/>
</div>
<div className='bg-muted/40 flex flex-col gap-2 border-t px-2 py-1.5 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-3 sm:px-3 sm:py-2'>
@@ -569,12 +548,10 @@ export function UpstreamConflictDialog({
{t('Rows per page')}
</span>
<Select
items={[
...[5, 10, 20, 50].map((size) => ({
items={PAGE_SIZE_OPTIONS.map((size) => ({
value: String(size),
label: size,
})),
]}
}))}
value={String(pageSize)}
onValueChange={(value) => {
setPageSize(Number(value))
@@ -586,7 +563,7 @@ export function UpstreamConflictDialog({
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{[5, 10, 20, 50].map((size) => (
{PAGE_SIZE_OPTIONS.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
@@ -639,36 +616,6 @@ export function UpstreamConflictDialog({
</div>
)}
</div>
<DialogFooter className='flex-shrink-0'>
<div className='flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<div className='text-muted-foreground flex flex-1 items-start gap-2 text-xs'>
<Info className='h-4 w-4 flex-shrink-0' />
<span>
{t(
'Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.'
)}
</span>
</div>
<div className='flex flex-col gap-2 sm:flex-row sm:justify-end'>
<Button
variant='outline'
onClick={() => {
setUpstreamConflicts([])
onOpenChange(false)
}}
>
{t('Cancel')}
</Button>
<Button
onClick={handleApplyOverwrite}
disabled={isSubmitting || !hasSelection}
>
{isSubmitting ? t('Applying...') : t('Apply Overwrite')}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -24,14 +24,6 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -43,6 +35,7 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { createVendor, updateVendor } from '../../api'
import { vendorsQueryKeys, modelsQueryKeys } from '../../lib'
import { vendorFormSchema, type Vendor } from '../../types'
@@ -53,6 +46,8 @@ type VendorMutateDialogProps = {
currentVendor?: Vendor | null
}
const VENDOR_MUTATE_FORM_ID = 'vendor-mutate-form'
export function VendorMutateDialog({
open,
onOpenChange,
@@ -118,23 +113,48 @@ export function VendorMutateDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isEdit ? t('Edit Vendor') : t('Create Vendor')}
</DialogTitle>
<DialogDescription>
{isEdit
<Dialog
open={open}
onOpenChange={onOpenChange}
title={isEdit ? t('Edit Vendor') : t('Create Vendor')}
description={
isEdit
? t('Update vendor information for {{name}}', {
name: currentVendor?.name,
})
: t('Add a new vendor to the system')}
</DialogDescription>
</DialogHeader>
: t('Add a new vendor to the system')
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
{t('Cancel')}
</Button>
<Button
type='submit'
form={VENDOR_MUTATE_FORM_ID}
disabled={isSaving}
>
{isSaving ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{isSaving ? t('Saving...') : isEdit ? t('Update') : t('Create')}
</Button>
</>
}
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<form
id={VENDOR_MUTATE_FORM_ID}
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-4'
>
<FormField
control={form.control}
name='name'
@@ -192,24 +212,8 @@ export function VendorMutateDialog({
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
{t('Cancel')}
</Button>
<Button type='submit' disabled={isSaving}>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isSaving ? 'Saving...' : isEdit ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}
@@ -27,14 +27,8 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Separator } from '@/components/ui/separator'
import { Dialog } from '@/components/dialog'
import { getDeployment, listDeploymentContainers } from '../../api'
export function ViewDetailsDialog({
@@ -116,12 +110,25 @@ export function ViewDetailsDialog({
}, [details])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-h-[calc(100dvh-2rem)] overflow-hidden max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-3xl'>
<DialogHeader>
<DialogTitle>{t('Deployment details')}</DialogTitle>
</DialogHeader>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Deployment details')}
contentClassName='max-h-[calc(100dvh-2rem)] overflow-hidden max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
className='w-full sm:w-auto'
>
{t('Close')}
</Button>
</>
}
>
<div className='max-h-[calc(100dvh-8.5rem)] space-y-3 overflow-y-auto py-2 pr-1 sm:max-h-[72vh] sm:space-y-4'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='text-muted-foreground text-sm'>
@@ -184,9 +191,7 @@ export function ViewDetailsDialog({
{t('Total GPUs')}
</div>
<div className='mt-1 font-medium'>
{String(
details?.total_gpus ?? details?.hardware_qty ?? '-'
)}
{String(details?.total_gpus ?? details?.hardware_qty ?? '-')}
</div>
</div>
<div className='rounded-lg border p-3'>
@@ -231,9 +236,7 @@ export function ViewDetailsDialog({
className='flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2'
>
<div className='min-w-0'>
<div className='truncate font-mono text-sm'>
{id}
</div>
<div className='truncate font-mono text-sm'>{id}</div>
<div className='text-muted-foreground text-xs'>
{status ? `${t('Status')}: ${status}` : ''}
</div>
@@ -268,17 +271,6 @@ export function ViewDetailsDialog({
</>
)}
</div>
<DialogFooter>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
className='w-full sm:w-auto'
>
{t('Close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -21,12 +21,6 @@ import { useQuery } from '@tanstack/react-query'
import { Download, Loader2, RefreshCcw, Terminal } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
@@ -36,6 +30,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Dialog } from '@/components/dialog'
import { getDeploymentLogs, listDeploymentContainers } from '../../api'
interface ViewLogsDialogProps {
@@ -142,15 +137,20 @@ export function ViewLogsDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='flex h-[calc(100dvh-2rem)] flex-col max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:h-[80vh] sm:max-w-4xl'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
<Terminal className='h-5 w-5' />
{t('Deployment logs')}
</DialogTitle>
</DialogHeader>
</>
}
contentClassName='flex h-[calc(100dvh-2rem)] flex-col max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:h-[80vh] sm:max-w-4xl'
titleClassName='flex items-center gap-2'
contentHeight='min(72vh, 720px)'
bodyClassName='space-y-4'
>
<div className='mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}: {deploymentId}
@@ -187,12 +187,9 @@ export function ViewLogsDialog({
</div>
</div>
</div>
<div className='mb-3 grid gap-2 sm:grid-cols-2 sm:gap-3'>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs'>
{t('Container')}
</div>
<div className='text-muted-foreground text-xs'>{t('Container')}</div>
<Select
items={[
...containers.flatMap((c) => {
@@ -280,7 +277,6 @@ export function ViewLogsDialog({
</Select>
</div>
</div>
<div
ref={scrollRef}
className='flex-1 overflow-auto rounded-md border bg-black p-3 sm:p-4'
@@ -315,7 +311,6 @@ export function ViewLogsDialog({
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}
+15 -15
View File
@@ -27,8 +27,9 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { DataTableColumnHeader } from '@/components/data-table/column-header'
import { DataTableColumnHeader } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge'
import { ProviderBadge } from '@/components/provider-badge'
import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import {
@@ -41,6 +42,12 @@ import type { Model, Vendor } from '../types'
import { DataTableRowActions } from './data-table-row-actions'
import { DescriptionCell } from './description-cell'
function getCompactModelIcon(iconKey: string) {
const baseIconKey = iconKey.split('.')[0]
return getLobeIcon(`${baseIconKey}.Avatar.type={'platform'}`, 20)
}
/**
* Render limited items with "and X more" indicator
*/
@@ -123,9 +130,13 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
vendorMap[model.vendor_id || 0]?.icon ||
model.model_name?.[0] ||
'N'
const icon = getLobeIcon(iconKey, 20)
const icon = getCompactModelIcon(iconKey)
return <div className='flex items-center justify-center'>{icon}</div>
return (
<div className='ms-1 flex size-5 items-center justify-center overflow-hidden'>
{icon}
</div>
)
},
size: 70,
enableSorting: false,
@@ -259,18 +270,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
return <span className='text-muted-foreground text-xs'>-</span>
}
const icon = vendor.icon ? getLobeIcon(vendor.icon, 14) : null
return (
<div className='flex items-center gap-1.5'>
{icon}
<StatusBadge
label={vendor.name}
autoColor={vendor.name}
size='sm'
/>
</div>
)
return <ProviderBadge iconKey={vendor.icon} label={vendor.name} />
},
filterFn: (row, id, value) => {
if (!value || value.length === 0 || value.includes('all')) return true
+10 -34
View File
@@ -16,19 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useMemo, useEffect } from 'react'
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import {
getCoreRowModel,
useReactTable,
type SortingState,
type VisibilityState,
} from '@tanstack/react-table'
import { useMediaQuery } from '@/hooks'
import { useTranslation } from 'react-i18next'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import { DataTablePage } from '@/components/data-table'
import { DataTablePage, useDataTable } from '@/components/data-table'
import { getModels, searchModels, getVendors } from '../api'
import {
DEFAULT_PAGE_SIZE,
@@ -47,15 +41,6 @@ export function ModelsTable() {
const { selectedVendor } = useModels()
const isMobile = useMediaQuery('(max-width: 640px)')
// Table state
const [sorting, setSorting] = useState<SortingState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
description: false,
bound_channels: false,
quota_types: false,
})
const [rowSelection, setRowSelection] = useState({})
// URL state management
const {
globalFilter,
@@ -176,37 +161,28 @@ export function ModelsTable() {
const columns = useModelsColumns(vendors)
// React Table instance
const table = useReactTable({
const { table } = useDataTable({
data: models,
columns,
pageCount: Math.ceil(totalCount / pagination.pageSize),
state: {
sorting,
totalCount,
initialColumnVisibility: {
description: false,
bound_channels: false,
quota_types: false,
},
columnFilters,
columnVisibility,
rowSelection,
pagination,
globalFilter,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange,
onGlobalFilterChange,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualSorting: true,
manualFiltering: true,
ensurePageInRange,
})
// Ensure page is in range when total count changes
const pageCount = table.getPageCount()
useEffect(() => {
ensurePageInRange(pageCount)
}, [pageCount, ensurePageInRange])
// Prepare filter options
const vendorFilterOptions = [
{
+4 -2
View File
@@ -119,7 +119,7 @@ function ModelsContent() {
return (
<>
<SectionPageLayout>
<SectionPageLayout fixedContent>
<SectionPageLayout.Title>{t(meta.titleKey)}</SectionPageLayout.Title>
<SectionPageLayout.Actions>
{activeSection === 'metadata' ? (
@@ -132,7 +132,7 @@ function ModelsContent() {
)}
</SectionPageLayout.Actions>
<SectionPageLayout.Content>
<div className='space-y-4'>
<div className='flex h-full min-h-0 flex-col gap-4'>
<Tabs value={activeSection} onValueChange={handleSectionChange}>
<TabsList className='max-w-full flex-wrap justify-start group-data-horizontal/tabs:h-auto'>
{MODELS_SECTION_IDS.map((section) => (
@@ -142,6 +142,7 @@ function ModelsContent() {
))}
</TabsList>
</Tabs>
<div className='min-h-0 flex-1'>
{activeSection === 'metadata' ? (
<ModelsTable />
) : (
@@ -158,6 +159,7 @@ function ModelsContent() {
</DeploymentAccessGuard>
)}
</div>
</div>
</SectionPageLayout.Content>
</SectionPageLayout>
@@ -22,14 +22,7 @@ import { useTranslation } from 'react-i18next'
import { useSystemConfigStore } from '@/stores/system-config-store'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { StaticDataTable } from '@/components/data-table'
import {
BILLING_PRICING_VARS,
MATCH_CONTAINS,
@@ -307,39 +300,37 @@ export function DynamicPricingBreakdown({
)
})}
</div>
<div className='hidden overflow-x-auto sm:block'>
<Table className='text-sm'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className='text-muted-foreground py-2 font-medium'>
{t('Tier')}
</TableHead>
{visiblePriceFields.map((v) => (
<TableHead
key={v.field}
className='text-muted-foreground py-2 text-right font-medium'
>
{t(v.shortLabel)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{tiers.map((tier, i) => {
const condSummary = formatConditionSummary(tier.conditions, t)
<StaticDataTable
className='hidden rounded-none border-0 sm:block'
tableClassName='text-sm'
headerRowClassName='hover:bg-transparent'
data={tiers}
getRowKey={(_tier, index) => `tier-${index}`}
getRowClassName={(tier) => {
const isMatched =
normalizedMatchedTierLabel !== '' &&
normalizeTierLabel(tier.label) ===
normalizedMatchedTierLabel
return (
<TableRow
key={`tier-${i}`}
className={cn(
normalizeTierLabel(tier.label) === normalizedMatchedTierLabel
return cn(
isMatched &&
'bg-emerald-50/70 hover:bg-emerald-50/70 dark:bg-emerald-500/10 dark:hover:bg-emerald-500/10'
)}
>
<TableCell className='py-2.5 align-top'>
)
}}
columns={[
{
id: 'tier',
header: t('Tier'),
className: 'text-muted-foreground py-2 font-medium',
cellClassName: 'py-2.5 align-top',
cell: (tier) => {
const condSummary = formatConditionSummary(
tier.conditions,
t
)
const isMatched =
normalizedMatchedTierLabel !== '' &&
normalizeTierLabel(tier.label) === normalizedMatchedTierLabel
return (
<>
<div className='flex flex-wrap items-center gap-1.5'>
<Badge
variant='secondary'
@@ -361,32 +352,30 @@ export function DynamicPricingBreakdown({
{condSummary}
</div>
)}
</TableCell>
{visiblePriceFields.map((v) => {
</>
)
},
},
...visiblePriceFields.map((v, index) => ({
id: v.field ?? `price-${index}`,
header: t(v.shortLabel),
className: 'text-muted-foreground py-2 text-right font-medium',
cellClassName: 'py-2.5 text-right align-top font-mono',
cell: (tier: ParsedTier) => {
const value = Number(
tier[v.field as string as keyof ParsedTier] || 0
)
return (
<TableCell
key={v.field}
className='py-2.5 text-right align-top font-mono'
>
{value > 0 ? (
return value > 0 ? (
<span className='font-semibold'>
{`${symbol}${(value * rate).toFixed(4)}`}
</span>
) : (
'-'
)}
</TableCell>
)
})}
</TableRow>
)
})}
</TableBody>
</Table>
</div>
},
})),
]}
/>
</div>
)}
@@ -32,19 +32,15 @@ import type { BundledLanguage } from 'shiki/bundle/web'
import { cn } from '@/lib/utils'
import { useStatus } from '@/hooks/use-status'
import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
CodeBlock,
CodeBlockCopyButton,
} from '@/components/ai-elements/code-block'
import {
StaticDataTable,
staticDataTableClassNames as tableStyles,
} from '@/components/data-table'
import {
buildRateLimits,
buildSupportedParameters,
@@ -570,24 +566,21 @@ function SupportedParametersSection(props: { model: PricingModel }) {
return (
<section>
<SectionTitle icon={Sigma}>{t('Supported parameters')}</SectionTitle>
<div className='border-border/60 overflow-hidden rounded-lg border'>
<Table>
<TableHeader>
<TableRow className='bg-muted/30 hover:bg-muted/30'>
<TableHead className='h-9 w-44'>{t('Parameter')}</TableHead>
<TableHead className='h-9 w-24'>{t('Type')}</TableHead>
<TableHead className='h-9 w-32'>{t('Default / range')}</TableHead>
<TableHead className='h-9'>{t('Description')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{params.map((p) => (
<TableRow key={p.name} className='hover:bg-muted/20'>
<TableCell className='py-2 align-top'>
<StaticDataTable
className={tableStyles.sectionContainer}
headerRowClassName={tableStyles.mutedHeaderRow}
data={params}
getRowKey={(param) => param.name}
getRowClassName={() => 'hover:bg-muted/20'}
columns={[
{
id: 'parameter',
header: t('Parameter'),
className: 'h-9 w-44',
cellClassName: tableStyles.topCell,
cell: (p) => (
<div className='flex items-center gap-1.5'>
<code className='font-mono text-sm font-medium'>
{p.name}
</code>
<code className='font-mono text-sm font-medium'>{p.name}</code>
{p.required && (
<Badge
variant='outline'
@@ -597,26 +590,38 @@ function SupportedParametersSection(props: { model: PricingModel }) {
</Badge>
)}
</div>
</TableCell>
<TableCell className='py-2 align-top'>
),
},
{
id: 'type',
header: t('Type'),
className: 'h-9 w-24',
cellClassName: tableStyles.topCell,
cell: (p) => (
<Badge
variant='secondary'
className='h-7 rounded-full px-2.5 font-mono text-sm font-normal'
>
{p.type}
</Badge>
</TableCell>
<TableCell className='py-2 align-top'>
<ParamRangeCell param={p} />
</TableCell>
<TableCell className='text-muted-foreground py-2 align-top'>
{t(p.descriptionKey)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
),
},
{
id: 'range',
header: t('Default / range'),
className: 'h-9 w-32',
cellClassName: tableStyles.topCell,
cell: (p) => <ParamRangeCell param={p} />,
},
{
id: 'description',
header: t('Description'),
className: 'h-9',
cellClassName: tableStyles.topMutedCell,
cell: (p) => t(p.descriptionKey),
},
]}
/>
</section>
)
}
@@ -671,34 +676,43 @@ function RateLimitsSection(props: { model: PricingModel }) {
return (
<section>
<SectionTitle icon={Gauge}>{t('Rate limits')}</SectionTitle>
<div className='border-border/60 overflow-hidden rounded-lg border'>
<Table>
<TableHeader>
<TableRow className='bg-muted/30 hover:bg-muted/30'>
<TableHead className='h-9'>{t('Group')}</TableHead>
<TableHead className='h-9 text-right'>RPM</TableHead>
<TableHead className='h-9 text-right'>TPM</TableHead>
<TableHead className='h-9 text-right'>RPD</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{limits.map((l) => (
<TableRow key={l.group} className='hover:bg-muted/20'>
<TableCell className='py-2 font-mono'>{l.group}</TableCell>
<TableCell className='py-2 text-right font-mono'>
{formatRateLimit(l.rpm)}
</TableCell>
<TableCell className='py-2 text-right font-mono'>
{formatRateLimit(l.tpm)}
</TableCell>
<TableCell className='py-2 text-right font-mono'>
{formatRateLimit(l.rpd)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<StaticDataTable
className={tableStyles.sectionContainer}
headerRowClassName={tableStyles.mutedHeaderRow}
data={limits}
getRowKey={(limit) => limit.group}
getRowClassName={() => 'hover:bg-muted/20'}
columns={[
{
id: 'group',
header: t('Group'),
className: 'h-9',
cellClassName: 'py-2 font-mono',
cell: (limit) => limit.group,
},
{
id: 'rpm',
header: 'RPM',
className: 'h-9 text-right',
cellClassName: tableStyles.topNumericCell,
cell: (limit) => formatRateLimit(limit.rpm),
},
{
id: 'tpm',
header: 'TPM',
className: 'h-9 text-right',
cellClassName: tableStyles.topNumericCell,
cell: (limit) => formatRateLimit(limit.tpm),
},
{
id: 'rpd',
header: 'RPD',
className: 'h-9 text-right',
cellClassName: tableStyles.topNumericCell,
cell: (limit) => formatRateLimit(limit.rpd),
},
]}
/>
<p className='text-muted-foreground mt-2 text-[11px] leading-relaxed'>
{t(
'RPM = requests per minute, TPM = tokens per minute, RPD = requests per day. Limits apply per token group.'
@@ -26,13 +26,9 @@ import {
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
StaticDataTable,
staticDataTableClassNames as tableStyles,
} from '@/components/data-table'
import {
buildAppRankings,
formatTokenVolume,
@@ -123,9 +119,6 @@ export function ModelDetailsApps(props: { model: PricingModel }) {
const totalMonthlyTokens = apps.reduce((s, a) => s + a.monthly_tokens, 0)
const top = apps[0]
const headerCellClass =
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase'
return (
<div className='flex flex-col gap-4'>
<div className='grid grid-cols-1 gap-2 sm:grid-cols-3'>
@@ -165,32 +158,26 @@ export function ModelDetailsApps(props: { model: PricingModel }) {
</div>
</div>
<div className='overflow-x-auto rounded-lg border'>
<Table className='text-sm'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className={cn(headerCellClass, 'w-12')}>#</TableHead>
<TableHead className={headerCellClass}>{t('App')}</TableHead>
<TableHead
className={cn(headerCellClass, 'hidden md:table-cell')}
>
{t('Category')}
</TableHead>
<TableHead className={`${headerCellClass} text-right`}>
{t('Monthly tokens')}
</TableHead>
<TableHead className={`${headerCellClass} text-right`}>
{t('30d change')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{apps.map((app) => (
<TableRow key={`${app.rank}-${app.name}`}>
<TableCell className='py-2.5'>
<RankBadge rank={app.rank} />
</TableCell>
<TableCell className='py-2.5'>
<StaticDataTable
className='rounded-lg'
tableClassName='text-sm'
headerRowClassName={tableStyles.compactHeaderRow}
data={apps}
getRowKey={(app) => `${app.rank}-${app.name}`}
columns={[
{
id: 'rank',
header: '#',
className: cn(tableStyles.compactHeaderCell, 'w-12'),
cellClassName: tableStyles.compactCell,
cell: (app) => <RankBadge rank={app.rank} />,
},
{
id: 'app',
header: t('App'),
className: tableStyles.compactHeaderCell,
cellClassName: tableStyles.compactCell,
cell: (app) => (
<div className='flex items-center gap-3'>
<span className='bg-muted text-muted-foreground inline-flex size-7 shrink-0 items-center justify-center rounded-md font-bold'>
{app.initial}
@@ -204,21 +191,37 @@ export function ModelDetailsApps(props: { model: PricingModel }) {
</p>
</div>
</div>
</TableCell>
<TableCell className='text-muted-foreground hidden py-2.5 md:table-cell'>
{app.category}
</TableCell>
<TableCell className='py-2.5 text-right font-mono tabular-nums'>
{formatTokenVolume(app.monthly_tokens)}
</TableCell>
<TableCell className='py-2.5 text-right'>
<GrowthChip value={app.growth_pct} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
),
},
{
id: 'category',
header: t('Category'),
className: cn(
tableStyles.compactHeaderCell,
'hidden md:table-cell'
),
cellClassName: cn(
tableStyles.compactMutedCell,
'hidden md:table-cell'
),
cell: (app) => app.category,
},
{
id: 'monthly-tokens',
header: t('Monthly tokens'),
className: tableStyles.compactHeaderCellRight,
cellClassName: cn(tableStyles.compactNumericCell, 'tabular-nums'),
cell: (app) => formatTokenVolume(app.monthly_tokens),
},
{
id: 'growth',
header: t('30d change'),
className: tableStyles.compactHeaderCellRight,
cellClassName: cn(tableStyles.compactCell, 'text-right'),
cell: (app) => <GrowthChip value={app.growth_pct} />,
},
]}
/>
<p className='text-muted-foreground/60 text-[11px] leading-relaxed'>
{t(
@@ -30,6 +30,7 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { StaticDataTable } from '@/components/data-table'
import type { Modality } from '../types'
type IconComponent = React.ComponentType<{ className?: string }>
@@ -95,27 +96,42 @@ export function ModalitiesMatrix(props: {
const inputSet = new Set(props.input)
const outputSet = new Set(props.output)
const renderRow = (label: string, set: Set<Modality>) => (
<tr>
<th
scope='row'
className='text-muted-foreground bg-muted/30 px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase'
>
{label}
</th>
{ALL_MODALITIES.map((modality) => {
const enabled = set.has(modality)
const Icon = MODALITY_META[modality].icon
return (
<td
key={modality}
className={cn(
<StaticDataTable
className='rounded-lg'
tableClassName='text-sm'
headerRowClassName='bg-muted/40'
data={[
{ label: t('Input'), set: inputSet },
{ label: t('Output'), set: outputSet },
]}
getRowKey={(row) => row.label}
columns={[
{
id: 'modality',
header: t('Modality'),
className:
'text-muted-foreground px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase',
cellClassName:
'text-muted-foreground bg-muted/30 px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase',
cell: (row) => row.label,
},
...ALL_MODALITIES.map((modality) => ({
id: modality,
header: t(MODALITY_META[modality].labelKey),
className:
'text-muted-foreground border-l px-3 py-2 text-center text-[11px] font-medium tracking-wider uppercase',
cellClassName: (row: { label: string; set: Set<Modality> }) =>
cn(
'border-l px-3 py-2 text-center',
enabled
row.set.has(modality)
? 'bg-emerald-50/40 dark:bg-emerald-500/10'
: 'bg-background'
)}
>
),
cell: (row: { label: string; set: Set<Modality> }) => {
const enabled = row.set.has(modality)
const Icon = MODALITY_META[modality].icon
return (
<span
className={cn(
'inline-flex items-center justify-center',
@@ -135,39 +151,10 @@ export function ModalitiesMatrix(props: {
>
<Icon className='size-4' />
</span>
</td>
)
})}
</tr>
)
return (
<div className='overflow-x-auto rounded-lg border'>
<table className='w-full text-sm'>
<thead>
<tr className='bg-muted/40'>
<th
scope='col'
className='text-muted-foreground px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase'
>
{t('Modality')}
</th>
{ALL_MODALITIES.map((modality) => (
<th
key={modality}
scope='col'
className='text-muted-foreground border-l px-3 py-2 text-center text-[11px] font-medium tracking-wider uppercase'
>
{t(MODALITY_META[modality].labelKey)}
</th>
))}
</tr>
</thead>
<tbody>
{renderRow(t('Input'), inputSet)}
{renderRow(t('Output'), outputSet)}
</tbody>
</table>
</div>
},
})),
]}
/>
)
}
@@ -22,13 +22,9 @@ import { AlertTriangle, HeartPulse, Timer } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
StaticDataTable,
staticDataTableClassNames as tableStyles,
} from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge'
import { getPerfMetrics } from '@/features/performance-metrics/api'
import {
@@ -218,9 +214,6 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
intent = 'default'
}
const headerCellClass =
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase'
return (
<div className='flex flex-col gap-4'>
<div className='grid grid-cols-1 gap-2 sm:grid-cols-3'>
@@ -256,53 +249,55 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
title={t('Per-group performance')}
description={t('Average latency, TTFT, TPS, and success rate')}
/>
<div className='overflow-x-auto rounded-lg border'>
<Table className='text-sm'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className={headerCellClass}>{t('Group')}</TableHead>
<TableHead className={`${headerCellClass} text-right`}>
TPS
</TableHead>
<TableHead className={`${headerCellClass} text-right`}>
{t('Average TTFT')}
</TableHead>
<TableHead className={`${headerCellClass} text-right`}>
{t('Average latency')}
</TableHead>
<TableHead
className={`${headerCellClass} min-w-[180px] text-left`}
>
{t('Success rate')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{performances.map((perf) => (
<TableRow key={perf.group}>
<TableCell className='py-2.5'>
<GroupBadge group={perf.group} size='sm' />
</TableCell>
<TableCell className='py-2.5 text-right font-mono'>
{formatThroughput(perf.avg_tps)}
</TableCell>
<TableCell className='py-2.5 text-right font-mono'>
{formatLatency(perf.avg_ttft_ms)}
</TableCell>
<TableCell className='text-muted-foreground py-2.5 text-right font-mono'>
{formatLatency(perf.avg_latency_ms)}
</TableCell>
<TableCell className='py-2.5'>
<StaticDataTable
className='rounded-lg'
tableClassName='text-sm'
headerRowClassName={tableStyles.compactHeaderRow}
data={performances}
getRowKey={(perf) => perf.group}
columns={[
{
id: 'group',
header: t('Group'),
className: tableStyles.compactHeaderCell,
cellClassName: tableStyles.compactCell,
cell: (perf) => <GroupBadge group={perf.group} size='sm' />,
},
{
id: 'tps',
header: 'TPS',
className: tableStyles.compactHeaderCellRight,
cellClassName: tableStyles.compactNumericCell,
cell: (perf) => formatThroughput(perf.avg_tps),
},
{
id: 'ttft',
header: t('Average TTFT'),
className: tableStyles.compactHeaderCellRight,
cellClassName: tableStyles.compactNumericCell,
cell: (perf) => formatLatency(perf.avg_ttft_ms),
},
{
id: 'latency',
header: t('Average latency'),
className: tableStyles.compactHeaderCellRight,
cellClassName: tableStyles.compactMutedNumericCell,
cell: (perf) => formatLatency(perf.avg_latency_ms),
},
{
id: 'success',
header: t('Success rate'),
className: cn(tableStyles.compactHeaderCell, 'min-w-[180px]'),
cellClassName: tableStyles.compactCell,
cell: (perf) => (
<UptimeSparkline
size='sm'
series={uptimeByGroup[perf.group] ?? []}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
),
},
]}
/>
</section>
<section>
+161 -171
View File
@@ -32,16 +32,9 @@ import {
SheetTitle,
} from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { CopyButton } from '@/components/copy-button'
import { StaticDataTable } from '@/components/data-table'
import { sideDrawerContentClassName } from '@/components/drawer-layout'
import { GroupBadge } from '@/components/group-badge'
import { PublicLayout } from '@/components/layout'
@@ -269,9 +262,7 @@ function ModelHeader(props: { model: PricingModel }) {
const { t } = useTranslation()
const model = props.model
const modelIconKey = model.icon || model.vendor_icon
const modelIcon = modelIconKey
? getLobeIcon(modelIconKey, 20)
: null
const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 20) : null
const description = model.description || model.vendor_description || null
const tags = parseTags(model.tags)
const isSpecialExpression =
@@ -586,6 +577,40 @@ function AutoGroupChain(props: { model: PricingModel; autoGroups: string[] }) {
)
}
type DynamicPriceOptions = Parameters<typeof getDynamicPriceEntries>[1]
type DynamicPricingTier = ReturnType<typeof getDynamicPricingTiers>[number]
type DynamicFormattedPricesByTier = Map<DynamicPricingTier, Map<string, string>>
function getDynamicPriceFields(
tiers: DynamicPricingTier[],
options: DynamicPriceOptions
) {
return Array.from(
new Map(
tiers
.flatMap((tier) => getDynamicPriceEntries(tier, options))
.map((entry) => [entry.field, entry])
).values()
)
}
function getDynamicFormattedPricesByTier(
tiers: DynamicPricingTier[],
options: DynamicPriceOptions
): DynamicFormattedPricesByTier {
return new Map(
tiers.map((tier) => [
tier,
new Map(
getDynamicPriceEntries(tier, options).map((entry) => [
entry.field,
entry.formatted,
])
),
])
)
}
// ----------------------------------------------------------------------------
// Group pricing table
// ----------------------------------------------------------------------------
@@ -676,20 +701,27 @@ function GroupPricingSection(props: {
)
}
const priceFields = Array.from(
new Map(
dynamicTiers
.flatMap((tier) =>
getDynamicPriceEntries(tier, {
const priceFields = getDynamicPriceFields(dynamicTiers, {
tokenUnit: props.tokenUnit,
showRechargePrice,
priceRate: props.priceRate,
usdExchangeRate: props.usdExchangeRate,
groupRatioMultiplier: 1,
})
)
.map((entry) => [entry.field, entry])
).values()
const formattedPricesByGroup = new Map(
availableGroups.map((group) => {
const ratio = props.groupRatio[group] || 1
return [
group,
getDynamicFormattedPricesByTier(dynamicTiers, {
tokenUnit: props.tokenUnit,
showRechargePrice,
priceRate: props.priceRate,
usdExchangeRate: props.usdExchangeRate,
groupRatioMultiplier: ratio,
}),
] as const
})
)
return (
@@ -699,6 +731,10 @@ function GroupPricingSection(props: {
<div className='space-y-3'>
{availableGroups.map((group) => {
const ratio = props.groupRatio[group] || 1
const formattedPricesByTier =
formattedPricesByGroup.get(group) ??
new Map<DynamicPricingTier, Map<string, string>>()
return (
<div key={group} className='overflow-hidden rounded-lg border'>
<div className='bg-muted/20 flex items-center justify-between gap-3 border-b px-3 py-2'>
@@ -707,56 +743,34 @@ function GroupPricingSection(props: {
{ratio}x
</span>
</div>
<div className='overflow-x-auto'>
<Table className='text-sm'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className={thClass}>{t('Tier')}</TableHead>
{priceFields.map((entry) => (
<TableHead
key={entry.field}
className={`${thClass} text-right`}
>
{t(entry.shortLabel)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{dynamicTiers.map((tier, tierIndex) => {
const entries = getDynamicPriceEntries(tier, {
tokenUnit: props.tokenUnit,
showRechargePrice,
priceRate: props.priceRate,
usdExchangeRate: props.usdExchangeRate,
groupRatioMultiplier: ratio,
})
const entryMap = new Map(
entries.map((entry) => [entry.field, entry])
)
return (
<TableRow key={`${group}-${tier.label || tierIndex}`}>
<TableCell className='text-muted-foreground py-2.5'>
{tier.label || t('Default')}
</TableCell>
{priceFields.map((fieldEntry) => {
const entry = entryMap.get(fieldEntry.field)
return (
<TableCell
key={fieldEntry.field}
className='py-2.5 text-right font-mono'
>
{entry?.formatted ?? '-'}
</TableCell>
)
})}
</TableRow>
)
})}
</TableBody>
</Table>
</div>
<StaticDataTable
className='rounded-none border-0'
tableClassName='text-sm'
headerRowClassName='hover:bg-transparent'
data={dynamicTiers}
getRowKey={(tier, tierIndex) =>
`${group}-${tier.label || tierIndex}`
}
columns={[
{
id: 'tier',
header: t('Tier'),
className: thClass,
cellClassName: 'text-muted-foreground py-2.5',
cell: (tier) => tier.label || t('Default'),
},
...priceFields.map((fieldEntry) => ({
id: fieldEntry.field,
header: t(fieldEntry.shortLabel),
className: `${thClass} text-right`,
cellClassName: 'py-2.5 text-right font-mono',
cell: (tier: (typeof dynamicTiers)[number]) =>
formattedPricesByTier
.get(tier)
?.get(fieldEntry.field) ?? '-',
})),
]}
/>
</div>
)
})}
@@ -768,112 +782,88 @@ function GroupPricingSection(props: {
)
}
const renderGroupPrice = (group: string, type: PriceType) =>
formatGroupPrice(
props.model,
group,
type,
props.tokenUnit,
showRechargePrice,
props.priceRate,
props.usdExchangeRate,
props.groupRatio
)
const renderFixedGroupPrice = (group: string) =>
formatFixedPrice(
props.model,
group,
showRechargePrice,
props.priceRate,
props.usdExchangeRate,
props.groupRatio
)
return (
<section>
<SectionTitle>{t('Pricing by Group')}</SectionTitle>
<AutoGroupChain model={props.model} autoGroups={props.autoGroups} />
<div className='-mx-4 overflow-x-auto sm:mx-0'>
<Table className='text-sm'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className={thClass}>{t('Group')}</TableHead>
<TableHead className={thClass}>{t('Ratio')}</TableHead>
{isTokenBased ? (
<>
<TableHead className={`${thClass} text-right`}>
{t('Input')}
</TableHead>
<TableHead className={`${thClass} text-right`}>
{t('Output')}
</TableHead>
{extraPriceTypes.map((ep) => (
<TableHead
key={ep.type}
className={`${thClass} text-right`}
>
{ep.label}
</TableHead>
))}
</>
) : (
<TableHead className={`${thClass} text-right`}>
{t('Price')}
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{availableGroups.map((group) => {
const ratio = props.groupRatio[group] || 1
return (
<TableRow key={group}>
<TableCell className='py-2.5'>
<GroupBadge group={group} size='sm' />
</TableCell>
<TableCell className='text-muted-foreground py-2.5 font-mono'>
{ratio}x
</TableCell>
{isTokenBased ? (
<>
<TableCell className='py-2.5 text-right font-mono'>
{formatGroupPrice(
props.model,
group,
'input',
props.tokenUnit,
showRechargePrice,
props.priceRate,
props.usdExchangeRate,
props.groupRatio
)}
</TableCell>
<TableCell className='py-2.5 text-right font-mono'>
{formatGroupPrice(
props.model,
group,
'output',
props.tokenUnit,
showRechargePrice,
props.priceRate,
props.usdExchangeRate,
props.groupRatio
)}
</TableCell>
{extraPriceTypes.map((ep) => (
<TableCell
key={ep.type}
className='py-2.5 text-right font-mono'
>
{formatGroupPrice(
props.model,
group,
ep.type,
props.tokenUnit,
showRechargePrice,
props.priceRate,
props.usdExchangeRate,
props.groupRatio
)}
</TableCell>
))}
</>
) : (
<TableCell className='py-2.5 text-right font-mono'>
{formatFixedPrice(
props.model,
group,
showRechargePrice,
props.priceRate,
props.usdExchangeRate,
props.groupRatio
)}
</TableCell>
)}
</TableRow>
)
})}
</TableBody>
</Table>
<StaticDataTable
className='-mx-4 rounded-none border-0 sm:mx-0'
tableClassName='text-sm'
headerRowClassName='hover:bg-transparent'
data={availableGroups}
getRowKey={(group) => group}
columns={[
{
id: 'group',
header: t('Group'),
className: thClass,
cellClassName: 'py-2.5',
cell: (group) => <GroupBadge group={group} size='sm' />,
},
{
id: 'ratio',
header: t('Ratio'),
className: thClass,
cellClassName: 'text-muted-foreground py-2.5 font-mono',
cell: (group) => `${props.groupRatio[group] || 1}x`,
},
...(isTokenBased
? [
{
id: 'input',
header: t('Input'),
className: `${thClass} text-right`,
cellClassName: 'py-2.5 text-right font-mono',
cell: (group: string) => renderGroupPrice(group, 'input'),
},
{
id: 'output',
header: t('Output'),
className: `${thClass} text-right`,
cellClassName: 'py-2.5 text-right font-mono',
cell: (group: string) => renderGroupPrice(group, 'output'),
},
...extraPriceTypes.map((ep) => ({
id: ep.type,
header: ep.label,
className: `${thClass} text-right`,
cellClassName: 'py-2.5 text-right font-mono',
cell: (group: string) => renderGroupPrice(group, ep.type),
})),
]
: [
{
id: 'price',
header: t('Price'),
className: `${thClass} text-right`,
cellClassName: 'py-2.5 text-right font-mono',
cell: renderFixedGroupPrice,
},
]),
]}
/>
<div className='-mx-4 sm:mx-0'>
{isTokenBased && (
<p className='text-muted-foreground/40 mt-1.5 px-4 text-[10px] sm:px-0'>
{t('Prices shown per')} {tokenUnitLabel} tokens

Some files were not shown because too many files have changed in this diff Show More