From 966af88ec343a72d2617be2e92036c5eefe61a19 Mon Sep 17 00:00:00 2001 From: QuentinHsu Date: Sat, 27 Jun 2026 17:23:25 +0800 Subject: [PATCH] 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. --- controller/model_list_test.go | 49 ++ controller/user.go | 19 + web/bun.lock | 89 ++- web/default/package.json | 7 +- web/default/rsbuild.config.ts | 7 +- .../src/components/ai-elements/code-block.tsx | 628 +++++++++++++++--- .../components/ai-elements/conversation.tsx | 7 +- .../src/components/ai-elements/reasoning.tsx | 60 +- .../ai-elements/response-content.ts | 188 ++++++ .../ai-elements/response-node-guards.ts | 98 +++ .../ai-elements/response-renderer-alert.tsx | 168 +++++ .../ai-elements/response-renderer-blocks.tsx | 213 ++++++ .../ai-elements/response-renderer-details.tsx | 64 ++ .../response-renderer-footnotes.tsx | 55 ++ .../ai-elements/response-renderer-image.tsx | 50 ++ .../ai-elements/response-renderer-inline.tsx | 59 ++ .../ai-elements/response-renderer-table.tsx | 99 +++ .../ai-elements/response-renderer.tsx | 227 +++++++ .../components/ai-elements/response-types.ts | 45 ++ .../src/components/ai-elements/response.tsx | 70 +- .../src/components/ai-elements/sources.tsx | 7 +- .../src/components/model-group-selector.tsx | 465 ++++++++----- web/default/src/features/playground/api.ts | 11 +- .../components/chat/playground-chat.tsx | 223 +++++++ .../chat/playground-empty-state.tsx | 84 +++ .../input/playground-input-controls.tsx | 125 ++++ .../input/playground-input-tools.tsx | 169 +++++ .../components/input/playground-input.tsx | 120 ++++ .../playground/components/message-actions.tsx | 128 ---- .../{ => message}/message-action-button.tsx | 2 +- .../components/message/message-actions.tsx | 221 ++++++ .../message/message-error-actions.tsx | 74 +++ .../{ => message}/message-error.tsx | 53 +- .../components/message/message-metadata.tsx | 84 +++ .../message/playground-message-content.tsx | 167 +++++ .../message/playground-message-editor.tsx | 155 +++++ .../playground/components/playground-chat.tsx | 291 -------- .../components/playground-input.tsx | 239 ------- .../src/features/playground/constants.ts | 2 + .../src/features/playground/hooks/index.ts | 2 + .../playground/hooks/use-chat-handler.ts | 246 +++++-- .../hooks/use-message-action-guard.ts | 6 +- .../hooks/use-playground-conversation.ts | 116 ++++ .../hooks/use-playground-options.ts | 125 ++++ .../playground/hooks/use-playground-state.ts | 94 ++- .../playground/hooks/use-stream-request.ts | 95 ++- web/default/src/features/playground/index.tsx | 190 ++---- .../src/features/playground/lib/index.ts | 24 +- .../lib/input/input-control-utils.ts | 68 ++ .../playground/lib/input/input-tool-utils.ts | 60 ++ .../features/playground/lib/message-utils.ts | 355 ---------- .../lib/message/conversation-message-utils.ts | 159 +++++ .../lib/message/message-action-utils.ts | 49 ++ .../lib/message/message-content-utils.ts | 115 ++++ .../lib/message/message-editor-utils.ts | 41 ++ .../lib/message/message-error-utils.ts | 59 ++ .../lib/message/message-layout-utils.ts | 39 ++ .../lib/message/message-reasoning-utils.ts | 71 ++ .../lib/message/message-streaming-utils.ts | 256 +++++++ .../lib/{ => message}/message-styles.ts | 34 +- .../lib/message/message-timing-utils.ts | 75 +++ .../lib/message/message-update-utils.ts | 63 ++ .../playground/lib/message/message-utils.ts | 176 +++++ .../lib/options/playground-option-utils.ts | 65 ++ .../lib/state/playground-state-utils.ts | 44 ++ .../src/features/playground/lib/storage.ts | 133 ---- .../playground/lib/storage/storage-schema.ts | 91 +++ .../playground/lib/storage/storage.ts | 398 +++++++++++ .../lib/{ => streaming}/payload-builder.ts | 43 +- .../lib/streaming/request-error-utils.ts | 48 ++ .../playground/lib/streaming/stream-utils.ts | 112 ++++ web/default/src/features/playground/types.ts | 9 + web/default/src/i18n/locales/en.json | 32 + web/default/src/i18n/locales/fr.json | 32 + web/default/src/i18n/locales/ja.json | 32 + web/default/src/i18n/locales/ru.json | 32 + web/default/src/i18n/locales/vi.json | 32 + web/default/src/i18n/locales/zh.json | 32 + web/default/src/i18n/static-keys.ts | 14 + web/default/src/styles/index.css | 7 + 80 files changed, 6695 insertions(+), 1801 deletions(-) create mode 100644 web/default/src/components/ai-elements/response-content.ts create mode 100644 web/default/src/components/ai-elements/response-node-guards.ts create mode 100644 web/default/src/components/ai-elements/response-renderer-alert.tsx create mode 100644 web/default/src/components/ai-elements/response-renderer-blocks.tsx create mode 100644 web/default/src/components/ai-elements/response-renderer-details.tsx create mode 100644 web/default/src/components/ai-elements/response-renderer-footnotes.tsx create mode 100644 web/default/src/components/ai-elements/response-renderer-image.tsx create mode 100644 web/default/src/components/ai-elements/response-renderer-inline.tsx create mode 100644 web/default/src/components/ai-elements/response-renderer-table.tsx create mode 100644 web/default/src/components/ai-elements/response-renderer.tsx create mode 100644 web/default/src/components/ai-elements/response-types.ts create mode 100644 web/default/src/features/playground/components/chat/playground-chat.tsx create mode 100644 web/default/src/features/playground/components/chat/playground-empty-state.tsx create mode 100644 web/default/src/features/playground/components/input/playground-input-controls.tsx create mode 100644 web/default/src/features/playground/components/input/playground-input-tools.tsx create mode 100644 web/default/src/features/playground/components/input/playground-input.tsx delete mode 100644 web/default/src/features/playground/components/message-actions.tsx rename web/default/src/features/playground/components/{ => message}/message-action-button.tsx (96%) create mode 100644 web/default/src/features/playground/components/message/message-actions.tsx create mode 100644 web/default/src/features/playground/components/message/message-error-actions.tsx rename web/default/src/features/playground/components/{ => message}/message-error.tsx (65%) create mode 100644 web/default/src/features/playground/components/message/message-metadata.tsx create mode 100644 web/default/src/features/playground/components/message/playground-message-content.tsx create mode 100644 web/default/src/features/playground/components/message/playground-message-editor.tsx delete mode 100644 web/default/src/features/playground/components/playground-chat.tsx delete mode 100644 web/default/src/features/playground/components/playground-input.tsx create mode 100644 web/default/src/features/playground/hooks/use-playground-conversation.ts create mode 100644 web/default/src/features/playground/hooks/use-playground-options.ts create mode 100644 web/default/src/features/playground/lib/input/input-control-utils.ts create mode 100644 web/default/src/features/playground/lib/input/input-tool-utils.ts delete mode 100644 web/default/src/features/playground/lib/message-utils.ts create mode 100644 web/default/src/features/playground/lib/message/conversation-message-utils.ts create mode 100644 web/default/src/features/playground/lib/message/message-action-utils.ts create mode 100644 web/default/src/features/playground/lib/message/message-content-utils.ts create mode 100644 web/default/src/features/playground/lib/message/message-editor-utils.ts create mode 100644 web/default/src/features/playground/lib/message/message-error-utils.ts create mode 100644 web/default/src/features/playground/lib/message/message-layout-utils.ts create mode 100644 web/default/src/features/playground/lib/message/message-reasoning-utils.ts create mode 100644 web/default/src/features/playground/lib/message/message-streaming-utils.ts rename web/default/src/features/playground/lib/{ => message}/message-styles.ts (62%) create mode 100644 web/default/src/features/playground/lib/message/message-timing-utils.ts create mode 100644 web/default/src/features/playground/lib/message/message-update-utils.ts create mode 100644 web/default/src/features/playground/lib/message/message-utils.ts create mode 100644 web/default/src/features/playground/lib/options/playground-option-utils.ts create mode 100644 web/default/src/features/playground/lib/state/playground-state-utils.ts delete mode 100644 web/default/src/features/playground/lib/storage.ts create mode 100644 web/default/src/features/playground/lib/storage/storage-schema.ts create mode 100644 web/default/src/features/playground/lib/storage/storage.ts rename web/default/src/features/playground/lib/{ => streaming}/payload-builder.ts (68%) create mode 100644 web/default/src/features/playground/lib/streaming/request-error-utils.ts create mode 100644 web/default/src/features/playground/lib/streaming/stream-utils.ts diff --git a/controller/model_list_test.go b/controller/model_list_test.go index 4819c6fc..9fba5b4a 100644 --- a/controller/model_list_test.go +++ b/controller/model_list_test.go @@ -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{ diff --git a/controller/user.go b/controller/user.go index 190c2b1d..1fc52dd9 100644 --- a/controller/user.go +++ b/controller/user.go @@ -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) { diff --git a/web/bun.lock b/web/bun.lock index 2cde778d..ba931280 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -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=="], diff --git a/web/default/package.json b/web/default/package.json index f6ce409e..fa17c11c 100644 --- a/web/default/package.json +++ b/web/default/package.json @@ -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", diff --git a/web/default/rsbuild.config.ts b/web/default/rsbuild.config.ts index 4839bfce..125638fb 100644 --- a/web/default/rsbuild.config.ts +++ b/web/default/rsbuild.config.ts @@ -1,5 +1,6 @@ -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 return { diff --git a/web/default/src/components/ai-elements/code-block.tsx b/web/default/src/components/ai-elements/code-block.tsx index 69bcb156..df70915f 100644 --- a/web/default/src/components/ai-elements/code-block.tsx +++ b/web/default/src/components/ai-elements/code-block.tsx @@ -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 { - type BundledLanguage, - codeToHtml, - type ShikiTransformer, -} from 'shiki/bundle/web' -import { cn } from '@/lib/utils' +import { useTranslation } from 'react-i18next' +import type { BundledLanguage } from 'shiki' + import { Button } from '@/components/ui/button' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' type CodeBlockProps = HTMLAttributes & { 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, + '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, 'title'> & { + bodyClassName?: string + bodyMaxHeight?: string + bodyOverlay?: ReactNode + children: ReactNode + endActions?: ReactNode + showToolbar?: boolean + title?: ReactNode } type CodeBlockContextType = { code: string + language: string } const CodeBlockContext = createContext({ 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 = { + csharp: 'c#', + golang: 'go', + js: 'javascript', + shell: 'bash', + shellscript: 'bash', + ts: 'typescript', +} + +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', + }, +}) -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 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', }, - transformers, - }) + { + tag: [highlightTags.keyword, highlightTags.processingInstruction], + color: '#c678dd', + }, + { + tag: [highlightTags.atom, highlightTags.bool, highlightTags.number], + color: '#d19a66', + }, + { 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(null) + const editorViewRef = useRef(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 ( +
+ ) +} + +export const CodeBlockFrame = ({ + bodyClassName, + bodyMaxHeight, + bodyOverlay, + children, + className, + endActions, + showToolbar = false, + title, + ...props +}: CodeBlockFrameProps) => ( +
+ {showToolbar && ( +
+
+
+ {title} +
+
+ {endActions && ( +
{endActions}
+ )} +
+ )} +
+
+ {children} +
+ {bodyOverlay} +
+
+) + 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('') + 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 ( - -
+ + {isCodeCollapsed && ( +
+ )} + {!showToolbar && children && ( +
+ {children} +
+ )} + + } + className={className} + endActions={ + <> + {canCollapse && ( + + setIsCollapsed((value) => !value)} + size='icon-sm' + type='button' + variant='ghost' + > + {isCodeCollapsed ? ( + + ) : ( + + )} + + } + /> + +

{isCodeCollapsed ? t('Expand') : t('Collapse')}

+
+
+ )} + {showToolbar && children} + + + + + } + /> + +

{t('Download')}

+
+
+ + } + showToolbar={showToolbar} + title={displayTitle} {...props} > -
-
- {children && ( -
- {children} -
- )} -
-
+ + ) } +export const CodeBlockEditor = ({ + actions, + ariaLabel, + className, + language, + onChange, + onKeyDown, + rows = 8, + title, + value, + ...props +}: CodeBlockEditorProps) => { + return ( + + + + ) +} + export type CodeBlockCopyButtonProps = ComponentProps & { 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 = ( ) + + return ( + + + +

{isCopied ? t('Copied!') : t('Copy code')}

+
+
+ ) } diff --git a/web/default/src/components/ai-elements/conversation.tsx b/web/default/src/components/ai-elements/conversation.tsx index 1d178de7..7c6aa385 100644 --- a/web/default/src/components/ai-elements/conversation.tsx +++ b/web/default/src/components/ai-elements/conversation.tsx @@ -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 export const Conversation = ({ className, ...props }: ConversationProps) => ( -const getThinkingMessage = (isStreaming: boolean, duration?: number) => { - if (isStreaming) { - return Thinking... - } - // When duration is unknown or 0 (e.g., non-streaming responses), show a generic message - if (duration === undefined || duration === 0) { - return

Thought for a few seconds

- } - return

Thought for {duration} seconds

-} - 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 ( {children ?? ( <> - - {getThinkingMessage(isStreaming, duration)} - + + + + {isStreaming ? ( + {t('Thinking...')} + ) : ( + thinkingText )} - /> + + + + )} @@ -188,13 +194,17 @@ export const ReasoningContent = memo( ({ className, children, ...props }: ReasoningContentProps) => ( - {children} +
+ + {children} + +
) ) diff --git a/web/default/src/components/ai-elements/response-content.ts b/web/default/src/components/ai-elements/response-content.ts new file mode 100644 index 00000000..041f6a86 --- /dev/null +++ b/web/default/src/components/ai-elements/response-content.ts @@ -0,0 +1,188 @@ +/* +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 . + +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 } +} diff --git a/web/default/src/components/ai-elements/response-node-guards.ts b/web/default/src/components/ai-elements/response-node-guards.ts new file mode 100644 index 00000000..9399018b --- /dev/null +++ b/web/default/src/components/ai-elements/response-node-guards.ts @@ -0,0 +1,98 @@ +/* +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 . + +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 +} diff --git a/web/default/src/components/ai-elements/response-renderer-alert.tsx b/web/default/src/components/ai-elements/response-renderer-alert.tsx new file mode 100644 index 00000000..157e48d8 --- /dev/null +++ b/web/default/src/components/ai-elements/response-renderer-alert.tsx @@ -0,0 +1,168 @@ +/* +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 . + +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 + +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 ( + + ) + } + + return ( +
+ {options.renderChildren(node.children)} +
+ ) +} diff --git a/web/default/src/components/ai-elements/response-renderer-blocks.tsx b/web/default/src/components/ai-elements/response-renderer-blocks.tsx new file mode 100644 index 00000000..4996b4bf --- /dev/null +++ b/web/default/src/components/ai-elements/response-renderer-blocks.tsx @@ -0,0 +1,213 @@ +/* +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 . + +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 ( +

+ {children} +

+ ) + } + + if (headingLevel === 2) { + return ( +

+ {children} +

+ ) + } + + if (headingLevel === 3) { + return ( +

+ {children} +

+ ) + } + + if (headingLevel === 4) { + return ( +

+ {children} +

+ ) + } + + if (headingLevel === 5) { + return ( +
+ {children} +
+ ) + } + + return ( +
+ {children} +
+ ) +} + +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) => ( +
  • + {options.renderChildren(item.children)} +
  • + )) + + if (node.ordered) { + return ( +
      + {items} +
    + ) + } + + return ( +
      + {items} +
    + ) +} + +export function renderCodeBlock(node: CodeBlockNode, key: string): ReactNode { + const language = node.language || 'plaintext' + const lineCount = node.code.split('\n').length + + return ( + 14} + key={key} + language={language} + maxExpandedLines={44} + showLineNumbers + showToolbar + title={language} + > + + + ) +} + +export function renderDefinitionList( + node: DefinitionListNode, + key: string, + options: BlockRendererOptions +): ReactNode { + return ( +
    + {node.items.map((item, index) => + renderDefinitionItem(item, index, options) + )} +
    + ) +} + +function renderDefinitionItem( + node: DefinitionItemNode, + index: number, + options: BlockRendererOptions +): ReactNode { + return ( +
    +
    {options.renderChildren(node.term)}
    +
    + {options.renderChildren(node.definition)} +
    +
    + ) +} + +export function renderMathBlock(node: MathBlockNode, key: string): ReactNode { + return ( +
    +      {node.content}
    +    
    + ) +} + +export function renderMathInline(node: MathInlineNode, key: string): ReactNode { + return ( + + {node.content} + + ) +} diff --git a/web/default/src/components/ai-elements/response-renderer-details.tsx b/web/default/src/components/ai-elements/response-renderer-details.tsx new file mode 100644 index 00000000..08e9a4b0 --- /dev/null +++ b/web/default/src/components/ai-elements/response-renderer-details.tsx @@ -0,0 +1,64 @@ +/* +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 . + +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 ( +
    + + {summary} + +
    + {options.renderChildren(contentNodes)} +
    +
    + ) +} + +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) +} diff --git a/web/default/src/components/ai-elements/response-renderer-footnotes.tsx b/web/default/src/components/ai-elements/response-renderer-footnotes.tsx new file mode 100644 index 00000000..0923412b --- /dev/null +++ b/web/default/src/components/ai-elements/response-renderer-footnotes.tsx @@ -0,0 +1,55 @@ +/* +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 . + +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 ( +
    +
      + {footnotes.map((footnote) => ( +
    1. +
      + {options.renderChildren(footnote.children)} +
      + + {t('Back')} + +
    2. + ))} +
    +
    + ) +} diff --git a/web/default/src/components/ai-elements/response-renderer-image.tsx b/web/default/src/components/ai-elements/response-renderer-image.tsx new file mode 100644 index 00000000..cd4d5e30 --- /dev/null +++ b/web/default/src/components/ai-elements/response-renderer-image.tsx @@ -0,0 +1,50 @@ +/* +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 . + +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 ( + + {props.node.alt || t('Image not available')} + + ) + } + + return ( + {props.node.alt} setHasError(true)} + src={src} + title={props.node.title ?? undefined} + /> + ) +} diff --git a/web/default/src/components/ai-elements/response-renderer-inline.tsx b/web/default/src/components/ai-elements/response-renderer-inline.tsx new file mode 100644 index 00000000..0f2a0443 --- /dev/null +++ b/web/default/src/components/ai-elements/response-renderer-inline.tsx @@ -0,0 +1,59 @@ +/* +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 . + +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 ( + + {renderChildren(node.children)} + + ) +} + +export function renderImage(node: ImageNode, key: string): ReactNode { + return +} diff --git a/web/default/src/components/ai-elements/response-renderer-table.tsx b/web/default/src/components/ai-elements/response-renderer-table.tsx new file mode 100644 index 00000000..ecd1cc75 --- /dev/null +++ b/web/default/src/components/ai-elements/response-renderer-table.tsx @@ -0,0 +1,99 @@ +/* +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 . + +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 ( + + {options.renderChildren(node.children)} + + ) + } + + return ( + + {options.renderChildren(node.children)} + + ) +} + +export function renderTable( + node: TableNode, + key: string, + options: BlockRendererOptions +): ReactNode { + return ( +
    + + + + {node.header.cells.map((cell, index) => + renderTableCell(cell, getNodeKey(cell, index), options) + )} + + + + {node.rows.map((row, rowIndex) => ( + + {row.cells.map((cell, cellIndex) => + renderTableCell(cell, getNodeKey(cell, cellIndex), options) + )} + + ))} + +
    +
    + ) +} diff --git a/web/default/src/components/ai-elements/response-renderer.tsx b/web/default/src/components/ai-elements/response-renderer.tsx new file mode 100644 index 00000000..97230841 --- /dev/null +++ b/web/default/src/components/ai-elements/response-renderer.tsx @@ -0,0 +1,227 @@ +/* +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 . + +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 ( +

    + {renderChildren(node.children)} +

    + ) + } + + if (node.type === 'inline' && hasParsedChildren(node)) { + return {renderChildren(node.children)} + } + + if (isListNode(node)) { + return renderList(node, key, { renderChildren }) + } + + if (isCodeBlockNode(node)) { + return renderCodeBlock(node, key) + } + + if (node.type === 'inline_code' && 'code' in node) { + return ( + + {String(node.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 ( + + {renderChildren(node.children)} + + ) + } + + if (node.type === 'emphasis' && hasParsedChildren(node)) { + return {renderChildren(node.children)} + } + + if (node.type === 'strikethrough' && hasParsedChildren(node)) { + return {renderChildren(node.children)} + } + + if (node.type === 'highlight' && hasParsedChildren(node)) { + return {renderChildren(node.children)} + } + + if (node.type === 'insert' && hasParsedChildren(node)) { + return {renderChildren(node.children)} + } + + if (node.type === 'subscript' && hasParsedChildren(node)) { + return {renderChildren(node.children)} + } + + if (node.type === 'superscript' && hasParsedChildren(node)) { + return {renderChildren(node.children)} + } + + if ( + (node.type === 'checkbox' || node.type === 'checkbox_input') && + 'checked' in node + ) { + return ( + + ) + } + + if (node.type === 'hardbreak') { + return
    + } + + if (node.type === 'thematic_break') { + return
    + } + + if (isMathBlockNode(node)) { + return renderMathBlock(node, key) + } + + if (isMathInlineNode(node)) { + return renderMathInline(node, key) + } + + if (node.type === 'footnote_reference' && 'id' in node) { + return ( + + + [{String(node.id)}] + + + ) + } + + 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 {String(node.content)} + } + + if (node.type === 'html_inline' && 'content' in node) { + return {String(node.content)} + } + + if (hasParsedChildren(node)) { + return {renderChildren(node.children)} + } + + if ('content' in node && typeof node.content === 'string') { + return {node.content} + } + + return {node.raw} +} diff --git a/web/default/src/components/ai-elements/response-types.ts b/web/default/src/components/ai-elements/response-types.ts new file mode 100644 index 00000000..fb4b8e79 --- /dev/null +++ b/web/default/src/components/ai-elements/response-types.ts @@ -0,0 +1,45 @@ +/* +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 . + +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 +} diff --git a/web/default/src/components/ai-elements/response.tsx b/web/default/src/components/ai-elements/response.tsx index 43d769b5..66e267d0 100644 --- a/web/default/src/components/ai-elements/response.tsx +++ b/web/default/src/components/ai-elements/response.tsx @@ -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 +import { getMarkdownContent, parseResponseContent } from './response-content' +import { renderChildren, renderFootnotes } from './response-renderer' +import type { ResponseProps } from './response-types' -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 tags if they still appear - .replace(/<\/?think\b[^>]*>/gi, '') - ) +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 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 ( - *:first-child]:mt-0 [&>*:last-child]:mb-0', - className - )} - {...props} - > - {safeChildren} - - ) - }, - (prevProps, nextProps) => prevProps.children === nextProps.children -) + return ( +
    *:first-child]:mt-0 [&>*:last-child]:mb-0', + props.className + )} + > + {renderedContent} + {footnotes} +
    + ) +}) Response.displayName = 'Response' diff --git a/web/default/src/components/ai-elements/sources.tsx b/web/default/src/components/ai-elements/sources.tsx index 9423a327..f1980a07 100644 --- a/web/default/src/components/ai-elements/sources.tsx +++ b/web/default/src/components/ai-elements/sources.tsx @@ -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) => ( . 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 = React.memo( ) - return ( - <> - {isMobile ? ( - - - - - - - - {t('Select Model')} - - -
    - {renderModelCommandContent()} -
    -
    -
    - ) : ( - - - } + return isMobile ? ( + + + + + + + + {t('Select Model')} + + +
    + {renderModelCommandContent()} +
    +
    +
    + ) : ( + + - - {renderModelCommandContent()} - - - )} - + } + /> + + {renderModelCommandContent()} + +
    ) } ) @@ -444,95 +441,91 @@ export const GroupSelector: React.FC = React.memo( ) - return ( - <> - {isMobile ? ( - - - - - - - {t('Choose Group')} - -
    -
    - {groups.map((group) => ( - - ))} -
    -
    -
    -
    - ) : ( - - - } + )} +
    +
    + + + ))} +
    + + + + ) : ( + + - - {renderGroupCommandContent()} - - - )} - + } + /> + + {renderGroupCommandContent()} + + ) } ) @@ -568,20 +561,194 @@ export const ModelGroupSelector: React.FC = ({ className, disabled = false, }) => { - return ( -
    - - + 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 = () => ( + + ) + + const renderGroupList = () => ( +
    +
    + {t('Model Group')} +
    +
    + {groups.map((group) => { + const isSelected = selectedGroup === group.value + + return ( + + ) + })} +
    ) + + const renderModelList = () => ( + 1} + shouldFilter={false} + > + + + {filteredModels.length === 0 ? ( +
    + {t('No model found.')} +
    + ) : ( + + {filteredModels.map((model) => ( + + + {model.label} + + + + ))} + + )} +
    +
    + ) + + const renderContent = () => ( +
    + {renderGroupList()} +
    + {renderModelList()} +
    +
    + ) + + return isMobile ? ( + + {renderTrigger()} + + + {t('Select Model')} + +
    + {renderContent()} +
    +
    +
    + ) : ( + + + + {renderContent()} + + + ) } diff --git a/web/default/src/features/playground/api.ts b/web/default/src/features/playground/api.ts index 1b8858fe..4f6e205e 100644 --- a/web/default/src/features/playground/api.ts +++ b/web/default/src/features/playground/api.ts @@ -17,6 +17,7 @@ along with this program. If not, see . 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 { const res = await api.post(API_ENDPOINTS.CHAT_COMPLETIONS, payload, { + signal, skipErrorHandler: true, } as Record) return res.data @@ -40,8 +43,10 @@ export async function sendChatCompletion( /** * Get user available models */ -export async function getUserModels(): Promise { - const res = await api.get(API_ENDPOINTS.USER_MODELS) +export async function getUserModels(group: string): Promise { + const res = await api.get(API_ENDPOINTS.USER_MODELS, { + params: { group }, + }) const { data } = res if (!data.success || !Array.isArray(data.data)) { diff --git a/web/default/src/features/playground/components/chat/playground-chat.tsx b/web/default/src/features/playground/components/chat/playground-chat.tsx new file mode 100644 index 00000000..fcbd7c25 --- /dev/null +++ b/web/default/src/features/playground/components/chat/playground-chat.tsx @@ -0,0 +1,223 @@ +/* +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 . + +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 + >(() => 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 ( + +
    + {isEditing ? ( + + ) : ( + + } + isSourceVisible={isSourceVisible} + message={message} + errorActions={ + isError ? ( + onRegenerateMessage(message) + : undefined + } + onEditPrompt={ + onEditMessage && previousUserMessage + ? () => onEditMessage(previousUserMessage) + : undefined + } + onDelete={ + onDeleteMessage + ? () => onDeleteMessage(message) + : undefined + } + /> + ) : undefined + } + versionContent={content} + /> + )} +
    +
    + ) + }) + + if (visibleMessages.length === 0 && onSelectPrompt) { + chatContent = [ + , + ] + } + + if (isLoadingMessages) { + chatContent = [ +
    + + {t('Loading conversation...')} +
    , + ] + } + + return ( + + {/* Remove outer padding; apply padding to inner centered container to align with input */} + +
    {chatContent}
    +
    + +
    + ) +} diff --git a/web/default/src/features/playground/components/chat/playground-empty-state.tsx b/web/default/src/features/playground/components/chat/playground-empty-state.tsx new file mode 100644 index 00000000..8a963cb0 --- /dev/null +++ b/web/default/src/features/playground/components/chat/playground-empty-state.tsx @@ -0,0 +1,84 @@ +/* +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 . + +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 ( +
    +
    +
    +
    + +
    +

    + {t('Start a playground chat')} +

    +

    + {t( + 'Test a model with a starter prompt, or write your own request below.' + )} +

    +
    + +
    + {starterPrompts.map(({ icon: Icon, text }) => { + const prompt = t(text) + + return ( + + ) + })} +
    +
    +
    + ) +} diff --git a/web/default/src/features/playground/components/input/playground-input-controls.tsx b/web/default/src/features/playground/components/input/playground-input-controls.tsx new file mode 100644 index 00000000..19ea74f7 --- /dev/null +++ b/web/default/src/features/playground/components/input/playground-input-controls.tsx @@ -0,0 +1,125 @@ +/* +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 . + +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 = () => ( + + ) + + const renderSubmitButton = () => + shouldShowStop ? ( + + + {t('Stop')} + {t('Stop')} + + ) : ( + + + {t('Send')} + {t('Send')} + + ) + + return ( +
    +
    + {renderSelector()} +
    + +
    + {tools} +
    + {renderSubmitButton()} +
    +
    + +
    + {renderSelector()} + {renderSubmitButton()} +
    +
    + ) +} diff --git a/web/default/src/features/playground/components/input/playground-input-tools.tsx b/web/default/src/features/playground/components/input/playground-input-tools.tsx new file mode 100644 index 00000000..07b34fb3 --- /dev/null +++ b/web/default/src/features/playground/components/input/playground-input-tools.tsx @@ -0,0 +1,169 @@ +/* +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 . + +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 ( + <> + + + + + } + > + + + } + /> + +

    {t('Attach')}

    +
    + + {ATTACHMENT_ACTIONS.map(({ action, icon: Icon, label }) => ( + handleFileAction(action)} + > + + {t(label)} + + ))} + +
    +
    + + + + + + } + /> + +

    {t('Search')}

    +
    +
    + + + setClearConfirmOpen(true)} + variant='ghost' + > + + + } + /> + +

    {t('Clear chat history')}

    +
    +
    +
    + + + + ) +} diff --git a/web/default/src/features/playground/components/input/playground-input.tsx b/web/default/src/features/playground/components/input/playground-input.tsx new file mode 100644 index 00000000..bc38300b --- /dev/null +++ b/web/default/src/features/playground/components/input/playground-input.tsx @@ -0,0 +1,120 @@ +/* +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 . + +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 ( +
    + + setText(event.target.value)} + placeholder={t('Ask anything')} + value={text} + /> + + + + } + /> + + +
    + ) +} diff --git a/web/default/src/features/playground/components/message-actions.tsx b/web/default/src/features/playground/components/message-actions.tsx deleted file mode 100644 index 66793c1a..00000000 --- a/web/default/src/features/playground/components/message-actions.tsx +++ /dev/null @@ -1,128 +0,0 @@ -/* -Copyright (C) 2023-2026 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -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 ( - -
    - {/* Copy */} - {hasContent && ( - - )} - - {/* Regenerate - only for assistant messages */} - {isAssistant && !isLoading && onRegenerate && ( - - )} - - {/* Edit */} - {hasContent && onEdit && ( - - )} - - {/* Delete */} - {onDelete && ( - - )} -
    -
    - ) -} diff --git a/web/default/src/features/playground/components/message-action-button.tsx b/web/default/src/features/playground/components/message/message-action-button.tsx similarity index 96% rename from web/default/src/features/playground/components/message-action-button.tsx rename to web/default/src/features/playground/components/message/message-action-button.tsx index 7ab4976f..e4e99396 100644 --- a/web/default/src/features/playground/components/message-action-button.tsx +++ b/web/default/src/features/playground/components/message/message-action-button.tsx @@ -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 diff --git a/web/default/src/features/playground/components/message/message-actions.tsx b/web/default/src/features/playground/components/message/message-actions.tsx new file mode 100644 index 00000000..79db4adf --- /dev/null +++ b/web/default/src/features/playground/components/message/message-actions.tsx @@ -0,0 +1,221 @@ +/* +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 . + +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 ( + <> + +
    + {actions.map((action) => ( + + ))} +
    +
    + +
    + + + } + > + + {t('Open menu')} + + + {actions.map((action) => { + const Icon = action.icon + + return ( + + {t(action.label)} + + + + + ) + })} + + +
    + + ) +} diff --git a/web/default/src/features/playground/components/message/message-error-actions.tsx b/web/default/src/features/playground/components/message/message-error-actions.tsx new file mode 100644 index 00000000..415ba573 --- /dev/null +++ b/web/default/src/features/playground/components/message/message-error-actions.tsx @@ -0,0 +1,74 @@ +/* +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 . + +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 ( +
    + {onRetry && ( + + )} + + {onEditPrompt && ( + + )} + + {onDelete && ( + + )} +
    + ) +} diff --git a/web/default/src/features/playground/components/message-error.tsx b/web/default/src/features/playground/components/message/message-error.tsx similarity index 65% rename from web/default/src/features/playground/components/message-error.tsx rename to web/default/src/features/playground/components/message/message-error.tsx index 64967919..6cd627c9 100644 --- a/web/default/src/features/playground/components/message-error.tsx +++ b/web/default/src/features/playground/components/message/message-error.tsx @@ -1,3 +1,4 @@ +import { AlertCircle, AlertTriangle, Settings } from 'lucide-react' /* Copyright (C) 2023-2026 QuantumNous @@ -16,54 +17,67 @@ along with this program. If not, see . 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 ( {t('Model Price Not Configured')} -

    {errorContent}

    - {isAdmin && ( +

    {content}

    + {errorState.showSettingsLink && ( )} + {actions}
    ) @@ -73,7 +87,14 @@ export function MessageError({ message, className = '' }: MessageErrorProps) { {t('Error')} - {errorContent} + +

    + {errorState.content === FALLBACK_ERROR_CONTENT + ? t(FALLBACK_ERROR_CONTENT) + : errorState.content} +

    + {actions} +
    ) } diff --git a/web/default/src/features/playground/components/message/message-metadata.tsx b/web/default/src/features/playground/components/message/message-metadata.tsx new file mode 100644 index 00000000..672e0421 --- /dev/null +++ b/web/default/src/features/playground/components/message/message-metadata.tsx @@ -0,0 +1,84 @@ +/* +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 . + +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 ( +
    + {messageTime && } + {duration && ( + <> + {messageTime && } + {t('Response time: {{duration}}', { duration })} + + )} +
    + ) +} diff --git a/web/default/src/features/playground/components/message/playground-message-content.tsx b/web/default/src/features/playground/components/message/playground-message-content.tsx new file mode 100644 index 00000000..4a116bf8 --- /dev/null +++ b/web/default/src/features/playground/components/message/playground-message-content.tsx @@ -0,0 +1,167 @@ +/* +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 . + +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 ( +
    + {hasSources && ( + + + + {sources.map((source) => ( + + ))} + + + )} + + {hasReasoning && ( + + + {reasoningContent} + + )} + + {showLoader && ( +
    + + + {t('Responding...')} + +
    + )} + + {isError && ( + <> + + + {errorActions} + + )} + + {!isError && showMessageContent && ( + <> + {isSourceVisible ? ( + + + + ) : ( + + {displayContent} + + )} + + {actions} + + )} +
    + ) +} diff --git a/web/default/src/features/playground/components/message/playground-message-editor.tsx b/web/default/src/features/playground/components/message/playground-message-editor.tsx new file mode 100644 index 00000000..3d826159 --- /dev/null +++ b/web/default/src/features/playground/components/message/playground-message-editor.tsx @@ -0,0 +1,155 @@ +/* +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 . + +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 && ( + + )} + + + + {hasChanged && ( + + )} + + + + ) + + return ( + + {t('Edit')} + + {hasChanged ? t('Unsaved changes') : t('No changes')} + + + } + value={editText} + /> + ) +} diff --git a/web/default/src/features/playground/components/playground-chat.tsx b/web/default/src/features/playground/components/playground-chat.tsx deleted file mode 100644 index 867ff933..00000000 --- a/web/default/src/features/playground/components/playground-chat.tsx +++ /dev/null @@ -1,291 +0,0 @@ -/* -Copyright (C) 2023-2026 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -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 ( - - {/* Remove outer padding; apply padding to inner centered container to align with input */} - -
    - {messages.map((message, messageIndex) => { - const { versions = [] } = message - const isLastAssistantMessage = - messageIndex === messages.length - 1 && - message.from === MESSAGE_ROLES.ASSISTANT - return ( - - - {versions.map((version, versionIndex) => ( - -
    - {isEditing(message.key) ? ( -
    -