Commit 966af88e by QuentinHsu Committed by GitHub

feat(playground): improve Playground chat experience and Markdown rendering (#5217)

* refactor(playground): streamline chat request state

- extract conversation actions from the page component to keep message flow logic reusable.
- unify streaming and non-streaming generation state, including abort support for non-stream requests.
- simplify message rendering and payload construction while localizing Playground prompts.

* fix(playground): validate persisted chat state

- wrap saved Playground state with a storage version while still reading legacy values.

- validate config, parameter toggles, and messages before restoring them from localStorage.

- cap stored chat history to the latest messages to avoid oversized or stale state.

* refactor(playground): centralize message content access

- route chat rendering, copy actions, and error display through shared message helpers.

- reuse the current-version update helper for non-streaming assistant responses.

- keep message version details behind utility functions to reduce future model churn.

* refactor(playground): split storage schemas

- move Playground storage validation schemas into a dedicated module.

- keep storage read and write logic focused on migration, trimming, and persistence.

- preserve the existing storage envelope and validation behavior.

* refactor(playground): extract options loading hook

- move model and group queries into a dedicated hook so the page component stays focused on layout wiring.
- preserve existing fallback selection and error toast behavior while reusing the hook through the playground barrel export.

* refactor(playground): extract prompt suggestions

- move static prompt suggestion rendering into a focused component so the input stays centered on compose controls.
- preserve translated suggestion submission behavior while isolating icon metadata from the input form.

* refactor(playground): extract input tools

- move attachment and search controls into a dedicated component so the prompt input stays focused on compose state.
- keep existing development toast behavior and disabled handling while centralizing tool metadata.

* refactor(playground): extract input controls

- move model, group, send, and stop controls into a focused component so the input only manages compose state.
- preserve existing disabled states and generation button behavior while isolating control rendering.

* refactor(playground): extract message content display

- move sources, reasoning, loading, error, and response rendering into a dedicated message content component.
- keep the chat list focused on message iteration, edit state, and action wiring without changing display behavior.

* refactor(playground): extract message editor

- move inline message editing controls into a dedicated editor component so the chat list stays focused on rendering flow.
- preserve save, save-and-submit, cancel, and disabled-state behavior for edited messages.

* refactor(playground): extract stream error parsing

- move SSE error payload parsing into a reusable stream utility so the request hook stays focused on lifecycle handling.
- preserve existing error message, error code, and fallback behavior for raw or empty stream errors.

* refactor(playground): extract request error parsing

- move non-stream request error extraction into a shared utility so the chat handler stays focused on request flow.
- preserve the existing response message, error code, and fallback priority for failed chat completions.

* refactor(playground): extract streaming chunk updates

- move reasoning and content chunk application into a message utility so the chat handler only wires stream events.
- preserve error-state skipping, reasoning accumulation, and content streaming behavior for assistant messages.

* refactor(playground): extract message reasoning parser

- move think tag parsing into a dedicated playground message utility.
- export the parser through the shared playground lib barrel for consistent imports.

* refactor(playground): extract message streaming utilities

- move stream chunk application and message finalization into a dedicated utility.
- keep stored message sanitization with the streaming lifecycle helpers.

* refactor(playground): extract message update utilities

- move assistant message update helpers into a focused playground utility.
- keep error-state message updates separate from core message construction helpers.

* refactor(playground): extract completion choice handling

- move non-streaming choice application into the message streaming utilities.
- keep the chat handler focused on request orchestration and message updates.

* refactor(playground): centralize assistant completion state

- add a helper for finalizing assistant messages with complete status.
- reuse the helper in stream completion and stop-generation paths.

* refactor(playground): extract stream message parsing

- move SSE delta parsing into a shared stream utility.
- keep the stream request hook focused on lifecycle handling and update dispatch.

* refactor(playground): extract stream ready state checks

- move SSE ready-state status handling into stream utilities.
- keep weak source status typing outside the stream request hook.

* refactor(playground): extract conversation message helpers

- move send, regenerate, and edit message list construction into focused utilities.
- keep the conversation hook focused on edit state and update dispatch.

* refactor(playground): extract state initialization helpers

- move playground initial state loading into focused utility helpers.
- centralize message state updater resolution outside the React state hook.

* refactor(playground): extract option fallback helpers

- move model and group fallback selection into focused playground utilities.
- keep the options hook focused on query results, toasts, and config updates.

* refactor(playground): extract message action helpers

- move message action state derivation into focused utilities.

- keep the action component focused on guarded handlers and rendering.

* refactor(playground): extract input control state

- move submit, stop, and selector state derivation into a pure helper.

- keep input controls focused on rendering model selectors and action buttons.

* refactor(playground): extract message content state

- move source, reasoning, loader, and body visibility checks into a pure helper.

- use a discriminated state shape so rendered reasoning content stays type-safe.

* refactor(playground): extract message editor state

- move save eligibility and submit visibility checks into a pure helper.

- keep the editor component focused on textarea and button rendering.

* refactor(playground): extract message error state

- move error kind, fallback content, and admin visibility checks into a pure helper.

- centralize the model pricing settings path used by the error action.

* refactor(playground): extract chat render state

- move editing content lookup and per-message render flags into conversation helpers.

- keep the chat component focused on mapping messages to editor and content views.

* refactor(playground): extract suggestion display state

- move suggestion class selection into a pure helper.

- keep the suggestions component focused on translation and rendering.

* refactor(playground): extract assistant message state checks

- move final and pending assistant status checks into streaming utilities.

- keep the chat handler focused on request lifecycle updates.

* refactor(playground): extract input tool state

- move attachment action metadata and development notices into input tool utilities.

- keep the input tools component focused on menu and button rendering.

* refactor(playground): extract stream protocol checks

- move SSE done-message and closed-ready-state checks into stream utilities.

- keep the stream request hook focused on event handling flow.

* refactor(playground): extract message removal helper

- move delete-message filtering into conversation message utilities.

- keep the conversation hook focused on action orchestration.

* refactor(playground): extract option error messages

- move option load error message selection into playground option utilities
- keep the options hook focused on query effects and fallback updates

* refactor(playground): extract input submit text helper

- move prompt submit text validation into input control utilities
- let the input component submit only when a concrete text value is available

* refactor(playground): centralize error message checks

- add a shared helper for identifying error messages
- remove direct status string checks from message content rendering

* refactor(playground): extract message content display checks

- move loader and content visibility decisions into local helper functions
- keep message content state assembly focused on composing render state

* refactor(playground): replace raw message role checks

- use shared message role constants in conversation edit handling
- avoid raw assistant role literals when validating API messages

* refactor(playground): extract non-stream response handling

- move chat completion response choice handling into message streaming utilities
- keep the chat handler focused on request lifecycle and error routing

* refactor(playground): centralize stream cleanup

- reuse one stream cleanup path for completion, errors, startup failures, and manual stops
- preserve the current-source guard when closing SSE streams

* refactor(playground): extract pending assistant check

- centralize pending assistant message detection in streaming utilities
- reuse the helper when sanitizing stored playground messages

* perf(playground): improve mobile input controls

- split mobile input controls into selector and action rows
- keep the desktop input footer compact while reducing mobile control crowding

* perf(playground): add starter empty state

- show starter prompts in the empty playground chat area
- wire empty-state prompt selection into the existing send flow
- add localized copy for the new empty state

* perf(playground): improve mobile message actions

- collapse mobile message actions into a touch-friendly dropdown menu
- keep the desktop hover action strip unchanged for pointer workflows
- share one action list between desktop buttons and the mobile menu

* perf(playground): add error recovery actions

- show retry, edit, and delete actions inside error message alerts
- route edit recovery to the previous user prompt when available
- keep recovery controls touch-friendly on mobile layouts

* perf(playground): refine message editing experience

- present message edits in a focused bordered editor panel
- add unsaved-change state, reset, and cancel confirmation flows
- improve mobile touch targets and keyboard shortcuts for editing

* perf(playground): improve markdown code blocks

- render fenced markdown code with syntax highlighting, line numbers, and fallback plain text
- add copy, download, and collapse controls for playground AI responses
- tighten code block layout and theme token styles for responsive markdown rendering

* fix(playground): constrain markdown code block height

- collapse long playground code blocks after a short preview instead of waiting for very large snippets
- cap expanded code blocks so long responses scroll inside the code block
- keep generic code block usage unconstrained unless a caller opts in

* feat(playground): add chat history clearing

- add a toolbar action that is enabled only when saved playground messages exist.
- confirm destructive clears before removing browser-stored conversation state.
- add localized strings for the action, dialog, and completion toast.

* perf(playground): improve chat markdown rendering

- refine assistant and user message surfaces so chat content matches the app UI.
- normalize markdown typography, tables, images, lists, blockquotes, and details rendering.
- add indentation cues for collapsible reasoning and source sections.

* style: format code block component

* style: format playground frontend files

* feat(playground): render markdown with stream parser

- replace Streamdown with stream-markdown-parser for project-owned markdown rendering and styling.
- split response rendering into focused block, inline, table, alert, details, and footnote modules.
- pass message final state into response parsing so streaming content can be parsed incrementally.

* fix(playground): localize reasoning and chat feedback

- translate reasoning status, message actions, playground errors, and response renderer fallbacks across supported locales.
- keep reasoning duration numeric and tighten the collapsible layout to prevent trigger jitter.
- register dynamic keys so i18n sync keeps runtime labels covered.

* refactor(playground): group files by functional area

- move chat, input, and message components into focused subdirectories to make the UI structure easier to scan.
- split playground helpers into input, message, streaming, storage, options, state, and suggestions modules.
- update barrel exports and imports so existing feature entry points continue to work.

* fix(playground): prevent history replay from freezing page

- defer saved conversation loading so route entry no longer blocks on localStorage parsing and markdown rendering.
- limit initial history rendering and skip expensive markdown parsing for oversized responses.
- normalize corrupted streaming snapshots and cumulative chunks to keep saved playground history bounded.
- add message timing metadata and layout alignment groundwork without introducing live timers.

* feat(playground): allow regenerating from user messages

- show regenerate actions on user messages with saved content.
- truncate following conversation state before starting a fresh assistant response.

* feat(playground): add raw response source view

- add a per-message source toggle for assistant responses.
- render raw response content with the existing code block viewer.
- localize the new source and preview action labels.

* feat(playground): render code with unified editor

- replace Shiki HTML rendering with a read-only CodeMirror view for code blocks and raw responses.
- reuse the same CodeMirror frame for message editing so source and edit modes stay visually aligned.
- add lightweight CodeMirror dependencies while keeping language support scoped to Markdown.

* perf(playground): streamline chat input controls

- combine model and group selection into one compact picker for faster context switching.
- switch playground action buttons to icon-first controls with tooltips to reduce toolbar width.
- refresh input footer styling and submit states so active and destructive actions are clearer.
- bump dompurify lockfile entry to keep the frontend dependency current.

* fix(playground): filter models by selected group

- query user models by the selected playground group instead of reusing the cross-group model union.
- clear unavailable model selections and block sending when the active group has no models.
- align model selector and error action controls with the existing playground interaction style.

* perf(playground): remove input suggestion chips

- remove the prompt suggestion row below the playground input to reduce visual noise.
- delete the now-unused suggestion component and display helper.

* perf(playground): stabilize reasoning trigger layout

- use fixed icon slots around the reasoning label so the left content stays still when toggling.
- limit the open state animation to the chevron rotation for a smoother collapse interaction.

* perf(playground): smooth reasoning expansion

- use the collapsible panel height animation for vertical reasoning reveals.
- sync inner content opacity and position with the panel state.
parent df44a75d
......@@ -26,6 +26,11 @@ type listModelsResponse struct {
Object string `json:"object"`
}
type userModelsResponse struct {
Success bool `json:"success"`
Data []string `json:"data"`
}
func setupModelListControllerTestDB(t *testing.T) *gorm.DB {
t.Helper()
......@@ -147,6 +152,50 @@ func pricingByModelName(pricings []model.Pricing) map[string]model.Pricing {
return byName
}
func decodeUserModelsResponse(t *testing.T, recorder *httptest.ResponseRecorder) []string {
t.Helper()
require.Equal(t, http.StatusOK, recorder.Code)
var payload userModelsResponse
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &payload))
require.True(t, payload.Success)
return payload.Data
}
func TestGetUserModelsFiltersByRequestedGroup(t *testing.T) {
db := setupModelListControllerTestDB(t)
require.NoError(t, db.Create(&model.User{
Id: 1002,
Username: "playground-model-user",
Password: "password",
Group: "default",
Status: common.UserStatusEnabled,
}).Error)
require.NoError(t, db.Create(&[]model.Ability{
{Group: "default", Model: "zz-default-only-model", ChannelId: 1, Enabled: true},
{Group: "default", Model: "zz-disabled-model", ChannelId: 1, Enabled: false},
}).Error)
defaultRecorder := httptest.NewRecorder()
defaultContext, _ := gin.CreateTestContext(defaultRecorder)
defaultContext.Request = httptest.NewRequest(http.MethodGet, "/api/user/models?group=default", nil)
defaultContext.Set("id", 1002)
GetUserModels(defaultContext)
defaultModels := decodeUserModelsResponse(t, defaultRecorder)
require.ElementsMatch(t, []string{"zz-default-only-model"}, defaultModels)
vipRecorder := httptest.NewRecorder()
vipContext, _ := gin.CreateTestContext(vipRecorder)
vipContext.Request = httptest.NewRequest(http.MethodGet, "/api/user/models?group=vip", nil)
vipContext.Set("id", 1002)
GetUserModels(vipContext)
require.Empty(t, decodeUserModelsResponse(t, vipRecorder))
}
func TestListModelsIncludesTieredBillingModel(t *testing.T) {
withSelfUseModeDisabled(t)
withTieredBillingConfig(t, map[string]string{
......
......@@ -589,6 +589,25 @@ func GetUserModels(c *gin.Context) {
return
}
groups := service.GetUserUsableGroups(user.Group)
group := c.Query("group")
if group != "" {
if _, ok := groups[group]; !ok {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": []string{},
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": model.GetGroupEnabledModels(group),
})
return
}
var models []string
for group := range groups {
for _, g := range model.GetGroupEnabledModels(group) {
......
......@@ -69,11 +69,16 @@
"version": "1.0.0",
"dependencies": {
"@base-ui/react": "^1.5.0",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.43.3",
"@fontsource-variable/lora": "^5.2.8",
"@fontsource-variable/public-sans": "^5.2.7",
"@hookform/resolvers": "^5.4.0",
"@hugeicons/core-free-icons": "^4.1.4",
"@hugeicons/react": "^1.1.6",
"@lezer/highlight": "^1.2.3",
"@lobehub/icons": "catalog:",
"@tailwindcss/postcss": "^4.3.0",
"@tanstack/react-query": "^5.100.14",
......@@ -113,7 +118,7 @@
"shiki": "^4.1.0",
"sonner": "^2.0.7",
"sse.js": "catalog:",
"streamdown": "^2.5.0",
"stream-markdown-parser": "^1.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0",
"tokenlens": "^1.3.1",
......@@ -249,6 +254,24 @@
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g=="],
"@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="],
"@codemirror/lang-html": ["@codemirror/lang-html@6.4.11", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw=="],
"@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="],
"@codemirror/lang-markdown": ["@codemirror/lang-markdown@6.5.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.3.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/markdown": "^1.0.0" } }, "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw=="],
"@codemirror/language": ["@codemirror/language@6.12.4", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A=="],
"@codemirror/lint": ["@codemirror/lint@6.9.7", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="],
"@codemirror/state": ["@codemirror/state@6.7.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-Zbl9NyscLMZkfXPQnNAIIAFftidrA1UbcJEIMp24C0Bukc2I5T8wJS0wsXYsnDOqCFJUeJ1BITGNs5CqPDSmSg=="],
"@codemirror/view": ["@codemirror/view@6.43.3", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-MwEwCAr/o0agJefhC2+reBv5kfOQpMcDRUNQrRYZgWlhH8IwQcerMZrpqWyUFSyO0ebgN2cnh/w87F7G4BGSng=="],
"@croct/json": ["@croct/json@2.1.0", "", {}, "sha512-UrWfjNQVlBxN+OVcFwHmkjARMW55MBN04E9KfGac8ac8z1QnFVuiOOFtMWXCk3UwsyRqhsNaFoYLZC+xxqsVjQ=="],
"@croct/json5-parser": ["@croct/json5-parser@0.2.2", "", { "dependencies": { "@croct/json": "^2.1.0" } }, "sha512-0NJMLrbeLbQ0eCVj3UoH/kG2QckUgOASfwmfDTjyW1xAYPyTNJXcWVT/dssJdTJd0pRchW+qF0VFWQHcxs1OVw=="],
......@@ -453,6 +476,20 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="],
"@lezer/css": ["@lezer/css@1.3.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg=="],
"@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="],
"@lezer/html": ["@lezer/html@1.3.13", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg=="],
"@lezer/javascript": ["@lezer/javascript@1.5.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="],
"@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="],
"@lezer/markdown": ["@lezer/markdown@1.6.4", "", { "dependencies": { "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0" } }, "sha512-N0SxazMj4k65DBfaf1azqtMZd6u7MqluP84/NZnB/io8Td9aleFmAhz9hcbvSfsxT5tdYlJ5qgv5aMJGY4zEtA=="],
"@lit-labs/ssr-dom-shim": ["@lit-labs/ssr-dom-shim@1.6.0", "", {}, "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ=="],
"@lit/reactive-element": ["@lit/reactive-element@2.1.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0" } }, "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A=="],
......@@ -465,6 +502,8 @@
"@lobehub/ui": ["@lobehub/ui@5.15.6", "", { "dependencies": { "@ant-design/cssinjs": "^2.1.2", "@base-ui/react": "1.5.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", "@emotion/is-prop-valid": "^1.4.0", "@floating-ui/react": "^0.27.19", "@giscus/react": "^3.1.0", "@mdx-js/mdx": "^3.1.1", "@mdx-js/react": "^3.1.1", "@pierre/diffs": "^1.1.19", "@radix-ui/react-slot": "^1.2.4", "@shikijs/core": "^4.0.2", "@shikijs/transformers": "^4.0.2", "@splinetool/runtime": "0.9.526", "ahooks": "^3.9.7", "antd-style": "^4.1.0", "chroma-js": "^3.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.20", "emoji-mart": "^5.6.0", "es-toolkit": "^1.46.0", "fast-deep-equal": "^3.1.3", "immer": "^11.1.4", "katex": "^0.16.45", "leva": "^0.10.1", "lucide-react": "^1.11.0", "marked": "^17.0.6", "mermaid": "^11.14.0", "motion": "^12.38.0", "numeral": "^2.0.6", "polished": "^4.3.1", "query-string": "^9.3.1", "rc-collapse": "^4.0.0", "rc-footer": "^0.6.8", "rc-image": "^7.12.0", "rc-input-number": "^9.5.0", "rc-menu": "^9.16.1", "re-resizable": "^6.11.2", "react-avatar-editor": "^15.1.0", "react-error-boundary": "^6.1.1", "react-hotkeys-hook": "^5.2.4", "react-markdown": "^10.1.0", "react-merge-refs": "^3.0.2", "react-rnd": "^10.5.3", "react-zoom-pan-pinch": "^3.7.0", "rehype-github-alerts": "^4.2.0", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "remark-breaks": "^4.0.0", "remark-cjk-friendly": "^2.0.1", "remark-gfm": "^4.0.1", "remark-github": "^12.0.0", "remark-math": "^6.0.0", "remend": "^1.3.0", "shiki": "^4.0.2", "shiki-stream": "^0.1.4", "swr": "^2.4.1", "ts-md5": "^2.0.1", "unified": "^11.0.5", "url-join": "^5.0.0", "use-merge-value": "^1.2.0", "uuid": "^13.0.0", "virtua": "^0.49.1" }, "peerDependencies": { "@lobehub/fluent-emoji": "^4.0.0", "@lobehub/icons": "^5.0.0", "antd": "^6.1.1", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-sjx95F9viJWRuhFlhe+pN7y6/b+dv9U6ysMcO8F+sFUQNYTBfUl80UkBLclHQc2adpxdrkzEN+0g0AXeFsCC1g=="],
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="],
"@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="],
"@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="],
......@@ -1231,8 +1270,12 @@
"@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="],
"@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
"@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
......@@ -1513,6 +1556,8 @@
"cosmiconfig": ["cosmiconfig@9.0.1", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="],
"crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
......@@ -1671,7 +1716,7 @@
"enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
......@@ -1905,8 +1950,6 @@
"hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="],
"hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="],
"hast-util-to-estree": ["hast-util-to-estree@3.1.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-attach-comments": "^3.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w=="],
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
......@@ -2125,6 +2168,8 @@
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"linkify-it": ["linkify-it@5.0.1", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg=="],
"linkifyjs": ["linkifyjs@4.3.3", "", {}, "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg=="],
"lit": ["lit@3.3.3", "", { "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", "lit-html": "^3.3.0" } }, "sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw=="],
......@@ -2161,6 +2206,22 @@
"markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="],
"markdown-it-container": ["markdown-it-container@4.0.0", "", {}, "sha512-HaNccxUH0l7BNGYbFbjmGpf5aLHAMTinqRZQAEQbMr2cdD3z91Q6kIo1oUn1CQndkT03jat6ckrdRYuwwqLlQw=="],
"markdown-it-footnote": ["markdown-it-footnote@4.0.0", "", {}, "sha512-WYJ7urf+khJYl3DqofQpYfEYkZKbmXmwxQV8c8mO/hGIhgZ1wOe7R4HLFNwqx7TjILbnC98fuyeSsin19JdFcQ=="],
"markdown-it-ins": ["markdown-it-ins@4.0.0", "", {}, "sha512-sWbjK2DprrkINE4oYDhHdCijGT+MIDhEupjSHLXe5UXeVr5qmVxs/nTUVtgi0Oh/qtF+QKV0tNWDhQBEPxiMew=="],
"markdown-it-mark": ["markdown-it-mark@4.0.0", "", {}, "sha512-YLhzaOsU9THO/cal0lUjfMjrqSMPjjyjChYM7oyj4DnyaXEzA8gnW6cVJeyCrCVeyesrY2PlEdUYJSPFYL4Nkg=="],
"markdown-it-sub": ["markdown-it-sub@2.0.0", "", {}, "sha512-iCBKgwCkfQBRg2vApy9vx1C1Tu6D8XYo8NvevI3OlwzBRmiMtsJ2sXupBgEA7PPxiDwNni3qIUkhZ6j5wofDUA=="],
"markdown-it-sup": ["markdown-it-sup@2.0.0", "", {}, "sha512-5VgmdKlkBd8sgXuoDoxMpiU+BiEt3I49GItBzzw7Mxq9CxvnhE/k09HFli09zgfFDRixDQDfDxi0mgBCXtaTvA=="],
"markdown-it-task-checkbox": ["markdown-it-task-checkbox@1.0.6", "", {}, "sha512-7pxkHuvqTOu3iwVGmDPeYjQg+AIS9VQxzyLP9JCg9lBjgPAJXGEkChK6A2iFuj3tS0GV3HG2u5AMNhcQqwxpJw=="],
"markdown-it-ts": ["markdown-it-ts@1.0.2", "", { "dependencies": { "@types/linkify-it": "^5.0.0", "@types/mdurl": "^2.0.0", "entities": "^4.5.0", "linkify-it": "^5.0.1", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" } }, "sha512-zba9mN313K2HmKk+BOHqkO/nuZtj9M1TTnUlSbItGrCMpYzc8OHGCm+IaqxWCi2pGcgpiFC8ltxkasYWYpp/YQ=="],
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="],
......@@ -2203,6 +2264,8 @@
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="],
......@@ -2517,6 +2580,8 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
"qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="],
"qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
......@@ -2651,8 +2716,6 @@
"rehype-github-alerts": ["rehype-github-alerts@4.2.0", "", { "dependencies": { "@primer/octicons": "^19.20.0", "hast-util-from-html": "^2.0.3", "hast-util-is-element": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-6di6kEu9WUHKLKrkKG2xX6AOuaCMGghg0Wq7MEuM/jBYUPVIq6PJpMe00dxMfU+/YSBtDXhffpDimgDi+BObIQ=="],
"rehype-harden": ["rehype-harden@1.1.8", "", { "dependencies": { "unist-util-visit": "^5.0.0" } }, "sha512-Qn7vR1xrf6fZCrkm9TDWi/AB4ylrHy+jqsNm1EHOAmbARYA6gsnVJBq/sdBh6kmT4NEZxH5vgIjrscefJAOXcw=="],
"rehype-highlight": ["rehype-highlight@7.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-text": "^4.0.0", "lowlight": "^3.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA=="],
"rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="],
......@@ -2661,8 +2724,6 @@
"rehype-recma": ["rehype-recma@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "hast-util-to-estree": "^3.0.0" } }, "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw=="],
"rehype-sanitize": ["rehype-sanitize@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-sanitize": "^5.0.0" } }, "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg=="],
"remark-breaks": ["remark-breaks@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-newline-to-break": "^2.0.0", "unified": "^11.0.0" } }, "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ=="],
"remark-cjk-friendly": ["remark-cjk-friendly@2.0.1", "", { "dependencies": { "micromark-extension-cjk-friendly": "2.0.1" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-6WwkoQyZf/4j5k53zdFYrR8Ca+UVn992jXdLUSBDZR4eBpFhKyVxmA4gUHra/5fesjGIxrDhHesNr/sVoiiysA=="],
......@@ -2813,9 +2874,9 @@
"stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="],
"stream-source": ["stream-source@0.3.5", "", {}, "sha512-ZuEDP9sgjiAwUVoDModftG0JtYiLUV8K4ljYD1VyUMRWtbVf92474o4kuuul43iZ8t/hRuiDAx1dIJSvirrK/g=="],
"stream-markdown-parser": ["stream-markdown-parser@1.0.7", "", { "dependencies": { "markdown-it-container": "^4.0.0", "markdown-it-footnote": "^4.0.0", "markdown-it-ins": "^4.0.0", "markdown-it-mark": "^4.0.0", "markdown-it-sub": "^2.0.0", "markdown-it-sup": "^2.0.0", "markdown-it-task-checkbox": "^1.0.6", "markdown-it-ts": "^1.0.2" } }, "sha512-IkWYtBv+9QPDzKKOoy1ZxuiwpcL0APfgUrBlUt9L4s0Sq5XnHY9rQK7tOs46ouHOX/OR0Nq6zTqfV0vbtLD4RA=="],
"streamdown": ["streamdown@2.5.0", "", { "dependencies": { "clsx": "^2.1.1", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "marked": "^17.0.1", "mermaid": "^11.12.2", "rehype-harden": "^1.1.8", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.3.0", "tailwind-merge": "^3.4.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-/tTnURfIOxZK/pqJAxsfCvETG/XCJHoWnk3jq9xLcuz6CSpnjjuxSRBTTL4PKGhxiZQf0lqPxGhImdpwcZ2XwA=="],
"stream-source": ["stream-source@0.3.5", "", {}, "sha512-ZuEDP9sgjiAwUVoDModftG0JtYiLUV8K4ljYD1VyUMRWtbVf92474o4kuuul43iZ8t/hRuiDAx1dIJSvirrK/g=="],
"strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="],
......@@ -2837,6 +2898,8 @@
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
"style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
......@@ -2927,6 +2990,8 @@
"typescript": ["typescript@4.4.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-gzP+t5W4hdy4c+68bfcv0t400HVJMMd2+H9B7gae1nQlBzCqvrXX+6GL/b3GAgyTH966pzrZ70/fRjwAtZksSQ=="],
"uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
"unbash": ["unbash@3.0.0", "", {}, "sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
......@@ -3283,6 +3348,8 @@
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"path-scurry/lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="],
"postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
......@@ -3345,8 +3412,6 @@
"split-string/extend-shallow": ["extend-shallow@3.0.2", "", { "dependencies": { "assign-symbols": "^1.0.0", "is-extendable": "^1.0.1" } }, "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q=="],
"streamdown/marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
"string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
......
......@@ -20,11 +20,16 @@
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.43.3",
"@fontsource-variable/lora": "^5.2.8",
"@fontsource-variable/public-sans": "^5.2.7",
"@hookform/resolvers": "^5.4.0",
"@hugeicons/core-free-icons": "^4.1.4",
"@hugeicons/react": "^1.1.6",
"@lezer/highlight": "^1.2.3",
"@lobehub/icons": "catalog:",
"@tailwindcss/postcss": "^4.3.0",
"@tanstack/react-query": "^5.100.14",
......@@ -64,7 +69,7 @@
"shiki": "^4.1.0",
"sonner": "^2.0.7",
"sse.js": "catalog:",
"streamdown": "^2.5.0",
"stream-markdown-parser": "^1.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0",
"tokenlens": "^1.3.1",
......
import path from 'path'
import { fileURLToPath } from 'url'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig, loadEnv } from '@rsbuild/core'
import { pluginReact } from '@rsbuild/plugin-react'
import { tanstackRouter } from '@tanstack/router-plugin/rspack'
......@@ -18,7 +19,7 @@ export default defineConfig(({ envMode }) => {
(['/api', '/mj', '/pg'] as const).map((key) => [
key,
{ target: serverUrl, changeOrigin: true },
]),
])
) as Record<string, { target: string; changeOrigin: boolean }>
return {
......
......@@ -19,125 +19,589 @@ For commercial licensing, please contact support@quantumnous.com
/* eslint-disable react-refresh/only-export-components */
'use client'
import { markdown } from '@codemirror/lang-markdown'
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { EditorState, type Extension } from '@codemirror/state'
import { EditorView, lineNumbers } from '@codemirror/view'
import { tags as highlightTags } from '@lezer/highlight'
import {
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
CopyIcon,
DownloadIcon,
} from 'lucide-react'
import {
type ComponentProps,
createContext,
type CSSProperties,
type HTMLAttributes,
type ReactNode,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { CheckIcon, CopyIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { BundledLanguage } from 'shiki'
import { Button } from '@/components/ui/button'
import {
type BundledLanguage,
codeToHtml,
type ShikiTransformer,
} from 'shiki/bundle/web'
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string
language: BundledLanguage
collapsedLines?: number
defaultCollapsed?: boolean
enableCollapse?: boolean
filename?: string
language: BundledLanguage | string
maxExpandedLines?: number
/** @deprecated use collapsedLines for collapsed preview height. */
maxCollapsedLines?: number
showLineNumbers?: boolean
showToolbar?: boolean
title?: ReactNode
}
type CodeBlockEditorProps = Omit<
HTMLAttributes<HTMLDivElement>,
'onChange' | 'onKeyDown' | 'title'
> & {
actions?: ReactNode
ariaLabel: string
language: BundledLanguage | string
onChange: (value: string) => void
onKeyDown?: (event: globalThis.KeyboardEvent) => void
rows?: number
title?: ReactNode
value: string
}
type CodeMirrorCodeViewProps = {
ariaLabel: string
autoFocus?: boolean
language: BundledLanguage | string
onChange?: (value: string) => void
onKeyDown?: (event: globalThis.KeyboardEvent) => void
readOnly?: boolean
rows?: number
showLineNumbers?: boolean
value: string
}
type CodeBlockFrameProps = Omit<HTMLAttributes<HTMLDivElement>, 'title'> & {
bodyClassName?: string
bodyMaxHeight?: string
bodyOverlay?: ReactNode
children: ReactNode
endActions?: ReactNode
showToolbar?: boolean
title?: ReactNode
}
type CodeBlockContextType = {
code: string
language: string
}
const CodeBlockContext = createContext<CodeBlockContextType>({
code: '',
language: 'plaintext',
})
const lineNumberTransformer: ShikiTransformer = {
name: 'line-numbers',
line(node, line) {
node.children.unshift({
type: 'element',
tagName: 'span',
properties: {
className: [
'inline-block',
'min-w-10',
'mr-4',
'text-right',
'select-none',
'text-muted-foreground',
],
},
children: [{ type: 'text', value: String(line) }],
})
},
const LANGUAGE_ALIASES: Record<string, BundledLanguage> = {
csharp: 'c#',
golang: 'go',
js: 'javascript',
shell: 'bash',
shellscript: 'bash',
ts: 'typescript',
}
export async function highlightCode(
code: string,
language: BundledLanguage,
showLineNumbers = false
) {
const transformers: ShikiTransformer[] = showLineNumbers
? [lineNumberTransformer]
: []
return codeToHtml(code, {
lang: language,
themes: {
light: 'one-light',
dark: 'one-dark-pro',
const LANGUAGE_PATTERN = /^[a-z0-9][a-z0-9+#._-]{0,31}$/i
const codeMirrorTheme = EditorView.theme({
'&': {
background: 'transparent',
color: 'var(--foreground)',
fontSize: '13px',
},
'.cm-content': {
caretColor: 'var(--foreground)',
fontFamily: 'var(--font-mono)',
lineHeight: '1.5rem',
minHeight: 'var(--code-editor-min-height)',
minWidth: 'max-content',
padding: '1rem 1rem 1rem 0',
},
'.cm-editor': {
background: 'transparent',
width: '100%',
},
'.cm-focused': {
outline: 'none',
},
'.cm-gutters': {
background: 'transparent',
borderRight: '0',
color: 'var(--muted-foreground)',
fontFamily: 'var(--font-mono)',
fontSize: '13px',
lineHeight: '1.5rem',
padding: '1rem 1rem 1rem 0',
},
'.cm-gutters:empty': {
display: 'none',
},
'.cm-lineNumbers .cm-gutterElement': {
minWidth: '2.5rem',
padding: '0 1rem 0 0',
textAlign: 'right',
},
'.cm-line': {
padding: '0',
},
'.cm-scroller': {
fontFamily: 'var(--font-mono)',
lineHeight: '1.5rem',
minHeight: 'var(--code-editor-min-height)',
overflow: 'auto',
},
'.cm-selectionBackground': {
background:
'color-mix(in oklch, var(--primary) 28%, transparent) !important',
},
})
const codeMirrorHighlightStyle = syntaxHighlighting(
HighlightStyle.define([
{ tag: highlightTags.heading, color: '#e06c75', fontWeight: '600' },
{ tag: [highlightTags.strong, highlightTags.emphasis], color: '#d19a66' },
{ tag: [highlightTags.link, highlightTags.url], color: '#61afef' },
{
tag: [highlightTags.monospace, highlightTags.contentSeparator],
color: '#98c379',
},
{
tag: [highlightTags.keyword, highlightTags.processingInstruction],
color: '#c678dd',
},
{
tag: [highlightTags.atom, highlightTags.bool, highlightTags.number],
color: '#d19a66',
},
transformers,
})
{ tag: [highlightTags.string, highlightTags.inserted], color: '#98c379' },
{ tag: [highlightTags.deleted, highlightTags.invalid], color: '#e06c75' },
{
tag: [highlightTags.meta, highlightTags.comment],
color: 'var(--muted-foreground)',
},
])
)
function getRequestedCodeLanguage(language?: string) {
const normalized = language?.trim().toLowerCase() || 'plaintext'
if (!LANGUAGE_PATTERN.test(normalized)) {
return 'plaintext'
}
return LANGUAGE_ALIASES[normalized] ?? normalized
}
function getCodeMirrorLanguageExtension(language: BundledLanguage | string) {
const requestedLanguage = getRequestedCodeLanguage(language)
if (
requestedLanguage === 'markdown' ||
requestedLanguage === 'md' ||
requestedLanguage === 'mdx'
) {
return markdown()
}
return []
}
function getCodeLineCount(code: string) {
if (!code) {
return 1
}
return code.split('\n').length
}
function getDownloadFilename(language: string, filename?: string) {
if (filename) {
return filename
}
const extension = language === 'plaintext' ? 'txt' : language
return `code.${extension}`
}
function getCodeBlockHeight(lines: number) {
return `${Math.max(4, lines) * 1.5 + 2}rem`
}
function getCodeBlockMaxHeight(
isCodeCollapsed: boolean,
previewLines: number,
maxExpandedLines?: number
): string | undefined {
if (isCodeCollapsed) {
return getCodeBlockHeight(previewLines)
}
if (maxExpandedLines) {
return getCodeBlockHeight(maxExpandedLines)
}
return undefined
}
function getCodeMirrorExtensions(options: {
language: BundledLanguage | string
onKeyDown?: (event: globalThis.KeyboardEvent) => void
readOnly: boolean
showLineNumbers: boolean
}): Extension[] {
const extensions: Extension[] = [
getCodeMirrorLanguageExtension(options.language),
codeMirrorHighlightStyle,
codeMirrorTheme,
EditorState.tabSize.of(2),
EditorState.readOnly.of(options.readOnly),
EditorView.editable.of(!options.readOnly),
]
if (options.showLineNumbers) {
extensions.unshift(lineNumbers())
}
if (options.onKeyDown) {
extensions.push(
EditorView.domEventHandlers({
keydown(event) {
options.onKeyDown?.(event)
return event.defaultPrevented
},
})
)
}
return extensions
}
function CodeMirrorCodeView({
ariaLabel,
autoFocus = false,
language,
onChange,
onKeyDown,
readOnly = false,
rows = 8,
showLineNumbers = true,
value,
}: CodeMirrorCodeViewProps) {
const editorHostRef = useRef<HTMLDivElement>(null)
const editorViewRef = useRef<EditorView | null>(null)
const initialValueRef = useRef(value)
const onChangeRef = useRef(onChange)
const editorMinHeight = `${Math.max(4, rows) * 1.5 + 2}rem`
const editorExtensions = useMemo(
() =>
getCodeMirrorExtensions({
language,
onKeyDown,
readOnly,
showLineNumbers,
}),
[language, onKeyDown, readOnly, showLineNumbers]
)
useEffect(() => {
onChangeRef.current = onChange
}, [onChange])
useEffect(() => {
const editorHost = editorHostRef.current
if (!editorHost) {
return
}
const editorView = new EditorView({
doc: initialValueRef.current,
extensions: [
...editorExtensions,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChangeRef.current?.(update.state.doc.toString())
}
}),
],
parent: editorHost,
})
editorViewRef.current = editorView
if (autoFocus) {
editorView.focus()
}
return () => {
editorView.destroy()
editorViewRef.current = null
}
}, [autoFocus, editorExtensions])
useEffect(() => {
const editorView = editorViewRef.current
if (!editorView) {
return
}
const currentValue = editorView.state.doc.toString()
if (currentValue === value) {
return
}
editorView.dispatch({
changes: {
from: 0,
to: editorView.state.doc.length,
insert: value,
},
})
}, [value])
return (
<div
aria-label={ariaLabel}
aria-readonly={readOnly}
className='min-h-(--code-editor-min-height)'
ref={editorHostRef}
role='textbox'
style={
{
'--code-editor-min-height': editorMinHeight,
} as CSSProperties
}
/>
)
}
export const CodeBlockFrame = ({
bodyClassName,
bodyMaxHeight,
bodyOverlay,
children,
className,
endActions,
showToolbar = false,
title,
...props
}: CodeBlockFrameProps) => (
<div
className={cn(
'group/code-block bg-muted/20 text-foreground my-3 w-full max-w-full overflow-hidden rounded-lg border shadow-xs',
className
)}
{...props}
>
{showToolbar && (
<div className='bg-muted/35 border-border/70 flex min-h-10 items-center gap-2 border-b px-2 py-1.5'>
<div className='min-w-0 flex-1'>
<div className='text-muted-foreground truncate font-mono text-[11px] font-medium tracking-wide uppercase'>
{title}
</div>
</div>
{endActions && (
<div className='flex shrink-0 items-center gap-1'>{endActions}</div>
)}
</div>
)}
<div className='relative min-w-0'>
<div
className={cn(
'code-block-scroll max-w-full overflow-auto transition-[max-height] duration-200 ease-out',
bodyClassName
)}
style={{ maxHeight: bodyMaxHeight }}
>
{children}
</div>
{bodyOverlay}
</div>
</div>
)
export const CodeBlock = ({
code,
collapsedLines = 12,
defaultCollapsed,
enableCollapse = true,
filename,
language,
maxExpandedLines,
maxCollapsedLines,
showLineNumbers = false,
showToolbar = false,
title,
className,
children,
...props
}: CodeBlockProps) => {
const [html, setHtml] = useState<string>('')
const { t } = useTranslation()
const [isCollapsed, setIsCollapsed] = useState(Boolean(defaultCollapsed))
const displayLanguage = getRequestedCodeLanguage(language)
const lineCount = useMemo(() => getCodeLineCount(code), [code])
const previewLines = maxCollapsedLines ?? collapsedLines
const canCollapse = enableCollapse && lineCount > previewLines
const isCodeCollapsed = canCollapse && isCollapsed
const displayTitle = title ?? displayLanguage
const bodyMaxHeight = getCodeBlockMaxHeight(
isCodeCollapsed,
previewLines,
maxExpandedLines
)
useEffect(() => {
let cancelled = false
highlightCode(code, language, showLineNumbers).then((next) => {
if (!cancelled) {
setHtml(next)
}
})
return () => {
cancelled = true
const downloadCode = () => {
if (typeof window === 'undefined') {
return
}
}, [code, language, showLineNumbers])
const blob = new Blob([code], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = getDownloadFilename(displayLanguage, filename)
anchor.click()
URL.revokeObjectURL(url)
}
return (
<CodeBlockContext.Provider value={{ code }}>
<div
className={cn(
'group bg-background text-foreground relative w-full overflow-hidden rounded-md border',
className
)}
<CodeBlockContext.Provider value={{ code, language: displayLanguage }}>
<CodeBlockFrame
bodyClassName='p-0'
bodyMaxHeight={bodyMaxHeight}
bodyOverlay={
<>
{isCodeCollapsed && (
<div className='from-muted/20 to-background pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-linear-to-b' />
)}
{!showToolbar && children && (
<div className='absolute top-2 right-2 flex items-center gap-1'>
{children}
</div>
)}
</>
}
className={className}
endActions={
<>
{canCollapse && (
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label={isCodeCollapsed ? t('Expand') : t('Collapse')}
className='size-8'
onClick={() => setIsCollapsed((value) => !value)}
size='icon-sm'
type='button'
variant='ghost'
>
{isCodeCollapsed ? (
<ChevronRightIcon className='size-4' />
) : (
<ChevronDownIcon className='size-4' />
)}
</Button>
}
/>
<TooltipContent>
<p>{isCodeCollapsed ? t('Expand') : t('Collapse')}</p>
</TooltipContent>
</Tooltip>
)}
{showToolbar && children}
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label={t('Download')}
className='size-8'
onClick={downloadCode}
size='icon-sm'
type='button'
variant='ghost'
>
<DownloadIcon className='size-4' />
</Button>
}
/>
<TooltipContent>
<p>{t('Download')}</p>
</TooltipContent>
</Tooltip>
</>
}
showToolbar={showToolbar}
title={displayTitle}
{...props}
>
<div className='relative'>
<div
className='[&>pre]:bg-background! [&>pre]:text-foreground! overflow-hidden [&_code]:font-mono [&_code]:text-sm [&>pre]:m-0 [&>pre]:p-4 [&>pre]:text-sm'
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
dangerouslySetInnerHTML={{ __html: html }}
/>
{children && (
<div className='absolute top-2 right-2 flex items-center gap-2'>
{children}
</div>
)}
</div>
</div>
<CodeMirrorCodeView
ariaLabel={
typeof displayTitle === 'string' ? displayTitle : displayLanguage
}
language={language}
readOnly
rows={Math.min(Math.max(lineCount, 4), maxExpandedLines ?? lineCount)}
showLineNumbers={showLineNumbers}
value={code}
/>
</CodeBlockFrame>
</CodeBlockContext.Provider>
)
}
export const CodeBlockEditor = ({
actions,
ariaLabel,
className,
language,
onChange,
onKeyDown,
rows = 8,
title,
value,
...props
}: CodeBlockEditorProps) => {
return (
<CodeBlockFrame
bodyClassName='p-0'
className={className}
endActions={actions}
showToolbar
title={title}
{...props}
>
<CodeMirrorCodeView
ariaLabel={ariaLabel}
autoFocus
language={language}
onChange={onChange}
onKeyDown={onKeyDown}
rows={rows}
showLineNumbers
value={value}
/>
</CodeBlockFrame>
)
}
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void
onError?: (error: Error) => void
......@@ -152,6 +616,7 @@ export const CodeBlockCopyButton = ({
className,
...props
}: CodeBlockCopyButtonProps) => {
const { t } = useTranslation()
const [isCopied, setIsCopied] = useState(false)
const { code } = useContext(CodeBlockContext)
......@@ -173,15 +638,26 @@ export const CodeBlockCopyButton = ({
const Icon = isCopied ? CheckIcon : CopyIcon
return (
const button = (
<Button
className={cn('shrink-0', className)}
aria-label={isCopied ? t('Copied!') : t('Copy code')}
className={cn('size-8 shrink-0', className)}
onClick={copyToClipboard}
size='icon'
size='icon-sm'
type='button'
variant='ghost'
{...props}
>
{children ?? <Icon size={14} />}
</Button>
)
return (
<Tooltip>
<TooltipTrigger render={button} />
<TooltipContent>
<p>{isCopied ? t('Copied!') : t('Copy code')}</p>
</TooltipContent>
</Tooltip>
)
}
......@@ -18,18 +18,19 @@ For commercial licensing, please contact support@quantumnous.com
*/
'use client'
import { type ComponentProps, useCallback } from 'react'
import { ArrowDownIcon } from 'lucide-react'
import { type ComponentProps, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
export type ConversationProps = ComponentProps<typeof StickToBottom>
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn('relative flex-1 overflow-y-auto', className)}
className={cn('relative min-h-0 flex-1 overflow-hidden', className)}
initial='smooth'
resize='smooth'
role='log'
......
......@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
'use client'
import { BrainIcon, ChevronDownIcon } from 'lucide-react'
import {
type ComponentProps,
createContext,
......@@ -26,14 +27,16 @@ import {
useEffect,
useState,
} from 'react'
import { BrainIcon, ChevronDownIcon } from 'lucide-react'
import { useControllableState } from '@/lib/use-controllable-state'
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { useControllableState } from '@/lib/use-controllable-state'
import { cn } from '@/lib/utils'
import { Response } from './response'
import { Shimmer } from './shimmer'
......@@ -138,39 +141,42 @@ export const Reasoning = memo(
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger>
const getThinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming) {
return <Shimmer duration={1}>Thinking...</Shimmer>
}
// When duration is unknown or 0 (e.g., non-streaming responses), show a generic message
if (duration === undefined || duration === 0) {
return <p>Thought for a few seconds</p>
}
return <p>Thought for {duration} seconds</p>
}
export const ReasoningTrigger = memo(
({ className, children, ...props }: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning()
const { t } = useTranslation()
const thinkingText = t('Thought for {{duration}} seconds', {
duration: duration ?? 0,
})
return (
<CollapsibleTrigger
className={cn(
'text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors',
'text-muted-foreground hover:text-foreground inline-grid w-fit max-w-full grid-cols-[0.875rem_minmax(0,auto)_0.875rem] items-center gap-1.5 text-sm leading-none transition-colors [&_p]:m-0',
className
)}
{...props}
>
{children ?? (
<>
<BrainIcon className='size-4' />
{getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon
className={cn(
'size-4 transition-transform',
isOpen ? 'rotate-180' : 'rotate-0'
<span className='grid size-3.5 place-items-center'>
<BrainIcon className='size-3.5' />
</span>
<span className='min-w-0 truncate leading-none'>
{isStreaming ? (
<Shimmer duration={1}>{t('Thinking...')}</Shimmer>
) : (
thinkingText
)}
/>
</span>
<span className='grid size-3.5 place-items-center'>
<ChevronDownIcon
className={cn(
'size-3.5 transition-transform duration-200 ease-out',
isOpen ? 'rotate-180' : 'rotate-0'
)}
/>
</span>
</>
)}
</CollapsibleTrigger>
......@@ -188,13 +194,17 @@ export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
className={cn(
'mt-4 text-sm',
'data-closed:fade-out-0 data-closed:slide-out-to-top-2 data-open:slide-in-from-top-2 text-muted-foreground data-closed:animate-out data-open:animate-in outline-none',
'CollapsibleContent group/reasoning-content border-border/70 mt-2 ml-1.5 border-l pl-3 text-sm leading-5',
'text-muted-foreground outline-none',
className
)}
{...props}
>
<Response className='grid gap-2'>{children}</Response>
<div className='transition-[opacity,transform] duration-200 ease-out group-data-[closed]/reasoning-content:-translate-y-1 group-data-[closed]/reasoning-content:opacity-0 group-data-[open]/reasoning-content:translate-y-0 group-data-[open]/reasoning-content:opacity-100 motion-reduce:transition-none'>
<Response className='grid gap-1.5 [&_li]:my-0.5 [&_ol]:my-1.5 [&_p]:my-1.5 [&_p]:leading-5 [&_ul]:my-1.5'>
{children}
</Response>
</div>
</CollapsibleContent>
)
)
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { ReactNode } from 'react'
import type { ParsedNode } from 'stream-markdown-parser'
import { isFootnoteNode } from './response-node-guards'
import type { ParsedResponseContent } from './response-types'
const FENCE_START_PATTERN = /^(`{3,}|~{3,})([^\n]*)$/
const FENCE_END_PATTERN = /^(`{3,}|~{3,})\s*$/
const SECTION_HEADING_PATTERN = /^#{2,6}\s+\d+\.\s+/
const MARKDOWN_EXAMPLE_LANGUAGES = new Set(['markdown', 'md', 'mdx'])
type MarkdownExampleFence = {
contentLines: string[]
fenceChar: string
language: string
nestedFence: boolean
}
function getFenceRunLength(line: string, fenceChar: string): number {
let length = 0
for (const char of line) {
if (char !== fenceChar) {
break
}
length++
}
return length
}
function getMarkdownExampleFenceLength(block: MarkdownExampleFence): number {
let maxFenceLength = 3
for (const line of block.contentLines) {
if (!line.startsWith(block.fenceChar)) {
continue
}
maxFenceLength = Math.max(
maxFenceLength,
getFenceRunLength(line, block.fenceChar) + 1
)
}
return maxFenceLength
}
function appendMarkdownExampleFence(
output: string[],
block: MarkdownExampleFence
): void {
const fence = block.fenceChar.repeat(getMarkdownExampleFenceLength(block))
output.push(`${fence}${block.language}`)
output.push(...block.contentLines)
output.push(fence)
}
function normalizeMarkdownExampleFences(input: string): string {
const lines = input.split('\n')
const output: string[] = []
let exampleFence: MarkdownExampleFence | null = null
for (const line of lines) {
if (!exampleFence) {
const match = line.match(FENCE_START_PATTERN)
if (!match) {
output.push(line)
continue
}
const language = match[2].trim().toLowerCase()
if (MARKDOWN_EXAMPLE_LANGUAGES.has(language)) {
exampleFence = {
contentLines: [],
fenceChar: match[1][0],
language,
nestedFence: false,
}
continue
}
output.push(line)
continue
}
if (!exampleFence.nestedFence && SECTION_HEADING_PATTERN.test(line)) {
appendMarkdownExampleFence(output, exampleFence)
output.push(line)
exampleFence = null
continue
}
if (exampleFence.nestedFence && FENCE_END_PATTERN.test(line)) {
exampleFence.contentLines.push(line)
exampleFence.nestedFence = false
continue
}
if (
line.startsWith(exampleFence.fenceChar.repeat(3)) &&
!FENCE_END_PATTERN.test(line)
) {
exampleFence.contentLines.push(line)
exampleFence.nestedFence = true
continue
}
if (FENCE_END_PATTERN.test(line)) {
appendMarkdownExampleFence(output, exampleFence)
exampleFence = null
continue
}
exampleFence.contentLines.push(line)
}
if (exampleFence) {
appendMarkdownExampleFence(output, exampleFence)
}
return output.join('\n')
}
export function stripCustomTags(input: unknown): string {
if (typeof input !== 'string') {
return String(input ?? '')
}
return input
.replaceAll(
/<\/?(conversation|conversationcontent|reasoning|reasoningcontent|reasoningtrigger|sources|sourcescontent|sourcestrigger|branch|branchmessages|branchnext|branchpage|branchprevious|branchselector|message|messagecontent)\b[^>]*>/gi,
''
)
.replaceAll(/<\/?think\b[^>]*>/gi, '')
}
export function getMarkdownContent(children: ReactNode): string {
if (Array.isArray(children)) {
return normalizeMarkdownExampleFences(stripCustomTags(children.join('')))
}
return normalizeMarkdownExampleFences(stripCustomTags(children))
}
export function getNodeKey(node: ParsedNode, index: number): string {
const raw = typeof node.raw === 'string' ? node.raw : ''
return `${node.type}-${index}-${raw.slice(0, 24)}`
}
export function parseResponseContent(
nodes: ParsedNode[]
): ParsedResponseContent {
const footnotes: ParsedResponseContent['footnotes'] = []
const bodyNodes: ParsedNode[] = []
for (const node of nodes) {
if (isFootnoteNode(node)) {
footnotes.push(node)
continue
}
bodyNodes.push(node)
}
return { bodyNodes, footnotes }
}
/*
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 {
BlockquoteNode,
CodeBlockNode,
DefinitionListNode,
FootnoteNode,
HeadingNode,
HtmlBlockNode,
ImageNode,
LinkNode,
ListNode,
MathBlockNode,
MathInlineNode,
ParsedNode,
TableNode,
TextNode,
} from 'stream-markdown-parser'
export function hasParsedChildren(
node: ParsedNode
): node is ParsedNode & { children: ParsedNode[] } {
return 'children' in node && Array.isArray(node.children)
}
export function isTextNode(node: ParsedNode): node is TextNode {
return node.type === 'text' && 'content' in node
}
export function isHeadingNode(node: ParsedNode): node is HeadingNode {
return node.type === 'heading' && 'level' in node && hasParsedChildren(node)
}
export function isListNode(node: ParsedNode): node is ListNode {
return node.type === 'list' && 'items' in node && Array.isArray(node.items)
}
export function isCodeBlockNode(node: ParsedNode): node is CodeBlockNode {
return node.type === 'code_block' && 'code' in node && 'language' in node
}
export function isLinkNode(node: ParsedNode): node is LinkNode {
return node.type === 'link' && 'href' in node && hasParsedChildren(node)
}
export function isImageNode(node: ParsedNode): node is ImageNode {
return node.type === 'image' && 'src' in node && 'alt' in node
}
export function isBlockquoteNode(node: ParsedNode): node is BlockquoteNode {
return node.type === 'blockquote' && hasParsedChildren(node)
}
export function isTableNode(node: ParsedNode): node is TableNode {
return node.type === 'table' && 'header' in node && 'rows' in node
}
export function isDefinitionListNode(
node: ParsedNode
): node is DefinitionListNode {
return (
node.type === 'definition_list' &&
'items' in node &&
Array.isArray(node.items)
)
}
export function isMathBlockNode(node: ParsedNode): node is MathBlockNode {
return node.type === 'math_block' && 'content' in node
}
export function isMathInlineNode(node: ParsedNode): node is MathInlineNode {
return node.type === 'math_inline' && 'content' in node
}
export function isFootnoteNode(node: ParsedNode): node is FootnoteNode {
return node.type === 'footnote' && 'id' in node && hasParsedChildren(node)
}
export function isHtmlBlockNode(node: ParsedNode): node is HtmlBlockNode {
return node.type === 'html_block' && 'tag' in node
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { ReactNode } from 'react'
import { t } from 'i18next'
import type { BlockquoteNode, ParsedNode } from 'stream-markdown-parser'
import { cn } from '@/lib/utils'
import { hasParsedChildren } from './response-node-guards'
import type {
AlertConfig,
AlertKind,
BlockRendererOptions,
} from './response-types'
const alertConfig = {
note: {
label: 'Note',
className:
'border-blue-500/40 bg-blue-500/8 text-blue-950 dark:text-blue-100',
markerClassName: 'text-blue-600 dark:text-blue-300',
},
tip: {
label: 'Tip',
className:
'border-emerald-500/40 bg-emerald-500/8 text-emerald-950 dark:text-emerald-100',
markerClassName: 'text-emerald-600 dark:text-emerald-300',
},
important: {
label: 'Important',
className:
'border-violet-500/40 bg-violet-500/8 text-violet-950 dark:text-violet-100',
markerClassName: 'text-violet-600 dark:text-violet-300',
},
warning: {
label: 'Warning',
className:
'border-amber-500/40 bg-amber-500/8 text-amber-950 dark:text-amber-100',
markerClassName: 'text-amber-600 dark:text-amber-300',
},
caution: {
label: 'Caution',
className: 'border-red-500/40 bg-red-500/8 text-red-950 dark:text-red-100',
markerClassName: 'text-red-600 dark:text-red-300',
},
} satisfies Record<AlertKind, AlertConfig>
function getAlertKind(node: BlockquoteNode): AlertKind | null {
const firstChild = node.children[0]
if (!firstChild || firstChild.type !== 'paragraph') {
return null
}
if (!hasParsedChildren(firstChild)) {
return null
}
const firstInline = firstChild.children[0]
if (!firstInline || firstInline.type !== 'text') {
return null
}
if (!('content' in firstInline) || typeof firstInline.content !== 'string') {
return null
}
const markerPattern = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*\n?/i
const match = firstInline.content.match(markerPattern)
if (!match) {
return null
}
return match[1].toLowerCase() as AlertKind
}
function getAlertChildren(node: BlockquoteNode, kind: AlertKind): ParsedNode[] {
const firstChild = node.children[0]
if (!firstChild || firstChild.type !== 'paragraph') {
return node.children
}
if (!hasParsedChildren(firstChild)) {
return node.children
}
const firstInline = firstChild.children[0]
if (!firstInline || firstInline.type !== 'text') {
return node.children
}
if (!('content' in firstInline) || typeof firstInline.content !== 'string') {
return node.children
}
const marker = `[!${kind.toUpperCase()}]`
const content = firstInline.content.replace(marker, '').replace(/^\s*\n?/, '')
const nextParagraph = {
...firstChild,
children: [
{ ...firstInline, content, raw: content },
...firstChild.children.slice(1),
],
}
if (!content && nextParagraph.children.length === 1) {
return node.children.slice(1)
}
return [nextParagraph, ...node.children.slice(1)]
}
export function renderBlockquote(
node: BlockquoteNode,
key: string,
options: BlockRendererOptions
): ReactNode {
const alertKind = getAlertKind(node)
if (alertKind) {
const config = alertConfig[alertKind]
const alertChildren = getAlertChildren(node, alertKind)
return (
<aside
className={cn(
'my-4 rounded-lg border px-4 py-3 text-sm',
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
config.className
)}
key={key}
>
<div
className={cn(
'mb-2 text-xs font-semibold tracking-wide uppercase',
config.markerClassName
)}
>
{t(config.label)}
</div>
{options.renderChildren(alertChildren)}
</aside>
)
}
return (
<blockquote
className='border-border text-muted-foreground my-4 border-l-2 pl-4'
key={key}
>
{options.renderChildren(node.children)}
</blockquote>
)
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { ReactNode } from 'react'
import type {
CodeBlockNode,
DefinitionItemNode,
DefinitionListNode,
HeadingNode,
ListNode,
MathBlockNode,
MathInlineNode,
} from 'stream-markdown-parser'
import {
CodeBlock,
CodeBlockCopyButton,
} from '@/components/ai-elements/code-block'
import { cn } from '@/lib/utils'
import { getNodeKey } from './response-content'
import type { BlockRendererOptions } from './response-types'
const headingClasses = {
1: 'mt-6 mb-3 text-xl font-semibold tracking-normal',
2: 'mt-6 mb-3 text-lg font-semibold tracking-normal',
3: 'mt-5 mb-2 text-base font-semibold tracking-normal',
4: 'mt-5 mb-2 text-sm font-semibold tracking-normal',
5: 'text-muted-foreground mt-4 mb-2 text-sm font-semibold tracking-normal',
6: 'text-muted-foreground mt-4 mb-2 text-xs font-semibold tracking-normal uppercase',
} satisfies Record<1 | 2 | 3 | 4 | 5 | 6, string>
export function renderHeading(
node: HeadingNode,
key: string,
options: BlockRendererOptions
): ReactNode {
const headingLevel = Math.min(Math.max(node.level, 1), 6) as
| 1
| 2
| 3
| 4
| 5
| 6
const className = headingClasses[headingLevel]
const children = options.renderChildren(node.children)
if (headingLevel === 1) {
return (
<h1 className={className} key={key}>
{children}
</h1>
)
}
if (headingLevel === 2) {
return (
<h2 className={className} key={key}>
{children}
</h2>
)
}
if (headingLevel === 3) {
return (
<h3 className={className} key={key}>
{children}
</h3>
)
}
if (headingLevel === 4) {
return (
<h4 className={className} key={key}>
{children}
</h4>
)
}
if (headingLevel === 5) {
return (
<h5 className={className} key={key}>
{children}
</h5>
)
}
return (
<h6 className={className} key={key}>
{children}
</h6>
)
}
export function renderList(
node: ListNode,
key: string,
options: BlockRendererOptions
): ReactNode {
const className = cn(
'my-3 list-outside space-y-1.5 pl-5',
node.ordered ? 'list-decimal' : 'list-disc'
)
const items = node.items.map((item, index) => (
<li
className='marker:text-muted-foreground pl-1 leading-7'
key={getNodeKey(item, index)}
>
{options.renderChildren(item.children)}
</li>
))
if (node.ordered) {
return (
<ol className={className} key={key} start={node.start}>
{items}
</ol>
)
}
return (
<ul className={className} key={key}>
{items}
</ul>
)
}
export function renderCodeBlock(node: CodeBlockNode, key: string): ReactNode {
const language = node.language || 'plaintext'
const lineCount = node.code.split('\n').length
return (
<CodeBlock
collapsedLines={14}
code={node.code}
defaultCollapsed={lineCount > 14}
key={key}
language={language}
maxExpandedLines={44}
showLineNumbers
showToolbar
title={language}
>
<CodeBlockCopyButton />
</CodeBlock>
)
}
export function renderDefinitionList(
node: DefinitionListNode,
key: string,
options: BlockRendererOptions
): ReactNode {
return (
<dl className='my-4 space-y-3' key={key}>
{node.items.map((item, index) =>
renderDefinitionItem(item, index, options)
)}
</dl>
)
}
function renderDefinitionItem(
node: DefinitionItemNode,
index: number,
options: BlockRendererOptions
): ReactNode {
return (
<div key={`definition-${index}`}>
<dt className='font-semibold'>{options.renderChildren(node.term)}</dt>
<dd className='text-muted-foreground mt-1 pl-4'>
{options.renderChildren(node.definition)}
</dd>
</div>
)
}
export function renderMathBlock(node: MathBlockNode, key: string): ReactNode {
return (
<pre
className='border-border bg-muted/40 my-4 overflow-x-auto rounded-lg border p-4 font-mono text-sm'
key={key}
>
{node.content}
</pre>
)
}
export function renderMathInline(node: MathInlineNode, key: string): ReactNode {
return (
<code
className='bg-muted/70 text-foreground rounded px-1 py-0.5 font-mono text-[0.9em]'
key={key}
>
{node.content}
</code>
)
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { ReactNode } from 'react'
import { t } from 'i18next'
import type { HtmlBlockNode, ParsedNode } from 'stream-markdown-parser'
import { hasParsedChildren, isHtmlBlockNode } from './response-node-guards'
import type { BlockRendererOptions } from './response-types'
export function renderDetails(
node: HtmlBlockNode,
key: string,
options: BlockRendererOptions
): ReactNode {
const children = Array.isArray(node.children) ? node.children : []
const summaryNode = children.find(isSummaryHtmlNode)
const contentNodes = children.filter((child) => child !== summaryNode)
const summary = getDetailsSummary(summaryNode, options)
return (
<details
className='border-border/70 my-4 rounded-lg border px-4 py-3'
key={key}
>
<summary className='text-foreground cursor-pointer text-sm font-semibold'>
{summary}
</summary>
<div className='border-border/70 mt-3 border-l pl-4 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0'>
{options.renderChildren(contentNodes)}
</div>
</details>
)
}
function isSummaryHtmlNode(node: ParsedNode): node is HtmlBlockNode {
return isHtmlBlockNode(node) && node.tag === 'summary'
}
function getDetailsSummary(
node: HtmlBlockNode | undefined,
options: BlockRendererOptions
): ReactNode {
if (!node || !hasParsedChildren(node)) {
return t('Details')
}
return options.renderChildren(node.children)
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { ReactNode } from 'react'
import { t } from 'i18next'
import type { FootnoteNode } from 'stream-markdown-parser'
import type { BlockRendererOptions } from './response-types'
export function renderFootnotes(
footnotes: FootnoteNode[],
options: BlockRendererOptions
): ReactNode {
if (footnotes.length === 0) {
return null
}
return (
<section className='border-border/70 text-muted-foreground mt-6 border-t pt-3 text-sm'>
<ol className='list-decimal space-y-2 pl-5'>
{footnotes.map((footnote) => (
<li id={`footnote-${footnote.id}`} key={footnote.id}>
<div className='inline [&>*:first-child]:mt-0 [&>*:last-child]:mb-0'>
{options.renderChildren(footnote.children)}
</div>
<a
aria-label={t('Back to footnote {{id}} reference', {
id: footnote.id,
})}
className='text-primary ml-2 underline-offset-2 hover:underline'
href={`#footnote-ref-${footnote.id}`}
>
{t('Back')}
</a>
</li>
))}
</ol>
</section>
)
}
/*
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 { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { sanitizeImageSrc, type ImageNode } from 'stream-markdown-parser'
type ResponseImageProps = {
node: ImageNode
}
export function ResponseImage(props: ResponseImageProps) {
const { t } = useTranslation()
const [hasError, setHasError] = useState(false)
const src = sanitizeImageSrc(props.node.src)
if (!src || hasError) {
return (
<span className='border-border/70 text-muted-foreground my-4 inline-flex rounded-md border px-3 py-2 text-xs italic'>
{props.node.alt || t('Image not available')}
</span>
)
}
return (
<img
alt={props.node.alt}
className='border-border/70 my-4 block h-auto max-h-96 max-w-full rounded-lg border object-contain'
loading='lazy'
onError={() => setHasError(true)}
src={src}
title={props.node.title ?? undefined}
/>
)
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { ReactNode } from 'react'
import {
shouldOpenLinkInNewTab,
type ImageNode,
type LinkNode,
type TextNode,
} from 'stream-markdown-parser'
import { ResponseImage } from './response-renderer-image'
import type { RenderChildren } from './response-types'
export function renderTextNode(node: TextNode): ReactNode {
return node.content
}
export function renderLink(
node: LinkNode,
key: string,
renderChildren: RenderChildren
): ReactNode {
const opensInNewTab = shouldOpenLinkInNewTab(node.href)
const rel = opensInNewTab ? 'noreferrer noopener' : undefined
const target = opensInNewTab ? '_blank' : undefined
return (
<a
className='text-primary underline-offset-4 hover:underline'
href={node.href}
key={key}
rel={rel}
target={target}
title={node.title ?? undefined}
>
{renderChildren(node.children)}
</a>
)
}
export function renderImage(node: ImageNode, key: string): ReactNode {
return <ResponseImage key={key} node={node} />
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { ReactNode } from 'react'
import type { TableCellNode, TableNode } from 'stream-markdown-parser'
import { cn } from '@/lib/utils'
import { getNodeKey } from './response-content'
import type { BlockRendererOptions } from './response-types'
function getTableCellAlignClass(
align: TableCellNode['align'] | undefined
): string {
if (align === 'right') {
return 'text-right'
}
if (align === 'center') {
return 'text-center'
}
return 'text-left'
}
function renderTableCell(
node: TableCellNode,
key: string,
options: BlockRendererOptions
): ReactNode {
const alignClass = getTableCellAlignClass(node.align)
if (node.header) {
return (
<th
className={cn(
'text-muted-foreground px-3 py-2 text-xs font-semibold whitespace-nowrap',
alignClass
)}
key={key}
>
{options.renderChildren(node.children)}
</th>
)
}
return (
<td className={cn('px-3 py-2 align-top', alignClass)} key={key}>
{options.renderChildren(node.children)}
</td>
)
}
export function renderTable(
node: TableNode,
key: string,
options: BlockRendererOptions
): ReactNode {
return (
<div
className='border-border/70 my-4 w-full overflow-x-auto rounded-lg border'
key={key}
>
<table className='my-0 w-full min-w-max border-separate border-spacing-0 text-sm'>
<thead className='bg-muted/60'>
<tr className='border-border/70'>
{node.header.cells.map((cell, index) =>
renderTableCell(cell, getNodeKey(cell, index), options)
)}
</tr>
</thead>
<tbody className='divide-border/70 divide-y'>
{node.rows.map((row, rowIndex) => (
<tr className='border-border/70' key={getNodeKey(row, rowIndex)}>
{row.cells.map((cell, cellIndex) =>
renderTableCell(cell, getNodeKey(cell, cellIndex), options)
)}
</tr>
))}
</tbody>
</table>
</div>
)
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { ReactNode } from 'react'
import type { FootnoteNode, ParsedNode } from 'stream-markdown-parser'
import { getNodeKey } from './response-content'
import {
hasParsedChildren,
isBlockquoteNode,
isCodeBlockNode,
isDefinitionListNode,
isHeadingNode,
isHtmlBlockNode,
isImageNode,
isLinkNode,
isListNode,
isMathBlockNode,
isMathInlineNode,
isTableNode,
isTextNode,
} from './response-node-guards'
import { renderBlockquote } from './response-renderer-alert'
import {
renderCodeBlock,
renderDefinitionList,
renderHeading,
renderList,
renderMathBlock,
renderMathInline,
} from './response-renderer-blocks'
import { renderDetails } from './response-renderer-details'
import { renderFootnotes as renderFootnotesBlock } from './response-renderer-footnotes'
import {
renderImage,
renderLink,
renderTextNode,
} from './response-renderer-inline'
import { renderTable } from './response-renderer-table'
export function renderChildren(nodes: ParsedNode[]): ReactNode {
return nodes.map((node, index) => renderNode(node, getNodeKey(node, index)))
}
export function renderFootnotes(footnotes: FootnoteNode[]): ReactNode {
return renderFootnotesBlock(footnotes, { renderChildren })
}
function renderNode(node: ParsedNode, key: string): ReactNode {
if (isTextNode(node)) {
return renderTextNode(node)
}
if (isHeadingNode(node)) {
return renderHeading(node, key, { renderChildren })
}
if (node.type === 'paragraph' && hasParsedChildren(node)) {
return (
<p className='my-3 leading-7' key={key}>
{renderChildren(node.children)}
</p>
)
}
if (node.type === 'inline' && hasParsedChildren(node)) {
return <span key={key}>{renderChildren(node.children)}</span>
}
if (isListNode(node)) {
return renderList(node, key, { renderChildren })
}
if (isCodeBlockNode(node)) {
return renderCodeBlock(node, key)
}
if (node.type === 'inline_code' && 'code' in node) {
return (
<code
className='bg-muted/70 text-foreground rounded px-1 py-0.5 font-mono text-[0.9em]'
key={key}
>
{String(node.code)}
</code>
)
}
if (isLinkNode(node)) {
return renderLink(node, key, renderChildren)
}
if (isImageNode(node)) {
return renderImage(node, key)
}
if (isBlockquoteNode(node)) {
return renderBlockquote(node, key, { renderChildren })
}
if (isTableNode(node)) {
return renderTable(node, key, { renderChildren })
}
if (isDefinitionListNode(node)) {
return renderDefinitionList(node, key, { renderChildren })
}
if (node.type === 'strong' && hasParsedChildren(node)) {
return (
<strong className='text-foreground font-semibold' key={key}>
{renderChildren(node.children)}
</strong>
)
}
if (node.type === 'emphasis' && hasParsedChildren(node)) {
return <em key={key}>{renderChildren(node.children)}</em>
}
if (node.type === 'strikethrough' && hasParsedChildren(node)) {
return <del key={key}>{renderChildren(node.children)}</del>
}
if (node.type === 'highlight' && hasParsedChildren(node)) {
return <mark key={key}>{renderChildren(node.children)}</mark>
}
if (node.type === 'insert' && hasParsedChildren(node)) {
return <ins key={key}>{renderChildren(node.children)}</ins>
}
if (node.type === 'subscript' && hasParsedChildren(node)) {
return <sub key={key}>{renderChildren(node.children)}</sub>
}
if (node.type === 'superscript' && hasParsedChildren(node)) {
return <sup key={key}>{renderChildren(node.children)}</sup>
}
if (
(node.type === 'checkbox' || node.type === 'checkbox_input') &&
'checked' in node
) {
return (
<input
checked={Boolean(node.checked)}
className='accent-primary mr-2 size-4 align-[-0.15em]'
disabled
key={key}
readOnly
type='checkbox'
/>
)
}
if (node.type === 'hardbreak') {
return <br key={key} />
}
if (node.type === 'thematic_break') {
return <hr className='border-border/70 my-6' key={key} />
}
if (isMathBlockNode(node)) {
return renderMathBlock(node, key)
}
if (isMathInlineNode(node)) {
return renderMathInline(node, key)
}
if (node.type === 'footnote_reference' && 'id' in node) {
return (
<sup className='text-primary mx-0.5 text-xs' key={key}>
<a
className='underline-offset-2 hover:underline'
href={`#footnote-${String(node.id)}`}
id={`footnote-ref-${String(node.id)}`}
>
[{String(node.id)}]
</a>
</sup>
)
}
if (node.type === 'footnote_anchor') {
return null
}
if (isHtmlBlockNode(node) && node.tag === 'details') {
return renderDetails(node, key, { renderChildren })
}
if (node.type === 'html_block' && 'content' in node) {
return <span key={key}>{String(node.content)}</span>
}
if (node.type === 'html_inline' && 'content' in node) {
return <span key={key}>{String(node.content)}</span>
}
if (hasParsedChildren(node)) {
return <span key={key}>{renderChildren(node.children)}</span>
}
if ('content' in node && typeof node.content === 'string') {
return <span key={key}>{node.content}</span>
}
return <span key={key}>{node.raw}</span>
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { ReactNode } from 'react'
import type { FootnoteNode, ParsedNode } from 'stream-markdown-parser'
export type ResponseProps = {
children?: ReactNode
className?: string
final?: boolean
}
export type AlertKind = 'note' | 'tip' | 'important' | 'warning' | 'caution'
export type AlertConfig = {
label: string
className: string
markerClassName: string
}
export type ParsedResponseContent = {
bodyNodes: ParsedNode[]
footnotes: FootnoteNode[]
}
export type RenderChildren = (nodes: ParsedNode[]) => ReactNode
export type BlockRendererOptions = {
renderChildren: RenderChildren
}
......@@ -18,43 +18,49 @@ For commercial licensing, please contact support@quantumnous.com
*/
'use client'
import { type ComponentProps, memo } from 'react'
import { Streamdown } from 'streamdown'
import { memo, useMemo } from 'react'
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
import { cn } from '@/lib/utils'
type ResponseProps = ComponentProps<typeof Streamdown>
export const Response = memo(
({ className, children, ...props }: ResponseProps) => {
const stripCustomTags = (input: unknown): unknown => {
if (typeof input !== 'string') return input
return (
input
// Remove known AI custom wrapper tags but keep inner content
.replace(
/<\/?(conversation|conversationcontent|reasoning|reasoningcontent|reasoningtrigger|sources|sourcescontent|sourcestrigger|branch|branchmessages|branchnext|branchpage|branchprevious|branchselector|message|messagecontent)\b[^>]*>/gi,
''
)
// Remove any stray <think> tags if they still appear
.replace(/<\/?think\b[^>]*>/gi, '')
)
import { getMarkdownContent, parseResponseContent } from './response-content'
import { renderChildren, renderFootnotes } from './response-renderer'
import type { ResponseProps } from './response-types'
const markdown = getMarkdown('new-api-response')
const MAX_PARSED_MARKDOWN_CHARS = 20_000
export const Response = memo((props: ResponseProps) => {
const content = getMarkdownContent(props.children)
const shouldParseMarkdown = content.length <= MAX_PARSED_MARKDOWN_CHARS
const nodes = useMemo(() => {
if (!shouldParseMarkdown) {
return []
}
const safeChildren = stripCustomTags(children) as string
return (
<Streamdown
className={cn(
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
className
)}
{...props}
>
{safeChildren}
</Streamdown>
)
},
(prevProps, nextProps) => prevProps.children === nextProps.children
)
return parseMarkdownToStructure(content, markdown, {
final: props.final ?? true,
validateLink: markdown.options.validateLink,
})
}, [content, props.final, shouldParseMarkdown])
const parsedContent = useMemo(() => parseResponseContent(nodes), [nodes])
const renderedContent =
parsedContent.bodyNodes.length > 0
? renderChildren(parsedContent.bodyNodes)
: content
const footnotes = renderFootnotes(parsedContent.footnotes)
return (
<div
className={cn(
'size-full min-w-0 text-pretty [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
props.className
)}
>
{renderedContent}
{footnotes}
</div>
)
})
Response.displayName = 'Response'
......@@ -18,15 +18,16 @@ For commercial licensing, please contact support@quantumnous.com
*/
'use client'
import type { ComponentProps } from 'react'
import { BookIcon, ChevronDownIcon } from 'lucide-react'
import type { ComponentProps } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { cn } from '@/lib/utils'
export type SourcesProps = ComponentProps<'div'>
......@@ -73,7 +74,7 @@ export const SourcesContent = ({
}: SourcesContentProps) => (
<CollapsibleContent
className={cn(
'mt-3 flex w-fit flex-col gap-2',
'border-border/70 mt-3 ml-2 flex w-fit flex-col gap-2 border-l pl-4',
'data-closed:fade-out-0 data-closed:slide-out-to-top-2 data-open:slide-in-from-top-2 data-closed:animate-out data-open:animate-in outline-none',
className
)}
......
import { ChevronsUpDown, Check, CpuIcon, LayersIcon } from 'lucide-react'
/*
Copyright (C) 2023-2026 QuantumNous
......@@ -17,10 +18,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState, useMemo, useCallback } from 'react'
import { ChevronsUpDown, Check, CpuIcon, LayersIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { useIsMobile } from '@/hooks/use-mobile'
import { Button } from '@/components/ui/button'
import {
Command,
......@@ -42,6 +41,8 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { useIsMobile } from '@/hooks/use-mobile'
import { cn } from '@/lib/utils'
interface ModelOption {
label: string
......@@ -292,53 +293,49 @@ export const ModelSelector: React.FC<ModelSelectorProps> = React.memo(
</Command>
)
return (
<>
{isMobile ? (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<ModelTriggerButton
currentLabel={currentModel?.label || t('Model')}
triggerClassName={className}
isDisabled={disabled}
aria-expanded={open}
/>
</DrawerTrigger>
<DrawerContent className='flex max-h-[80vh] min-h-[60vh] flex-col'>
<DrawerHeader className='flex-shrink-0 pb-4'>
<DrawerTitle className='flex items-center gap-2 text-left text-lg font-medium'>
{t('Select Model')}
</DrawerTitle>
</DrawerHeader>
<div className='flex min-h-0 flex-1 flex-col'>
{renderModelCommandContent()}
</div>
</DrawerContent>
</Drawer>
) : (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<ModelTriggerButton
currentLabel={currentModel?.label || t('Model')}
triggerClassName={className}
isDisabled={disabled}
aria-expanded={open}
/>
}
return isMobile ? (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<ModelTriggerButton
currentLabel={currentModel?.label || t('Model')}
triggerClassName={className}
isDisabled={disabled}
aria-expanded={open}
/>
</DrawerTrigger>
<DrawerContent className='flex max-h-[80vh] min-h-[60vh] flex-col'>
<DrawerHeader className='flex-shrink-0 pb-4'>
<DrawerTitle className='flex items-center gap-2 text-left text-lg font-medium'>
{t('Select Model')}
</DrawerTitle>
</DrawerHeader>
<div className='flex min-h-0 flex-1 flex-col'>
{renderModelCommandContent()}
</div>
</DrawerContent>
</Drawer>
) : (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<ModelTriggerButton
currentLabel={currentModel?.label || t('Model')}
triggerClassName={className}
isDisabled={disabled}
aria-expanded={open}
/>
<PopoverContent
className='bg-popover z-40 w-[90vw] max-w-[20em] rounded-lg border p-0 !shadow-none sm:w-[20em]'
align='start'
side='bottom'
sideOffset={4}
collisionPadding={8}
>
{renderModelCommandContent()}
</PopoverContent>
</Popover>
)}
</>
}
/>
<PopoverContent
className='bg-popover z-40 w-[90vw] max-w-[20em] rounded-lg border p-0 !shadow-none sm:w-[20em]'
align='start'
side='bottom'
sideOffset={4}
collisionPadding={8}
>
{renderModelCommandContent()}
</PopoverContent>
</Popover>
)
}
)
......@@ -444,95 +441,91 @@ export const GroupSelector: React.FC<GroupSelectorProps> = React.memo(
</Command>
)
return (
<>
{isMobile ? (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<GroupTriggerButton
currentLabel={currentGroup?.label || t('Group')}
triggerClassName={className}
isDisabled={disabled}
aria-expanded={open}
/>
</DrawerTrigger>
<DrawerContent className='max-h-[80vh]'>
<DrawerHeader className='pb-4 text-left'>
<DrawerTitle>{t('Choose Group')}</DrawerTitle>
</DrawerHeader>
<div className='max-h-[calc(80vh-100px)] overflow-y-auto px-4 pb-6'>
<div className='space-y-2'>
{groups.map((group) => (
<Button
key={group.value}
variant='outline'
onClick={() => handleGroupChange(group.value)}
className={cn(
'flex h-auto w-full items-center justify-between rounded-lg p-4 text-left whitespace-normal',
'border-border hover:bg-accent',
selectedGroup === group.value
? 'bg-accent border-primary/20'
: 'bg-background'
)}
>
<div className='flex min-w-0 flex-1 items-center gap-3'>
<div className='flex min-w-0 flex-1 flex-col'>
<span className='text-foreground text-sm font-medium'>
{group.label}
</span>
{(group.desc || group.description) && (
<div className='text-muted-foreground mt-0.5 text-xs'>
{group.desc || group.description}
{group.ratio && (
<>
{' · '}
{t('Ratio: {{value}}', {
value: group.ratio,
})}
</>
)}
</div>
return isMobile ? (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<GroupTriggerButton
currentLabel={currentGroup?.label || t('Group')}
triggerClassName={className}
isDisabled={disabled}
aria-expanded={open}
/>
</DrawerTrigger>
<DrawerContent className='max-h-[80vh]'>
<DrawerHeader className='pb-4 text-left'>
<DrawerTitle>{t('Choose Group')}</DrawerTitle>
</DrawerHeader>
<div className='max-h-[calc(80vh-100px)] overflow-y-auto px-4 pb-6'>
<div className='space-y-2'>
{groups.map((group) => (
<Button
key={group.value}
variant='outline'
onClick={() => handleGroupChange(group.value)}
className={cn(
'flex h-auto w-full items-center justify-between rounded-lg p-4 text-left whitespace-normal',
'border-border hover:bg-accent',
selectedGroup === group.value
? 'bg-accent border-primary/20'
: 'bg-background'
)}
>
<div className='flex min-w-0 flex-1 items-center gap-3'>
<div className='flex min-w-0 flex-1 flex-col'>
<span className='text-foreground text-sm font-medium'>
{group.label}
</span>
{(group.desc || group.description) && (
<div className='text-muted-foreground mt-0.5 text-xs'>
{group.desc || group.description}
{group.ratio && (
<>
{' · '}
{t('Ratio: {{value}}', {
value: group.ratio,
})}
</>
)}
</div>
</div>
<Check
className={cn(
'ml-3 h-5 w-5 shrink-0',
selectedGroup === group.value
? 'opacity-100'
: 'opacity-0'
)}
/>
</Button>
))}
</div>
</div>
</DrawerContent>
</Drawer>
) : (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<GroupTriggerButton
currentLabel={currentGroup?.label || t('Group')}
triggerClassName={className}
isDisabled={disabled}
aria-expanded={open}
/>
}
)}
</div>
</div>
<Check
className={cn(
'ml-3 h-5 w-5 shrink-0',
selectedGroup === group.value
? 'opacity-100'
: 'opacity-0'
)}
/>
</Button>
))}
</div>
</div>
</DrawerContent>
</Drawer>
) : (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<GroupTriggerButton
currentLabel={currentGroup?.label || t('Group')}
triggerClassName={className}
isDisabled={disabled}
aria-expanded={open}
/>
<PopoverContent
className='bg-popover z-50 w-[90vw] max-w-[14em] rounded-lg border p-0 !shadow-none sm:w-[14em]'
align='start'
side='bottom'
sideOffset={4}
collisionPadding={8}
>
{renderGroupCommandContent()}
</PopoverContent>
</Popover>
)}
</>
}
/>
<PopoverContent
className='bg-popover z-50 w-[90vw] max-w-[14em] rounded-lg border p-0 !shadow-none sm:w-[14em]'
align='start'
side='bottom'
sideOffset={4}
collisionPadding={8}
>
{renderGroupCommandContent()}
</PopoverContent>
</Popover>
)
}
)
......@@ -568,20 +561,194 @@ export const ModelGroupSelector: React.FC<ModelGroupSelectorProps> = ({
className,
disabled = false,
}) => {
return (
<div className={cn('flex items-center gap-2', className)}>
<GroupSelector
selectedGroup={selectedGroup}
groups={groups}
onGroupChange={onGroupChange}
disabled={disabled}
/>
<ModelSelector
selectedModel={selectedModel}
models={models}
onModelChange={onModelChange}
disabled={disabled}
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const isMobile = useIsMobile()
const currentModel = useMemo(
() => models.find((model) => model.value === selectedModel),
[models, selectedModel]
)
const currentGroup = useMemo(
() => groups.find((group) => group.value === selectedGroup),
[groups, selectedGroup]
)
const filteredModels = useMemo(() => {
const query = searchQuery.trim().toLowerCase()
if (!query) {
return models
}
return models.filter((model) => {
const searchableText = [
model.label,
model.value,
model.description || '',
model.category || '',
]
.join(' ')
.toLowerCase()
return searchableText.includes(query)
})
}, [models, searchQuery])
const handleModelChange = useCallback(
(value: string) => {
onModelChange(value)
setOpen(false)
setSearchQuery('')
},
[onModelChange]
)
const handleGroupChange = useCallback(
(value: string) => {
onGroupChange(value)
},
[onGroupChange]
)
const renderTrigger = () => (
<Button
aria-expanded={open}
className={cn(
'h-8 max-w-[15rem] justify-start gap-2 border px-2.5 font-medium shadow-none',
'bg-background/80 hover:bg-accent/70 text-foreground',
'focus:!ring-0 focus:!outline-none',
className
)}
disabled={disabled}
role='combobox'
size='sm'
variant='outline'
>
<CpuIcon className='text-muted-foreground size-4 shrink-0' />
<span className='min-w-0 truncate text-xs'>
{currentModel?.label || t('Model')}
</span>
<span className='bg-muted text-muted-foreground hidden max-w-20 shrink-0 rounded px-1.5 py-0.5 text-[10px] sm:inline-flex'>
{currentGroup?.label || t('Group')}
</span>
<ChevronsUpDown className='text-muted-foreground ml-auto size-3.5 shrink-0 opacity-60' />
</Button>
)
const renderGroupList = () => (
<div className='min-w-0 space-y-2'>
<div className='text-muted-foreground px-1 text-[11px] leading-4 font-medium'>
{t('Model Group')}
</div>
<div className='grid gap-1'>
{groups.map((group) => {
const isSelected = selectedGroup === group.value
return (
<button
className={cn(
'flex min-w-0 items-center justify-between gap-2 rounded-md px-2.5 py-2 text-left text-[12px] leading-4 transition-colors',
isSelected
? 'bg-primary/10 text-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
)}
disabled={disabled}
key={group.value}
onClick={() => handleGroupChange(group.value)}
type='button'
>
<span className='min-w-0 truncate font-medium'>
{group.label}
</span>
<Check
className={cn(
'size-3.5 shrink-0',
isSelected ? 'opacity-100' : 'opacity-0'
)}
/>
</button>
)
})}
</div>
</div>
)
const renderModelList = () => (
<Command
className='min-w-0 rounded-lg border-0 bg-transparent p-1'
filter={() => 1}
shouldFilter={false}
>
<CommandInput
className='h-8 text-[13px]'
onValueChange={setSearchQuery}
placeholder={t('Search models...')}
value={searchQuery}
/>
<CommandList className={isMobile ? 'max-h-[45vh]' : 'max-h-[20rem]'}>
{filteredModels.length === 0 ? (
<div className='text-muted-foreground px-3 py-8 text-center text-[12px] leading-5'>
{t('No model found.')}
</div>
) : (
<CommandGroup className='p-1'>
{filteredModels.map((model) => (
<CommandItem
className='mb-0.5 flex items-center justify-between rounded-md px-2 py-1.5 text-[12px] leading-4 transition-colors'
key={model.value}
onSelect={handleModelChange}
value={model.value}
>
<span className='min-w-0 truncate font-medium'>
{model.label}
</span>
<Check
className={cn(
'size-3.5 shrink-0',
selectedModel === model.value ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
)
const renderContent = () => (
<div className='grid gap-3 p-2 md:grid-cols-[9.5rem_minmax(0,1fr)]'>
{renderGroupList()}
<div className='min-w-0 overflow-hidden rounded-lg border'>
{renderModelList()}
</div>
</div>
)
return isMobile ? (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{renderTrigger()}</DrawerTrigger>
<DrawerContent className='flex max-h-[80vh] min-h-[60vh] flex-col'>
<DrawerHeader className='pb-3 text-left'>
<DrawerTitle>{t('Select Model')}</DrawerTitle>
</DrawerHeader>
<div className='min-h-0 flex-1 overflow-y-auto px-4 pb-5'>
{renderContent()}
</div>
</DrawerContent>
</Drawer>
) : (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger render={renderTrigger()} />
<PopoverContent
align='end'
className='bg-popover z-50 w-[34rem] max-w-[calc(100vw-2rem)] rounded-xl border p-0 shadow-lg'
collisionPadding={8}
side='top'
sideOffset={8}
>
{renderContent()}
</PopoverContent>
</Popover>
)
}
......@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { api } from '@/lib/api'
import { API_ENDPOINTS } from './constants'
import type {
ChatCompletionRequest,
......@@ -29,9 +30,11 @@ import type {
* Send chat completion request (non-streaming)
*/
export async function sendChatCompletion(
payload: ChatCompletionRequest
payload: ChatCompletionRequest,
signal?: AbortSignal
): Promise<ChatCompletionResponse> {
const res = await api.post(API_ENDPOINTS.CHAT_COMPLETIONS, payload, {
signal,
skipErrorHandler: true,
} as Record<string, unknown>)
return res.data
......@@ -40,8 +43,10 @@ export async function sendChatCompletion(
/**
* Get user available models
*/
export async function getUserModels(): Promise<ModelOption[]> {
const res = await api.get(API_ENDPOINTS.USER_MODELS)
export async function getUserModels(group: string): Promise<ModelOption[]> {
const res = await api.get(API_ENDPOINTS.USER_MODELS, {
params: { group },
})
const { data } = res
if (!data.success || !Array.isArray(data.data)) {
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from '@/components/ai-elements/conversation'
import { Loader } from '@/components/ai-elements/loader'
import { Message } from '@/components/ai-elements/message'
import {
getChatMessageRenderState,
getEditingMessageContent,
getMessageAlignment,
getPreviousUserMessage,
isErrorMessage,
} from '../../lib'
import type {
Message as MessageType,
PlaygroundMessageLayoutMode,
} from '../../types'
import { MessageActions } from '../message/message-actions'
import { MessageErrorActions } from '../message/message-error-actions'
import { PlaygroundMessageContent } from '../message/playground-message-content'
import { PlaygroundMessageEditor } from '../message/playground-message-editor'
import { PlaygroundEmptyState } from './playground-empty-state'
const MAX_RENDERED_HISTORY_MESSAGES = 24
interface PlaygroundChatProps {
messages: MessageType[]
onCopyMessage?: (message: MessageType) => void
onRegenerateMessage?: (message: MessageType) => void
onEditMessage?: (message: MessageType) => void
onDeleteMessage?: (message: MessageType) => void
onSelectPrompt?: (prompt: string) => void
isGenerating?: boolean
isLoadingMessages?: boolean
editingKey?: string | null
onSaveEdit?: (newContent: string) => void
onCancelEdit?: (open: boolean) => void
onSaveEditAndSubmit?: (newContent: string) => void
messageLayoutMode?: PlaygroundMessageLayoutMode
}
export function PlaygroundChat({
messages,
onCopyMessage,
onRegenerateMessage,
onEditMessage,
onDeleteMessage,
onSelectPrompt,
isGenerating = false,
isLoadingMessages = false,
editingKey,
onSaveEdit,
onCancelEdit,
onSaveEditAndSubmit,
messageLayoutMode = 'alternating',
}: PlaygroundChatProps) {
const { t } = useTranslation()
const [editText, setEditText] = useState('')
const [originalText, setOriginalText] = useState('')
const [sourceMessageKeys, setSourceMessageKeys] = useState<
ReadonlySet<string>
>(() => new Set())
const visibleMessageOffset = Math.max(
0,
messages.length - MAX_RENDERED_HISTORY_MESSAGES
)
const visibleMessages = messages.slice(visibleMessageOffset)
function handleToggleMessageSource(message: MessageType): void {
setSourceMessageKeys((currentKeys) => {
const nextKeys = new Set(currentKeys)
if (nextKeys.has(message.key)) {
nextKeys.delete(message.key)
} else {
nextKeys.add(message.key)
}
return nextKeys
})
}
useEffect(() => {
if (!editingKey) return
const content = getEditingMessageContent(messages, editingKey)
// eslint-disable-next-line react-hooks/set-state-in-effect
setEditText(content)
setOriginalText(content)
}, [editingKey, messages])
let chatContent = visibleMessages.map((message, visibleMessageIndex) => {
const messageIndex = visibleMessageOffset + visibleMessageIndex
const { alwaysShowActions, content, isEditing } = getChatMessageRenderState(
messages,
message,
messageIndex,
editingKey
)
const isError = isErrorMessage(message)
const previousUserMessage = isError
? getPreviousUserMessage(messages, messageIndex)
: null
const alignment = getMessageAlignment(message, messageLayoutMode)
const isSourceVisible = sourceMessageKeys.has(message.key)
return (
<Message
className='group flex-row-reverse py-2.5'
from={message.from}
key={message.key}
>
<div className='w-full min-w-0 flex-1 basis-full'>
{isEditing ? (
<PlaygroundMessageEditor
editText={editText}
message={message}
onCancelEdit={onCancelEdit}
onEditTextChange={setEditText}
onSaveEdit={onSaveEdit}
onSaveEditAndSubmit={onSaveEditAndSubmit}
originalText={originalText}
/>
) : (
<PlaygroundMessageContent
alignment={alignment}
actions={
<MessageActions
message={message}
onCopy={onCopyMessage}
onRegenerate={onRegenerateMessage}
onToggleSource={handleToggleMessageSource}
onEdit={onEditMessage}
onDelete={onDeleteMessage}
isSourceVisible={isSourceVisible}
isGenerating={isGenerating}
alwaysVisible={alwaysShowActions}
className='mt-1.5'
/>
}
isSourceVisible={isSourceVisible}
message={message}
errorActions={
isError ? (
<MessageErrorActions
disabled={isGenerating}
onRetry={
onRegenerateMessage
? () => onRegenerateMessage(message)
: undefined
}
onEditPrompt={
onEditMessage && previousUserMessage
? () => onEditMessage(previousUserMessage)
: undefined
}
onDelete={
onDeleteMessage
? () => onDeleteMessage(message)
: undefined
}
/>
) : undefined
}
versionContent={content}
/>
)}
</div>
</Message>
)
})
if (visibleMessages.length === 0 && onSelectPrompt) {
chatContent = [
<PlaygroundEmptyState key='empty' onSelectPrompt={onSelectPrompt} />,
]
}
if (isLoadingMessages) {
chatContent = [
<div
className='text-muted-foreground flex min-h-[min(520px,calc(100svh-18rem))] items-center justify-center gap-2 text-sm'
key='loading'
>
<Loader />
<span>{t('Loading conversation...')}</span>
</div>,
]
}
return (
<Conversation>
{/* Remove outer padding; apply padding to inner centered container to align with input */}
<ConversationContent className='p-0'>
<div className='mx-auto w-full max-w-4xl px-4 py-4'>{chatContent}</div>
</ConversationContent>
<ConversationScrollButton />
</Conversation>
)
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import {
BarChartIcon,
CodeSquareIcon,
GraduationCapIcon,
MessageSquarePlusIcon,
NotepadTextIcon,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
type PlaygroundEmptyStateProps = {
onSelectPrompt: (prompt: string) => void
}
const starterPrompts = [
{ icon: BarChartIcon, text: 'Analyze data' },
{ icon: NotepadTextIcon, text: 'Summarize text' },
{ icon: CodeSquareIcon, text: 'Code' },
{ icon: GraduationCapIcon, text: 'Get advice' },
]
export function PlaygroundEmptyState({
onSelectPrompt,
}: PlaygroundEmptyStateProps) {
const { t } = useTranslation()
return (
<div className='flex min-h-[min(520px,calc(100svh-18rem))] items-center justify-center px-1 py-8 md:py-12'>
<div className='grid w-full max-w-2xl gap-5 text-center'>
<div className='bg-muted/50 text-muted-foreground mx-auto flex size-11 items-center justify-center rounded-xl border'>
<MessageSquarePlusIcon className='size-5' aria-hidden='true' />
</div>
<div className='grid gap-2'>
<h2 className='text-xl font-semibold tracking-tight text-balance md:text-2xl'>
{t('Start a playground chat')}
</h2>
<p className='text-muted-foreground mx-auto max-w-lg text-sm leading-6 text-balance'>
{t(
'Test a model with a starter prompt, or write your own request below.'
)}
</p>
</div>
<div className='grid gap-2 sm:grid-cols-2'>
{starterPrompts.map(({ icon: Icon, text }) => {
const prompt = t(text)
return (
<Button
className='h-auto min-h-11 justify-start gap-2 px-3 py-2.5 text-left whitespace-normal'
key={text}
onClick={() => onSelectPrompt(prompt)}
variant='outline'
>
<Icon className='text-muted-foreground size-4' />
<span>{prompt}</span>
</Button>
)
})}
</div>
</div>
</div>
)
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { SendIcon, SquareIcon } from 'lucide-react'
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { PromptInputButton } from '@/components/ai-elements/prompt-input'
import { ModelGroupSelector } from '@/components/model-group-selector'
import { getInputControlState } from '../../lib'
import type { GroupOption, ModelOption } from '../../types'
type PlaygroundInputControlsProps = {
disabled?: boolean
groups: GroupOption[]
groupValue: string
isGenerating?: boolean
isModelLoading?: boolean
models: ModelOption[]
modelValue: string
onGroupChange: (value: string) => void
onModelChange: (value: string) => void
onStop?: () => void
text: string
tools: ReactNode
}
export function PlaygroundInputControls({
disabled,
groups,
groupValue,
isGenerating,
isModelLoading = false,
models,
modelValue,
onGroupChange,
onModelChange,
onStop,
text,
tools,
}: PlaygroundInputControlsProps) {
const { t } = useTranslation()
const { canSubmit, isSelectorDisabled, shouldShowStop } =
getInputControlState({
disabled,
groups,
hasStopHandler: Boolean(onStop),
isGenerating,
isModelLoading,
models,
text,
})
const renderSelector = () => (
<ModelGroupSelector
selectedModel={modelValue}
models={models}
onModelChange={onModelChange}
selectedGroup={groupValue}
groups={groups}
onGroupChange={onGroupChange}
disabled={isSelectorDisabled}
/>
)
const renderSubmitButton = () =>
shouldShowStop ? (
<PromptInputButton
className='border-destructive/25 bg-destructive/10 text-destructive hover:bg-destructive/15 font-medium'
onClick={onStop}
variant='secondary'
>
<SquareIcon className='fill-current' size={16} />
<span className='hidden sm:inline'>{t('Stop')}</span>
<span className='sr-only sm:hidden'>{t('Stop')}</span>
</PromptInputButton>
) : (
<PromptInputButton
className='bg-primary text-primary-foreground hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground h-8 px-3 font-medium shadow-sm'
disabled={!canSubmit}
type='submit'
variant='default'
>
<SendIcon size={16} />
<span className='hidden sm:inline'>{t('Send')}</span>
<span className='sr-only sm:hidden'>{t('Send')}</span>
</PromptInputButton>
)
return (
<div className='flex w-full flex-col gap-2.5 md:flex-row md:items-center md:justify-between'>
<div className='flex min-w-0 items-center justify-end md:hidden'>
{renderSelector()}
</div>
<div className='flex items-center justify-between gap-2 md:justify-start'>
{tools}
<div className='flex items-center gap-1.5 md:hidden'>
{renderSubmitButton()}
</div>
</div>
<div className='hidden min-w-0 items-center gap-2 md:flex'>
{renderSelector()}
{renderSubmitButton()}
</div>
</div>
)
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { GlobeIcon, PaperclipIcon, Trash2Icon } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
PromptInputButton,
PromptInputTools,
} from '@/components/ai-elements/prompt-input'
import { ConfirmDialog } from '@/components/confirm-dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
ATTACHMENT_ACTIONS,
getAttachmentActionNotice,
getSearchActionNotice,
} from '../../lib'
type PlaygroundInputToolsProps = {
disabled?: boolean
hasMessages?: boolean
onClearMessages?: () => void
}
export function PlaygroundInputTools({
disabled,
hasMessages = false,
onClearMessages,
}: PlaygroundInputToolsProps) {
const { t } = useTranslation()
const [clearConfirmOpen, setClearConfirmOpen] = useState(false)
const handleFileAction = (action: string) => {
const notice = getAttachmentActionNotice(action)
toast.info(t(notice.title), {
description: notice.description,
})
}
const handleSearchAction = () => {
const notice = getSearchActionNotice()
toast.info(t(notice.title))
}
const handleClearMessages = () => {
onClearMessages?.()
setClearConfirmOpen(false)
toast.success(t('Conversation cleared'))
}
return (
<>
<PromptInputTools className='bg-background/70 border-border/60 rounded-lg border p-1 shadow-xs'>
<Tooltip>
<DropdownMenu>
<TooltipTrigger
render={
<DropdownMenuTrigger
render={
<PromptInputButton
aria-label={t('Attach')}
className='text-muted-foreground hover:text-foreground hover:bg-muted/70 font-medium'
disabled={disabled}
variant='ghost'
/>
}
>
<PaperclipIcon size={16} />
</DropdownMenuTrigger>
}
/>
<TooltipContent>
<p>{t('Attach')}</p>
</TooltipContent>
<DropdownMenuContent align='start'>
{ATTACHMENT_ACTIONS.map(({ action, icon: Icon, label }) => (
<DropdownMenuItem
key={action}
onClick={() => handleFileAction(action)}
>
<Icon className='mr-2' size={16} />
{t(label)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<PromptInputButton
aria-label={t('Search')}
className='text-muted-foreground hover:text-foreground hover:bg-muted/70 font-medium'
disabled={disabled}
onClick={handleSearchAction}
variant='ghost'
>
<GlobeIcon size={16} />
</PromptInputButton>
}
/>
<TooltipContent>
<p>{t('Search')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<PromptInputButton
aria-label={t('Clear chat history')}
className='text-muted-foreground hover:text-destructive hover:bg-destructive/10 font-medium'
disabled={disabled || !hasMessages || !onClearMessages}
onClick={() => setClearConfirmOpen(true)}
variant='ghost'
>
<Trash2Icon size={16} />
</PromptInputButton>
}
/>
<TooltipContent>
<p>{t('Clear chat history')}</p>
</TooltipContent>
</Tooltip>
</PromptInputTools>
<ConfirmDialog
destructive
desc={t(
'All playground messages saved in this browser will be removed. This cannot be undone.'
)}
confirmText={t('Clear')}
handleConfirm={handleClearMessages}
open={clearConfirmOpen}
onOpenChange={setClearConfirmOpen}
title={t('Clear chat history?')}
/>
</>
)
}
/*
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 { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PromptInput,
PromptInputFooter,
PromptInputTextarea,
type PromptInputMessage,
} from '@/components/ai-elements/prompt-input'
import { getSubmittableInputText } from '../../lib'
import type { ModelOption, GroupOption } from '../../types'
import { PlaygroundInputControls } from './playground-input-controls'
import { PlaygroundInputTools } from './playground-input-tools'
interface PlaygroundInputProps {
onSubmit: (text: string) => void
onStop?: () => void
disabled?: boolean
isGenerating?: boolean
models: ModelOption[]
modelValue: string
onModelChange: (value: string) => void
isModelLoading?: boolean
groups: GroupOption[]
groupValue: string
onGroupChange: (value: string) => void
hasMessages?: boolean
onClearMessages?: () => void
}
export function PlaygroundInput({
onSubmit,
onStop,
disabled,
isGenerating,
models,
modelValue,
onModelChange,
isModelLoading = false,
groups,
groupValue,
onGroupChange,
hasMessages = false,
onClearMessages,
}: PlaygroundInputProps) {
const { t } = useTranslation()
const [text, setText] = useState('')
const handleSubmit = (message: PromptInputMessage) => {
const submittableText = getSubmittableInputText(message, disabled)
if (!submittableText) return
onSubmit(submittableText)
setText('')
}
return (
<div className='grid shrink-0 gap-4 px-1 md:pb-4'>
<PromptInput
className='relative'
groupClassName='bg-background/95 dark:bg-background/80 border-border/70 shadow-[0_18px_60px_-32px_rgba(0,0,0,0.65)] ring-1 ring-foreground/5 rounded-xl overflow-hidden transition-all duration-200 focus-within:border-primary/45 focus-within:ring-primary/15 focus-within:shadow-[0_22px_70px_-34px_rgba(0,0,0,0.75)]'
onSubmit={handleSubmit}
>
<PromptInputTextarea
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck={false}
className='min-h-20 px-5 pt-4 pb-3 leading-7 md:min-h-24 md:text-base'
disabled={disabled}
onChange={(event) => setText(event.target.value)}
placeholder={t('Ask anything')}
value={text}
/>
<PromptInputFooter className='border-border/60 bg-muted/20 dark:bg-muted/10 border-t px-3 py-2.5 backdrop-blur'>
<PlaygroundInputControls
disabled={disabled}
groups={groups}
groupValue={groupValue}
isGenerating={isGenerating}
isModelLoading={isModelLoading}
models={models}
modelValue={modelValue}
onGroupChange={onGroupChange}
onModelChange={onModelChange}
onStop={onStop}
text={text}
tools={
<PlaygroundInputTools
disabled={disabled}
hasMessages={hasMessages}
onClearMessages={onClearMessages}
/>
}
/>
</PromptInputFooter>
</PromptInput>
</div>
)
}
/*
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 { Copy, Check, RefreshCw, Edit, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { TooltipProvider } from '@/components/ui/tooltip'
import { MESSAGE_ACTION_LABELS } from '../constants'
import { useMessageActionGuard } from '../hooks/use-message-action-guard'
import type { Message } from '../types'
import { MessageActionButton } from './message-action-button'
interface MessageActionsProps {
message: Message
onCopy?: (message: Message) => void
onRegenerate?: (message: Message) => void
onEdit?: (message: Message) => void
onDelete?: (message: Message) => void
isGenerating?: boolean
alwaysVisible?: boolean
className?: string
}
export function MessageActions({
message,
onCopy,
onRegenerate,
onEdit,
onDelete,
isGenerating = false,
alwaysVisible = false,
className = '',
}: MessageActionsProps) {
const { copiedText, copyToClipboard } = useCopyToClipboard()
const { guardAction } = useMessageActionGuard(isGenerating)
const isAssistant = message.from === 'assistant'
const hasContent = message.versions.some((v) => v.content)
const isLoading =
message.status === 'loading' || message.status === 'streaming'
const content = message.versions[0]?.content || ''
const isCopied = copiedText === content
const handleCopy = () => {
if (!content) {
toast.warning(MESSAGE_ACTION_LABELS.NO_CONTENT)
return
}
copyToClipboard(content)
onCopy?.(message)
}
const handleRegenerate = guardAction(() => onRegenerate?.(message))
const handleEdit = guardAction(() => onEdit?.(message))
const handleDelete = guardAction(() => onDelete?.(message))
const visibilityClass = alwaysVisible
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100 max-md:opacity-100'
return (
<TooltipProvider delay={300}>
<div
className={`flex items-center gap-0.5 transition-opacity ${visibilityClass} ${className}`}
>
{/* Copy */}
{hasContent && (
<MessageActionButton
icon={isCopied ? Check : Copy}
label={
isCopied
? MESSAGE_ACTION_LABELS.COPIED
: MESSAGE_ACTION_LABELS.COPY
}
onClick={handleCopy}
className={isCopied ? 'text-green-600' : ''}
/>
)}
{/* Regenerate - only for assistant messages */}
{isAssistant && !isLoading && onRegenerate && (
<MessageActionButton
icon={RefreshCw}
label={MESSAGE_ACTION_LABELS.REGENERATE}
onClick={handleRegenerate}
disabled={isGenerating}
/>
)}
{/* Edit */}
{hasContent && onEdit && (
<MessageActionButton
icon={Edit}
label={MESSAGE_ACTION_LABELS.EDIT}
onClick={handleEdit}
disabled={isGenerating}
/>
)}
{/* Delete */}
{onDelete && (
<MessageActionButton
icon={Trash2}
label={MESSAGE_ACTION_LABELS.DELETE}
onClick={handleDelete}
disabled={isGenerating}
variant='destructive'
/>
)}
</div>
</TooltipProvider>
)
}
......@@ -23,7 +23,7 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { MESSAGE_ACTION_BUTTON_STYLES } from '../constants'
import { MESSAGE_ACTION_BUTTON_STYLES } from '../../constants'
interface MessageActionButtonProps {
icon: LucideIcon
......
/*
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 {
Check,
Copy,
Edit,
FileCode2,
MoreHorizontal,
RefreshCw,
Trash2,
type LucideIcon,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { MESSAGE_ACTION_LABELS } from '../../constants'
import { useMessageActionGuard } from '../../hooks/use-message-action-guard'
import {
getMessageActionState,
getMessageActionsVisibilityClass,
} from '../../lib'
import type { Message } from '../../types'
import { MessageActionButton } from './message-action-button'
interface MessageActionsProps {
message: Message
onCopy?: (message: Message) => void
onRegenerate?: (message: Message) => void
onToggleSource?: (message: Message) => void
onEdit?: (message: Message) => void
onDelete?: (message: Message) => void
isSourceVisible?: boolean
isGenerating?: boolean
alwaysVisible?: boolean
className?: string
}
type MessageActionItem = {
className?: string
disabled?: boolean
icon: LucideIcon
label: string
onClick: () => void
variant?: 'default' | 'destructive'
}
export function MessageActions({
message,
onCopy,
onRegenerate,
onToggleSource,
onEdit,
onDelete,
isSourceVisible = false,
isGenerating = false,
alwaysVisible = false,
className = '',
}: MessageActionsProps) {
const { t } = useTranslation()
const { copiedText, copyToClipboard } = useCopyToClipboard()
const { guardAction } = useMessageActionGuard(isGenerating)
const { content, hasContent, isAssistant, isLoading, isUser } =
getMessageActionState(message)
const isCopied = copiedText === content
const handleCopy = () => {
if (!content) {
toast.warning(t(MESSAGE_ACTION_LABELS.NO_CONTENT))
return
}
copyToClipboard(content)
onCopy?.(message)
}
const handleRegenerate = guardAction(() => onRegenerate?.(message))
const handleToggleSource = () => onToggleSource?.(message)
const handleEdit = guardAction(() => onEdit?.(message))
const handleDelete = guardAction(() => onDelete?.(message))
const visibilityClass = getMessageActionsVisibilityClass(alwaysVisible)
const actions: MessageActionItem[] = []
if (hasContent) {
actions.push({
className: isCopied ? 'text-green-600' : '',
icon: isCopied ? Check : Copy,
label: isCopied
? MESSAGE_ACTION_LABELS.COPIED
: MESSAGE_ACTION_LABELS.COPY,
onClick: handleCopy,
})
}
if (isAssistant && hasContent && !isLoading && onToggleSource) {
actions.push({
icon: FileCode2,
label: isSourceVisible
? MESSAGE_ACTION_LABELS.SHOW_PREVIEW
: MESSAGE_ACTION_LABELS.SHOW_SOURCE,
onClick: handleToggleSource,
})
}
if ((isAssistant || isUser) && hasContent && !isLoading && onRegenerate) {
actions.push({
disabled: isGenerating,
icon: RefreshCw,
label: MESSAGE_ACTION_LABELS.REGENERATE,
onClick: handleRegenerate,
})
}
if (hasContent && onEdit) {
actions.push({
disabled: isGenerating,
icon: Edit,
label: MESSAGE_ACTION_LABELS.EDIT,
onClick: handleEdit,
})
}
if (onDelete) {
actions.push({
disabled: isGenerating,
icon: Trash2,
label: MESSAGE_ACTION_LABELS.DELETE,
onClick: handleDelete,
variant: 'destructive',
})
}
if (actions.length === 0) return null
return (
<>
<TooltipProvider delay={300}>
<div
className={`hidden items-center gap-0.5 transition-opacity md:flex ${visibilityClass} ${className}`}
>
{actions.map((action) => (
<MessageActionButton
className={action.className}
disabled={action.disabled}
icon={action.icon}
key={action.label}
label={t(action.label)}
onClick={action.onClick}
variant={action.variant}
/>
))}
</div>
</TooltipProvider>
<div className={`md:hidden ${className}`}>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<Button
aria-label={t('Open menu')}
className='data-popup-open:bg-muted text-muted-foreground hover:text-foreground size-11'
size='icon'
variant='ghost'
/>
}
>
<MoreHorizontal className='size-4' />
<span className='sr-only'>{t('Open menu')}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-44'>
{actions.map((action) => {
const Icon = action.icon
return (
<DropdownMenuItem
className='min-h-11'
disabled={action.disabled}
key={action.label}
onClick={action.onClick}
variant={action.variant}
>
{t(action.label)}
<DropdownMenuShortcut>
<Icon className='size-4' />
</DropdownMenuShortcut>
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
)
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Edit, RefreshCw, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { MessageActionButton } from './message-action-button'
type MessageErrorActionsProps = {
disabled?: boolean
onDelete?: () => void
onEditPrompt?: () => void
onRetry?: () => void
}
export function MessageErrorActions({
disabled = false,
onDelete,
onEditPrompt,
onRetry,
}: MessageErrorActionsProps) {
const { t } = useTranslation()
if (!onRetry && !onEditPrompt && !onDelete) {
return null
}
return (
<div className='flex flex-wrap items-center gap-0.5 pt-2'>
{onRetry && (
<MessageActionButton
disabled={disabled}
icon={RefreshCw}
label={t('Retry')}
onClick={onRetry}
/>
)}
{onEditPrompt && (
<MessageActionButton
disabled={disabled}
icon={Edit}
label={t('Edit')}
onClick={onEditPrompt}
/>
)}
{onDelete && (
<MessageActionButton
disabled={disabled}
icon={Trash2}
label={t('Delete')}
onClick={onDelete}
variant='destructive'
/>
)}
</div>
)
}
import { AlertCircle, AlertTriangle, Settings } from 'lucide-react'
/*
Copyright (C) 2023-2026 QuantumNous
......@@ -16,54 +17,67 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { AlertCircle, AlertTriangle, Settings } from 'lucide-react'
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/stores/auth-store'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { MESSAGE_STATUS } from '../constants'
import type { Message } from '../types'
import { useAuthStore } from '@/stores/auth-store'
import {
FALLBACK_ERROR_CONTENT,
getMessageErrorState,
isAdminRole,
MODEL_PRICING_SETTINGS_PATH,
} from '../../lib'
import type { Message } from '../../types'
interface MessageErrorProps {
message: Message
className?: string
actions?: ReactNode
}
/**
* Display error messages using Alert component
* Following ai-elements pattern for error handling
*/
export function MessageError({ message, className = '' }: MessageErrorProps) {
export function MessageError({
message,
className = '',
actions,
}: MessageErrorProps) {
const { t } = useTranslation()
const user = useAuthStore((s) => s.auth.user)
const isAdmin = user?.role != null && user.role >= 10
const errorState = getMessageErrorState(message, isAdminRole(user?.role))
if (message.status !== MESSAGE_STATUS.ERROR) {
if (!errorState) {
return null
}
const errorContent =
message.versions[0]?.content || 'An unknown error occurred'
if (errorState.kind === 'model-price') {
const content =
errorState.content === FALLBACK_ERROR_CONTENT
? t(FALLBACK_ERROR_CONTENT)
: errorState.content
if (message.errorCode === 'model_price_error') {
return (
<Alert variant='default' className={className}>
<AlertTriangle className='text-orange-500' />
<AlertTitle>{t('Model Price Not Configured')}</AlertTitle>
<AlertDescription className='space-y-2'>
<p>{errorContent}</p>
{isAdmin && (
<p>{content}</p>
{errorState.showSettingsLink && (
<Button
variant='outline'
size='sm'
onClick={() =>
window.open('/system-settings/billing/model-pricing', '_blank')
}
onClick={() => window.open(MODEL_PRICING_SETTINGS_PATH, '_blank')}
>
<Settings className='mr-1 h-3.5 w-3.5' />
{t('Go to Settings')}
</Button>
)}
{actions}
</AlertDescription>
</Alert>
)
......@@ -73,7 +87,14 @@ export function MessageError({ message, className = '' }: MessageErrorProps) {
<Alert variant='destructive' className={className}>
<AlertCircle />
<AlertTitle>{t('Error')}</AlertTitle>
<AlertDescription>{errorContent}</AlertDescription>
<AlertDescription className='space-y-2'>
<p>
{errorState.content === FALLBACK_ERROR_CONTENT
? t(FALLBACK_ERROR_CONTENT)
: errorState.content}
</p>
{actions}
</AlertDescription>
</Alert>
)
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { TFunction } from 'i18next'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import type { MessageAlignment } from '../../lib'
import type { Message } from '../../types'
type MessageMetadataProps = {
alignment: MessageAlignment
message: Message
}
function formatMessageTime(timestamp?: number): string | undefined {
if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) {
return undefined
}
return new Intl.DateTimeFormat(undefined, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(new Date(timestamp))
}
function formatDuration(
durationMs: number | undefined,
t: TFunction
): string | undefined {
if (typeof durationMs !== 'number' || !Number.isFinite(durationMs)) {
return undefined
}
if (durationMs < 1000) {
return t('{{value}}ms', { value: Math.max(1, Math.round(durationMs)) })
}
return t('{{value}}s', { value: (durationMs / 1000).toFixed(2) })
}
export function MessageMetadata(props: MessageMetadataProps) {
const { t } = useTranslation()
const messageTime = formatMessageTime(props.message.createdAt)
const duration = formatDuration(props.message.durationMs, t)
if (!messageTime && !duration) {
return null
}
return (
<div
className={cn(
'text-muted-foreground mt-1 flex min-h-4 items-center gap-1.5 text-[11px] leading-none',
props.alignment === 'right' && 'justify-end'
)}
>
{messageTime && <time>{messageTime}</time>}
{duration && (
<>
{messageTime && <span aria-hidden='true'>·</span>}
<span>{t('Response time: {{duration}}', { duration })}</span>
</>
)}
</div>
)
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import {
CodeBlock,
CodeBlockCopyButton,
} from '@/components/ai-elements/code-block'
import { Loader } from '@/components/ai-elements/loader'
import { MessageContent } from '@/components/ai-elements/message'
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from '@/components/ai-elements/reasoning'
import { Response } from '@/components/ai-elements/response'
import { Shimmer } from '@/components/ai-elements/shimmer'
import {
Source,
Sources,
SourcesContent,
SourcesTrigger,
} from '@/components/ai-elements/sources'
import { cn } from '@/lib/utils'
import { MESSAGE_STATUS } from '../../constants'
import {
getMessageAlignmentClass,
getMessageContentState,
isErrorMessage,
type MessageAlignment,
} from '../../lib'
import { getMessageContentStyles } from '../../lib/message/message-styles'
import type { Message } from '../../types'
import { MessageError } from './message-error'
import { MessageMetadata } from './message-metadata'
type PlaygroundMessageContentProps = {
actions: ReactNode
alignment: MessageAlignment
errorActions?: ReactNode
isSourceVisible?: boolean
message: Message
versionContent: string
}
export function PlaygroundMessageContent({
actions,
alignment,
errorActions,
isSourceVisible = false,
message,
versionContent,
}: PlaygroundMessageContentProps) {
const { t } = useTranslation()
const {
displayContent,
hasReasoning,
hasSources,
reasoningContent,
showLoader,
showMessageContent,
sources,
} = getMessageContentState(message, versionContent)
const isError = isErrorMessage(message)
const isMessageFinal =
message.status !== MESSAGE_STATUS.LOADING &&
message.status !== MESSAGE_STATUS.STREAMING
return (
<div
className={cn(
'flex w-full min-w-0 flex-col',
getMessageAlignmentClass(alignment)
)}
>
{hasSources && (
<Sources>
<SourcesTrigger count={sources.length} />
<SourcesContent>
{sources.map((source) => (
<Source
href={source.href}
key={`${source.href}-${source.title}`}
title={source.title}
/>
))}
</SourcesContent>
</Sources>
)}
{hasReasoning && (
<Reasoning
defaultOpen
duration={message.reasoning?.duration}
isStreaming={message.isReasoningStreaming}
>
<ReasoningTrigger />
<ReasoningContent>{reasoningContent}</ReasoningContent>
</Reasoning>
)}
{showLoader && (
<div className='flex items-center gap-2 py-2'>
<Loader />
<Shimmer className='text-sm' duration={1}>
{t('Responding...')}
</Shimmer>
</div>
)}
{isError && (
<>
<MessageError message={message} className='mb-2' />
<MessageMetadata alignment={alignment} message={message} />
{errorActions}
</>
)}
{!isError && showMessageContent && (
<>
{isSourceVisible ? (
<CodeBlock
code={versionContent}
className='my-0 group-[.is-assistant]:w-full group-[.is-assistant]:max-w-[78ch]'
collapsedLines={24}
defaultCollapsed={false}
language='markdown'
maxExpandedLines={48}
showLineNumbers
showToolbar
title={t('Raw response')}
>
<CodeBlockCopyButton />
</CodeBlock>
) : (
<MessageContent
variant='flat'
className={cn(getMessageContentStyles())}
>
<Response final={isMessageFinal}>{displayContent}</Response>
</MessageContent>
)}
<MessageMetadata alignment={alignment} message={message} />
{actions}
</>
)}
</div>
)
}
/*
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 { Check, RotateCcw, Send, X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { CodeBlockEditor } from '@/components/ai-elements/code-block'
import { Button } from '@/components/ui/button'
import { getMessageEditorState } from '../../lib'
import type { Message } from '../../types'
type PlaygroundMessageEditorProps = {
editText: string
message: Message
onCancelEdit?: (open: boolean) => void
onEditTextChange: (text: string) => void
onSaveEdit?: (newContent: string) => void
onSaveEditAndSubmit?: (newContent: string) => void
originalText: string
}
export function PlaygroundMessageEditor({
editText,
message,
onCancelEdit,
onEditTextChange,
onSaveEdit,
onSaveEditAndSubmit,
originalText,
}: PlaygroundMessageEditorProps) {
const { t } = useTranslation()
const { canSave, hasChanged, showSaveAndSubmit } = getMessageEditorState(
message,
editText,
originalText
)
const handleCancel = () => {
if (
hasChanged &&
!window.confirm(
t('You have unsaved changes. Are you sure you want to leave?')
)
) {
return
}
onCancelEdit?.(false)
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
handleCancel()
return
}
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault()
if (!canSave) return
if (showSaveAndSubmit) {
onSaveEditAndSubmit?.(editText)
} else {
onSaveEdit?.(editText)
}
}
}
const editorActions = (
<>
{showSaveAndSubmit && (
<Button
aria-label={t('Save & Submit')}
disabled={!canSave}
onClick={() => onSaveEditAndSubmit?.(editText)}
size='icon-sm'
type='button'
>
<Send className='size-4' />
</Button>
)}
<Button
aria-label={t('Save')}
disabled={!canSave}
onClick={() => onSaveEdit?.(editText)}
size='icon-sm'
type='button'
variant={showSaveAndSubmit ? 'ghost' : 'default'}
>
<Check className='size-4' />
</Button>
{hasChanged && (
<Button
aria-label={t('Reset')}
onClick={() => onEditTextChange(originalText)}
size='icon-sm'
type='button'
variant='ghost'
>
<RotateCcw className='size-4' />
</Button>
)}
<Button
aria-label={t('Cancel')}
onClick={handleCancel}
size='icon-sm'
type='button'
variant='ghost'
>
<X className='size-4' />
</Button>
</>
)
return (
<CodeBlockEditor
actions={editorActions}
ariaLabel={t('Edit')}
className='my-0 group-[.is-assistant]:w-full group-[.is-assistant]:max-w-[78ch] group-[.is-user]:max-w-[85%] sm:group-[.is-user]:max-w-[62ch] md:group-[.is-user]:max-w-[68ch] lg:group-[.is-user]:max-w-[72ch]'
language='markdown'
onChange={onEditTextChange}
onKeyDown={handleKeyDown}
rows={8}
title={
<span className='inline-flex items-center gap-2'>
<span>{t('Edit')}</span>
<span className='text-muted-foreground/80 normal-case'>
{hasChanged ? t('Unsaved changes') : t('No changes')}
</span>
</span>
}
value={editText}
/>
)
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useEffect, useMemo, useState } from 'react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import {
Branch,
BranchMessages,
BranchNext,
BranchPage,
BranchPrevious,
BranchSelector,
} from '@/components/ai-elements/branch'
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from '@/components/ai-elements/conversation'
import { Loader } from '@/components/ai-elements/loader'
import { Message, MessageContent } from '@/components/ai-elements/message'
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from '@/components/ai-elements/reasoning'
import { Response } from '@/components/ai-elements/response'
import { Shimmer } from '@/components/ai-elements/shimmer'
import {
Source,
Sources,
SourcesContent,
SourcesTrigger,
} from '@/components/ai-elements/sources'
import { MESSAGE_ROLES } from '../constants'
import { getMessageContentStyles } from '../lib/message-styles'
import { parseThinkTags } from '../lib/message-utils'
import type { Message as MessageType } from '../types'
import { MessageActions } from './message-actions'
import { MessageError } from './message-error'
interface PlaygroundChatProps {
messages: MessageType[]
onCopyMessage?: (message: MessageType) => void
onRegenerateMessage?: (message: MessageType) => void
onEditMessage?: (message: MessageType) => void
onDeleteMessage?: (message: MessageType) => void
isGenerating?: boolean
editingKey?: string | null
onSaveEdit?: (newContent: string) => void
onCancelEdit?: (open: boolean) => void
onSaveEditAndSubmit?: (newContent: string) => void
}
export function PlaygroundChat({
messages,
onCopyMessage,
onRegenerateMessage,
onEditMessage,
onDeleteMessage,
isGenerating = false,
editingKey,
onSaveEdit,
onCancelEdit,
onSaveEditAndSubmit,
}: PlaygroundChatProps) {
const [editText, setEditText] = useState('')
const [originalText, setOriginalText] = useState('')
useEffect(() => {
if (!editingKey) return
const message = messages.find((m) => m.key === editingKey)
const content = message?.versions?.[0]?.content || ''
// eslint-disable-next-line react-hooks/set-state-in-effect
setEditText(content)
setOriginalText(content)
}, [editingKey, messages])
const isEditing = (key: string) => editingKey === key
const isEmpty = useMemo(() => !editText.trim(), [editText])
const isChanged = useMemo(
() => editText !== originalText,
[editText, originalText]
)
return (
<Conversation>
{/* Remove outer padding; apply padding to inner centered container to align with input */}
<ConversationContent className='p-0'>
<div className='mx-auto w-full max-w-4xl px-4 py-4'>
{messages.map((message, messageIndex) => {
const { versions = [] } = message
const isLastAssistantMessage =
messageIndex === messages.length - 1 &&
message.from === MESSAGE_ROLES.ASSISTANT
return (
<Branch defaultBranch={0} key={message.key}>
<BranchMessages>
{versions.map((version, versionIndex) => (
<Message
className='group flex-row-reverse'
from={message.from}
key={`${message.key}-${version.id}-${versionIndex}`}
>
<div className='w-full min-w-0 flex-1 basis-full py-1'>
{isEditing(message.key) ? (
<div className='space-y-2'>
<Textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
className='font-mono text-sm'
rows={8}
/>
<div className='flex gap-2'>
{/* Save & Submit only makes sense for user messages */}
{message.from === MESSAGE_ROLES.USER && (
<Button
size='sm'
onClick={() =>
onSaveEditAndSubmit?.(editText)
}
disabled={isEmpty || !isChanged}
>
Save & Submit
</Button>
)}
<Button
size='sm'
onClick={() => onSaveEdit?.(editText)}
disabled={isEmpty || !isChanged}
>
Save
</Button>
<Button
size='sm'
variant='outline'
onClick={() => onCancelEdit?.(false)}
>
Cancel
</Button>
</div>
</div>
) : (
<>
{(() => {
const isAssistant =
message.from === MESSAGE_ROLES.ASSISTANT
const hasSources = !!message.sources?.length
const showReasoning =
isAssistant && !!message.reasoning?.content
const showLoader =
isAssistant &&
!message.isReasoningStreaming &&
(message.status === 'loading' ||
(message.status === 'streaming' &&
!version.content))
const showMessageContent =
(message.from === MESSAGE_ROLES.USER ||
!message.isReasoningStreaming) &&
!!version.content
// Extract visible content (remove <think> tags for assistant messages)
const displayContent = isAssistant
? parseThinkTags(version.content).visibleContent
: version.content
const actions = (
<MessageActions
message={message}
onCopy={onCopyMessage}
onRegenerate={onRegenerateMessage}
onEdit={onEditMessage}
onDelete={onDeleteMessage}
isGenerating={isGenerating}
alwaysVisible={isLastAssistantMessage}
className='mt-1'
/>
)
return (
<>
{/* Sources */}
{hasSources && (
<Sources>
<SourcesTrigger
count={message.sources!.length}
/>
<SourcesContent>
{message.sources!.map(
(source, sourceIndex) => (
<Source
href={source.href}
key={`${message.key}-source-${sourceIndex}`}
title={source.title}
/>
)
)}
</SourcesContent>
</Sources>
)}
{/* Reasoning */}
{showReasoning && (
<Reasoning
defaultOpen={true}
isStreaming={message.isReasoningStreaming}
>
<ReasoningTrigger />
<ReasoningContent>
{message.reasoning!.content}
</ReasoningContent>
</Reasoning>
)}
{/* Loader */}
{showLoader && (
<div className='flex items-center gap-2 py-2'>
<Loader />
<Shimmer className='text-sm' duration={1}>
Responding...
</Shimmer>
</div>
)}
{/* Error or Content */}
{message.status === 'error' ? (
<>
<MessageError
message={message}
className='mb-2'
/>
{actions}
</>
) : (
showMessageContent && (
<>
<MessageContent
variant='flat'
className={cn(
getMessageContentStyles()
)}
>
<Response>{displayContent}</Response>
</MessageContent>
{actions}
</>
)
)}
</>
)
})()}
</>
)}
</div>
</Message>
))}
</BranchMessages>
{/* Branch selector for multiple versions */}
{versions.length > 1 && (
<BranchSelector className='px-0' from={message.from}>
<BranchPrevious />
<BranchPage />
<BranchNext />
</BranchSelector>
)}
</Branch>
)
})}
</div>
</ConversationContent>
<ConversationScrollButton />
</Conversation>
)
}
/*
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 { useState } from 'react'
import {
PaperclipIcon,
FileIcon,
ImageIcon,
ScreenShareIcon,
CameraIcon,
GlobeIcon,
SendIcon,
SquareIcon,
BarChartIcon,
BoxIcon,
NotepadTextIcon,
CodeSquareIcon,
GraduationCapIcon,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
PromptInput,
PromptInputButton,
PromptInputFooter,
PromptInputTextarea,
PromptInputTools,
type PromptInputMessage,
} from '@/components/ai-elements/prompt-input'
import { Suggestion, Suggestions } from '@/components/ai-elements/suggestion'
import { ModelGroupSelector } from '@/components/model-group-selector'
import type { ModelOption, GroupOption } from '../types'
interface PlaygroundInputProps {
onSubmit: (text: string) => void
onStop?: () => void
disabled?: boolean
isGenerating?: boolean
models: ModelOption[]
modelValue: string
onModelChange: (value: string) => void
isModelLoading?: boolean
groups: GroupOption[]
groupValue: string
onGroupChange: (value: string) => void
}
const suggestions = [
{ icon: BarChartIcon, text: 'Analyze data', color: '#76d0eb' },
{ icon: BoxIcon, text: 'Surprise me', color: '#76d0eb' },
{ icon: NotepadTextIcon, text: 'Summarize text', color: '#ea8444' },
{ icon: CodeSquareIcon, text: 'Code', color: '#6c71ff' },
{ icon: GraduationCapIcon, text: 'Get advice', color: '#76d0eb' },
{ icon: null, text: 'More' },
]
export function PlaygroundInput({
onSubmit,
onStop,
disabled,
isGenerating,
models,
modelValue,
onModelChange,
isModelLoading = false,
groups,
groupValue,
onGroupChange,
}: PlaygroundInputProps) {
const { t } = useTranslation()
const [text, setText] = useState('')
const isModelSelectDisabled =
disabled || isModelLoading || models.length === 0
const isGroupSelectDisabled = disabled || groups.length === 0
const handleSubmit = (message: PromptInputMessage) => {
if (!message.text?.trim() || disabled) return
onSubmit(message.text)
setText('')
}
const handleFileAction = (action: string) => {
toast.info(t('Feature in development'), {
description: action,
})
}
const handleSuggestionClick = (suggestion: string) => {
onSubmit(suggestion)
}
return (
<div className='grid shrink-0 gap-4 px-1 md:pb-4'>
<PromptInput groupClassName='rounded-xl' onSubmit={handleSubmit}>
<PromptInputTextarea
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck={false}
className='px-5 md:text-base'
disabled={disabled}
onChange={(event) => setText(event.target.value)}
placeholder={t('Ask anything')}
value={text}
/>
<PromptInputFooter className='p-2.5'>
<PromptInputTools>
<DropdownMenu>
<DropdownMenuTrigger
render={
<PromptInputButton
className='border font-medium'
disabled={disabled}
variant='outline'
/>
}
>
<PaperclipIcon size={16} />
<span className='hidden sm:inline'>{t('Attach')}</span>
<span className='sr-only sm:hidden'>{t('Attach')}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
<DropdownMenuItem
onClick={() => handleFileAction('upload-file')}
>
<FileIcon className='mr-2' size={16} />
{t('Upload file')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleFileAction('upload-photo')}
>
<ImageIcon className='mr-2' size={16} />
{t('Upload photo')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleFileAction('take-screenshot')}
>
<ScreenShareIcon className='mr-2' size={16} />
{t('Take screenshot')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleFileAction('take-photo')}
>
<CameraIcon className='mr-2' size={16} />
{t('Take photo')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<PromptInputButton
className='border font-medium'
disabled={disabled}
onClick={() => toast.info(t('Search feature in development'))}
variant='outline'
>
<GlobeIcon size={16} />
<span className='hidden sm:inline'>{t('Search')}</span>
<span className='sr-only sm:hidden'>{t('Search')}</span>
</PromptInputButton>
</PromptInputTools>
<div className='flex items-center gap-1.5 md:gap-2'>
<ModelGroupSelector
selectedModel={modelValue}
models={models}
onModelChange={onModelChange}
selectedGroup={groupValue}
groups={groups}
onGroupChange={onGroupChange}
disabled={isModelSelectDisabled || isGroupSelectDisabled}
/>
{isGenerating && onStop ? (
<PromptInputButton
className='text-foreground font-medium'
onClick={onStop}
variant='secondary'
>
<SquareIcon className='fill-current' size={16} />
<span className='hidden sm:inline'>{t('Stop')}</span>
<span className='sr-only sm:hidden'>{t('Stop')}</span>
</PromptInputButton>
) : (
<PromptInputButton
className='text-foreground font-medium'
disabled={disabled || !text.trim()}
type='submit'
variant='secondary'
>
<SendIcon size={16} />
<span className='hidden sm:inline'>{t('Send')}</span>
<span className='sr-only sm:hidden'>{t('Send')}</span>
</PromptInputButton>
)}
</div>
</PromptInputFooter>
</PromptInput>
<Suggestions>
{suggestions.map(({ icon: Icon, text, color }) => (
<Suggestion
className={`text-xs font-normal sm:text-sm ${
text === 'More' ? 'hidden sm:flex' : ''
}`}
key={text}
onClick={() => handleSuggestionClick(text)}
suggestion={text}
>
{Icon && <Icon size={16} style={{ color }} />}
{text}
</Suggestion>
))}
</Suggestions>
</div>
)
}
......@@ -94,6 +94,8 @@ export const MESSAGE_ACTION_LABELS = {
COPY: 'Copy',
COPIED: 'Copied!',
REGENERATE: 'Regenerate',
SHOW_PREVIEW: 'Show preview',
SHOW_SOURCE: 'Show source',
EDIT: 'Edit',
DELETE: 'Delete',
NO_CONTENT: 'No content to copy',
......
......@@ -20,3 +20,5 @@ export * from './use-playground-state'
export * from './use-stream-request'
export * from './use-chat-handler'
export * from './use-message-action-guard'
export * from './use-playground-conversation'
export * from './use-playground-options'
......@@ -16,16 +16,23 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useCallback } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { sendChatCompletion } from '../api'
import { MESSAGE_STATUS, ERROR_MESSAGES } from '../constants'
import { ERROR_MESSAGES } from '../constants'
import {
applyStreamingChunk,
buildChatCompletionPayload,
updateAssistantMessageWithError,
updateLastAssistantMessage,
processStreamingContent,
finalizeMessage,
parseRequestErrorDetails,
applyChatCompletionResponse,
completeAssistantMessage,
hasChatCompletionChoice,
isAssistantMessageFinal,
isAssistantMessagePending,
} from '../lib'
import type { Message, PlaygroundConfig, ParameterEnabled } from '../types'
import { useStreamRequest } from './use-stream-request'
......@@ -36,6 +43,25 @@ interface UseChatHandlerOptions {
onMessageUpdate: (updater: (prev: Message[]) => Message[]) => void
}
const KNOWN_ERROR_MESSAGES = new Set<string>(Object.values(ERROR_MESSAGES))
const STREAM_UPDATE_FLUSH_MS = 50
type PendingStreamChunks = {
content: string
reasoning: string
}
function mergePendingStreamChunk(
currentChunk: string,
nextChunk: string
): string {
if (!currentChunk || !nextChunk.startsWith(currentChunk)) {
return currentChunk + nextChunk
}
return nextChunk
}
/**
* Hook for handling chat message sending and receiving
*/
......@@ -44,65 +70,141 @@ export function useChatHandler({
parameterEnabled,
onMessageUpdate,
}: UseChatHandlerOptions) {
const { t } = useTranslation()
const { sendStreamRequest, stopStream, isStreaming } = useStreamRequest()
const [isRequesting, setIsRequesting] = useState(false)
const abortControllerRef = useRef<AbortController | null>(null)
const requestIdRef = useRef(0)
const pendingStreamChunksRef = useRef<PendingStreamChunks>({
content: '',
reasoning: '',
})
const streamFlushTimerRef = useRef<number | null>(null)
const flushStreamUpdates = useCallback(() => {
if (streamFlushTimerRef.current !== null) {
window.clearTimeout(streamFlushTimerRef.current)
streamFlushTimerRef.current = null
}
const pendingChunks = pendingStreamChunksRef.current
if (!pendingChunks.reasoning && !pendingChunks.content) {
return
}
pendingStreamChunksRef.current = { content: '', reasoning: '' }
onMessageUpdate((prev) =>
updateLastAssistantMessage(prev, (message) => {
let updatedMessage = message
if (pendingChunks.reasoning) {
updatedMessage = applyStreamingChunk(
updatedMessage,
'reasoning',
pendingChunks.reasoning
)
}
if (pendingChunks.content) {
updatedMessage = applyStreamingChunk(
updatedMessage,
'content',
pendingChunks.content
)
}
return updatedMessage
})
)
}, [onMessageUpdate])
const scheduleStreamFlush = useCallback(() => {
if (streamFlushTimerRef.current !== null) {
return
}
streamFlushTimerRef.current = window.setTimeout(
flushStreamUpdates,
STREAM_UPDATE_FLUSH_MS
)
}, [flushStreamUpdates])
useEffect(
() => () => {
if (streamFlushTimerRef.current !== null) {
window.clearTimeout(streamFlushTimerRef.current)
}
},
[]
)
const getDisplayError = useCallback(
(error: string) => {
if (KNOWN_ERROR_MESSAGES.has(error)) {
return t(error)
}
const connectionClosedSuffix = `: ${ERROR_MESSAGES.CONNECTION_CLOSED}`
if (error.endsWith(connectionClosedSuffix)) {
return `${error.slice(0, -ERROR_MESSAGES.CONNECTION_CLOSED.length)}${t(
ERROR_MESSAGES.CONNECTION_CLOSED
)}`
}
return error
},
[t]
)
// Handle stream update
const handleStreamUpdate = useCallback(
(type: 'reasoning' | 'content', chunk: string) => {
onMessageUpdate((prev) =>
updateLastAssistantMessage(prev, (message) => {
if (message.status === MESSAGE_STATUS.ERROR) return message
if (type === 'reasoning') {
// Direct API reasoning_content
return {
...message,
reasoning: {
content: (message.reasoning?.content || '') + chunk,
duration: 0,
},
isReasoningStreaming: true,
status: MESSAGE_STATUS.STREAMING,
}
}
// Content streaming: handle <think> tags
return {
...processStreamingContent(message, chunk),
status: MESSAGE_STATUS.STREAMING,
}
})
pendingStreamChunksRef.current[type] = mergePendingStreamChunk(
pendingStreamChunksRef.current[type],
chunk
)
scheduleStreamFlush()
},
[onMessageUpdate]
[scheduleStreamFlush]
)
// Handle stream complete
const handleStreamComplete = useCallback(() => {
flushStreamUpdates()
setIsRequesting(false)
onMessageUpdate((prev) =>
updateLastAssistantMessage(prev, (message) =>
message.status === MESSAGE_STATUS.COMPLETE ||
message.status === MESSAGE_STATUS.ERROR
isAssistantMessageFinal(message)
? message
: { ...finalizeMessage(message), status: MESSAGE_STATUS.COMPLETE }
: completeAssistantMessage(message)
)
)
}, [onMessageUpdate])
}, [flushStreamUpdates, onMessageUpdate])
// Handle stream error
const handleStreamError = useCallback(
(error: string, errorCode?: string) => {
toast.error(error)
flushStreamUpdates()
setIsRequesting(false)
const displayError = getDisplayError(error)
toast.error(displayError)
const errorTitle = t(ERROR_MESSAGES.API_REQUEST_ERROR)
onMessageUpdate((prev) =>
updateAssistantMessageWithError(prev, error, errorCode)
updateAssistantMessageWithError(
prev,
displayError,
errorCode,
errorTitle
)
)
},
[onMessageUpdate]
[flushStreamUpdates, getDisplayError, onMessageUpdate, t]
)
// Send streaming chat request
const sendStreamingChat = useCallback(
(messages: Message[]) => {
setIsRequesting(true)
const payload = buildChatCompletionPayload(
messages,
config,
......@@ -133,42 +235,45 @@ export function useChatHandler({
config,
parameterEnabled
)
const requestId = requestIdRef.current + 1
const abortController = new AbortController()
requestIdRef.current = requestId
abortControllerRef.current = abortController
try {
const response = await sendChatCompletion(payload)
const choice = response.choices?.[0]
if (!choice) return
setIsRequesting(true)
const response = await sendChatCompletion(
payload,
abortController.signal
)
if (abortController.signal.aborted) return
if (!hasChatCompletionChoice(response)) {
handleStreamError(ERROR_MESSAGES.API_REQUEST_ERROR)
return
}
onMessageUpdate((prev) =>
updateLastAssistantMessage(prev, (message) => ({
...finalizeMessage(
{
...message,
versions: [
{
...message.versions[0],
content: choice.message?.content || '',
},
],
},
choice.message?.reasoning_content
),
status: MESSAGE_STATUS.COMPLETE,
}))
updateLastAssistantMessage(prev, (message) => {
const updatedMessage = applyChatCompletionResponse(
message,
response
)
return updatedMessage ?? message
})
)
} catch (error: unknown) {
const err = error as {
response?: {
data?: { message?: string; error?: { code?: string } }
}
message?: string
if (abortController.signal.aborted) return
const { errorCode, errorMessage } = parseRequestErrorDetails(error)
handleStreamError(errorMessage, errorCode)
} finally {
if (requestIdRef.current === requestId) {
abortControllerRef.current = null
setIsRequesting(false)
}
handleStreamError(
err?.response?.data?.message ||
err?.message ||
ERROR_MESSAGES.API_REQUEST_ERROR,
err?.response?.data?.error?.code || undefined
)
}
},
[config, parameterEnabled, onMessageUpdate, handleStreamError]
......@@ -189,19 +294,22 @@ export function useChatHandler({
// Stop generation
const stopGeneration = useCallback(() => {
stopStream()
flushStreamUpdates()
abortControllerRef.current?.abort()
abortControllerRef.current = null
setIsRequesting(false)
onMessageUpdate((prev) =>
updateLastAssistantMessage(prev, (message) =>
message.status === MESSAGE_STATUS.LOADING ||
message.status === MESSAGE_STATUS.STREAMING
? { ...finalizeMessage(message), status: MESSAGE_STATUS.COMPLETE }
isAssistantMessagePending(message)
? completeAssistantMessage(message)
: message
)
)
}, [stopStream, onMessageUpdate])
}, [stopStream, flushStreamUpdates, onMessageUpdate])
return {
sendChat,
stopGeneration,
isGenerating: isStreaming,
isGenerating: isStreaming || isRequesting,
}
}
......@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { MESSAGE_ACTION_LABELS } from '../constants'
......@@ -25,17 +26,18 @@ import { MESSAGE_ACTION_LABELS } from '../constants'
* Provides a wrapper that checks if generation is active before executing
*/
export function useMessageActionGuard(isGenerating: boolean) {
const { t } = useTranslation()
const guardAction = useCallback(
(action: () => void) => {
return () => {
if (isGenerating) {
toast.warning(MESSAGE_ACTION_LABELS.WAIT_GENERATION)
toast.warning(t(MESSAGE_ACTION_LABELS.WAIT_GENERATION))
return
}
action()
}
},
[isGenerating]
[isGenerating, t]
)
return { guardAction }
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useCallback, useState } from 'react'
import {
appendUserMessagePair,
applyMessageEdit,
createRegeneratedMessages,
removeMessageByKey,
} from '../lib'
import type { Message } from '../types'
type UsePlaygroundConversationOptions = {
messages: Message[]
updateMessages: (
updater: Message[] | ((prev: Message[]) => Message[])
) => void
sendChat: (messages: Message[]) => void
}
export function usePlaygroundConversation({
messages,
updateMessages,
sendChat,
}: UsePlaygroundConversationOptions) {
const [editingMessageKey, setEditingMessageKey] = useState<string | null>(
null
)
const handleSendMessage = useCallback(
(text: string) => {
const nextMessages = appendUserMessagePair(messages, text)
updateMessages(nextMessages)
sendChat(nextMessages)
},
[messages, updateMessages, sendChat]
)
const handleRegenerateMessage = useCallback(
(message: Message) => {
const nextMessages = createRegeneratedMessages(messages, message.key)
if (!nextMessages) return
updateMessages(nextMessages)
sendChat(nextMessages)
},
[messages, updateMessages, sendChat]
)
const handleEditMessage = useCallback((message: Message) => {
setEditingMessageKey(message.key)
}, [])
const handleEditOpenChange = useCallback((open: boolean) => {
if (!open) {
setEditingMessageKey(null)
}
}, [])
const applyEdit = useCallback(
(newContent: string, shouldSubmit: boolean) => {
if (!editingMessageKey) return
const editResult = applyMessageEdit(
messages,
editingMessageKey,
newContent,
shouldSubmit
)
if (!editResult) return
setEditingMessageKey(null)
updateMessages(editResult.messages)
if (editResult.shouldSend) {
sendChat(editResult.messages)
}
},
[editingMessageKey, messages, updateMessages, sendChat]
)
const handleDeleteMessage = useCallback(
(message: Message) => {
updateMessages((previousMessages) =>
removeMessageByKey(previousMessages, message.key)
)
},
[updateMessages]
)
return {
editingMessageKey,
handleSendMessage,
handleRegenerateMessage,
handleEditMessage,
handleEditOpenChange,
applyEdit,
handleDeleteMessage,
}
}
import { useQuery } from '@tanstack/react-query'
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { getUserGroups, getUserModels } from '../api'
import {
getGroupFallback,
getModelFallback,
getOptionLoadErrorMessage,
shouldClearModelForGroup,
} from '../lib'
import type { GroupOption, ModelOption, PlaygroundConfig } from '../types'
type UsePlaygroundOptionsParams = {
currentGroup: string
currentModel: string
setGroups: (groups: GroupOption[]) => void
setModels: (models: ModelOption[]) => void
updateConfig: <K extends keyof PlaygroundConfig>(
key: K,
value: PlaygroundConfig[K]
) => void
}
export function usePlaygroundOptions({
currentGroup,
currentModel,
setGroups,
setModels,
updateConfig,
}: UsePlaygroundOptionsParams) {
const { t } = useTranslation()
const {
data: modelsData,
error: modelsError,
isError: isModelsError,
isLoading: isLoadingModels,
} = useQuery({
queryKey: ['playground-models', currentGroup],
queryFn: () => getUserModels(currentGroup),
enabled: currentGroup !== '',
})
const {
data: groupsData,
error: groupsError,
isError: isGroupsError,
} = useQuery({
queryKey: ['playground-groups'],
queryFn: getUserGroups,
})
useEffect(() => {
if (!isModelsError) return
toast.error(
getOptionLoadErrorMessage(
modelsError,
t('Failed to load playground models')
)
)
}, [isModelsError, modelsError, t])
useEffect(() => {
if (!isGroupsError) return
toast.error(
getOptionLoadErrorMessage(
groupsError,
t('Failed to load playground groups')
)
)
}, [isGroupsError, groupsError, t])
useEffect(() => {
if (!modelsData) return
setModels(modelsData)
const fallback = getModelFallback(modelsData, currentModel)
if (fallback) {
updateConfig('model', fallback)
return
}
if (shouldClearModelForGroup(modelsData, currentModel)) {
updateConfig('model', '')
}
}, [modelsData, currentModel, setModels, updateConfig])
useEffect(() => {
if (!groupsData) return
setGroups(groupsData)
const fallback = getGroupFallback(groupsData, currentGroup)
if (fallback) {
updateConfig('group', fallback)
}
}, [groupsData, currentGroup, setGroups, updateConfig])
return {
isLoadingModels,
}
}
......@@ -16,15 +16,18 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useCallback } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { DEFAULT_CONFIG, DEFAULT_PARAMETER_ENABLED } from '../constants'
import {
loadConfig,
saveConfig,
loadParameterEnabled,
saveParameterEnabled,
loadMessages,
saveMessages,
applyMessageStateUpdate,
getInitialParameterEnabled,
getInitialPlaygroundConfig,
loadMessages,
type MessageStateUpdater,
} from '../lib'
import type {
Message,
......@@ -34,30 +37,77 @@ import type {
GroupOption,
} from '../types'
const MESSAGE_SAVE_DEBOUNCE_MS = 500
/**
* Main state management hook for playground
*/
export function usePlaygroundState() {
// Load initial state from localStorage
const [config, setConfig] = useState<PlaygroundConfig>(() => {
const savedConfig = loadConfig()
return { ...DEFAULT_CONFIG, ...savedConfig }
})
const [config, setConfig] = useState<PlaygroundConfig>(
getInitialPlaygroundConfig
)
const [parameterEnabled, setParameterEnabled] = useState<ParameterEnabled>(
() => {
const saved = loadParameterEnabled()
return { ...DEFAULT_PARAMETER_ENABLED, ...saved }
}
getInitialParameterEnabled
)
const [messages, setMessages] = useState<Message[]>(() => {
return loadMessages() || []
})
const [messages, setMessages] = useState<Message[]>([])
const [isLoadingMessages, setIsLoadingMessages] = useState(true)
const messagesSaveTimerRef = useRef<number | null>(null)
const latestMessagesRef = useRef<Message[]>(messages)
const hasLoadedMessagesRef = useRef(false)
const [models, setModels] = useState<ModelOption[]>([])
const [groups, setGroups] = useState<GroupOption[]>([])
const persistMessages = useCallback((messagesToSave: Message[]) => {
latestMessagesRef.current = messagesToSave
if (!hasLoadedMessagesRef.current) {
return
}
if (messagesSaveTimerRef.current !== null) {
window.clearTimeout(messagesSaveTimerRef.current)
}
messagesSaveTimerRef.current = window.setTimeout(() => {
messagesSaveTimerRef.current = null
saveMessages(latestMessagesRef.current)
}, MESSAGE_SAVE_DEBOUNCE_MS)
}, [])
useEffect(() => {
let cancelled = false
window.setTimeout(() => {
const loadedMessages = loadMessages() ?? []
if (cancelled) {
return
}
latestMessagesRef.current = loadedMessages
hasLoadedMessagesRef.current = true
setMessages(loadedMessages)
setIsLoadingMessages(false)
}, 0)
return () => {
cancelled = true
}
}, [])
useEffect(
() => () => {
if (messagesSaveTimerRef.current !== null) {
window.clearTimeout(messagesSaveTimerRef.current)
saveMessages(latestMessagesRef.current)
}
},
[]
)
// Update config with automatic save
const updateConfig = useCallback(
<K extends keyof PlaygroundConfig>(key: K, value: PlaygroundConfig[K]) => {
......@@ -84,15 +134,14 @@ export function usePlaygroundState() {
// Update messages with automatic save
const updateMessages = useCallback(
(updater: Message[] | ((prev: Message[]) => Message[])) => {
(updater: MessageStateUpdater) => {
setMessages((prev) => {
const newMessages =
typeof updater === 'function' ? updater(prev) : updater
saveMessages(newMessages)
const newMessages = applyMessageStateUpdate(prev, updater)
persistMessages(newMessages)
return newMessages
})
},
[]
[persistMessages]
)
// Clear all messages
......@@ -113,6 +162,7 @@ export function usePlaygroundState() {
config,
parameterEnabled,
messages,
isLoadingMessages,
models,
groups,
......
......@@ -16,11 +16,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useCallback, useRef } from 'react'
import { useCallback, useRef, useState } from 'react'
import { SSE } from 'sse.js'
import { getCommonHeaders } from '@/lib/api'
import { API_ENDPOINTS, ERROR_MESSAGES } from '../constants'
import type { ChatCompletionRequest, ChatCompletionChunk } from '../types'
import {
getStreamReadyStateError,
isStreamClosedReadyState,
isStreamDoneMessage,
parseStreamErrorDetails,
parseStreamMessageUpdates,
} from '../lib'
import type { ChatCompletionRequest } from '../types'
/**
* Hook for handling streaming chat completion requests
......@@ -28,6 +37,17 @@ import type { ChatCompletionRequest, ChatCompletionChunk } from '../types'
export function useStreamRequest() {
const sseSourceRef = useRef<SSE | null>(null)
const isStreamCompleteRef = useRef(false)
const [isStreaming, setIsStreaming] = useState(false)
const closeActiveStream = useCallback((source?: SSE) => {
const streamSource = source ?? sseSourceRef.current
streamSource?.close()
if (!source || sseSourceRef.current === source) {
sseSourceRef.current = null
setIsStreaming(false)
}
}, [])
const sendStreamRequest = useCallback(
(
......@@ -36,6 +56,8 @@ export function useStreamRequest() {
onComplete: () => void,
onError: (error: string, errorCode?: string) => void
) => {
sseSourceRef.current?.close()
const source = new SSE(API_ENDPOINTS.CHAT_COMPLETIONS, {
headers: getCommonHeaders(),
method: 'POST',
......@@ -44,38 +66,28 @@ export function useStreamRequest() {
sseSourceRef.current = source
isStreamCompleteRef.current = false
const closeSource = () => {
source.close()
sseSourceRef.current = null
}
setIsStreaming(true)
const handleError = (errorMessage: string, errorCode?: string) => {
if (!isStreamCompleteRef.current) {
onError(errorMessage, errorCode)
closeSource()
closeActiveStream(source)
}
}
source.addEventListener('message', (e: MessageEvent) => {
if (e.data === '[DONE]') {
if (isStreamDoneMessage(e.data)) {
isStreamCompleteRef.current = true
closeSource()
closeActiveStream(source)
onComplete()
return
}
try {
const chunk: ChatCompletionChunk = JSON.parse(e.data)
const delta = chunk.choices?.[0]?.delta
if (delta) {
if (delta.reasoning_content) {
onUpdate('reasoning', delta.reasoning_content)
}
if (delta.content) {
onUpdate('content', delta.content)
}
const updates = parseStreamMessageUpdates(e.data)
for (const update of updates) {
onUpdate(update.type, update.chunk)
}
} catch (error) {
// eslint-disable-next-line no-console
......@@ -86,24 +98,10 @@ export function useStreamRequest() {
source.addEventListener('error', (e: Event & { data?: string }) => {
// Only handle errors if stream didn't complete normally
if (source.readyState !== 2) {
if (!isStreamClosedReadyState(source.readyState)) {
// eslint-disable-next-line no-console
console.error('SSE Error:', e)
let errorMessage = e.data || ERROR_MESSAGES.API_REQUEST_ERROR
let errorCode: string | undefined
if (e.data) {
try {
const parsed = JSON.parse(e.data) as {
error?: { message?: string; code?: string }
}
if (parsed?.error) {
errorMessage = parsed.error.message || errorMessage
errorCode = parsed.error.code || undefined
}
} catch {
// not JSON, use raw string
}
}
const { errorCode, errorMessage } = parseStreamErrorDetails(e.data)
handleError(errorMessage, errorCode)
}
})
......@@ -111,14 +109,10 @@ export function useStreamRequest() {
source.addEventListener(
'readystatechange',
(e: Event & { readyState?: number }) => {
const status = (source as unknown as { status?: number }).status
if (
e.readyState !== undefined &&
e.readyState >= 2 &&
status !== undefined &&
status !== 200
) {
handleError(`HTTP ${status}: ${ERROR_MESSAGES.CONNECTION_CLOSED}`)
const errorMessage = getStreamReadyStateError(e.readyState, source)
if (errorMessage) {
handleError(errorMessage)
}
}
)
......@@ -129,26 +123,19 @@ export function useStreamRequest() {
// eslint-disable-next-line no-console
console.error('Failed to start SSE stream:', error)
onError(ERROR_MESSAGES.STREAM_START_ERROR)
sseSourceRef.current = null
closeActiveStream(source)
}
},
[]
[closeActiveStream]
)
const stopStream = useCallback(() => {
if (sseSourceRef.current) {
sseSourceRef.current.close()
sseSourceRef.current = null
}
}, [])
// eslint-disable-next-line react-hooks/refs
const isStreaming = sseSourceRef.current !== null
closeActiveStream()
}, [closeActiveStream])
return {
sendStreamRequest,
stopStream,
// eslint-disable-next-line react-hooks/refs
isStreaming,
}
}
......@@ -16,29 +16,28 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useCallback, useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { getUserModels, getUserGroups } from './api'
import { PlaygroundChat } from './components/playground-chat'
import { PlaygroundInput } from './components/playground-input'
import { usePlaygroundState, useChatHandler } from './hooks'
import { createUserMessage, createLoadingAssistantMessage } from './lib'
import type { Message as MessageType } from './types'
import { PlaygroundChat } from './components/chat/playground-chat'
import { PlaygroundInput } from './components/input/playground-input'
import {
useChatHandler,
usePlaygroundConversation,
usePlaygroundOptions,
usePlaygroundState,
} from './hooks'
export function Playground() {
const { t } = useTranslation()
const {
config,
parameterEnabled,
messages,
isLoadingMessages,
models,
groups,
updateMessages,
setModels,
setGroups,
updateConfig,
clearMessages,
} = usePlaygroundState()
const { sendChat, stopGeneration, isGenerating } = useChatHandler({
......@@ -47,157 +46,44 @@ export function Playground() {
onMessageUpdate: updateMessages,
})
// Edit dialog state
const [editingMessageKey, setEditingMessageKey] = useState<string | null>(
null
)
// Load models
const { data: modelsData, isLoading: isLoadingModels } = useQuery({
queryKey: ['playground-models'],
queryFn: async () => {
try {
return await getUserModels()
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t('Failed to load playground models')
)
return []
}
},
})
// Load groups
const { data: groupsData } = useQuery({
queryKey: ['playground-groups'],
queryFn: async () => {
try {
return await getUserGroups()
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t('Failed to load playground groups')
)
return []
}
},
const {
editingMessageKey,
handleSendMessage,
handleRegenerateMessage,
handleEditMessage,
handleEditOpenChange,
applyEdit,
handleDeleteMessage,
} = usePlaygroundConversation({
messages,
updateMessages,
sendChat,
})
// Update models when data changes
useEffect(() => {
if (!modelsData) return
setModels(modelsData)
// Set default model if current model is not available
const isCurrentModelValid = modelsData.some((m) => m.value === config.model)
if (modelsData.length > 0 && !isCurrentModelValid) {
updateConfig('model', modelsData[0].value)
}
}, [modelsData, config.model, setModels, updateConfig])
// Update groups when data changes
useEffect(() => {
if (!groupsData) return
setGroups(groupsData)
const hasCurrentGroup = groupsData.some((g) => g.value === config.group)
if (!hasCurrentGroup && groupsData.length > 0) {
const fallback =
groupsData.find((g) => g.value === 'default')?.value ??
groupsData[0].value
updateConfig('group', fallback)
}
}, [groupsData, setGroups, config.group, updateConfig])
const handleSendMessage = (text: string) => {
const userMessage = createUserMessage(text)
const assistantMessage = createLoadingAssistantMessage()
const newMessages = [...messages, userMessage, assistantMessage]
updateMessages(newMessages)
// Send chat request
sendChat(newMessages)
}
const handleCopyMessage = (message: MessageType) => {
// Copy is handled in MessageActions component
// eslint-disable-next-line no-console
console.log('Message copied:', message.key)
const handleClearMessages = () => {
handleEditOpenChange(false)
clearMessages()
}
const handleRegenerateMessage = (message: MessageType) => {
// Find the message index and regenerate from there
const messageIndex = messages.findIndex((m) => m.key === message.key)
if (messageIndex === -1) return
// Remove messages after this one and regenerate
const messagesUpToHere = messages.slice(0, messageIndex)
const loadingMessage = createLoadingAssistantMessage()
const newMessages = [...messagesUpToHere, loadingMessage]
updateMessages(newMessages)
sendChat(newMessages)
}
const handleEditMessage = useCallback((message: MessageType) => {
setEditingMessageKey(message.key)
}, [])
const handleEditOpenChange = useCallback((open: boolean) => {
if (!open) setEditingMessageKey(null)
}, [])
// Apply edit and optionally re-submit from the edited user message
const applyEdit = useCallback(
(newContent: string, submit: boolean) => {
if (!editingMessageKey) return
const index = messages.findIndex((m) => m.key === editingMessageKey)
if (index === -1) return
const updated = messages.map((m) =>
m.key === editingMessageKey
? { ...m, versions: [{ ...m.versions[0], content: newContent }] }
: m
)
setEditingMessageKey(null)
if (!submit || updated[index].from !== 'user') {
updateMessages(updated)
return
}
const toSubmit = [
...updated.slice(0, index + 1),
createLoadingAssistantMessage(),
]
updateMessages(toSubmit)
sendChat(toSubmit)
},
[editingMessageKey, messages, updateMessages, sendChat]
)
const handleDeleteMessage = (message: MessageType) => {
const newMessages = messages.filter((m) => m.key !== message.key)
updateMessages(newMessages)
}
const { isLoadingModels } = usePlaygroundOptions({
currentGroup: config.group,
currentModel: config.model,
setGroups,
setModels,
updateConfig,
})
return (
<div className='relative flex size-full flex-col overflow-hidden'>
<div className='relative flex size-full min-h-0 flex-col overflow-hidden'>
{/* Full-width scroll container: scrolling works even over side whitespace */}
<div className='flex flex-1 flex-col overflow-hidden'>
<div className='flex min-h-0 flex-1 flex-col overflow-hidden'>
<PlaygroundChat
messages={messages}
onCopyMessage={handleCopyMessage}
isLoadingMessages={isLoadingMessages}
onRegenerateMessage={handleRegenerateMessage}
onEditMessage={handleEditMessage}
onDeleteMessage={handleDeleteMessage}
onSelectPrompt={handleSendMessage}
isGenerating={isGenerating}
editingKey={editingMessageKey}
onCancelEdit={handleEditOpenChange}
......@@ -217,9 +103,11 @@ export function Playground() {
modelValue={config.model}
models={models}
onGroupChange={(value) => updateConfig('group', value)}
onClearMessages={handleClearMessages}
onModelChange={(value) => updateConfig('model', value)}
onStop={stopGeneration}
onSubmit={handleSendMessage}
hasMessages={messages.length > 0}
/>
</div>
</div>
......
......@@ -16,7 +16,23 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
export * from './message-utils'
export * from './payload-builder'
export * from './storage'
export * from './message-styles'
export * from './input/input-control-utils'
export * from './input/input-tool-utils'
export * from './message/conversation-message-utils'
export * from './message/message-action-utils'
export * from './message/message-content-utils'
export * from './message/message-editor-utils'
export * from './message/message-error-utils'
export * from './message/message-layout-utils'
export * from './message/message-reasoning-utils'
export * from './message/message-streaming-utils'
export * from './message/message-styles'
export * from './message/message-timing-utils'
export * from './message/message-update-utils'
export * from './message/message-utils'
export * from './options/playground-option-utils'
export * from './state/playground-state-utils'
export * from './storage/storage'
export * from './streaming/payload-builder'
export * from './streaming/request-error-utils'
export * from './streaming/stream-utils'
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { GroupOption, ModelOption } from '../../types'
type InputControlStateOptions = {
disabled?: boolean
groups: GroupOption[]
hasStopHandler: boolean
isGenerating?: boolean
isModelLoading?: boolean
models: ModelOption[]
text: string
}
type InputControlState = {
canSubmit: boolean
isSelectorDisabled: boolean
shouldShowStop: boolean
}
type SubmittableInputMessage = {
text?: string | null
}
export function getSubmittableInputText(
message: SubmittableInputMessage,
disabled?: boolean
): string | null {
if (disabled || !message.text?.trim()) {
return null
}
return message.text
}
export function getInputControlState({
disabled,
groups,
hasStopHandler,
isGenerating,
isModelLoading,
models,
text,
}: InputControlStateOptions): InputControlState {
const hasModels = models.length > 0
return {
canSubmit: !disabled && hasModels && text.trim().length > 0,
isSelectorDisabled: disabled || isModelLoading || groups.length === 0,
shouldShowStop: Boolean(isGenerating && hasStopHandler),
}
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import {
CameraIcon,
FileIcon,
ImageIcon,
ScreenShareIcon,
type LucideIcon,
} from 'lucide-react'
type AttachmentAction = {
action: string
icon: LucideIcon
label: string
}
type InputToolNotice = {
description?: string
title: string
}
export const ATTACHMENT_ACTIONS = [
{ action: 'upload-file', icon: FileIcon, label: 'Upload file' },
{ action: 'upload-photo', icon: ImageIcon, label: 'Upload photo' },
{
action: 'take-screenshot',
icon: ScreenShareIcon,
label: 'Take screenshot',
},
{ action: 'take-photo', icon: CameraIcon, label: 'Take photo' },
] satisfies AttachmentAction[]
export function getAttachmentActionNotice(action: string): InputToolNotice {
return {
description: action,
title: 'Feature in development',
}
}
export function getSearchActionNotice(): InputToolNotice {
return {
title: 'Search feature in development',
}
}
import { MESSAGE_ROLES } from '../../constants'
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { Message } from '../../types'
import {
createLoadingAssistantMessage,
createUserMessage,
getMessageContent,
updateCurrentVersionContent,
} from './message-utils'
type ApplyMessageEditResult = {
messages: Message[]
shouldSend: boolean
}
type ChatMessageRenderState = {
alwaysShowActions: boolean
content: string
isEditing: boolean
}
export function appendUserMessagePair(
messages: Message[],
content: string
): Message[] {
const submittedAt = Date.now()
return [
...messages,
createUserMessage(content, submittedAt),
createLoadingAssistantMessage(submittedAt),
]
}
export function createRegeneratedMessages(
messages: Message[],
messageKey: string
): Message[] | null {
const messageIndex = messages.findIndex(
(message) => message.key === messageKey
)
if (messageIndex === -1) {
return null
}
if (messages[messageIndex].from === MESSAGE_ROLES.USER) {
return [
...messages.slice(0, messageIndex + 1),
createLoadingAssistantMessage(),
]
}
return [...messages.slice(0, messageIndex), createLoadingAssistantMessage()]
}
export function removeMessageByKey(
messages: Message[],
messageKey: string
): Message[] {
return messages.filter((message) => message.key !== messageKey)
}
export function getPreviousUserMessage(
messages: Message[],
beforeIndex: number
): Message | null {
for (let index = beforeIndex - 1; index >= 0; index--) {
if (messages[index].from === MESSAGE_ROLES.USER) {
return messages[index]
}
}
return null
}
export function applyMessageEdit(
messages: Message[],
messageKey: string,
content: string,
shouldSubmit: boolean
): ApplyMessageEditResult | null {
const submittedAt = Date.now()
const messageIndex = messages.findIndex(
(message) => message.key === messageKey
)
if (messageIndex === -1) {
return null
}
const updatedMessages = messages.map((message) =>
message.key === messageKey
? {
...updateCurrentVersionContent(message, content),
createdAt: shouldSubmit ? submittedAt : message.createdAt,
}
: message
)
if (
!shouldSubmit ||
updatedMessages[messageIndex].from !== MESSAGE_ROLES.USER
) {
return { messages: updatedMessages, shouldSend: false }
}
return {
messages: [
...updatedMessages.slice(0, messageIndex + 1),
createLoadingAssistantMessage(submittedAt),
],
shouldSend: true,
}
}
export function getEditingMessageContent(
messages: Message[],
editingKey?: string | null
): string {
if (!editingKey) {
return ''
}
const message = messages.find((item) => item.key === editingKey)
return message ? getMessageContent(message) : ''
}
export function getChatMessageRenderState(
messages: Message[],
message: Message,
messageIndex: number,
editingKey?: string | null
): ChatMessageRenderState {
return {
alwaysShowActions:
messageIndex === messages.length - 1 &&
message.from === MESSAGE_ROLES.ASSISTANT,
content: getMessageContent(message),
isEditing: editingKey === message.key,
}
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { MESSAGE_ROLES, MESSAGE_STATUS } from '../../constants'
import type { Message } from '../../types'
import { getMessageContent, hasMessageContent } from './message-utils'
type MessageActionState = {
content: string
hasContent: boolean
isAssistant: boolean
isLoading: boolean
isUser: boolean
}
export function getMessageActionState(message: Message): MessageActionState {
return {
content: getMessageContent(message),
hasContent: hasMessageContent(message),
isAssistant: message.from === MESSAGE_ROLES.ASSISTANT,
isUser: message.from === MESSAGE_ROLES.USER,
isLoading:
message.status === MESSAGE_STATUS.LOADING ||
message.status === MESSAGE_STATUS.STREAMING,
}
}
export function getMessageActionsVisibilityClass(
alwaysVisible: boolean
): string {
return alwaysVisible
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100 max-md:opacity-100'
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { MESSAGE_ROLES, MESSAGE_STATUS } from '../../constants'
import type { Message } from '../../types'
import { parseThinkTags } from './message-reasoning-utils'
type MessageContentStateBase = {
displayContent: string
hasSources: boolean
isAssistant: boolean
showLoader: boolean
showMessageContent: boolean
sources: NonNullable<Message['sources']>
}
type MessageContentState = MessageContentStateBase &
(
| {
hasReasoning: true
reasoningContent: string
}
| {
hasReasoning: false
reasoningContent: undefined
}
)
function shouldShowMessageLoader(
message: Message,
isAssistant: boolean,
versionContent: string
): boolean {
return (
isAssistant &&
!message.isReasoningStreaming &&
(message.status === MESSAGE_STATUS.LOADING ||
(message.status === MESSAGE_STATUS.STREAMING && !versionContent))
)
}
function shouldShowMessageContent(
message: Message,
versionContent: string
): boolean {
return (
(message.from === MESSAGE_ROLES.USER || !message.isReasoningStreaming) &&
versionContent.length > 0
)
}
function getDisplayContent(message: Message, versionContent: string): string {
if (message.from !== MESSAGE_ROLES.ASSISTANT) {
return versionContent
}
if (!versionContent.includes('<think>')) {
return versionContent
}
return parseThinkTags(versionContent).visibleContent
}
export function getMessageContentState(
message: Message,
versionContent: string
): MessageContentState {
const isAssistant = message.from === MESSAGE_ROLES.ASSISTANT
const sources = message.sources ?? []
const reasoningContent = isAssistant ? message.reasoning?.content : undefined
const showLoader = shouldShowMessageLoader(
message,
isAssistant,
versionContent
)
const showMessageContent = shouldShowMessageContent(message, versionContent)
const baseState: MessageContentStateBase = {
displayContent: getDisplayContent(message, versionContent),
hasSources: sources.length > 0,
isAssistant,
showLoader,
showMessageContent,
sources,
}
if (reasoningContent) {
return {
...baseState,
hasReasoning: true,
reasoningContent,
}
}
return {
...baseState,
hasReasoning: false,
reasoningContent: undefined,
}
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { MESSAGE_ROLES } from '../../constants'
import type { Message } from '../../types'
type MessageEditorState = {
canSave: boolean
hasChanged: boolean
showSaveAndSubmit: boolean
}
export function getMessageEditorState(
message: Message,
editText: string,
originalText: string
): MessageEditorState {
const hasText = editText.trim().length > 0
const hasChanged = editText !== originalText
return {
canSave: hasText && hasChanged,
hasChanged,
showSaveAndSubmit: message.from === MESSAGE_ROLES.USER,
}
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { MESSAGE_STATUS } from '../../constants'
import type { Message } from '../../types'
import { getMessageContent } from './message-utils'
export const MODEL_PRICING_SETTINGS_PATH =
'/system-settings/billing/model-pricing'
const MODEL_PRICE_ERROR_CODE = 'model_price_error'
export const FALLBACK_ERROR_CONTENT = 'An unknown error occurred'
type MessageErrorState = {
content: string
kind: 'generic' | 'model-price'
showSettingsLink: boolean
}
export function isAdminRole(role?: number | null): boolean {
return role != null && role >= 10
}
export function isErrorMessage(message: Message): boolean {
return message.status === MESSAGE_STATUS.ERROR
}
export function getMessageErrorState(
message: Message,
isAdmin: boolean
): MessageErrorState | null {
if (!isErrorMessage(message)) {
return null
}
const content = getMessageContent(message) || FALLBACK_ERROR_CONTENT
const isModelPriceError = message.errorCode === MODEL_PRICE_ERROR_CODE
return {
content,
kind: isModelPriceError ? 'model-price' : 'generic',
showSettingsLink: isModelPriceError && isAdmin,
}
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { MESSAGE_ROLES } from '../../constants'
import type { Message, PlaygroundMessageLayoutMode } from '../../types'
export type MessageAlignment = 'left' | 'right'
export function getMessageAlignment(
message: Message,
layoutMode: PlaygroundMessageLayoutMode
): MessageAlignment {
if (layoutMode === 'left') {
return 'left'
}
return message.from === MESSAGE_ROLES.USER ? 'right' : 'left'
}
export function getMessageAlignmentClass(alignment: MessageAlignment): string {
return alignment === 'right'
? 'items-end text-right'
: 'items-start text-left'
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
interface ParsedThinkTags {
visibleContent: string
reasoning: string
hasUnclosedTag: boolean
}
/**
* Parse content to separate thinking from visible text.
* Handles both complete and incomplete <think> tags.
*/
export function parseThinkTags(content: string): ParsedThinkTags {
if (!content.includes('<think>')) {
return { visibleContent: content, reasoning: '', hasUnclosedTag: false }
}
const visibleParts: string[] = []
const reasoningParts: string[] = []
let currentPos = 0
let hasUnclosedTag = false
while (true) {
const openPos = content.indexOf('<think>', currentPos)
if (openPos === -1) {
if (currentPos < content.length) {
visibleParts.push(content.slice(currentPos))
}
break
}
if (openPos > currentPos) {
visibleParts.push(content.slice(currentPos, openPos))
}
const closePos = content.indexOf('</think>', openPos + 7)
if (closePos === -1) {
reasoningParts.push(content.slice(openPos + 7))
hasUnclosedTag = true
break
}
reasoningParts.push(content.slice(openPos + 7, closePos))
currentPos = closePos + 8
}
return {
visibleContent: visibleParts.join('').trim(),
reasoning: reasoningParts.join('\n\n').trim(),
hasUnclosedTag,
}
}
/*
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 { t } from 'i18next'
import { ERROR_MESSAGES, MESSAGE_ROLES, MESSAGE_STATUS } from '../../constants'
import type { ChatCompletionResponse, Message } from '../../types'
import { parseThinkTags } from './message-reasoning-utils'
import {
completeAssistantTiming,
completeReasoningTiming,
startReasoningTiming,
} from './message-timing-utils'
import {
getCurrentVersion,
hasMessageContent,
updateCurrentVersionContent,
} from './message-utils'
/**
* Process content chunk during streaming.
* Separates <think> reasoning from visible content in real-time.
* Note: versions[0].content keeps the full raw content with tags during streaming.
*/
export function processStreamingContent(
message: Message,
contentChunk?: string
): Message {
const currentVersion = getCurrentVersion(message)
const fullContent = contentChunk
? currentVersion.content + contentChunk
: currentVersion.content
if (!message.reasoning && !fullContent.includes('<think>')) {
return {
...updateCurrentVersionContent(message, fullContent),
isReasoningStreaming: false,
}
}
const { reasoning, hasUnclosedTag } = parseThinkTags(fullContent)
const finalReasoning = reasoning
? {
...startReasoningTiming(message),
content: reasoning,
}
: message.reasoning
return {
...updateCurrentVersionContent(message, fullContent),
reasoning: finalReasoning,
isReasoningStreaming: hasUnclosedTag,
}
}
export type StreamChunkType = 'reasoning' | 'content'
function getAppendableChunk(currentContent: string, chunk: string): string {
if (!currentContent || !chunk.startsWith(currentContent)) {
return chunk
}
return chunk.slice(currentContent.length)
}
export function applyStreamingChunk(
message: Message,
type: StreamChunkType,
chunk: string
): Message {
if (message.status === MESSAGE_STATUS.ERROR) {
return message
}
if (type === 'reasoning') {
const reasoning = startReasoningTiming(message)
const appendableChunk = getAppendableChunk(reasoning.content, chunk)
return {
...message,
reasoning: {
...reasoning,
content: reasoning.content + appendableChunk,
},
isReasoningStreaming: true,
status: MESSAGE_STATUS.STREAMING,
}
}
const currentVersion = getCurrentVersion(message)
const appendableChunk = getAppendableChunk(currentVersion.content, chunk)
const contentMessage = processStreamingContent(message, appendableChunk)
return {
...(contentMessage.isReasoningStreaming
? contentMessage
: completeReasoningTiming(contentMessage)),
status: MESSAGE_STATUS.STREAMING,
}
}
/**
* Finalize message after streaming completes.
* Cleans content and consolidates reasoning from all sources.
*/
export function finalizeMessage(
message: Message,
apiReasoningContent?: string
): Message {
const currentVersion = getCurrentVersion(message)
const parsedThinkTags = currentVersion.content.includes('<think>')
? parseThinkTags(currentVersion.content)
: undefined
const visibleContent =
parsedThinkTags?.visibleContent ?? currentVersion.content
const finalReasoning =
apiReasoningContent ||
message.reasoning?.content ||
parsedThinkTags?.reasoning ||
''
const finalized = {
...updateCurrentVersionContent(message, visibleContent),
reasoning: finalReasoning
? {
...startReasoningTiming(message),
content: finalReasoning,
}
: undefined,
isReasoningStreaming: false,
}
return completeReasoningTiming(finalized)
}
export function completeAssistantMessage(message: Message): Message {
return completeAssistantTiming({
...finalizeMessage(message),
status: MESSAGE_STATUS.COMPLETE,
})
}
export function isAssistantMessageFinal(message: Message): boolean {
return (
message.status === MESSAGE_STATUS.COMPLETE ||
message.status === MESSAGE_STATUS.ERROR
)
}
export function isAssistantMessagePending(message: Message): boolean {
return (
message.status === MESSAGE_STATUS.LOADING ||
message.status === MESSAGE_STATUS.STREAMING
)
}
export function isPendingAssistantMessage(message?: Message): boolean {
return Boolean(
message?.from === MESSAGE_ROLES.ASSISTANT &&
isAssistantMessagePending(message)
)
}
type ChatCompletionChoice = ChatCompletionResponse['choices'][number]
export function hasChatCompletionChoice(
response: ChatCompletionResponse
): boolean {
return Boolean(response.choices?.[0])
}
export function applyChatCompletionChoice(
message: Message,
choice: ChatCompletionChoice
): Message {
return completeAssistantTiming({
...finalizeMessage(
updateCurrentVersionContent(message, choice.message?.content || ''),
choice.message?.reasoning_content
),
status: MESSAGE_STATUS.COMPLETE,
})
}
export function applyChatCompletionResponse(
message: Message,
response: ChatCompletionResponse
): Message | null {
const choice = response.choices?.[0]
if (!choice) {
return null
}
return applyChatCompletionChoice(message, choice)
}
/**
* Sanitize messages loaded from storage.
* Converts stuck loading/streaming messages to stable state.
*/
export function sanitizeMessagesOnLoad(messages: Message[]): Message[] {
let targetIndex = -1
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (isPendingAssistantMessage(message)) {
targetIndex = i
break
}
}
if (targetIndex === -1) return messages
const finalized = finalizeMessage(messages[targetIndex])
const hasContent = hasMessageContent(finalized)
const hasReasoning = finalized.reasoning?.content?.trim()
const sanitized: Message =
hasContent || hasReasoning
? completeAssistantTiming({
...finalized,
status: MESSAGE_STATUS.COMPLETE,
isReasoningStreaming: false,
})
: completeAssistantTiming({
...updateCurrentVersionContent(
finalized,
`${t(ERROR_MESSAGES.API_REQUEST_ERROR)}: ${t(
ERROR_MESSAGES.INTERRUPTED
)}`
),
status: MESSAGE_STATUS.ERROR,
isReasoningStreaming: false,
})
const result = [...messages]
result[targetIndex] = sanitized
return result
}
......@@ -22,25 +22,39 @@ For commercial licensing, please contact support@quantumnous.com
*/
export function getMessageContentStyles() {
return [
// Assistant content fills the row; user bubble auto-width
// Assistant content reads like a document column; user bubble stays compact.
'group-[.is-assistant]:w-full',
'group-[.is-assistant]:max-w-none',
'group-[.is-assistant]:max-w-[78ch]',
'group-[.is-user]:w-fit',
// User bubble: rounded and themed background
// User bubble: compact surface that stays calm in both light and dark themes.
'group-[.is-user]:rounded-2xl',
'group-[.is-user]:rounded-br-md',
'group-[.is-user]:border',
'group-[.is-user]:border-border/70',
'group-[.is-user]:bg-muted/70',
'group-[.is-user]:px-4',
'group-[.is-user]:py-2.5',
'group-[.is-user]:text-foreground',
'group-[.is-user]:bg-secondary',
'dark:group-[.is-user]:bg-muted',
'group-[.is-user]:rounded-3xl',
// Assistant bubble: flat serif style (one-sided style)
'group-[.is-assistant]:text-foreground',
'group-[.is-user]:shadow-sm',
'group-[.is-user]:shadow-black/5',
// Assistant response: flat reading surface using the active UI font axis.
'group-[.is-assistant]:bg-transparent',
'group-[.is-assistant]:p-0',
'group-[.is-assistant]:font-serif',
'group-[.is-assistant]:rounded-none',
'group-[.is-assistant]:overflow-visible',
'group-[.is-assistant]:[font-family:var(--font-body)]',
'group-[.is-assistant]:text-foreground/90',
// Preferred readable widths and wrapping
'leading-relaxed',
'text-[0.95rem]',
'leading-6',
'break-words',
'whitespace-pre-wrap',
'sm:text-[0.975rem]',
'sm:leading-7',
// Cap user bubble width so it does not look like a banner
'group-[.is-user]:max-w-[85%]',
'sm:group-[.is-user]:max-w-[62ch]',
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { MESSAGE_ROLES } from '../../constants'
import type { Message } from '../../types'
export function completeAssistantTiming(
message: Message,
completedAt: number = Date.now()
): Message {
if (message.from !== MESSAGE_ROLES.ASSISTANT) {
return message
}
const startedAt = message.startedAt ?? message.createdAt ?? completedAt
return {
...message,
startedAt,
completedAt,
durationMs: Math.max(0, completedAt - startedAt),
}
}
export function startReasoningTiming(
message: Message,
startedAt: number = Date.now()
): NonNullable<Message['reasoning']> {
return {
content: message.reasoning?.content ?? '',
duration: message.reasoning?.duration ?? 0,
startedAt: message.reasoning?.startedAt ?? startedAt,
completedAt: message.reasoning?.completedAt,
durationMs: message.reasoning?.durationMs,
}
}
export function completeReasoningTiming(
message: Message,
completedAt: number = Date.now()
): Message {
if (!message.reasoning || message.reasoning.durationMs !== undefined) {
return message
}
const startedAt =
message.reasoning.startedAt ?? message.startedAt ?? completedAt
const durationMs = Math.max(0, completedAt - startedAt)
return {
...message,
reasoning: {
...message.reasoning,
startedAt,
completedAt,
durationMs,
duration: Math.ceil(durationMs / 1000),
},
}
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ERROR_MESSAGES, MESSAGE_ROLES, MESSAGE_STATUS } from '../../constants'
import type { Message } from '../../types'
import { completeAssistantTiming } from './message-timing-utils'
import { updateCurrentVersionContent } from './message-utils'
/**
* Update the last assistant message with an error.
*/
export function updateAssistantMessageWithError(
messages: Message[],
errorMessage: string,
errorCode?: string,
title: string = ERROR_MESSAGES.API_REQUEST_ERROR
): Message[] {
return updateLastAssistantMessage(messages, (message) => {
const updatedMessage = updateCurrentVersionContent(
message,
`${title}: ${errorMessage}`
)
return completeAssistantTiming({
...updatedMessage,
status: MESSAGE_STATUS.ERROR,
isReasoningStreaming: false,
errorCode: errorCode || null,
})
})
}
/**
* Update the most recent assistant message, preserving the array when absent.
*/
export function updateLastAssistantMessage(
messages: Message[],
updater: (message: Message) => Message
): Message[] {
if (messages.length === 0) return messages
const last = messages.at(-1)
if (!last || last.from !== MESSAGE_ROLES.ASSISTANT) return messages
const updated = [...messages]
updated[updated.length - 1] = updater(last)
return updated
}
......@@ -17,13 +17,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { nanoid } from 'nanoid'
import { MESSAGE_ROLES, MESSAGE_STATUS, ERROR_MESSAGES } from '../constants'
import { MESSAGE_ROLES, MESSAGE_STATUS } from '../../constants'
import type {
Message,
MessageVersion,
ChatCompletionMessage,
ContentPart,
} from '../types'
} from '../../types'
/**
* Create a new message version
......@@ -43,6 +44,20 @@ export function getCurrentVersion(message: Message): MessageVersion {
}
/**
* Get displayable content from the current message version.
*/
export function getMessageContent(message: Message): string {
return getCurrentVersion(message).content
}
/**
* Check whether a message has non-empty content in its current version.
*/
export function hasMessageContent(message: Message): boolean {
return getMessageContent(message).trim() !== ''
}
/**
* Update current version content in message
*/
export function updateCurrentVersionContent(
......@@ -59,22 +74,30 @@ export function updateCurrentVersionContent(
/**
* Create a user message
*/
export function createUserMessage(content: string): Message {
export function createUserMessage(
content: string,
createdAt: number = Date.now()
): Message {
return {
key: nanoid(),
from: MESSAGE_ROLES.USER,
versions: [createMessageVersion(content)],
createdAt,
}
}
/**
* Create a loading assistant message
*/
export function createLoadingAssistantMessage(): Message {
export function createLoadingAssistantMessage(
startedAt: number = Date.now()
): Message {
return {
key: nanoid(),
from: MESSAGE_ROLES.ASSISTANT,
versions: [createMessageVersion('')],
createdAt: startedAt,
startedAt,
reasoning: undefined,
isReasoningComplete: false,
isContentComplete: false,
......@@ -144,212 +167,10 @@ export function formatMessageForAPI(message: Message): ChatCompletionMessage {
export function isValidMessage(message: Message): boolean {
if (!message || !message.from || !message.versions.length) return false
const content = message.versions[0]?.content
if (content === undefined) return false
// Exclude empty assistant messages (loading/streaming placeholders)
if (message.from === 'assistant' && !content.trim()) return false
return true
}
/**
* Parse content to separate thinking from visible text
* Handles both complete and incomplete <think> tags
*/
export function parseThinkTags(content: string): {
visibleContent: string
reasoning: string
hasUnclosedTag: boolean
} {
if (!content.includes('<think>')) {
return { visibleContent: content, reasoning: '', hasUnclosedTag: false }
if (message.from === MESSAGE_ROLES.ASSISTANT && !hasMessageContent(message)) {
return false
}
const visibleParts: string[] = []
const reasoningParts: string[] = []
let currentPos = 0
let hasUnclosed = false
while (true) {
// Find next <think> tag
const openPos = content.indexOf('<think>', currentPos)
if (openPos === -1) {
// No more think tags, add remaining content
if (currentPos < content.length) {
visibleParts.push(content.substring(currentPos))
}
break
}
// Add visible content before this tag
if (openPos > currentPos) {
visibleParts.push(content.substring(currentPos, openPos))
}
// Look for matching </think> tag
const closePos = content.indexOf('</think>', openPos + 7)
if (closePos === -1) {
// Unclosed tag: rest is reasoning buffer
reasoningParts.push(content.substring(openPos + 7))
hasUnclosed = true
break
}
// Extract reasoning content between tags
reasoningParts.push(content.substring(openPos + 7, closePos))
currentPos = closePos + 8
}
return {
visibleContent: visibleParts.join('').trim(),
reasoning: reasoningParts.join('\n\n').trim(),
hasUnclosedTag: hasUnclosed,
}
}
/**
* Update the last assistant message with an error
* @param messages - Current messages array
* @param errorMessage - Error message to display
* @returns Updated messages array
*/
export function updateAssistantMessageWithError(
messages: Message[],
errorMessage: string,
errorCode?: string
): Message[] {
return updateLastAssistantMessage(messages, (message) => {
const updatedMessage = updateCurrentVersionContent(
message,
`${ERROR_MESSAGES.API_REQUEST_ERROR}: ${errorMessage}`
)
return {
...updatedMessage,
status: MESSAGE_STATUS.ERROR,
isReasoningStreaming: false,
errorCode: errorCode || null,
}
})
}
/**
* Helper function to update the last assistant message
* @param messages - Current messages array
* @param updater - Function to update the message
* @returns Updated messages array or original if no assistant message found
*/
export function updateLastAssistantMessage(
messages: Message[],
updater: (message: Message) => Message
): Message[] {
if (messages.length === 0) return messages
const last = messages[messages.length - 1]
if (!last || last.from !== MESSAGE_ROLES.ASSISTANT) return messages
const updated = [...messages]
updated[updated.length - 1] = updater(last)
return updated
}
/**
* Process content chunk during streaming
* Separates <think> reasoning from visible content in real-time
* Note: versions[0].content keeps the full raw content (with tags) during streaming
*/
export function processStreamingContent(
message: Message,
contentChunk?: string
): Message {
const currentVersion = getCurrentVersion(message)
const fullContent = contentChunk
? currentVersion.content + contentChunk
: currentVersion.content
const { reasoning, hasUnclosedTag } = parseThinkTags(fullContent)
// Preserve existing reasoning if no think tags found (e.g., from API reasoning_content)
const finalReasoning = reasoning
? { content: reasoning, duration: 0 }
: message.reasoning
return {
...updateCurrentVersionContent(message, fullContent),
reasoning: finalReasoning,
isReasoningStreaming: hasUnclosedTag,
}
}
/**
* Finalize message after streaming completes
* Cleans content and consolidates reasoning from all sources
*/
export function finalizeMessage(
message: Message,
apiReasoningContent?: string
): Message {
const currentVersion = getCurrentVersion(message)
const { visibleContent, reasoning } = parseThinkTags(currentVersion.content)
// Priority:
// 1. API reasoning_content passed as parameter (non-streaming response)
// 2. Existing message.reasoning (from streaming reasoning_content)
// 3. Extracted think tags from content
const finalReasoning =
apiReasoningContent || message.reasoning?.content || reasoning || ''
return {
...updateCurrentVersionContent(message, visibleContent),
reasoning: finalReasoning
? { content: finalReasoning, duration: message.reasoning?.duration || 0 }
: undefined,
isReasoningStreaming: false,
}
}
/**
* Sanitize messages loaded from storage
* Converts stuck loading/streaming messages to stable state
*/
export function sanitizeMessagesOnLoad(messages: Message[]): Message[] {
let targetIndex = -1
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]
if (
m?.from === MESSAGE_ROLES.ASSISTANT &&
(m?.status === MESSAGE_STATUS.LOADING ||
m?.status === MESSAGE_STATUS.STREAMING)
) {
targetIndex = i
break
}
}
if (targetIndex === -1) return messages
const finalized = finalizeMessage(messages[targetIndex])
const hasContent = finalized.versions?.[0]?.content?.trim()
const hasReasoning = finalized.reasoning?.content?.trim()
const sanitized: Message =
hasContent || hasReasoning
? {
...finalized,
status: MESSAGE_STATUS.COMPLETE,
isReasoningStreaming: false,
}
: {
...updateCurrentVersionContent(
finalized,
`${ERROR_MESSAGES.API_REQUEST_ERROR}: ${ERROR_MESSAGES.INTERRUPTED}`
),
status: MESSAGE_STATUS.ERROR,
isReasoningStreaming: false,
}
const result = [...messages]
result[targetIndex] = sanitized
return result
return true
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { GroupOption, ModelOption } from '../../types'
export function getModelFallback(
models: ModelOption[],
currentModel: string
): string | null {
const hasCurrentModel = models.some((model) => model.value === currentModel)
if (hasCurrentModel || models.length === 0) {
return null
}
return models[0].value
}
export function shouldClearModelForGroup(
models: ModelOption[],
currentModel: string
): boolean {
if (currentModel === '') {
return false
}
return !models.some((model) => model.value === currentModel)
}
export function getGroupFallback(
groups: GroupOption[],
currentGroup: string
): string | null {
const hasCurrentGroup = groups.some((group) => group.value === currentGroup)
if (hasCurrentGroup || groups.length === 0) {
return null
}
return (
groups.find((group) => group.value === 'default')?.value ?? groups[0].value
)
}
export function getOptionLoadErrorMessage(
error: unknown,
fallbackMessage: string
): string {
return error instanceof Error ? error.message : fallbackMessage
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { DEFAULT_CONFIG, DEFAULT_PARAMETER_ENABLED } from '../../constants'
import type { Message, ParameterEnabled, PlaygroundConfig } from '../../types'
import { loadConfig, loadMessages, loadParameterEnabled } from '../storage/storage'
export type MessageStateUpdater =
| Message[]
| ((previousMessages: Message[]) => Message[])
export function getInitialPlaygroundConfig(): PlaygroundConfig {
return { ...DEFAULT_CONFIG, ...loadConfig() }
}
export function getInitialParameterEnabled(): ParameterEnabled {
return { ...DEFAULT_PARAMETER_ENABLED, ...loadParameterEnabled() }
}
export function getInitialMessages(): Message[] {
return loadMessages() || []
}
export function applyMessageStateUpdate(
previousMessages: Message[],
updater: MessageStateUpdater
): Message[] {
return typeof updater === 'function' ? updater(previousMessages) : updater
}
/*
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 { STORAGE_KEYS } from '../constants'
import type { PlaygroundConfig, ParameterEnabled, Message } from '../types'
import { sanitizeMessagesOnLoad } from './message-utils'
/**
* Load playground config from localStorage
*/
export function loadConfig(): Partial<PlaygroundConfig> {
try {
const saved = localStorage.getItem(STORAGE_KEYS.CONFIG)
if (saved) {
return JSON.parse(saved)
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to load config:', error)
}
return {}
}
/**
* Save playground config to localStorage
*/
export function saveConfig(config: Partial<PlaygroundConfig>): void {
try {
localStorage.setItem(STORAGE_KEYS.CONFIG, JSON.stringify(config))
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to save config:', error)
}
}
/**
* Load parameter enabled state from localStorage
*/
export function loadParameterEnabled(): Partial<ParameterEnabled> {
try {
const saved = localStorage.getItem(STORAGE_KEYS.PARAMETER_ENABLED)
if (saved) {
return JSON.parse(saved)
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to load parameter enabled:', error)
}
return {}
}
/**
* Save parameter enabled state to localStorage
*/
export function saveParameterEnabled(
parameterEnabled: Partial<ParameterEnabled>
): void {
try {
localStorage.setItem(
STORAGE_KEYS.PARAMETER_ENABLED,
JSON.stringify(parameterEnabled)
)
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to save parameter enabled:', error)
}
}
/**
* Load messages from localStorage
*/
export function loadMessages(): Message[] | null {
try {
const saved = localStorage.getItem(STORAGE_KEYS.MESSAGES)
if (saved) {
const parsed: unknown = JSON.parse(saved)
if (!Array.isArray(parsed)) {
return null
}
const sanitized = sanitizeMessagesOnLoad(parsed as Message[])
// Persist sanitized result to avoid re-sanitizing on subsequent loads
if (sanitized !== parsed) {
saveMessages(sanitized)
}
return sanitized
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to load messages:', error)
}
return null
}
/**
* Save messages to localStorage
*/
export function saveMessages(messages: Message[]): void {
try {
localStorage.setItem(STORAGE_KEYS.MESSAGES, JSON.stringify(messages))
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to save messages:', error)
}
}
/**
* Clear all playground data
*/
export function clearPlaygroundData(): void {
try {
localStorage.removeItem(STORAGE_KEYS.CONFIG)
localStorage.removeItem(STORAGE_KEYS.PARAMETER_ENABLED)
localStorage.removeItem(STORAGE_KEYS.MESSAGES)
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to clear playground data:', error)
}
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { z } from 'zod'
export const STORAGE_VERSION = 1
export const MAX_STORED_MESSAGES = 100
export const MAX_STORED_MESSAGES_BYTES = 1024 * 1024
export const MAX_LOADED_MESSAGES_CHARS = 120_000
export const MAX_LOADED_MESSAGE_CHARS = 40_000
export const playgroundConfigSchema = z.object({
model: z.string().optional(),
group: z.string().optional(),
temperature: z.number().optional(),
top_p: z.number().optional(),
max_tokens: z.number().optional(),
frequency_penalty: z.number().optional(),
presence_penalty: z.number().optional(),
seed: z.number().nullable().optional(),
stream: z.boolean().optional(),
})
export const parameterEnabledSchema = z.object({
temperature: z.boolean().optional(),
top_p: z.boolean().optional(),
max_tokens: z.boolean().optional(),
frequency_penalty: z.boolean().optional(),
presence_penalty: z.boolean().optional(),
seed: z.boolean().optional(),
})
const messageRoleSchema = z.enum(['user', 'assistant', 'system'])
const messageStatusSchema = z.enum([
'loading',
'streaming',
'complete',
'error',
])
const messageVersionSchema = z.object({
id: z.string(),
content: z.string(),
})
const sourceSchema = z.object({
href: z.string(),
title: z.string(),
})
const reasoningSchema = z.object({
content: z.string(),
duration: z.number(),
startedAt: z.number().optional(),
completedAt: z.number().optional(),
durationMs: z.number().optional(),
})
const messageSchema = z.object({
key: z.string(),
from: messageRoleSchema,
versions: z.array(messageVersionSchema).min(1),
createdAt: z.number().optional(),
startedAt: z.number().optional(),
completedAt: z.number().optional(),
durationMs: z.number().optional(),
sources: z.array(sourceSchema).optional(),
reasoning: reasoningSchema.optional(),
isReasoningStreaming: z.boolean().optional(),
isReasoningComplete: z.boolean().optional(),
isContentComplete: z.boolean().optional(),
status: messageStatusSchema.optional(),
errorCode: z.string().nullable().optional(),
})
export const messagesSchema = z.array(messageSchema)
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { MESSAGE_STATUS, STORAGE_KEYS } from '../../constants'
import type { PlaygroundConfig, ParameterEnabled, Message } from '../../types'
import {
finalizeMessage,
isAssistantMessagePending,
sanitizeMessagesOnLoad,
} from '../message/message-streaming-utils'
import { completeAssistantTiming } from '../message/message-timing-utils'
import { hasMessageContent } from '../message/message-utils'
import {
MAX_LOADED_MESSAGE_CHARS,
MAX_LOADED_MESSAGES_CHARS,
MAX_STORED_MESSAGES,
MAX_STORED_MESSAGES_BYTES,
STORAGE_VERSION,
messagesSchema,
parameterEnabledSchema,
playgroundConfigSchema,
} from './storage-schema'
type StoredEnvelope<T> = {
version: number
data: T
}
const TRUNCATED_CONTENT_SUFFIX = '\n\n[...]'
const MIN_PREFIX_COLLAPSE_LENGTH = 2000
const MIN_REPEATED_SECTION_COUNT = 3
const SECTION_HEADING_LINE_PATTERN = /^#{2,6}\s+\d+\.\s+.+$/gm
function readStoredValue(key: string): unknown | null {
const saved = localStorage.getItem(key)
if (!saved) return null
return JSON.parse(saved) as unknown
}
function readStoredMessagesValue(): unknown | null {
const saved = localStorage.getItem(STORAGE_KEYS.MESSAGES)
if (!saved) return null
if (saved.length > MAX_STORED_MESSAGES_BYTES) {
localStorage.removeItem(STORAGE_KEYS.MESSAGES)
return null
}
return JSON.parse(saved) as unknown
}
function unwrapStoredValue(value: unknown): unknown {
if (!value || typeof value !== 'object') {
return value
}
if ('version' in value && 'data' in value) {
return (value as StoredEnvelope<unknown>).data
}
return value
}
function writeStoredValue<T>(key: string, data: T): void {
const payload: StoredEnvelope<T> = {
version: STORAGE_VERSION,
data,
}
localStorage.setItem(key, JSON.stringify(payload))
}
function trimMessages(messages: Message[]): Message[] {
if (messages.length <= MAX_STORED_MESSAGES) {
return messages
}
return messages.slice(-MAX_STORED_MESSAGES)
}
function getMessageSize(message: Message): number {
const versionsSize = message.versions.reduce(
(total, version) => total + version.content.length,
0
)
const reasoningSize = message.reasoning?.content.length ?? 0
return versionsSize + reasoningSize
}
function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) {
return text
}
if (maxLength <= TRUNCATED_CONTENT_SUFFIX.length) {
return text.slice(0, maxLength)
}
return `${text.slice(0, maxLength - TRUNCATED_CONTENT_SUFFIX.length)}${TRUNCATED_CONTENT_SUFFIX}`
}
type SectionOccurrence = {
heading: string
index: number
}
function getSectionOccurrences(text: string): SectionOccurrence[] {
const occurrences: SectionOccurrence[] = []
const matches = text.matchAll(SECTION_HEADING_LINE_PATTERN)
for (const match of matches) {
const index = match.index
if (index === undefined) {
continue
}
occurrences.push({
heading: match[0],
index,
})
}
return occurrences
}
function getHeadingCounts(
occurrences: SectionOccurrence[]
): Map<string, number> {
const counts = new Map<string, number>()
for (const occurrence of occurrences) {
counts.set(occurrence.heading, (counts.get(occurrence.heading) ?? 0) + 1)
}
return counts
}
function findLastRepeatedSectionRunStart(text: string): number {
const occurrences = getSectionOccurrences(text)
const headingCounts = getHeadingCounts(occurrences)
const lastRepeatedIndexes: number[] = []
const seenHeadings = new Set<string>()
for (let index = occurrences.length - 1; index >= 0; index--) {
const occurrence = occurrences[index]
const count = headingCounts.get(occurrence.heading) ?? 0
if (
count < MIN_REPEATED_SECTION_COUNT ||
seenHeadings.has(occurrence.heading)
) {
continue
}
seenHeadings.add(occurrence.heading)
lastRepeatedIndexes.push(occurrence.index)
}
if (lastRepeatedIndexes.length === 0) {
return -1
}
return Math.min(...lastRepeatedIndexes)
}
function collapseRepeatedSectionSnapshots(text: string): string {
if (text.length < MIN_PREFIX_COLLAPSE_LENGTH) {
return text
}
const lastRepeatedRunStart = findLastRepeatedSectionRunStart(text)
if (lastRepeatedRunStart === -1) {
return text
}
return text.slice(lastRepeatedRunStart)
}
function normalizeStoredMessageForLoad(message: Message): Message {
let changed = false
const versions = message.versions.map((version) => {
const collapsedContent = collapseRepeatedSectionSnapshots(version.content)
const content = truncateText(collapsedContent, MAX_LOADED_MESSAGE_CHARS)
if (content === version.content && collapsedContent === version.content) {
return version
}
changed = true
return {
...version,
content,
}
})
const reasoning = message.reasoning
? {
...message.reasoning,
content: truncateText(
message.reasoning.content,
MAX_LOADED_MESSAGE_CHARS
),
}
: undefined
if (reasoning?.content !== message.reasoning?.content) {
changed = true
}
const normalized = changed ? { ...message, versions, reasoning } : message
if (!isAssistantMessagePending(normalized)) {
return normalized
}
const hasContent = hasMessageContent(normalized)
const hasReasoning = normalized.reasoning?.content.trim()
if (!hasContent && !hasReasoning) {
return normalized
}
const completedAt =
normalized.completedAt ??
normalized.reasoning?.completedAt ??
normalized.startedAt ??
normalized.createdAt ??
Date.now()
return completeAssistantTiming(
{
...finalizeMessage(normalized),
status: MESSAGE_STATUS.COMPLETE,
isReasoningStreaming: false,
},
completedAt
)
}
function trimMessagesByContentSize(messages: Message[]): Message[] {
let totalSize = 0
const result: Message[] = []
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index]
const messageSize = getMessageSize(message)
if (
result.length > 0 &&
totalSize + messageSize > MAX_LOADED_MESSAGES_CHARS
) {
break
}
totalSize += messageSize
result.push(message)
}
return result.reverse()
}
/**
* Load playground config from localStorage
*/
export function loadConfig(): Partial<PlaygroundConfig> {
try {
const saved = readStoredValue(STORAGE_KEYS.CONFIG)
if (!saved) return {}
return playgroundConfigSchema.parse(unwrapStoredValue(saved))
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to load config:', error)
}
return {}
}
/**
* Save playground config to localStorage
*/
export function saveConfig(config: Partial<PlaygroundConfig>): void {
try {
const parsed = playgroundConfigSchema.parse(config)
writeStoredValue(STORAGE_KEYS.CONFIG, parsed)
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to save config:', error)
}
}
/**
* Load parameter enabled state from localStorage
*/
export function loadParameterEnabled(): Partial<ParameterEnabled> {
try {
const saved = readStoredValue(STORAGE_KEYS.PARAMETER_ENABLED)
if (!saved) return {}
return parameterEnabledSchema.parse(unwrapStoredValue(saved))
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to load parameter enabled:', error)
}
return {}
}
/**
* Save parameter enabled state to localStorage
*/
export function saveParameterEnabled(
parameterEnabled: Partial<ParameterEnabled>
): void {
try {
const parsed = parameterEnabledSchema.parse(parameterEnabled)
writeStoredValue(STORAGE_KEYS.PARAMETER_ENABLED, parsed)
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to save parameter enabled:', error)
}
}
/**
* Load messages from localStorage
*/
export function loadMessages(): Message[] | null {
try {
const saved = readStoredMessagesValue()
if (!saved) return null
const parsed = messagesSchema.parse(unwrapStoredValue(saved)) as Message[]
const normalized = parsed.map(normalizeStoredMessageForLoad)
const normalizedChanged = normalized.some(
(message, index) => message !== parsed[index]
)
const trimmed = trimMessages(normalized)
const sizeTrimmed = trimMessagesByContentSize(trimmed)
const sanitized = sanitizeMessagesOnLoad(sizeTrimmed)
if (
normalizedChanged ||
trimmed !== normalized ||
sizeTrimmed !== trimmed ||
sanitized !== sizeTrimmed
) {
saveMessages(sanitized)
}
return sanitized
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to load messages:', error)
}
return null
}
/**
* Save messages to localStorage
*/
export function saveMessages(messages: Message[]): void {
try {
const trimmed = trimMessages(messages)
const parsed = messagesSchema.parse(trimmed) as Message[]
writeStoredValue(STORAGE_KEYS.MESSAGES, parsed)
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to save messages:', error)
}
}
/**
* Clear all playground data
*/
export function clearPlaygroundData(): void {
try {
localStorage.removeItem(STORAGE_KEYS.CONFIG)
localStorage.removeItem(STORAGE_KEYS.PARAMETER_ENABLED)
localStorage.removeItem(STORAGE_KEYS.MESSAGES)
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to clear playground data:', error)
}
}
......@@ -21,8 +21,8 @@ import type {
Message,
PlaygroundConfig,
ParameterEnabled,
} from '../types'
import { formatMessageForAPI, isValidMessage } from './message-utils'
} from '../../types'
import { formatMessageForAPI, isValidMessage } from '../message/message-utils'
/**
* Build API request payload from messages and config
......@@ -44,24 +44,29 @@ export function buildChatCompletionPayload(
stream: config.stream,
}
// Add enabled parameters
const parameterKeys: Array<keyof ParameterEnabled> = [
'temperature',
'top_p',
'max_tokens',
'frequency_penalty',
'presence_penalty',
'seed',
]
if (parameterEnabled.temperature) {
payload.temperature = config.temperature
}
if (parameterEnabled.top_p) {
payload.top_p = config.top_p
}
if (parameterEnabled.max_tokens) {
payload.max_tokens = config.max_tokens
}
parameterKeys.forEach((key) => {
if (parameterEnabled[key]) {
const value = config[key as keyof PlaygroundConfig]
if (value !== undefined && value !== null) {
;(payload as unknown as Record<string, unknown>)[key] = value
}
}
})
if (parameterEnabled.frequency_penalty) {
payload.frequency_penalty = config.frequency_penalty
}
if (parameterEnabled.presence_penalty) {
payload.presence_penalty = config.presence_penalty
}
if (parameterEnabled.seed && config.seed !== null) {
payload.seed = config.seed
}
return payload
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ERROR_MESSAGES } from '../../constants'
type RequestErrorLike = {
message?: string
response?: {
data?: {
error?: {
code?: string
}
message?: string
}
}
}
export type RequestErrorDetails = {
errorCode?: string
errorMessage: string
}
export function parseRequestErrorDetails(error: unknown): RequestErrorDetails {
const requestError = error as RequestErrorLike
return {
errorCode: requestError?.response?.data?.error?.code || undefined,
errorMessage:
requestError?.response?.data?.message ||
requestError?.message ||
ERROR_MESSAGES.API_REQUEST_ERROR,
}
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ERROR_MESSAGES } from '../../constants'
import type { ChatCompletionChunk } from '../../types'
const STREAM_DONE_MESSAGE = '[DONE]'
const STREAM_CLOSED_READY_STATE = 2
export type StreamUpdateType = 'reasoning' | 'content'
export type StreamMessageUpdate = {
type: StreamUpdateType
chunk: string
}
type StreamErrorPayload = {
error?: {
code?: string
message?: string
}
}
export type StreamErrorDetails = {
errorCode?: string
errorMessage: string
}
export function parseStreamErrorDetails(data?: string): StreamErrorDetails {
const fallbackMessage = data || ERROR_MESSAGES.API_REQUEST_ERROR
if (!data) {
return { errorMessage: fallbackMessage }
}
try {
const parsed = JSON.parse(data) as StreamErrorPayload
if (!parsed?.error) {
return { errorMessage: fallbackMessage }
}
return {
errorCode: parsed.error.code || undefined,
errorMessage: parsed.error.message || fallbackMessage,
}
} catch {
return { errorMessage: fallbackMessage }
}
}
export function parseStreamMessageUpdates(data: string): StreamMessageUpdate[] {
const chunk = JSON.parse(data) as ChatCompletionChunk
const delta = chunk.choices?.[0]?.delta
if (!delta) {
return []
}
const updates: StreamMessageUpdate[] = []
if (delta.reasoning_content) {
updates.push({ type: 'reasoning', chunk: delta.reasoning_content })
}
if (delta.content) {
updates.push({ type: 'content', chunk: delta.content })
}
return updates
}
export function isStreamDoneMessage(data: string): boolean {
return data === STREAM_DONE_MESSAGE
}
export function isStreamClosedReadyState(readyState?: number): boolean {
return readyState === STREAM_CLOSED_READY_STATE
}
export function getStreamReadyStateError(
eventReadyState: number | undefined,
source: unknown
): string | null {
const status = (source as { status?: number }).status
if (
eventReadyState !== undefined &&
eventReadyState >= STREAM_CLOSED_READY_STATE &&
status !== undefined &&
status !== 200
) {
return `HTTP ${status}: ${ERROR_MESSAGES.CONNECTION_CLOSED}`
}
return null
}
......@@ -21,6 +21,8 @@ export type MessageRole = 'user' | 'assistant' | 'system'
export type MessageStatus = 'loading' | 'streaming' | 'complete' | 'error'
export type PlaygroundMessageLayoutMode = 'alternating' | 'left'
export interface MessageVersion {
id: string
content: string
......@@ -30,10 +32,17 @@ export interface Message {
key: string
from: MessageRole
versions: MessageVersion[]
createdAt?: number
startedAt?: number
completedAt?: number
durationMs?: number
sources?: { href: string; title: string }[]
reasoning?: {
content: string
duration: number
startedAt?: number
completedAt?: number
durationMs?: number
}
isReasoningStreaming?: boolean
isReasoningComplete?: boolean
......
......@@ -280,6 +280,7 @@
"All models in use are properly configured.": "All models in use are properly configured.",
"All Must Match (AND)": "All Must Match (AND)",
"All nodes": "All nodes",
"All playground messages saved in this browser will be removed. This cannot be undone.": "All playground messages saved in this browser will be removed. This cannot be undone.",
"All requests must include": "All requests must include",
"All Status": "All Status",
"All Sync Status": "All Sync Status",
......@@ -340,6 +341,8 @@
"Amount the user pays to purchase this plan; the actual currency depends on the payment gateway.": "Amount the user pays to purchase this plan; the actual currency depends on the payment gateway.",
"Amount to pay:": "Amount to pay:",
"An unexpected error occurred": "An unexpected error occurred",
"An unknown error occurred": "An unknown error occurred",
"Analyze data": "Analyze data",
"and": "and",
"Announcement added. Click \"Save Settings\" to apply.": "Announcement added. Click \"Save Settings\" to apply.",
"Announcement content": "Announcement content",
......@@ -526,6 +529,7 @@
"AZURE_OPENAI_ENDPOINT *": "AZURE_OPENAI_ENDPOINT *",
"Back": "Back",
"Back to Dashboard": "Back to Dashboard",
"Back to footnote {{id}} reference": "Back to footnote {{id}} reference",
"Back to Home": "Back to Home",
"Back to login": "Back to login",
"Back to Models": "Back to Models",
......@@ -798,6 +802,8 @@
"Clear All Cache": "Clear All Cache",
"Clear all filters": "Clear all filters",
"Clear cache for this rule": "Clear cache for this rule",
"Clear chat history": "Clear chat history",
"Clear chat history?": "Clear chat history?",
"Clear filters": "Clear filters",
"Clear Mapping": "Clear Mapping",
"Clear mode flags in prompts": "Clear mode flags in prompts",
......@@ -975,6 +981,7 @@
"Connect": "Connect",
"Connect through OpenAI, Claude, Gemini, and other compatible API routes": "Connect through OpenAI, Claude, Gemini, and other compatible API routes",
"Connected to io.net service normally.": "Connected to io.net service normally.",
"Connection closed": "Connection closed",
"Connection error": "Connection error",
"Connection failed": "Connection failed",
"Connection successful": "Connection successful",
......@@ -1007,6 +1014,7 @@
"Control which models are exposed and which groups may use them.": "Control which models are exposed and which groups may use them.",
"Controls how much the model thinks before answering": "Controls how much the model thinks before answering",
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Controls whether user verification (biometrics/PIN) is required during Passkey flows.",
"Conversation cleared": "Conversation cleared",
"Conversion rate from USD to your custom currency": "Conversion rate from USD to your custom currency",
"Convert reasoning_content to <think> tag in content": "Convert reasoning_content to <think> tag in content",
"Convert string to lowercase": "Convert string to lowercase",
......@@ -1646,8 +1654,10 @@
"Equals": "Equals",
"Error": "Error",
"Error Code (optional)": "Error Code (optional)",
"Error establishing connection": "Error establishing connection",
"Error Message": "Error Message",
"Error Message (required)": "Error Message (required)",
"Error parsing response data": "Error parsing response data",
"Error Type (optional)": "Error Type (optional)",
"Estimated cost": "Estimated cost",
"Estimated quota cost": "Estimated quota cost",
......@@ -2001,7 +2011,9 @@
"Generating new codes will invalidate all existing backup codes.": "Generating new codes will invalidate all existing backup codes.",
"Generating...": "Generating...",
"Generation quality preset": "Generation quality preset",
"Generation was interrupted": "Generation was interrupted",
"Generic cache": "Generic cache",
"Get advice": "Get advice",
"Get notified when balance falls below this value": "Get notified when balance falls below this value",
"Get one here": "Get one here",
"Get started": "Get started",
......@@ -2175,6 +2187,7 @@
"Image In": "Image In",
"Image input": "Image input",
"Image input price": "Image input price",
"Image not available": "Image not available",
"Image Out": "Image Out",
"Image output price": "Image output price",
"Image Preview": "Image Preview",
......@@ -2182,6 +2195,7 @@
"Image to Video": "Image to Video",
"Image Tokens": "Image Tokens",
"Import to CC Switch": "Import to CC Switch",
"Important": "Important",
"In Progress": "In Progress",
"In:": "In:",
"incident": "incident",
......@@ -2384,6 +2398,7 @@
"Loading channel details": "Loading channel details",
"Loading configuration": "Loading configuration",
"Loading content settings...": "Loading content settings...",
"Loading conversation...": "Loading conversation...",
"Loading current models...": "Loading current models...",
"Loading failed": "Loading failed",
"Loading maintenance settings...": "Loading maintenance settings...",
......@@ -2673,6 +2688,7 @@
"Needs API key": "Needs API key",
"Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.",
"Nested JSON: source group →": "Nested JSON: source group →",
"Network connection failed or server not responding": "Network connection failed or server not responding",
"Network proxy for this channel (supports socks5 protocol)": "Network proxy for this channel (supports socks5 protocol)",
"Never": "Never",
"Never expires": "Never expires",
......@@ -2736,6 +2752,7 @@
"No conflicts match your search.": "No conflicts match your search.",
"No console output": "No console output",
"No containers": "No containers",
"No content to copy": "No content to copy",
"No custom OAuth providers configured yet.": "No custom OAuth providers configured yet.",
"No data": "No data",
"No Data": "No Data",
......@@ -2879,6 +2896,7 @@
"Not tested": "Not tested",
"Not used for upstream training by default": "Not used for upstream training by default",
"Not used yet": "Not used yet",
"Note": "Note",
"Notice": "Notice",
"Notification Email": "Notification Email",
"Notification Method": "Notification Method",
......@@ -3245,6 +3263,7 @@
"Please wait a moment before trying again.": "Please wait a moment before trying again.",
"Please wait a moment, human check is initializing...": "Please wait a moment, human check is initializing...",
"Please wait before editing to avoid overwriting saved values.": "Please wait before editing to avoid overwriting saved values.",
"Please wait for the current generation to complete": "Please wait for the current generation to complete",
"Policy JSON": "Policy JSON",
"Polling": "Polling",
"Polling mode requires Redis and memory cache, otherwise performance will be significantly degraded": "Polling mode requires Redis and memory cache, otherwise performance will be significantly degraded",
......@@ -3440,6 +3459,7 @@
"Raw expression": "Raw expression",
"Raw JSON": "Raw JSON",
"Raw Quota": "Raw Quota",
"Raw response": "Raw response",
"Re-enable on success": "Re-enable on success",
"Re-login": "Re-login",
"Read channels": "Read channels",
......@@ -3578,6 +3598,7 @@
"Request conversion": "Request conversion",
"Request Conversion": "Request Conversion",
"Request Count": "Request Count",
"Request error occurred": "Request error occurred",
"Request failed": "Request failed",
"Request flow": "Request flow",
"Request Header Field": "Request Header Field",
......@@ -3649,6 +3670,7 @@
"Resetting...": "Resetting...",
"Resolve Conflicts": "Resolve Conflicts",
"Resource Configuration": "Resource Configuration",
"Responding...": "Responding...",
"Resources": "Resources",
"Response": "Response",
"Response Time": "Response Time",
......@@ -3727,6 +3749,7 @@
"Sampling temperature; lower is more deterministic": "Sampling temperature; lower is more deterministic",
"Sandbox mode": "Sandbox mode",
"Save": "Save",
"Save & Submit": "Save & Submit",
"Save all settings": "Save all settings",
"Save Backup Codes": "Save Backup Codes",
"Save changes": "Save changes",
......@@ -3959,9 +3982,11 @@
"Show all providers including unbound": "Show all providers including unbound",
"Show only bound providers": "Show only bound providers",
"Show or hide flow columns": "Show or hide flow columns",
"Show preview": "Show preview",
"Show prices in currency instead of quota.": "Show prices in currency instead of quota.",
"Show sensitive data": "Show sensitive data",
"Show setup guide": "Show setup guide",
"Show source": "Show source",
"Show token usage statistics in the UI": "Show token usage statistics in the UI",
"Showcase core capabilities with demo credentials and limited access.": "Showcase core capabilities with demo credentials and limited access.",
"Showing": "Showing",
......@@ -4037,6 +4062,7 @@
"Standard price": "Standard price",
"Start": "Start",
"Start a conversation to see messages here": "Start a conversation to see messages here",
"Start a playground chat": "Start a playground chat",
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.",
"Start for free with generous limits. No credit card required.": "Start for free with generous limits. No credit card required.",
"Start Time": "Start Time",
......@@ -4118,6 +4144,7 @@
"Successfully enabled {{count}} model(s)": "Successfully enabled {{count}} model(s)",
"Suffix": "Suffix",
"Suffix Match": "Suffix Match",
"Summarize text": "Summarize text",
"SunoAPI": "SunoAPI",
"Sunset Glow": "Sunset Glow",
"Super Admin": "Super Admin",
......@@ -4132,6 +4159,7 @@
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.",
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.",
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.",
"Surprise me": "Surprise me",
"Sustained tokens per second": "Sustained tokens per second",
"Swap Face": "Swap Face",
"Switch affinity on success": "Switch affinity on success",
......@@ -4216,6 +4244,7 @@
"Test": "Test",
"Test {{count}} matching models": "Test {{count}} matching models",
"Test {{count}} selected": "Test {{count}} selected",
"Test a model with a starter prompt, or write your own request below.": "Test a model with a starter prompt, or write your own request below.",
"Test all {{count}} models": "Test all {{count}} models",
"Test All Channels": "Test All Channels",
"Test Channel Connection": "Test Channel Connection",
......@@ -4280,6 +4309,7 @@
"These toggles affect whether certain request fields are passed through to the upstream provider.": "These toggles affect whether certain request fields are passed through to the upstream provider.",
"Thinking Suffix Adapter": "Thinking Suffix Adapter",
"Thinking to Content": "Thinking to Content",
"Thinking...": "Thinking...",
"Third-party account bindings (read-only, managed by user in profile settings)": "Third-party account bindings (read-only, managed by user in profile settings)",
"Third-party Payment Config": "Third-party Payment Config",
"This action cannot be undone.": "This action cannot be undone.",
......@@ -4336,6 +4366,7 @@
"This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?",
"This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?",
"This year": "This year",
"Thought for {{duration}} seconds": "Thought for {{duration}} seconds",
"Three steps to get started": "Three steps to get started",
"Throughput": "Throughput",
"Throughput by group": "Throughput by group",
......@@ -4359,6 +4390,7 @@
"Timeline": "Timeline",
"times": "times",
"Timing": "Timing",
"Tip": "Tip",
"to access this resource.": "to access this resource.",
"to confirm": "to confirm",
"To Lower": "To Lower",
......
......@@ -280,6 +280,7 @@
"All models in use are properly configured.": "Tous les modèles utilisés sont correctement configurés.",
"All Must Match (AND)": "Toutes doivent correspondre (AND)",
"All nodes": "Tous les nœuds",
"All playground messages saved in this browser will be removed. This cannot be undone.": "Tous les messages du Playground enregistrés dans ce navigateur seront supprimés. Cette action est irréversible.",
"All requests must include": "Toutes les requêtes doivent inclure",
"All Status": "Tous les statuts",
"All Sync Status": "Tous les statuts de synchronisation",
......@@ -340,6 +341,8 @@
"Amount the user pays to purchase this plan; the actual currency depends on the payment gateway.": "Montant que l'utilisateur paie pour acheter ce forfait ; la devise réelle dépend de la passerelle de paiement.",
"Amount to pay:": "Montant à payer :",
"An unexpected error occurred": "Une erreur inattendue est survenue",
"An unknown error occurred": "Une erreur inconnue est survenue",
"Analyze data": "Analyser les données",
"and": "et",
"Announcement added. Click \"Save Settings\" to apply.": "Annonce ajoutée. Cliquez sur \"Enregistrer les paramètres\" pour appliquer.",
"Announcement content": "Contenu de l'annonce",
......@@ -526,6 +529,7 @@
"AZURE_OPENAI_ENDPOINT *": "AZURE_OPENAI_ENDPOINT *",
"Back": "Retour",
"Back to Dashboard": "Retour au tableau de bord",
"Back to footnote {{id}} reference": "Retour à la référence de la note {{id}}",
"Back to Home": "Retour à l'accueil",
"Back to login": "Retour à la connexion",
"Back to Models": "Retour aux modèles",
......@@ -798,6 +802,8 @@
"Clear All Cache": "Vider tout le cache",
"Clear all filters": "Effacer tous les filtres",
"Clear cache for this rule": "Vider le cache de cette règle",
"Clear chat history": "Effacer l'historique du chat",
"Clear chat history?": "Effacer l'historique du chat ?",
"Clear filters": "Effacer les filtres",
"Clear Mapping": "Effacer le mappage",
"Clear mode flags in prompts": "Effacer les indicateurs de mode dans les prompts",
......@@ -975,6 +981,7 @@
"Connect": "Connecter",
"Connect through OpenAI, Claude, Gemini, and other compatible API routes": "Connectez-vous via OpenAI, Claude, Gemini et d'autres routes API compatibles",
"Connected to io.net service normally.": "Connexion au service io.net réussie.",
"Connection closed": "Connexion fermée",
"Connection error": "Erreur de connexion",
"Connection failed": "Connexion échouée",
"Connection successful": "Connexion réussie",
......@@ -1007,6 +1014,7 @@
"Control which models are exposed and which groups may use them.": "Contrôlez les modèles exposés et les groupes autorisés à les utiliser.",
"Controls how much the model thinks before answering": "Contrôle la quantité de raisonnement avant la réponse",
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Contrôle si la vérification de l'utilisateur (biométrie/PIN) est requise lors des flux de Passkey.",
"Conversation cleared": "Conversation effacée",
"Conversion rate from USD to your custom currency": "Taux de conversion de l'USD vers votre devise personnalisée",
"Convert reasoning_content to <think> tag in content": "Convertir reasoning_content en balise <think> dans content",
"Convert string to lowercase": "Convertir la chaîne en minuscules",
......@@ -1646,8 +1654,10 @@
"Equals": "Égal à",
"Error": "Erreur",
"Error Code (optional)": "Code d'erreur (optionnel)",
"Error establishing connection": "Erreur lors de l’établissement de la connexion",
"Error Message": "Message d'erreur",
"Error Message (required)": "Message d'erreur (requis)",
"Error parsing response data": "Erreur lors de l’analyse des données de réponse",
"Error Type (optional)": "Type d'erreur (optionnel)",
"Estimated cost": "Coût estimé",
"Estimated quota cost": "Coût de quota estimé",
......@@ -2001,7 +2011,9 @@
"Generating new codes will invalidate all existing backup codes.": "La génération de nouveaux codes invalidera tous les codes de sauvegarde existants.",
"Generating...": "Génération...",
"Generation quality preset": "Préréglage de qualité de génération",
"Generation was interrupted": "La génération a été interrompue",
"Generic cache": "Cache générique",
"Get advice": "Obtenir des conseils",
"Get notified when balance falls below this value": "Recevoir une notification lorsque le solde tombe en dessous de cette valeur",
"Get one here": "Obtenir ici",
"Get started": "Commencer",
......@@ -2175,6 +2187,7 @@
"Image In": "Entrée d’image",
"Image input": "Entrée image",
"Image input price": "Prix d’entrée image",
"Image not available": "Image indisponible",
"Image Out": "Sortie d’image",
"Image output price": "Prix de sortie image",
"Image Preview": "Aperçu de l'image",
......@@ -2182,6 +2195,7 @@
"Image to Video": "Image vers vidéo",
"Image Tokens": "Tokens image",
"Import to CC Switch": "Importer vers CC Switch",
"Important": "Important",
"In Progress": "En cours",
"In:": "Entrée :",
"incident": "incident",
......@@ -2384,6 +2398,7 @@
"Loading channel details": "Chargement des détails du canal",
"Loading configuration": "Chargement de la configuration",
"Loading content settings...": "Chargement des paramètres de contenu...",
"Loading conversation...": "Chargement de la conversation...",
"Loading current models...": "Chargement des modèles actuels...",
"Loading failed": "Échec du chargement",
"Loading maintenance settings...": "Chargement des paramètres de maintenance...",
......@@ -2673,6 +2688,7 @@
"Needs API key": "Clé API requise",
"Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "JSON imbriqué définissant des règles par groupe pour ajouter (+:), supprimer (-:), ou ajouter des groupes utilisables.",
"Nested JSON: source group →": "JSON imbriqué : groupe source →",
"Network connection failed or server not responding": "La connexion réseau a échoué ou le serveur ne répond pas",
"Network proxy for this channel (supports socks5 protocol)": "Proxy réseau pour ce canal (supporte le protocole socks5)",
"Never": "Jamais",
"Never expires": "N'expire jamais",
......@@ -2736,6 +2752,7 @@
"No conflicts match your search.": "Aucun conflit ne correspond à votre recherche.",
"No console output": "Aucune sortie console",
"No containers": "Aucun conteneur",
"No content to copy": "Aucun contenu à copier",
"No custom OAuth providers configured yet.": "Aucun fournisseur OAuth personnalisé configuré pour le moment.",
"No data": "Aucune donnée",
"No Data": "Aucune donnée",
......@@ -2879,6 +2896,7 @@
"Not tested": "Non testé",
"Not used for upstream training by default": "Non utilisé pour l'entraînement amont par défaut",
"Not used yet": "Pas encore utilisé",
"Note": "Note",
"Notice": "Avis",
"Notification Email": "E-mail de notification",
"Notification Method": "Méthode de notification",
......@@ -3245,6 +3263,7 @@
"Please wait a moment before trying again.": "Veuillez patienter un instant avant de réessayer.",
"Please wait a moment, human check is initializing...": "Veuillez patienter un instant, la vérification humaine s'initialise...",
"Please wait before editing to avoid overwriting saved values.": "Veuillez patienter avant de modifier afin d'éviter d'écraser les valeurs enregistrées.",
"Please wait for the current generation to complete": "Veuillez attendre la fin de la génération en cours",
"Policy JSON": "JSON de stratégie",
"Polling": "Sondage",
"Polling mode requires Redis and memory cache, otherwise performance will be significantly degraded": "Le mode d'interrogation nécessite Redis et un cache mémoire, sinon les performances seront considérablement dégradées",
......@@ -3440,6 +3459,7 @@
"Raw expression": "Expression brute",
"Raw JSON": "JSON brut",
"Raw Quota": "Quota brut",
"Raw response": "Reponse brute",
"Re-enable on success": "Réactiver en cas de succès",
"Re-login": "Se reconnecter",
"Read channels": "Lire les canaux",
......@@ -3578,6 +3598,7 @@
"Request conversion": "Conversion de requête",
"Request Conversion": "Conversion de requête",
"Request Count": "Nombre de requêtes",
"Request error occurred": "Une erreur de requête est survenue",
"Request failed": "Échec de la requête",
"Request flow": "Flux de requête",
"Request Header Field": "Champ d'en-tête de requête",
......@@ -3649,6 +3670,7 @@
"Resetting...": "Réinitialisation...",
"Resolve Conflicts": "Résoudre les conflits",
"Resource Configuration": "Configuration des ressources",
"Responding...": "Réponse en cours...",
"Resources": "Ressources",
"Response": "Réponse",
"Response Time": "Temps de réponse",
......@@ -3727,6 +3749,7 @@
"Sampling temperature; lower is more deterministic": "Température d'échantillonnage ; plus c'est bas, plus c'est déterministe",
"Sandbox mode": "Mode sandbox",
"Save": "Enregistrer",
"Save & Submit": "Enregistrer et envoyer",
"Save all settings": "Enregistrer tous les paramètres",
"Save Backup Codes": "Sauvegarder les codes de secours",
"Save changes": "Enregistrer les modifications",
......@@ -3959,9 +3982,11 @@
"Show all providers including unbound": "Afficher tous les fournisseurs (y compris non liés)",
"Show only bound providers": "Afficher uniquement les fournisseurs liés",
"Show or hide flow columns": "Afficher ou masquer les colonnes du flux",
"Show preview": "Afficher l'apercu",
"Show prices in currency instead of quota.": "Afficher les prix en devise au lieu du quota.",
"Show sensitive data": "Afficher les données sensibles",
"Show setup guide": "Afficher le guide de configuration",
"Show source": "Afficher la source",
"Show token usage statistics in the UI": "Afficher les statistiques d'utilisation des jetons dans l'interface utilisateur",
"Showcase core capabilities with demo credentials and limited access.": "Présenter les fonctionnalités principales avec des identifiants de démonstration et un accès limité.",
"Showing": "Affichage de",
......@@ -4037,6 +4062,7 @@
"Standard price": "Prix standard",
"Start": "Début",
"Start a conversation to see messages here": "Démarrez une conversation pour voir les messages ici",
"Start a playground chat": "Démarrer une conversation dans le playground",
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "Commencez à encaisser des paiements dans le monde entier sans créer de société. Conçu pour les développeurs indépendants, les entrepreneurs individuels OPC et les startups. Waffo Pancake agit comme Merchant of Record et prend en charge la conformité liée à l’encaissement mondial : taxes à la consommation, facturation, gestion des abonnements, remboursements et rétrofacturations. Les développeurs solo peuvent lancer rapidement leur produit et rester concentrés sur celui-ci plutôt que sur la conformité. Intégration en quelques minutes, d’une seule invite à une intégration complète.",
"Start for free with generous limits. No credit card required.": "Commencez gratuitement avec des limites généreuses. Aucune carte de crédit requise.",
"Start Time": "Heure de début",
......@@ -4118,6 +4144,7 @@
"Successfully enabled {{count}} model(s)": "{{count}} modèle(s) activé(s) avec succès",
"Suffix": "Suffixe",
"Suffix Match": "Correspondance de suffixe",
"Summarize text": "Résumer le texte",
"SunoAPI": "SunoAPI",
"Sunset Glow": "Lueur du couchant",
"Super Admin": "Super Administrateur",
......@@ -4132,6 +4159,7 @@
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "Prend en charge le balisage HTML ou l'intégration d'iframe. Entrez le code HTML directement, ou fournissez une URL complète pour l'intégrer automatiquement en tant qu'iframe.",
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "Prend en charge la configuration en un clic et s'adapte parfaitement à la configuration multi-protocole NewAPI.",
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "Prend en charge PNG, JPG, SVG ou WebP. Taille recommandée : 128×128 ou moins.",
"Surprise me": "Surprends-moi",
"Sustained tokens per second": "Jetons par seconde soutenus",
"Swap Face": "Échanger le visage",
"Switch affinity on success": "Changer l'affinité en cas de succès",
......@@ -4216,6 +4244,7 @@
"Test": "Tester",
"Test {{count}} matching models": "Tester les {{count}} modèles correspondants",
"Test {{count}} selected": "Tester {{count}} sélectionné(s)",
"Test a model with a starter prompt, or write your own request below.": "Testez un modèle avec un prompt de départ, ou rédigez votre propre demande ci-dessous.",
"Test all {{count}} models": "Tester les {{count}} modèles",
"Test All Channels": "Tester tous les canaux",
"Test Channel Connection": "Tester la connexion du canal",
......@@ -4280,6 +4309,7 @@
"These toggles affect whether certain request fields are passed through to the upstream provider.": "Ces bascules déterminent si certains champs de demande sont transmis au fournisseur en amont.",
"Thinking Suffix Adapter": "Adaptateur de suffixe thinking",
"Thinking to Content": "Réflexion vers Contenu",
"Thinking...": "Réflexion...",
"Third-party account bindings (read-only, managed by user in profile settings)": "Liaisons de comptes tiers (lecture seule, gérées par l'utilisateur dans les paramètres de profil)",
"Third-party Payment Config": "Configuration de paiement tiers",
"This action cannot be undone.": "Cette action est irréversible.",
......@@ -4336,6 +4366,7 @@
"This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "La priorité de tous les {{count}} canaux avec le tag \"{{tag}}\" sera mise à jour à {{value}}. Continuer ?",
"This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "Le poids de tous les {{count}} canaux avec le tag \"{{tag}}\" sera mis à jour à {{value}}. Continuer ?",
"This year": "Cette année",
"Thought for {{duration}} seconds": "Réflexion pendant {{duration}} secondes",
"Three steps to get started": "Trois étapes pour commencer",
"Throughput": "Débit",
"Throughput by group": "Débit par groupe",
......@@ -4359,6 +4390,7 @@
"Timeline": "Chronologie",
"times": "Fois",
"Timing": "Durée",
"Tip": "Astuce",
"to access this resource.": "pour accéder à cette ressource.",
"to confirm": "pour confirmer",
"To Lower": "En minuscules",
......
......@@ -280,6 +280,7 @@
"All models in use are properly configured.": "使用中のすべてのモデルが適切に構成されています。",
"All Must Match (AND)": "すべて一致(AND)",
"All nodes": "すべてのノード",
"All playground messages saved in this browser will be removed. This cannot be undone.": "このブラウザに保存されたすべての Playground メッセージが削除されます。この操作は元に戻せません。",
"All requests must include": "すべてのリクエストには",
"All Status": "すべてのステータス",
"All Sync Status": "すべての同期状態",
......@@ -340,6 +341,8 @@
"Amount the user pays to purchase this plan; the actual currency depends on the payment gateway.": "ユーザーがこのプランを購入する際に支払う金額。実際の通貨は決済ゲートウェイによって異なります。",
"Amount to pay:": "支払い金額:",
"An unexpected error occurred": "予期せぬエラーが発生しました",
"An unknown error occurred": "不明なエラーが発生しました",
"Analyze data": "データを分析",
"and": "および",
"Announcement added. Click \"Save Settings\" to apply.": "お知らせが追加されました。\"設定を保存\" をクリックして適用してください。",
"Announcement content": "お知らせの内容",
......@@ -526,6 +529,7 @@
"AZURE_OPENAI_ENDPOINT *": "AZURE_OPENAI_ENDPOINT *",
"Back": "戻る",
"Back to Dashboard": "ダッシュボードに戻る",
"Back to footnote {{id}} reference": "脚注 {{id}} の参照元に戻る",
"Back to Home": "ホームに戻る",
"Back to login": "ログインに戻る",
"Back to Models": "モデルに戻る",
......@@ -798,6 +802,8 @@
"Clear All Cache": "全キャッシュをクリア",
"Clear all filters": "すべてのフィルターをクリア",
"Clear cache for this rule": "このルールのキャッシュをクリア",
"Clear chat history": "チャット履歴を消去",
"Clear chat history?": "チャット履歴を消去しますか?",
"Clear filters": "フィルターをクリア",
"Clear Mapping": "マッピングをクリア",
"Clear mode flags in prompts": "プロンプト内のモードフラグをクリア",
......@@ -975,6 +981,7 @@
"Connect": "接続",
"Connect through OpenAI, Claude, Gemini, and other compatible API routes": "OpenAI、Claude、Gemini、その他の互換APIルートから接続",
"Connected to io.net service normally.": "io.net サービスに正常に接続しました。",
"Connection closed": "接続が閉じられました",
"Connection error": "接続エラー",
"Connection failed": "接続に失敗しました",
"Connection successful": "接続に成功しました",
......@@ -1007,6 +1014,7 @@
"Control which models are exposed and which groups may use them.": "公開するモデルと、それらを利用できるグループを制御します。",
"Controls how much the model thinks before answering": "モデルが回答前に考える深さを制御します",
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Passkeyフロー中にユーザー認証(生体認証/PIN)が必要かどうかを制御します。",
"Conversation cleared": "会話を消去しました",
"Conversion rate from USD to your custom currency": "USDからカスタム通貨への換算レート",
"Convert reasoning_content to <think> tag in content": "content内のreasoning_contentを<think>タグに変換",
"Convert string to lowercase": "文字列を小文字に変換",
......@@ -1646,8 +1654,10 @@
"Equals": "等しい",
"Error": "エラー",
"Error Code (optional)": "エラーコード(任意)",
"Error establishing connection": "接続の確立に失敗しました",
"Error Message": "エラーメッセージ",
"Error Message (required)": "エラーメッセージ(必須)",
"Error parsing response data": "レスポンスデータの解析に失敗しました",
"Error Type (optional)": "エラータイプ(任意)",
"Estimated cost": "推定コスト",
"Estimated quota cost": "想定クォートコスト",
......@@ -2001,7 +2011,9 @@
"Generating new codes will invalidate all existing backup codes.": "新しいコードを生成すると、既存のすべてのバックアップコードが無効になります。",
"Generating...": "生成中...",
"Generation quality preset": "生成品質プリセット",
"Generation was interrupted": "生成が中断されました",
"Generic cache": "汎用キャッシュ",
"Get advice": "アドバイスを得る",
"Get notified when balance falls below this value": "残高がこの値を下回ったときに通知を受け取る",
"Get one here": "こちらから取得",
"Get started": "はじめる",
......@@ -2175,6 +2187,7 @@
"Image In": "画像入力",
"Image input": "画像入力",
"Image input price": "画像入力価格",
"Image not available": "画像を利用できません",
"Image Out": "画像出力",
"Image output price": "画像出力価格",
"Image Preview": "画像プレビュー",
......@@ -2182,6 +2195,7 @@
"Image to Video": "画像から動画",
"Image Tokens": "画像トークン",
"Import to CC Switch": "CC Switch にインポート",
"Important": "重要",
"In Progress": "処理中",
"In:": "入力:",
"incident": "件",
......@@ -2384,6 +2398,7 @@
"Loading channel details": "チャネル詳細を読み込み中",
"Loading configuration": "設定を読み込んでいます",
"Loading content settings...": "コンテンツ設定をロード中...",
"Loading conversation...": "会話を読み込み中...",
"Loading current models...": "現在のモデルをロード中...",
"Loading failed": "読み込みに失敗しました",
"Loading maintenance settings...": "メンテナンス設定をロード中...",
......@@ -2673,6 +2688,7 @@
"Needs API key": "API キーが必要",
"Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "追加 (+:)、削除 (-:)、または使用可能なグループの追加を行うグループごとのルールを定義するネストされたJSON。",
"Nested JSON: source group →": "ネストされたJSON: ソースグループ →",
"Network connection failed or server not responding": "ネットワーク接続に失敗したか、サーバーが応答していません",
"Network proxy for this channel (supports socks5 protocol)": "このチャネルのネットワークプロキシ (socks5プロトコルをサポート)",
"Never": "しない",
"Never expires": "無期限",
......@@ -2736,6 +2752,7 @@
"No conflicts match your search.": "検索条件に一致する競合はありません。",
"No console output": "コンソール出力なし",
"No containers": "コンテナがありません",
"No content to copy": "コピーする内容がありません",
"No custom OAuth providers configured yet.": "カスタムOAuthプロバイダーはまだ設定されていません。",
"No data": "データがありません",
"No Data": "データなし",
......@@ -2879,6 +2896,7 @@
"Not tested": "未テスト",
"Not used for upstream training by default": "デフォルトで上流の学習には使用されません",
"Not used yet": "未使用",
"Note": "注記",
"Notice": "通知",
"Notification Email": "通知メール",
"Notification Method": "通知方法",
......@@ -3245,6 +3263,7 @@
"Please wait a moment before trying again.": "しばらく待ってからもう一度お試しください。",
"Please wait a moment, human check is initializing...": "しばらくお待ちください、人間チェックを初期化中です...",
"Please wait before editing to avoid overwriting saved values.": "保存済みの値を上書きしないよう、編集前に読み込み完了をお待ちください。",
"Please wait for the current generation to complete": "現在の生成が完了するまでお待ちください",
"Policy JSON": "ポリシーJSON",
"Polling": "ポーリング",
"Polling mode requires Redis and memory cache, otherwise performance will be significantly degraded": "ポーリングモードにはRedisとメモリキャッシュが必要です。そうでない場合、パフォーマンスが大幅に低下します",
......@@ -3440,6 +3459,7 @@
"Raw expression": "元の式",
"Raw JSON": "生 JSON",
"Raw Quota": "元のクォータ",
"Raw response": "生の応答",
"Re-enable on success": "成功時に再有効化",
"Re-login": "再ログイン",
"Read channels": "チャネルを読み取り",
......@@ -3578,6 +3598,7 @@
"Request conversion": "リクエスト変換",
"Request Conversion": "リクエスト変換",
"Request Count": "リクエスト数",
"Request error occurred": "リクエストエラーが発生しました",
"Request failed": "リクエスト失敗",
"Request flow": "リクエストフロー",
"Request Header Field": "リクエストヘッダーフィールド",
......@@ -3649,6 +3670,7 @@
"Resetting...": "リセット中...",
"Resolve Conflicts": "競合を解決",
"Resource Configuration": "リソース設定",
"Responding...": "応答中...",
"Resources": "リソース",
"Response": "レスポンス",
"Response Time": "応答時間",
......@@ -3727,6 +3749,7 @@
"Sampling temperature; lower is more deterministic": "サンプリング温度。低いほど決定論的になります",
"Sandbox mode": "サンドボックスモード",
"Save": "保存",
"Save & Submit": "保存して送信",
"Save all settings": "すべての設定を保存",
"Save Backup Codes": "バックアップコードを保存",
"Save changes": "変更を保存",
......@@ -3959,9 +3982,11 @@
"Show all providers including unbound": "未バインドを含むすべてのプロバイダーを表示",
"Show only bound providers": "バインド済みのプロバイダーのみ表示",
"Show or hide flow columns": "フロー列の表示・非表示",
"Show preview": "プレビューを表示",
"Show prices in currency instead of quota.": "クォータではなく通貨で価格を表示。",
"Show sensitive data": "機密データを表示",
"Show setup guide": "セットアップガイドを表示",
"Show source": "ソースを表示",
"Show token usage statistics in the UI": "UIでトークン使用統計を表示",
"Showcase core capabilities with demo credentials and limited access.": "デモ用の認証情報と制限付きアクセスでコア機能を紹介します。",
"Showing": "表示",
......@@ -4037,6 +4062,7 @@
"Standard price": "標準価格",
"Start": "開始",
"Start a conversation to see messages here": "会話を開始すると、ここにメッセージが表示されます",
"Start a playground chat": "Playground でチャットを開始",
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "法人を設立せずに世界中で決済を受け付けられます。個人開発者、OPC 個人事業主、スタートアップ向けに設計されています。Waffo Pancake は Merchant of Record として、消費税、請求書、サブスクリプション管理、返金、チャージバックなど、グローバル決済のコンプライアンス負担を引き受けます。個人開発者はコンプライアンスではなくプロダクトに集中しながら素早くローンチできます。数分でオンボーディングし、1 つのプロンプトから完全な統合まで進められます。",
"Start for free with generous limits. No credit card required.": "豊富な無料枠で始められます。クレジットカードは不要です。",
"Start Time": "開始時間",
......@@ -4118,6 +4144,7 @@
"Successfully enabled {{count}} model(s)": "{{count}} 個のモデルを有効にしました",
"Suffix": "サフィックス",
"Suffix Match": "サフィックス一致",
"Summarize text": "テキストを要約",
"SunoAPI": "SunoAPI",
"Sunset Glow": "サンセットグロウ",
"Super Admin": "スーパー管理者",
......@@ -4132,6 +4159,7 @@
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "HTMLマークアップまたはiframe埋め込みをサポートします。HTMLコードを直接入力するか、完全なURLを提供してiframeとして自動的に埋め込みます。",
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "ワンクリック設定をサポートし、NewAPIマルチプロトコル設定に完全に適応します。",
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "PNG、JPG、SVG、WebPに対応。推奨サイズ: 128×128以下。",
"Surprise me": "おまかせ",
"Sustained tokens per second": "持続的な毎秒トークン数",
"Swap Face": "顔入れ替え",
"Switch affinity on success": "成功時にアフィニティを切替",
......@@ -4216,6 +4244,7 @@
"Test": "テスト",
"Test {{count}} matching models": "{{count}} 件の一致モデルをテスト",
"Test {{count}} selected": "選択済み {{count}} 件をテスト",
"Test a model with a starter prompt, or write your own request below.": "スタータープロンプトでモデルをテストするか、下に独自のリクエストを入力してください。",
"Test all {{count}} models": "{{count}} 件すべてのモデルをテスト",
"Test All Channels": "すべてのチャネルをテスト",
"Test Channel Connection": "チャネル接続をテスト",
......@@ -4280,6 +4309,7 @@
"These toggles affect whether certain request fields are passed through to the upstream provider.": "これらの切り替えは、特定の要求フィールドがアップストリームプロバイダーに渡されるかどうかに影響します。",
"Thinking Suffix Adapter": "思考サフィックスアダプター",
"Thinking to Content": "思考からコンテンツへ",
"Thinking...": "思考中...",
"Third-party account bindings (read-only, managed by user in profile settings)": "サードパーティアカウントのバインディング(読み取り専用、プロファイル設定でユーザーが管理)",
"Third-party Payment Config": "サードパーティ決済設定",
"This action cannot be undone.": "この操作は元に戻せません。",
......@@ -4336,6 +4366,7 @@
"This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "タグ \"{{tag}}\" の {{count}} 件すべてのチャネルの優先度を {{value}} に更新します。続行しますか?",
"This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "タグ \"{{tag}}\" の {{count}} 件すべてのチャネルの重みを {{value}} に更新します。続行しますか?",
"This year": "今年",
"Thought for {{duration}} seconds": "{{duration}} 秒間思考しました",
"Three steps to get started": "3ステップで始める",
"Throughput": "スループット",
"Throughput by group": "グループ別スループット",
......@@ -4359,6 +4390,7 @@
"Timeline": "タイムライン",
"times": "回",
"Timing": "所要時間",
"Tip": "ヒント",
"to access this resource.": "このリソースにアクセスするには。",
"to confirm": "確認する",
"To Lower": "小文字に変換",
......
......@@ -280,6 +280,7 @@
"All models in use are properly configured.": "Все используемые модели настроены правильно.",
"All Must Match (AND)": "Все должны совпасть (AND)",
"All nodes": "Все узлы",
"All playground messages saved in this browser will be removed. This cannot be undone.": "Все сообщения Playground, сохраненные в этом браузере, будут удалены. Это действие нельзя отменить.",
"All requests must include": "Все запросы должны содержать",
"All Status": "Все статусы",
"All Sync Status": "Все статусы синхронизации",
......@@ -340,6 +341,8 @@
"Amount the user pays to purchase this plan; the actual currency depends on the payment gateway.": "Сумма, которую пользователь платит за покупку тарифа; фактическая валюта зависит от платёжного шлюза.",
"Amount to pay:": "Сумма к оплате:",
"An unexpected error occurred": "Произошла непредвиденная ошибка",
"An unknown error occurred": "Произошла неизвестная ошибка",
"Analyze data": "Анализировать данные",
"and": "и",
"Announcement added. Click \"Save Settings\" to apply.": "Объявление добавлено. Нажмите \"Сохранить настройки\", чтобы применить.",
"Announcement content": "Содержимое объявления",
......@@ -526,6 +529,7 @@
"AZURE_OPENAI_ENDPOINT *": "AZURE_OPENAI_ENDPOINT *",
"Back": "Назад",
"Back to Dashboard": "Вернуться к панели управления",
"Back to footnote {{id}} reference": "Вернуться к ссылке на сноску {{id}}",
"Back to Home": "Вернуться на главную",
"Back to login": "Вернуться к входу",
"Back to Models": "Вернуться к моделям",
......@@ -798,6 +802,8 @@
"Clear All Cache": "Очистить весь кэш",
"Clear all filters": "Очистить все фильтры",
"Clear cache for this rule": "Очистить кэш этого правила",
"Clear chat history": "Очистить историю чата",
"Clear chat history?": "Очистить историю чата?",
"Clear filters": "Очистить фильтры",
"Clear Mapping": "Очистить сопоставление",
"Clear mode flags in prompts": "Очистить флаги режимов в промптах",
......@@ -975,6 +981,7 @@
"Connect": "Подключение",
"Connect through OpenAI, Claude, Gemini, and other compatible API routes": "Подключайтесь через OpenAI, Claude, Gemini и другие совместимые API-маршруты",
"Connected to io.net service normally.": "Соединение с сервисом io.net установлено.",
"Connection closed": "Соединение закрыто",
"Connection error": "Ошибка соединения",
"Connection failed": "Не удалось подключиться",
"Connection successful": "Подключение успешно",
......@@ -1007,6 +1014,7 @@
"Control which models are exposed and which groups may use them.": "Управляйте тем, какие модели доступны и какие группы могут их использовать.",
"Controls how much the model thinks before answering": "Регулирует глубину размышлений модели перед ответом",
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Определяет, требуется ли проверка пользователя (биометрия/PIN) во время процессов Passkey.",
"Conversation cleared": "Диалог очищен",
"Conversion rate from USD to your custom currency": "Курс конвертации из USD в вашу пользовательскую валюту",
"Convert reasoning_content to <think> tag in content": "Преобразовать reasoning_content в тег <think> в content",
"Convert string to lowercase": "Преобразовать строку в нижний регистр",
......@@ -1646,8 +1654,10 @@
"Equals": "Равно",
"Error": "Ошибка",
"Error Code (optional)": "Код ошибки (необязательно)",
"Error establishing connection": "Ошибка при установлении соединения",
"Error Message": "Сообщение об ошибке",
"Error Message (required)": "Сообщение об ошибке (обязательно)",
"Error parsing response data": "Ошибка при разборе данных ответа",
"Error Type (optional)": "Тип ошибки (необязательно)",
"Estimated cost": "Примерная стоимость",
"Estimated quota cost": "Ориентир стоимости квоты",
......@@ -2001,7 +2011,9 @@
"Generating new codes will invalidate all existing backup codes.": "Генерация новых кодов аннулирует все существующие резервные коды.",
"Generating...": "Создание...",
"Generation quality preset": "Пресет качества генерации",
"Generation was interrupted": "Генерация была прервана",
"Generic cache": "Общий кэш",
"Get advice": "Получить совет",
"Get notified when balance falls below this value": "Получать уведомления, когда баланс опускается ниже этого значения",
"Get one here": "Получить здесь",
"Get started": "Начало работы",
......@@ -2175,6 +2187,7 @@
"Image In": "Вход изображения",
"Image input": "Ввод изображения",
"Image input price": "Цена входного изображения",
"Image not available": "Изображение недоступно",
"Image Out": "Выход изображения",
"Image output price": "Цена выходного изображения",
"Image Preview": "Предпросмотр изображения",
......@@ -2182,6 +2195,7 @@
"Image to Video": "Изображение в видео",
"Image Tokens": "Токены изображений",
"Import to CC Switch": "Импорт в CC Switch",
"Important": "Важно",
"In Progress": "Выполняется",
"In:": "Вх:",
"incident": "инцидент",
......@@ -2384,6 +2398,7 @@
"Loading channel details": "Загрузка сведений о канале",
"Loading configuration": "Загрузка конфигурации",
"Loading content settings...": "Загрузка настроек контента...",
"Loading conversation...": "Загрузка диалога...",
"Loading current models...": "Загрузка текущих моделей...",
"Loading failed": "Ошибка загрузки",
"Loading maintenance settings...": "Загрузка настроек обслуживания...",
......@@ -2673,6 +2688,7 @@
"Needs API key": "Нужен API-ключ",
"Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "Вложенный JSON, определяющий правила для каждой группы для добавления (+:), удаления (-:) или добавления используемых групп.",
"Nested JSON: source group →": "Вложенный JSON: исходная группа →",
"Network connection failed or server not responding": "Сбой сетевого подключения или сервер не отвечает",
"Network proxy for this channel (supports socks5 protocol)": "Сетевой прокси для этого канала (поддерживает протокол socks5)",
"Never": "Никогда",
"Never expires": "Никогда не истекает",
......@@ -2736,6 +2752,7 @@
"No conflicts match your search.": "Конфликты, соответствующие вашему поиску, не найдены.",
"No console output": "Нет вывода консоли",
"No containers": "Нет контейнеров",
"No content to copy": "Нет содержимого для копирования",
"No custom OAuth providers configured yet.": "Пользовательские поставщики OAuth еще не настроены.",
"No data": "Нет данных",
"No Data": "Нет данных",
......@@ -2879,6 +2896,7 @@
"Not tested": "Не протестировано",
"Not used for upstream training by default": "По умолчанию не используется для обучения у поставщика",
"Not used yet": "Ещё не использовано",
"Note": "Примечание",
"Notice": "Уведомления",
"Notification Email": "Электронная почта для уведомлений",
"Notification Method": "Метод уведомления",
......@@ -3245,6 +3263,7 @@
"Please wait a moment before trying again.": "Пожалуйста, подождите немного и попробуйте снова.",
"Please wait a moment, human check is initializing...": "Пожалуйста, подождите немного, инициализация проверки человеком...",
"Please wait before editing to avoid overwriting saved values.": "Дождитесь загрузки перед редактированием, чтобы не перезаписать сохраненные значения.",
"Please wait for the current generation to complete": "Дождитесь завершения текущей генерации",
"Policy JSON": "JSON политики",
"Polling": "Опрос",
"Polling mode requires Redis and memory cache, otherwise performance will be significantly degraded": "Режим опроса требует Redis и кэш памяти, в противном случае производительность будет значительно снижена",
......@@ -3440,6 +3459,7 @@
"Raw expression": "Исходное выражение",
"Raw JSON": "Сырой JSON",
"Raw Quota": "Исходная квота",
"Raw response": "Исходный ответ",
"Re-enable on success": "Повторно включить при успехе",
"Re-login": "Повторный вход",
"Read channels": "Чтение каналов",
......@@ -3578,6 +3598,7 @@
"Request conversion": "Преобразование запроса",
"Request Conversion": "Конвертация запроса",
"Request Count": "Количество запросов",
"Request error occurred": "Произошла ошибка запроса",
"Request failed": "Запрос не выполнен",
"Request flow": "Поток запросов",
"Request Header Field": "Поле заголовка запроса",
......@@ -3649,6 +3670,7 @@
"Resetting...": "Сброс...",
"Resolve Conflicts": "Разрешить конфликты",
"Resource Configuration": "Конфигурация ресурсов",
"Responding...": "Отвечаем...",
"Resources": "Ресурсы",
"Response": "Ответ",
"Response Time": "Время ответа",
......@@ -3727,6 +3749,7 @@
"Sampling temperature; lower is more deterministic": "Температура сэмплирования; чем ниже, тем детерминированнее",
"Sandbox mode": "Режим песочницы",
"Save": "Сохранить",
"Save & Submit": "Сохранить и отправить",
"Save all settings": "Сохранить все настройки",
"Save Backup Codes": "Сохранить резервные коды",
"Save changes": "Сохранить изменения",
......@@ -3959,9 +3982,11 @@
"Show all providers including unbound": "Показать всех провайдеров (включая непривязанные)",
"Show only bound providers": "Показать только привязанных провайдеров",
"Show or hide flow columns": "Показать или скрыть столбцы потока",
"Show preview": "Показать предпросмотр",
"Show prices in currency instead of quota.": "Показывать цены в валюте вместо квоты.",
"Show sensitive data": "Показать конфиденциальные данные",
"Show setup guide": "Показать руководство по настройке",
"Show source": "Показать исходный текст",
"Show token usage statistics in the UI": "Показывать статистику использования токенов в пользовательском интерфейсе",
"Showcase core capabilities with demo credentials and limited access.": "Демонстрация основных возможностей с демо-учётными данными и ограниченным доступом.",
"Showing": "Отображать",
......@@ -4037,6 +4062,7 @@
"Standard price": "Стандартная цена",
"Start": "Начало",
"Start a conversation to see messages here": "Начните разговор, чтобы увидеть сообщения здесь",
"Start a playground chat": "Начните чат в Playground",
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "Начните принимать платежи по всему миру без регистрации компании. Подходит для независимых разработчиков, индивидуальных предпринимателей OPC и стартапов. Waffo Pancake выступает как Merchant of Record и берет на себя комплаенс глобального приема платежей: потребительские налоги, выставление счетов, управление подписками, возвраты и чарджбеки. Одиночные разработчики могут быстро запуститься и сосредоточиться на продукте, а не на комплаенсе. Подключение за минуты — от одного запроса до полной интеграции.",
"Start for free with generous limits. No credit card required.": "Начните бесплатно с щедрыми лимитами. Кредитная карта не требуется.",
"Start Time": "Время начала",
......@@ -4118,6 +4144,7 @@
"Successfully enabled {{count}} model(s)": "Успешно включено {{count}} моделей",
"Suffix": "Суффикс",
"Suffix Match": "Совпадение по суффиксу",
"Summarize text": "Кратко изложить текст",
"SunoAPI": "SunoAPI",
"Sunset Glow": "Закатное сияние",
"Super Admin": "Суперадмин",
......@@ -4132,6 +4159,7 @@
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "Поддерживает HTML-разметку или встраивание iframe. Введите HTML-код напрямую или укажите полный URL для автоматического встраивания в виде iframe.",
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "Поддерживает настройку в один клик и идеально адаптируется к многопротокольной конфигурации NewAPI.",
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "Поддерживаются PNG, JPG, SVG или WebP. Рекомендуемый размер: 128×128 или меньше.",
"Surprise me": "Удиви меня",
"Sustained tokens per second": "Устойчивая скорость токенов в секунду",
"Swap Face": "Замена лица",
"Switch affinity on success": "Переключить привязку при успехе",
......@@ -4216,6 +4244,7 @@
"Test": "Проверить",
"Test {{count}} matching models": "Проверить совпадающие модели: {{count}}",
"Test {{count}} selected": "Проверить {{count}} выбранных",
"Test a model with a starter prompt, or write your own request below.": "Проверьте модель с начальным промптом или напишите собственный запрос ниже.",
"Test all {{count}} models": "Проверить все модели: {{count}}",
"Test All Channels": "Проверить все каналы",
"Test Channel Connection": "Проверить подключение канала",
......@@ -4280,6 +4309,7 @@
"These toggles affect whether certain request fields are passed through to the upstream provider.": "Эти переключатели влияют на то, передаются ли определенные поля запроса вышестоящему поставщику.",
"Thinking Suffix Adapter": "Адаптер суффикса thinking",
"Thinking to Content": "Мышление в контент",
"Thinking...": "Размышление...",
"Third-party account bindings (read-only, managed by user in profile settings)": "Привязки сторонних учетных записей (только для чтения, управляется пользователем в настройках профиля)",
"Third-party Payment Config": "Настройка стороннего платежа",
"This action cannot be undone.": "Это действие невозможно отменить.",
......@@ -4336,6 +4366,7 @@
"This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "Приоритет всех каналов ({{count}}) с тегом \"{{tag}}\" будет изменен на {{value}}. Продолжить?",
"This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "Вес всех каналов ({{count}}) с тегом \"{{tag}}\" будет изменен на {{value}}. Продолжить?",
"This year": "Этот год",
"Thought for {{duration}} seconds": "Размышлял {{duration}} сек.",
"Three steps to get started": "Три шага для начала работы",
"Throughput": "Пропускная способность",
"Throughput by group": "Пропускная способность по группам",
......@@ -4359,6 +4390,7 @@
"Timeline": "Хронология",
"times": "раз",
"Timing": "Время",
"Tip": "Совет",
"to access this resource.": "для доступа к этому ресурсу.",
"to confirm": "для подтверждения",
"To Lower": "В нижний регистр",
......
......@@ -280,6 +280,7 @@
"All models in use are properly configured.": "Tất cả các mô hình đang được sử dụng đều được cấu hình đúng cách.",
"All Must Match (AND)": "Tất cả phải khớp (AND)",
"All nodes": "Tất cả nút",
"All playground messages saved in this browser will be removed. This cannot be undone.": "Tất cả tin nhắn Playground đã lưu trong trình duyệt này sẽ bị xóa. Không thể hoàn tác hành động này.",
"All requests must include": "Mọi yêu cầu phải có header",
"All Status": "Tất cả trạng thái",
"All Sync Status": "Tất cả Trạng thái Đồng bộ",
......@@ -340,6 +341,8 @@
"Amount the user pays to purchase this plan; the actual currency depends on the payment gateway.": "Số tiền người dùng trả để mua gói này; loại tiền tệ thực tế tùy thuộc vào cổng thanh toán.",
"Amount to pay:": "Amount due:",
"An unexpected error occurred": "Đã xảy ra lỗi không mong muốn",
"An unknown error occurred": "Đã xảy ra lỗi không xác định",
"Analyze data": "Phân tích dữ liệu",
"and": "and",
"Announcement added. Click \"Save Settings\" to apply.": "Đã thêm thông báo. Nhấp \"Save Settings\" để áp dụng.",
"Announcement content": "Nội dung thông báo",
......@@ -526,6 +529,7 @@
"AZURE_OPENAI_ENDPOINT *": "AZURE_OPENAI_ENDPOINT *",
"Back": "Quay lại",
"Back to Dashboard": "Quay lại Bảng điều khiển",
"Back to footnote {{id}} reference": "Quay lại tham chiếu chú thích {{id}}",
"Back to Home": "Trở về Trang chủ",
"Back to login": "Quay lại đăng nhập",
"Back to Models": "Quay lại Mô hình",
......@@ -798,6 +802,8 @@
"Clear All Cache": "Xóa toàn bộ bộ nhớ đệm",
"Clear all filters": "Xóa tất cả bộ lọc",
"Clear cache for this rule": "Xóa bộ nhớ đệm của quy tắc này",
"Clear chat history": "Xóa lịch sử trò chuyện",
"Clear chat history?": "Xóa lịch sử trò chuyện?",
"Clear filters": "Clear filter",
"Clear Mapping": "Xóa Ánh xạ",
"Clear mode flags in prompts": "Xóa các cờ chế độ trong lời nhắc",
......@@ -975,6 +981,7 @@
"Connect": "Kết nối",
"Connect through OpenAI, Claude, Gemini, and other compatible API routes": "Kết nối qua OpenAI, Claude, Gemini và các tuyến API tương thích khác",
"Connected to io.net service normally.": "Đã kết nối bình thường tới dịch vụ io.net.",
"Connection closed": "Kết nối đã đóng",
"Connection error": "Lỗi kết nối",
"Connection failed": "Kết nối thất bại",
"Connection successful": "Kết nối thành công",
......@@ -1007,6 +1014,7 @@
"Control which models are exposed and which groups may use them.": "Kiểm soát mô hình được hiển thị và nhóm nào có thể sử dụng chúng.",
"Controls how much the model thinks before answering": "Điều chỉnh mức suy luận trước khi trả lời",
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "Kiểm soát xem liệu có yêu cầu xác minh người dùng (sinh trắc học/mã PIN) trong các luồng Passkey hay không.",
"Conversation cleared": "Đã xóa cuộc trò chuyện",
"Conversion rate from USD to your custom currency": "Tỷ giá chuyển đổi từ USD sang đơn vị tiền tệ tùy chỉnh của bạn",
"Convert reasoning_content to <think> tag in content": "Chuyển đổi reasoning_content thành thẻ <think> trong nội dung",
"Convert string to lowercase": "Chuyển chuỗi sang chữ thường",
......@@ -1646,8 +1654,10 @@
"Equals": "Bằng",
"Error": "Lỗi",
"Error Code (optional)": "Mã lỗi (tùy chọn)",
"Error establishing connection": "Lỗi khi thiết lập kết nối",
"Error Message": "Thông báo lỗi",
"Error Message (required)": "Thông báo lỗi (bắt buộc)",
"Error parsing response data": "Lỗi khi phân tích dữ liệu phản hồi",
"Error Type (optional)": "Loại lỗi (tùy chọn)",
"Estimated cost": "Chi phí ước tính",
"Estimated quota cost": "Ước tính chi phí hạn mức",
......@@ -2001,7 +2011,9 @@
"Generating new codes will invalidate all existing backup codes.": "Tạo mã mới sẽ vô hiệu hóa tất cả các mã dự phòng hiện có.",
"Generating...": "Đang tạo...",
"Generation quality preset": "Mức chất lượng sinh",
"Generation was interrupted": "Quá trình tạo đã bị gián đoạn",
"Generic cache": "Bộ đệm chung",
"Get advice": "Nhận lời khuyên",
"Get notified when balance falls below this value": "Nhận thông báo khi số dư giảm xuống dưới giá trị này",
"Get one here": "Nhận tại đây",
"Get started": "Bắt đầu",
......@@ -2175,6 +2187,7 @@
"Image In": "Ảnh vào",
"Image input": "Đầu vào hình ảnh",
"Image input price": "Giá đầu vào hình ảnh",
"Image not available": "Hình ảnh không khả dụng",
"Image Out": "Ảnh ra",
"Image output price": "Giá đầu ra hình ảnh",
"Image Preview": "Xem trước ảnh",
......@@ -2182,6 +2195,7 @@
"Image to Video": "Ảnh sang video",
"Image Tokens": "Token hình ảnh",
"Import to CC Switch": "Nhập vào CC Switch",
"Important": "Quan trọng",
"In Progress": "Đang xử lý",
"In:": "Vào:",
"incident": "sự cố",
......@@ -2384,6 +2398,7 @@
"Loading channel details": "Đang tải chi tiết kênh",
"Loading configuration": "Đang tải cấu hình",
"Loading content settings...": "Đang tải cài đặt nội dung...",
"Loading conversation...": "Đang tải cuộc trò chuyện...",
"Loading current models...": "Đang tải các mô hình hiện tại...",
"Loading failed": "Tải thất bại",
"Loading maintenance settings...": "Đang tải cài đặt bảo trì...",
......@@ -2673,6 +2688,7 @@
"Needs API key": "Cần khóa API",
"Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "JSON lồng nhau xác định quy tắc theo nhóm để thêm (+:), xóa (-:), hoặc nối các nhóm có thể sử dụng.",
"Nested JSON: source group →": "JSON lồng nhau: nhóm nguồn →",
"Network connection failed or server not responding": "Kết nối mạng thất bại hoặc máy chủ không phản hồi",
"Network proxy for this channel (supports socks5 protocol)": "Proxy mạng cho kênh này (hỗ trợ giao thức socks5)",
"Never": "Không bao giờ",
"Never expires": "Không hết hạn",
......@@ -2736,6 +2752,7 @@
"No conflicts match your search.": "Không có xung đột nào khớp với tìm kiếm của bạn.",
"No console output": "Không có đầu ra console",
"No containers": "Không có container",
"No content to copy": "Không có nội dung để sao chép",
"No custom OAuth providers configured yet.": "Chưa có nhà cung cấp OAuth tùy chỉnh nào được cấu hình.",
"No data": "Không có dữ liệu",
"No Data": "Không có dữ liệu",
......@@ -2879,6 +2896,7 @@
"Not tested": "Chưa kiểm tra",
"Not used for upstream training by default": "Mặc định không dùng để huấn luyện ở phía nhà cung cấp",
"Not used yet": "Chưa sử dụng",
"Note": "Ghi chú",
"Notice": "Thông báo",
"Notification Email": "Email thông báo",
"Notification Method": "Phương thức thông báo",
......@@ -3245,6 +3263,7 @@
"Please wait a moment before trying again.": "Vui lòng chờ một lát rồi thử lại.",
"Please wait a moment, human check is initializing...": "Vui lòng đợi một chút, kiểm tra con người đang khởi tạo...",
"Please wait before editing to avoid overwriting saved values.": "Vui lòng chờ trước khi chỉnh sửa để tránh ghi đè các giá trị đã lưu.",
"Please wait for the current generation to complete": "Vui lòng đợi lượt tạo hiện tại hoàn tất",
"Policy JSON": "JSON chính sách",
"Polling": "Thăm dò",
"Polling mode requires Redis and memory cache, otherwise performance will be significantly degraded": "Chế độ thăm dò yêu cầu Redis và bộ nhớ đệm, nếu không hiệu suất sẽ bị suy giảm đáng kể.",
......@@ -3440,6 +3459,7 @@
"Raw expression": "Biểu thức gốc",
"Raw JSON": "JSON thô",
"Raw Quota": "Hạn mức gốc",
"Raw response": "Phản hồi thô",
"Re-enable on success": "Kích hoạt lại khi thành công",
"Re-login": "Đăng nhập lại",
"Read channels": "Đọc kênh",
......@@ -3578,6 +3598,7 @@
"Request conversion": "Chuyển đổi yêu cầu",
"Request Conversion": "Chuyển đổi yêu cầu",
"Request Count": "Number of requests",
"Request error occurred": "Đã xảy ra lỗi yêu cầu",
"Request failed": "Yêu cầu thất bại",
"Request flow": "Luồng yêu cầu",
"Request Header Field": "Trường header yêu cầu",
......@@ -3649,6 +3670,7 @@
"Resetting...": "Đang đặt lại...",
"Resolve Conflicts": "Giải quyết Xung đột",
"Resource Configuration": "Cấu hình tài nguyên",
"Responding...": "Đang phản hồi...",
"Resources": "Tài nguyên",
"Response": "Phản hồi",
"Response Time": "Thời gian phản hồi",
......@@ -3727,6 +3749,7 @@
"Sampling temperature; lower is more deterministic": "Nhiệt độ lấy mẫu; càng thấp càng ổn định",
"Sandbox mode": "Chế độ sandbox",
"Save": "Lưu",
"Save & Submit": "Lưu và gửi",
"Save all settings": "Lưu tất cả cài đặt",
"Save Backup Codes": "Lưu mã dự phòng",
"Save changes": "Lưu thay đổi",
......@@ -3959,9 +3982,11 @@
"Show all providers including unbound": "Hiển thị tất cả nhà cung cấp (bao gồm chưa liên kết)",
"Show only bound providers": "Chỉ hiển thị nhà cung cấp đã liên kết",
"Show or hide flow columns": "Hiện hoặc ẩn các cột luồng",
"Show preview": "Hiển thị bản xem trước",
"Show prices in currency instead of quota.": "Hiển thị giá bằng tiền tệ thay vì hạn ngạch.",
"Show sensitive data": "Hiển thị dữ liệu nhạy cảm",
"Show setup guide": "Hiển thị hướng dẫn thiết lập",
"Show source": "Hiển thị nguồn",
"Show token usage statistics in the UI": "Hiển thị thống kê sử dụng token trong giao diện người dùng",
"Showcase core capabilities with demo credentials and limited access.": "Trình diễn các tính năng cốt lõi với thông tin đăng nhập demo và quyền truy cập hạn chế.",
"Showing": "Đang hiển thị",
......@@ -4037,6 +4062,7 @@
"Standard price": "Giá tiêu chuẩn",
"Start": "Bắt đầu",
"Start a conversation to see messages here": "Bắt đầu một cuộc trò chuyện để xem tin nhắn tại đây",
"Start a playground chat": "Bắt đầu cuộc trò chuyện trong playground",
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "Bắt đầu thu thanh toán toàn cầu mà không cần đăng ký công ty. Dành cho lập trình viên độc lập, chủ sở hữu OPC và startup. Waffo Pancake đóng vai trò Merchant of Record, chịu trách nhiệm tuân thủ cho việc thu thanh toán toàn cầu — thuế tiêu dùng, hóa đơn, quản lý đăng ký, hoàn tiền và tranh chấp thanh toán. Lập trình viên cá nhân có thể ra mắt nhanh và tập trung vào sản phẩm thay vì tuân thủ. Onboard trong vài phút — từ một prompt đến tích hợp hoàn chỉnh.",
"Start for free with generous limits. No credit card required.": "Bắt đầu miễn phí với giới hạn hào phóng. Không cần thẻ tín dụng.",
"Start Time": "Thời gian bắt đầu",
......@@ -4118,6 +4144,7 @@
"Successfully enabled {{count}} model(s)": "Đã bật thành công {{count}} mô hình",
"Suffix": "Hậu tố",
"Suffix Match": "Khớp hậu tố",
"Summarize text": "Tóm tắt văn bản",
"SunoAPI": "SunoAPI",
"Sunset Glow": "Hoàng hôn",
"Super Admin": "Siêu Quản trị viên",
......@@ -4132,6 +4159,7 @@
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "Hỗ trợ đánh dấu HTML hoặc nhúng iframe. Nhập mã HTML trực tiếp, hoặc cung cấp một URL đầy đủ để tự động nhúng nó dưới dạng một iframe.",
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "Hỗ trợ cấu hình bằng một cú nhấp chuột và thích ứng hoàn hảo với cấu hình đa giao thức NewAPI.",
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "Hỗ trợ PNG, JPG, SVG hoặc WebP. Kích thước khuyến nghị: 128×128 hoặc nhỏ hơn.",
"Surprise me": "Gợi ý bất ngờ",
"Sustained tokens per second": "Token mỗi giây duy trì",
"Swap Face": "Đổi mặt",
"Switch affinity on success": "Chuyển ưu tiên khi thành công",
......@@ -4216,6 +4244,7 @@
"Test": "Kiểm tra",
"Test {{count}} matching models": "Kiểm thử {{count}} mô hình phù hợp",
"Test {{count}} selected": "Kiểm tra {{count}} mục đã chọn",
"Test a model with a starter prompt, or write your own request below.": "Kiểm thử mô hình bằng prompt gợi ý, hoặc viết yêu cầu của riêng bạn bên dưới.",
"Test all {{count}} models": "Kiểm thử tất cả {{count}} mô hình",
"Test All Channels": "Kiểm tra tất cả các kênh",
"Test Channel Connection": "Kiểm tra kết nối kênh",
......@@ -4280,6 +4309,7 @@
"These toggles affect whether certain request fields are passed through to the upstream provider.": "Các chuyển đổi này ảnh hưởng đến việc các trường yêu cầu nhất định có được chuyển đến nhà cung cấp dịch vụ đầu vào hay không.",
"Thinking Suffix Adapter": "Adapter hậu tố thinking",
"Thinking to Content": "Suy nghĩ thành Nội dung",
"Thinking...": "Đang suy nghĩ...",
"Third-party account bindings (read-only, managed by user in profile settings)": "Liên kết tài khoản bên thứ ba (chỉ đọc, do người dùng quản lý trong cài đặt hồ sơ)",
"Third-party Payment Config": "Cấu hình thanh toán bên thứ ba",
"This action cannot be undone.": "Hành động này không thể hoàn tác.",
......@@ -4336,6 +4366,7 @@
"This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "Thao tác này sẽ cập nhật mức ưu tiên thành {{value}} cho tất cả {{count}} kênh có thẻ \"{{tag}}\". Tiếp tục?",
"This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "Thao tác này sẽ cập nhật trọng số thành {{value}} cho tất cả {{count}} kênh có thẻ \"{{tag}}\". Tiếp tục?",
"This year": "Năm nay",
"Thought for {{duration}} seconds": "Đã suy nghĩ trong {{duration}} giây",
"Three steps to get started": "Ba bước để bắt đầu",
"Throughput": "Thông lượng",
"Throughput by group": "Thông lượng theo nhóm",
......@@ -4359,6 +4390,7 @@
"Timeline": "Dòng thời gian",
"times": "lần",
"Timing": "Thời gian",
"Tip": "Mẹo",
"to access this resource.": "để truy cập tài nguyên này.",
"to confirm": "Chờ xác nhận",
"To Lower": "Chữ thường",
......
......@@ -280,6 +280,7 @@
"All models in use are properly configured.": "所有正在使用的模型都已正确配置。",
"All Must Match (AND)": "全部满足(AND)",
"All nodes": "全部节点",
"All playground messages saved in this browser will be removed. This cannot be undone.": "保存在此浏览器中的所有游乐场消息都将被移除。此操作无法撤销。",
"All requests must include": "所有请求必须携带",
"All Status": "所有状态",
"All Sync Status": "所有同步状态",
......@@ -340,6 +341,8 @@
"Amount the user pays to purchase this plan; the actual currency depends on the payment gateway.": "用户购买该套餐需支付的金额,具体币种由支付渠道决定",
"Amount to pay:": "待支付金额:",
"An unexpected error occurred": "发生意外错误",
"An unknown error occurred": "发生未知错误",
"Analyze data": "分析数据",
"and": "和",
"Announcement added. Click \"Save Settings\" to apply.": "公告已添加。点击 \"保存设置\" 以应用。",
"Announcement content": "公告内容",
......@@ -526,6 +529,7 @@
"AZURE_OPENAI_ENDPOINT *": "AZURE_OPENAI_ENDPOINT *",
"Back": "返回",
"Back to Dashboard": "返回控制台",
"Back to footnote {{id}} reference": "返回脚注 {{id}} 引用处",
"Back to Home": "返回主页",
"Back to login": "返回登录",
"Back to Models": "返回模型",
......@@ -798,6 +802,8 @@
"Clear All Cache": "清空全部缓存",
"Clear all filters": "清除所有筛选",
"Clear cache for this rule": "清空该规则缓存",
"Clear chat history": "清空聊天历史",
"Clear chat history?": "清空聊天历史?",
"Clear filters": "清除筛选器",
"Clear Mapping": "清除映射",
"Clear mode flags in prompts": "在提示中清除模式标志",
......@@ -975,6 +981,7 @@
"Connect": "连接",
"Connect through OpenAI, Claude, Gemini, and other compatible API routes": "通过 OpenAI、Claude、Gemini 以及其他兼容 API 路由接入",
"Connected to io.net service normally.": "已正常连接 io.net 服务。",
"Connection closed": "连接已关闭",
"Connection error": "连接错误",
"Connection failed": "连接失败",
"Connection successful": "连接成功",
......@@ -1007,6 +1014,7 @@
"Control which models are exposed and which groups may use them.": "控制对外暴露的模型,以及哪些分组可以使用它们。",
"Controls how much the model thinks before answering": "控制模型回答前的推理深度",
"Controls whether user verification (biometrics/PIN) is required during Passkey flows.": "控制在通行密钥流程中是否需要用户验证(生物识别/PIN)。",
"Conversation cleared": "对话已清空",
"Conversion rate from USD to your custom currency": "从美元到您的自定义货币的转换率",
"Convert reasoning_content to <think> tag in content": "将 reasoning_content 转换为 content 中的 <think> 标签",
"Convert string to lowercase": "把字符串转成小写",
......@@ -1646,8 +1654,10 @@
"Equals": "等于",
"Error": "错误",
"Error Code (optional)": "错误代码(可选)",
"Error establishing connection": "建立连接失败",
"Error Message": "错误消息",
"Error Message (required)": "错误消息(必填)",
"Error parsing response data": "解析响应数据失败",
"Error Type (optional)": "错误类型(可选)",
"Estimated cost": "预计成本",
"Estimated quota cost": "估算配额费用",
......@@ -2001,7 +2011,9 @@
"Generating new codes will invalidate all existing backup codes.": "生成新代码将使所有现有备份代码失效。",
"Generating...": "生成中...",
"Generation quality preset": "生成质量预设",
"Generation was interrupted": "生成已中断",
"Generic cache": "通用缓存",
"Get advice": "获取建议",
"Get notified when balance falls below this value": "当余额低于此值时接收通知",
"Get one here": "点此获取",
"Get started": "开始使用",
......@@ -2175,6 +2187,7 @@
"Image In": "图像输入",
"Image input": "图片输入",
"Image input price": "图像输入价格",
"Image not available": "图片不可用",
"Image Out": "图像输出",
"Image output price": "图像输出价格",
"Image Preview": "图片预览",
......@@ -2182,6 +2195,7 @@
"Image to Video": "图生视频",
"Image Tokens": "图像 Token",
"Import to CC Switch": "填入 CC Switch",
"Important": "重要",
"In Progress": "进行中",
"In:": "入:",
"incident": "次故障",
......@@ -2384,6 +2398,7 @@
"Loading channel details": "正在加载渠道详情",
"Loading configuration": "正在加载配置",
"Loading content settings...": "正在加载内容设置...",
"Loading conversation...": "正在加载对话...",
"Loading current models...": "正在加载当前模型...",
"Loading failed": "加载失败",
"Loading maintenance settings...": "正在加载维护设置...",
......@@ -2673,6 +2688,7 @@
"Needs API key": "需要 API 密钥",
"Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "嵌套 JSON,定义按分组添加(+:)、移除(-:)或追加可用分组的规则。",
"Nested JSON: source group →": "嵌套 JSON:源分组 →",
"Network connection failed or server not responding": "网络连接失败或服务器未响应",
"Network proxy for this channel (supports socks5 protocol)": "此渠道的网络代理(支持 socks5 协议)",
"Never": "永不",
"Never expires": "永不过期",
......@@ -2736,6 +2752,7 @@
"No conflicts match your search.": "没有冲突匹配您的搜索。",
"No console output": "无控制台输出",
"No containers": "无容器",
"No content to copy": "没有可复制的内容",
"No custom OAuth providers configured yet.": "尚未配置自定义 OAuth 提供商。",
"No data": "暂无数据",
"No Data": "无数据",
......@@ -2879,6 +2896,7 @@
"Not tested": "未测试",
"Not used for upstream training by default": "默认不会用于上游训练",
"Not used yet": "暂未使用",
"Note": "备注",
"Notice": "通知",
"Notification Email": "通知邮箱",
"Notification Method": "通知方式",
......@@ -3245,6 +3263,7 @@
"Please wait a moment before trying again.": "请稍候再试。",
"Please wait a moment, human check is initializing...": "请稍等,人机验证正在初始化...",
"Please wait before editing to avoid overwriting saved values.": "请等待加载完成后再编辑,以免覆盖已保存的值。",
"Please wait for the current generation to complete": "请等待当前生成完成",
"Policy JSON": "策略 JSON",
"Polling": "轮询",
"Polling mode requires Redis and memory cache, otherwise performance will be significantly degraded": "轮询模式需要 Redis 和内存缓存,否则性能将显著下降",
......@@ -3440,6 +3459,7 @@
"Raw expression": "原始表达式",
"Raw JSON": "原始 JSON",
"Raw Quota": "原生额度",
"Raw response": "原始回复",
"Re-enable on success": "成功后重新启用",
"Re-login": "重新登录",
"Read channels": "读取渠道",
......@@ -3578,6 +3598,7 @@
"Request conversion": "请求转换",
"Request Conversion": "请求转换",
"Request Count": "请求计数",
"Request error occurred": "请求发生错误",
"Request failed": "请求失败",
"Request flow": "请求流",
"Request Header Field": "请求头字段",
......@@ -3649,6 +3670,7 @@
"Resetting...": "重置中...",
"Resolve Conflicts": "解决冲突",
"Resource Configuration": "资源配置",
"Responding...": "正在回复...",
"Resources": "资源",
"Response": "响应",
"Response Time": "响应时间",
......@@ -3727,6 +3749,7 @@
"Sampling temperature; lower is more deterministic": "采样温度;越低越稳定",
"Sandbox mode": "沙盒模式",
"Save": "保存",
"Save & Submit": "保存并提交",
"Save all settings": "保存所有设置",
"Save Backup Codes": "保存备份代码",
"Save changes": "保存更改",
......@@ -3959,9 +3982,11 @@
"Show all providers including unbound": "显示所有提供商(包括未绑定)",
"Show only bound providers": "仅显示已绑定的提供商",
"Show or hide flow columns": "显示或隐藏分流列",
"Show preview": "显示预览",
"Show prices in currency instead of quota.": "以货币而非配额显示价格。",
"Show sensitive data": "显示敏感数据",
"Show setup guide": "显示设置引导",
"Show source": "显示源码",
"Show token usage statistics in the UI": "在用户界面中显示令牌使用统计信息",
"Showcase core capabilities with demo credentials and limited access.": "使用演示凭据和有限访问权限展示核心功能。",
"Showing": "显示第",
......@@ -4037,6 +4062,7 @@
"Standard price": "标准价格",
"Start": "开始",
"Start a conversation to see messages here": "开始对话以在此处查看消息",
"Start a playground chat": "开始一场游乐场对话",
"Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "无需注册公司即可开始全球收款。面向独立开发者、OPC 个体经营者和初创团队构建。Waffo Pancake 作为你的登记商户(Merchant of Record),承担全球收款相关的合规负担,包括消费税、开票、订阅管理、退款和拒付。个人开发者可以快速上线,专注产品而不是合规事务。几分钟即可完成入驻,从一个提示词到完整集成。",
"Start for free with generous limits. No credit card required.": "免费开始使用,额度充足,无需绑定信用卡。",
"Start Time": "起始时间",
......@@ -4118,6 +4144,7 @@
"Successfully enabled {{count}} model(s)": "成功启用 {{count}} 个模型",
"Suffix": "后缀",
"Suffix Match": "后缀匹配",
"Summarize text": "总结文本",
"SunoAPI": "SunoAPI",
"Sunset Glow": "日落霞光",
"Super Admin": "超级管理员",
......@@ -4132,6 +4159,7 @@
"Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.": "支持 HTML 标记或 iframe 嵌入。直接输入 HTML 代码,或提供完整的 URL 以将其自动嵌入为 iframe。",
"Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.": "支持一键配置并完美适配 NewAPI 多协议配置",
"Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.": "支持 PNG、JPG、SVG 或 WebP,建议尺寸不超过 128×128。",
"Surprise me": "给我惊喜",
"Sustained tokens per second": "持续每秒 Token 数",
"Swap Face": "换脸",
"Switch affinity on success": "成功后切换亲和",
......@@ -4216,6 +4244,7 @@
"Test": "测试",
"Test {{count}} matching models": "测试 {{count}} 个匹配模型",
"Test {{count}} selected": "测试 {{count}} 个已选择项",
"Test a model with a starter prompt, or write your own request below.": "使用入门提示词测试模型,或在下方编写自己的请求。",
"Test all {{count}} models": "测试全部 {{count}} 个模型",
"Test All Channels": "测试所有渠道",
"Test Channel Connection": "测试渠道连接",
......@@ -4280,6 +4309,7 @@
"These toggles affect whether certain request fields are passed through to the upstream provider.": "这些开关控制某些请求字段是否透传到上游服务。",
"Thinking Suffix Adapter": "思考后缀适配器",
"Thinking to Content": "思维到内容",
"Thinking...": "思考中...",
"Third-party account bindings (read-only, managed by user in profile settings)": "第三方账户绑定(只读,由用户在个人资料设置中管理)",
"Third-party Payment Config": "第三方支付配置",
"This action cannot be undone.": "此操作无法撤消。",
......@@ -4336,6 +4366,7 @@
"This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "这会将标签 \"{{tag}}\" 下所有 {{count}} 个渠道的优先级更新为 {{value}}。继续吗?",
"This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "这会将标签 \"{{tag}}\" 下所有 {{count}} 个渠道的权重更新为 {{value}}。继续吗?",
"This year": "本年",
"Thought for {{duration}} seconds": "思考了 {{duration}} 秒",
"Three steps to get started": "三步快速上手",
"Throughput": "吞吐量",
"Throughput by group": "各分组吞吐量",
......@@ -4359,6 +4390,7 @@
"Timeline": "时间线",
"times": "次",
"Timing": "耗时",
"Tip": "提示",
"to access this resource.": "访问此资源。",
"to confirm": "以确认",
"To Lower": "转小写",
......
......@@ -439,6 +439,20 @@ export const STATIC_I18N_KEYS = [
'Playground',
'AI model testing environment',
'Chat session management',
'No content to copy',
'Please wait for the current generation to complete',
'An unknown error occurred',
'Request error occurred',
'Network connection failed or server not responding',
'Error parsing response data',
'Error establishing connection',
'Connection closed',
'Generation was interrupted',
'Note',
'Tip',
'Important',
'Image not available',
'Back to footnote {{id}} reference',
'Console Area',
'Data management and log viewing',
'Dashboard',
......
......@@ -32,6 +32,13 @@ For commercial licensing, please contact support@quantumnous.com
/* Shiki dual themes: token colors follow dark theme (pre background stays `bg-background` on the block) */
@layer components {
.shiki span {
color: var(--shiki-light) !important;
font-style: var(--shiki-light-font-style) !important;
font-weight: var(--shiki-light-font-weight) !important;
text-decoration: var(--shiki-light-text-decoration) !important;
}
.dark .shiki span {
color: var(--shiki-dark) !important;
font-style: var(--shiki-dark-font-style) !important;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment