From 39ef873678be645622d68f95128249ed9c3296a2 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:23:23 +0800 Subject: [PATCH 1/2] feat: stateful interactive blocks with formulas and explorable diagrams and state round-trip --- apps/desktop/README.md | 18 + apps/desktop/src-tauri/src/lib.rs | 18 +- apps/desktop/src-tauri/src/menu.rs | 249 +++++ apps/desktop/src-tauri/src/tabs.rs | 3 + .../chat/components/app-chat-area.tsx | 60 +- .../chat/components/message/message-bot.tsx | 6 + .../chat/components/sections/chat-area.tsx | 45 +- .../components/sections/chat-artifact.tsx | 150 ++- .../sections/chat-ui-block-panel.tsx | 93 ++ .../features/chat/hooks/use-chat-artifacts.ts | 28 + .../chat/hooks/use-chat-message-submit.ts | 37 + .../chat/hooks/use-chat-run-stream.ts | 6 + .../features/chat/hooks/use-chat-runtime.ts | 7 + .../chat/hooks/use-chat-submit-stream.ts | 4 + .../chat/hooks/use-chat-temporary-runtime.ts | 123 ++- .../chat/hooks/use-chat-ui-block-state.ts | 160 +++ .../chat/model/chat-message-render.ts | 5 +- apps/web/features/chat/model/chat-thread.ts | 7 + .../chat/model/chat-ui-block-state-store.ts | 237 +++++ .../features/chat/model/ui-component-icons.ts | 5 + apps/web/features/chat/types/messages.ts | 4 + apps/web/i18n/messages/en-US/chat.json | 27 +- apps/web/i18n/messages/en-US/errors.json | 2 + .../i18n/messages/en-US/ui-components.json | 10 +- apps/web/i18n/messages/zh-CN/chat.json | 27 +- apps/web/i18n/messages/zh-CN/errors.json | 2 + .../i18n/messages/zh-CN/ui-components.json | 10 +- apps/web/shared/api/conversation-types.ts | 30 +- apps/web/shared/api/conversation.ts | 23 + apps/web/shared/api/http-client.ts | 4 + .../markdown/streamdown-components.tsx | 13 +- .../markdown/streamdown-raw-html.ts | 34 + .../components/markdown/streamdown-render.tsx | 87 +- .../markdown/ui-blocks/block-text.ts | 63 ++ .../components/markdown/ui-blocks/block.ts | 222 +--- .../markdown/ui-blocks/calculator.tsx | 43 +- .../markdown/ui-blocks/card-grid.tsx | 21 +- .../components/markdown/ui-blocks/chart.tsx | 21 +- .../markdown/ui-blocks/data-table.tsx | 41 +- .../markdown/ui-blocks/decision-tree.tsx | 79 +- .../components/markdown/ui-blocks/diff.tsx | 10 + .../markdown/ui-blocks/function-plot.tsx | 64 +- .../components/markdown/ui-blocks/gantt.tsx | 14 + .../components/markdown/ui-blocks/index.ts | 5 +- .../markdown/ui-blocks/live/live-block.tsx | 180 ++++ .../markdown/ui-blocks/live/live-context.tsx | 155 +++ .../markdown/ui-blocks/live/live-controls.tsx | 252 +++++ .../ui-blocks/live/live-primitives.tsx | 336 ++++++ .../markdown/ui-blocks/live/live-text.ts | 145 +++ .../markdown/ui-blocks/live/live-visuals.tsx | 281 +++++ .../markdown/ui-blocks/message-text.ts | 39 + .../components/markdown/ui-blocks/quiz.tsx | 119 ++- .../markdown/ui-blocks/sandbox-host.tsx | 200 +++- .../components/markdown/ui-blocks/schema.ts | 10 + .../markdown/ui-blocks/stat-grid.tsx | 10 + .../markdown/ui-blocks/ui-block-actions.tsx | 41 + .../markdown/ui-blocks/ui-block-ask.tsx | 101 ++ .../ui-blocks/ui-block-error-boundary.tsx | 38 + .../markdown/ui-blocks/ui-block-host.tsx | 112 +- .../markdown/ui-blocks/ui-block-state.tsx | 271 +++++ .../markdown/ui-blocks/widget-host.tsx | 203 ++++ apps/web/shared/lib/safe-expression.ts | 366 ------- apps/web/shared/lib/sandbox-document.ts | 1 - backend/docs/docs.go | 206 ++++ backend/docs/swagger.json | 206 ++++ backend/docs/swagger.yaml | 149 +++ .../internal/application/conversation/errs.go | 4 + .../application/conversation/prompt_plan.go | 4 +- .../application/conversation/service.go | 12 +- .../conversation/service_conversation.go | 15 + .../conversation/service_message_context.go | 18 +- .../service_message_route_attempt.go | 3 +- .../conversation/service_message_send.go | 3 + .../conversation/service_message_ui_state.go | 277 +++++ .../service_message_ui_state_test.go | 274 +++++ .../conversation/service_temporary_chat.go | 33 +- .../service_trace_integration_test.go | 12 + .../application/conversation/system_prompt.go | 62 +- .../conversation/system_prompt_test.go | 75 ++ .../conversation/ui_state_context.go | 221 ++++ .../conversation/ui_state_context_test.go | 232 +++++ .../uicomponent/explorable_example.json | 11 + .../application/uicomponent/live_example.json | 12 + .../application/uicomponent/service.go | 178 +++- .../application/uicomponent/service_test.go | 159 ++- backend/internal/domain/conversation/types.go | 24 +- backend/internal/domain/uicomponent/types.go | 63 ++ .../internal/domain/uicomponent/types_test.go | 3 + .../persistence/models/message_ui_state.go | 22 + .../persistence/models/table_names_test.go | 1 + .../postgres/conversation/repository_fork.go | 3 + .../conversation/repository_message_delete.go | 5 + .../repository_message_ui_state.go | 203 ++++ .../repository_message_ui_state_test.go | 333 ++++++ .../postgres/conversation/repository_test.go | 2 +- .../infra/persistence/postgres/postgres.go | 1 + .../persistence/postgres/user/repository.go | 6 + .../infra/persistence/schema/schema.go | 1 + backend/internal/repository/conversation.go | 1 + .../internal/repository/conversation_core.go | 9 + backend/internal/repository/errors.go | 4 + .../http/conversation/dto_request.go | 42 + .../http/conversation/dto_request_test.go | 78 ++ .../http/conversation/dto_response.go | 54 +- .../http/conversation/handler_message_send.go | 2 + .../conversation/handler_message_ui_state.go | 123 +++ .../handler_message_ui_state_test.go | 74 ++ .../conversation/handler_temporary_chat.go | 2 + .../transport/http/conversation/router.go | 1 + docs/ARCHITECTURE.md | 1 + packages/api-contract/src/types.generated.ts | 95 ++ packages/core/README.md | 9 +- packages/core/package.json | 3 +- packages/core/src/auth/errors.test.ts | 23 - packages/core/src/auth/refresh.test.ts | 319 ------ .../src/capabilities/capabilities.test.ts | 58 -- packages/core/src/index.ts | 22 +- .../core/src/providers/import-link.test.ts | 132 --- packages/core/src/server/url.test.ts | 77 -- packages/core/src/ui-block/block.ts | 801 ++++++++++++++ packages/core/src/ui-block/expression.ts | 986 ++++++++++++++++++ packages/core/src/ui-block/fence.ts | 153 +++ packages/core/src/ui-block/partial-json.ts | 273 +++++ packages/core/src/ui-block/points.ts | 74 ++ packages/core/src/ui-block/primitives.ts | 39 + packages/core/src/ui-block/template.ts | 173 +++ packages/core/tsconfig.json | 5 +- packages/core/tsconfig.test.json | 10 - 128 files changed, 9993 insertions(+), 1460 deletions(-) create mode 100644 apps/desktop/src-tauri/src/menu.rs create mode 100644 apps/web/features/chat/components/sections/chat-ui-block-panel.tsx create mode 100644 apps/web/features/chat/hooks/use-chat-ui-block-state.ts create mode 100644 apps/web/features/chat/model/chat-ui-block-state-store.ts create mode 100644 apps/web/shared/components/markdown/streamdown-raw-html.ts create mode 100644 apps/web/shared/components/markdown/ui-blocks/block-text.ts create mode 100644 apps/web/shared/components/markdown/ui-blocks/live/live-block.tsx create mode 100644 apps/web/shared/components/markdown/ui-blocks/live/live-context.tsx create mode 100644 apps/web/shared/components/markdown/ui-blocks/live/live-controls.tsx create mode 100644 apps/web/shared/components/markdown/ui-blocks/live/live-primitives.tsx create mode 100644 apps/web/shared/components/markdown/ui-blocks/live/live-text.ts create mode 100644 apps/web/shared/components/markdown/ui-blocks/live/live-visuals.tsx create mode 100644 apps/web/shared/components/markdown/ui-blocks/message-text.ts create mode 100644 apps/web/shared/components/markdown/ui-blocks/ui-block-actions.tsx create mode 100644 apps/web/shared/components/markdown/ui-blocks/ui-block-ask.tsx create mode 100644 apps/web/shared/components/markdown/ui-blocks/ui-block-error-boundary.tsx create mode 100644 apps/web/shared/components/markdown/ui-blocks/ui-block-state.tsx create mode 100644 apps/web/shared/components/markdown/ui-blocks/widget-host.tsx delete mode 100644 apps/web/shared/lib/safe-expression.ts create mode 100644 backend/internal/application/conversation/service_message_ui_state.go create mode 100644 backend/internal/application/conversation/service_message_ui_state_test.go create mode 100644 backend/internal/application/conversation/ui_state_context.go create mode 100644 backend/internal/application/conversation/ui_state_context_test.go create mode 100644 backend/internal/application/uicomponent/explorable_example.json create mode 100644 backend/internal/application/uicomponent/live_example.json create mode 100644 backend/internal/infra/persistence/models/message_ui_state.go create mode 100644 backend/internal/infra/persistence/postgres/conversation/repository_message_ui_state.go create mode 100644 backend/internal/infra/persistence/postgres/conversation/repository_message_ui_state_test.go create mode 100644 backend/internal/transport/http/conversation/handler_message_ui_state.go create mode 100644 backend/internal/transport/http/conversation/handler_message_ui_state_test.go delete mode 100644 packages/core/src/auth/errors.test.ts delete mode 100644 packages/core/src/auth/refresh.test.ts delete mode 100644 packages/core/src/capabilities/capabilities.test.ts delete mode 100644 packages/core/src/providers/import-link.test.ts delete mode 100644 packages/core/src/server/url.test.ts create mode 100644 packages/core/src/ui-block/block.ts create mode 100644 packages/core/src/ui-block/expression.ts create mode 100644 packages/core/src/ui-block/fence.ts create mode 100644 packages/core/src/ui-block/partial-json.ts create mode 100644 packages/core/src/ui-block/points.ts create mode 100644 packages/core/src/ui-block/primitives.ts create mode 100644 packages/core/src/ui-block/template.ts delete mode 100644 packages/core/tsconfig.test.json diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 4a4826a93..bc4f24abb 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -16,6 +16,7 @@ apps/desktop/src-tauri (Rust) ├── sidecar src/sidecar.rs bundled Go server for local mode (loopback, SQLite) ├── session src/session.rs server choice + keychain + refresh (token never exposed to JS) ├── tray src/tray.rs show / quit, left click restores the window + ├── menu src/menu.rs macOS menu bar: File ▸ Close Tab ⌘W / Close Window ⇧⌘W ├── OAuth loopback src/oauth_loopback.rs RFC 8252 receiver on 127.0.0.1: ├── updater tauri-plugin-updater + apps/web/shared/platform/desktop-updater.ts └── CSP no inline/remote scripts; network open to http(s) (user picks the server) @@ -57,6 +58,23 @@ focuses the running copy (`tauri-plugin-single-instance`) rather than starting another sidecar on the same database. On Linux close quits: a tray icon is not guaranteed there, so a hidden window could be unreachable. +Because one window holds every tab, macOS gets its own menu bar +(`src/menu.rs`): Tauri's default menu binds ⌘W to Close Window, and with a close +turned into a hide a stray ⌘W would make the whole app disappear instead of +closing a tab. The menu replaces that binding the way browsers do — ⌘W closes +the tab that is showing and ⇧⌘W the window — with one deviation: the last tab is +never closed, since closing it would forget its server (token deleted, sidecar +stopped). ⌘W there closes the window instead, and the item says so: with a +single tab File reads `Close Window ⌘W`, with more it reads `Close Tab ⌘W` and +adds `Close Window ⇧⌘W`. The red traffic light and the tray still close/hide the +window. + +⇧⌘W needs a nudge: AppKit matches it against the shifted key equivalent `W`, +while a Tauri accelerator string only ever produces the unshifted `w`, which +would leave the item printed but inert. `menu::promote_shifted_shortcut` fixes +the installed item through AppKit. (Tauri's own `Edit ▸ Redo` ⇧⌘Z has the same +defect and is not touched here.) + Every tab is a full instance of the web app with its own DOM, caches, SSE connections and in-memory session, so the app never has to model "several servers at once". A tab is bound to at most one server; two tabs never share a diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 2fd533943..802cab005 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,8 +1,10 @@ -// DEEIX Chat desktop shell: window/tray lifecycle, OAuth loopback, updater and -// session storage. No business logic — everything else is the apps/web build. +// DEEIX Chat desktop shell: window/tray/menu lifecycle, OAuth loopback, updater +// and session storage. No business logic — everything else is the apps/web build. mod dialog; mod distribution; +#[cfg(target_os = "macos")] +mod menu; mod oauth_loopback; mod paths; mod policy; @@ -24,7 +26,7 @@ pub fn run() { let distribution = distribution::bootstrap(&mut context); let portable = distribution.portable.clone(); - tauri::Builder::default() + let builder = tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { tray::show_main_window(app); })) @@ -57,7 +59,15 @@ pub fn run() { tabs::tabs_activate, tabs::tabs_close, tabs::tabs_move, - ]) + ]); + + // macOS only: replaces Tauri's default menu, whose ⌘W hides the whole tab container. + #[cfg(target_os = "macos")] + let builder = builder + .menu(menu::build) + .on_menu_event(|app, event| menu::on_event(app, event.id().as_ref())); + + builder .setup(move |app| { // Plugins are set up first, so a second instance has already // handed over and exited by now. diff --git a/apps/desktop/src-tauri/src/menu.rs b/apps/desktop/src-tauri/src/menu.rs new file mode 100644 index 000000000..41087fc95 --- /dev/null +++ b/apps/desktop/src-tauri/src/menu.rs @@ -0,0 +1,249 @@ +// Application menu (macOS). Tauri's default menu binds ⌘W to Close Window, which +// hides this tab container, and muda gives that predefined item a fixed +// accelerator. This menu mirrors the default without it and adds browser-style +// File ▸ Close Tab (⌘W) / Close Window (⇧⌘W). + +use tauri::{ + menu::{ + AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, + WINDOW_SUBMENU_ID, + }, + AppHandle, Manager, Runtime, +}; + +/// The ⌘W item. Reads "Close Tab", or "Close Window" while a single tab is open. +const CLOSE_TAB: &str = "close-tab"; +/// The ⇧⌘W item: the window, with tabs still open. Absent with a single tab. +const CLOSE_WINDOW: &str = "close-window"; +/// The submenu `sync` finds both items in. +const FILE_MENU: &str = "file"; + +const CLOSE_TAB_TEXT: &str = "Close Tab"; +const CLOSE_WINDOW_TEXT: &str = "Close Window"; + +/// Build the app menu: `Menu::default` without Close Window, plus File. The +/// Window submenu keeps [`WINDOW_SUBMENU_ID`] so macOS still appends the window +/// list and tiling entries to it. +pub fn build(app: &AppHandle) -> tauri::Result> { + let package = app.package_info(); + let config = app.config(); + let about = AboutMetadata { + name: Some(package.name.clone()), + version: Some(package.version.to_string()), + copyright: config.bundle.copyright.clone(), + authors: config.bundle.publisher.clone().map(|p| vec![p]), + ..Default::default() + }; + + let app_menu = Submenu::with_items( + app, + package.name.clone(), + true, + &[ + &PredefinedMenuItem::about(app, None, Some(about))?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::services(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::hide(app, None)?, + &PredefinedMenuItem::hide_others(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::quit(app, None)?, + ], + )?; + + let file_menu = + Submenu::with_id_and_items(app, FILE_MENU, "File", true, &[&close_tab_item(app)?])?; + + let edit_menu = Submenu::with_items( + app, + "Edit", + true, + &[ + &PredefinedMenuItem::undo(app, None)?, + &PredefinedMenuItem::redo(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::cut(app, None)?, + &PredefinedMenuItem::copy(app, None)?, + &PredefinedMenuItem::paste(app, None)?, + &PredefinedMenuItem::select_all(app, None)?, + ], + )?; + + let view_menu = Submenu::with_items( + app, + "View", + true, + &[&PredefinedMenuItem::fullscreen(app, None)?], + )?; + + let window_menu = Submenu::with_id_and_items( + app, + WINDOW_SUBMENU_ID, + "Window", + true, + &[ + &PredefinedMenuItem::minimize(app, None)?, + &PredefinedMenuItem::maximize(app, None)?, + ], + )?; + + let help_menu = Submenu::with_id_and_items(app, HELP_SUBMENU_ID, "Help", true, &[])?; + + Menu::with_items( + app, + &[ + &app_menu, + &file_menu, + &edit_menu, + &view_menu, + &window_menu, + &help_menu, + ], + ) +} + +/// ⌘W, File's first item: built with the menu, its text follows the tab count. +fn close_tab_item(app: &AppHandle) -> tauri::Result> { + MenuItem::with_id(app, CLOSE_TAB, CLOSE_TAB_TEXT, true, Some("CmdOrCtrl+W")) +} + +/// ⇧⌘W: added by `sync` while more than one tab is open. muda rebuilds the +/// native item whenever it is re-added, so the AppKit fix runs after each append. +fn close_window_item(app: &AppHandle) -> tauri::Result> { + MenuItem::with_id( + app, + CLOSE_WINDOW, + CLOSE_WINDOW_TEXT, + true, + Some("CmdOrCtrl+Shift+W"), + ) +} + +/// Keep File in step with the tab count, as a browser does: with several tabs ⌘W +/// closes the active one and ⇧⌘W the window; with one, ⌘W is Close Window and +/// ⇧⌘W is removed. Called from `tabs::changed`. +pub fn sync(app: &AppHandle) { + let several = crate::tabs::snapshot(app).tabs.len() > 1; + let Some(file) = file_menu(app) else { + return; + }; + + let close_tab = file + .get(CLOSE_TAB) + .and_then(|item| item.as_menuitem().cloned()); + let close_window = file + .get(CLOSE_WINDOW) + .and_then(|item| item.as_menuitem().cloned()); + + if let Some(item) = &close_tab { + let text = if several { + CLOSE_TAB_TEXT + } else { + CLOSE_WINDOW_TEXT + }; + if item.text().unwrap_or_default() != text { + let _ = item.set_text(text); + } + } + match (several, close_window) { + (true, None) => { + if let Ok(item) = close_window_item(app) { + let _ = file.append(&item); + promote_shifted_shortcut(); + } + } + (false, Some(item)) => { + let _ = file.remove(&item); + } + _ => {} + } +} + +/// The File submenu of the installed menu. +fn file_menu(app: &AppHandle) -> Option> { + app.menu()? + .get(FILE_MENU) + .and_then(|item| item.as_submenu().cloned()) +} + +/// Act on the items this module created. Other menus (the tray's) keep their +/// own handlers; their ids are ignored here. +pub fn on_event(app: &AppHandle, id: &str) { + match id { + CLOSE_TAB => close_tab(app), + CLOSE_WINDOW => close_window(app), + _ => {} + } +} + +/// ⌘W: close the active tab. The last tab is kept — closing it would forget its +/// server (token deleted, sidecar stopped) — and the window closes instead, as in +/// a browser. +fn close_tab(app: &AppHandle) { + let snapshot = crate::tabs::snapshot(app); + let Some(id) = snapshot.active.filter(|_| snapshot.tabs.len() > 1) else { + return close_window(app); + }; + // `tabs::close` drives webviews and the sidecar: off the main thread, as the + // `tabs_close` command does. + let app = app.clone(); + tauri::async_runtime::spawn(async move { + if let Err(error) = crate::tabs::close(&app, &id).await { + eprintln!("[menu] close tab {id}: {}", error.0); + } + }); +} + +/// Close the window: the request the red traffic light sends. `tabs::init` +/// hides instead of destroying it, and the tray brings it back. +fn close_window(app: &AppHandle) { + if let Some(window) = app.get_window(crate::tabs::WINDOW_LABEL) { + let _ = window.close(); + } +} + +/// AppKit matches ⇧⌘W against the shifted key equivalent "W", but muda turns a +/// `Cmd+Shift+W` accelerator into "w", so the item would show its shortcut and +/// never fire. Patch the installed item through AppKit. +fn promote_shifted_shortcut() { + use objc2::runtime::AnyObject; + use objc2::{class, msg_send}; + use objc2_foundation::NSString; + + /// NSEventModifierFlagShift. + const SHIFT: usize = 1 << 17; + + unsafe { + let application: *mut AnyObject = msg_send![class!(NSApplication), sharedApplication]; + let menu: *mut AnyObject = msg_send![application, mainMenu]; + if menu.is_null() { + return; + } + let submenu_count: isize = msg_send![menu, numberOfItems]; + for submenu_index in 0..submenu_count { + let submenu_item: *mut AnyObject = msg_send![menu, itemAtIndex: submenu_index]; + let submenu: *mut AnyObject = msg_send![submenu_item, submenu]; + if submenu.is_null() { + continue; + } + let item_count: isize = msg_send![submenu, numberOfItems]; + for item_index in 0..item_count { + let item: *mut AnyObject = msg_send![submenu, itemAtIndex: item_index]; + let modifiers: usize = msg_send![item, keyEquivalentModifierMask]; + if modifiers & SHIFT == 0 { + continue; + } + let key_equivalent: *mut AnyObject = msg_send![item, keyEquivalent]; + if key_equivalent.is_null() { + continue; + } + let key_equivalent = &*(key_equivalent as *const NSString); + if key_equivalent.to_string() != "w" { + continue; + } + let shifted = NSString::from_str("W"); + let _: () = msg_send![item, setKeyEquivalent: &*shifted]; + } + } + } +} diff --git a/apps/desktop/src-tauri/src/tabs.rs b/apps/desktop/src-tauri/src/tabs.rs index ef6ecb9d2..f521848a5 100644 --- a/apps/desktop/src-tauri/src/tabs.rs +++ b/apps/desktop/src-tauri/src/tabs.rs @@ -532,6 +532,9 @@ fn persist(app: &AppHandle) -> Result<()> { } fn changed(app: &AppHandle) -> Result<()> { + // macOS: the File menu's ⌘W item follows the tab count (src/menu.rs). + #[cfg(target_os = "macos")] + crate::menu::sync(app); persist(app)?; app.emit_to( EventTarget::webview(CHROME_LABEL), diff --git a/apps/web/features/chat/components/app-chat-area.tsx b/apps/web/features/chat/components/app-chat-area.tsx index 15787d8cf..10b86b12a 100644 --- a/apps/web/features/chat/components/app-chat-area.tsx +++ b/apps/web/features/chat/components/app-chat-area.tsx @@ -18,6 +18,7 @@ import { import { ConversationShareDialog, sharePatchFromDTO, useSidebarConversationField } from "@/entities/conversation"; import { ChatArea, ChatAreaLoadError, ChatAreaSkeleton } from "@/features/chat/components/sections/chat-area"; import { ChatArtifactWorkspace } from "@/features/chat/components/sections/chat-artifact"; +import { ChatUIBlockPanel } from "@/features/chat/components/sections/chat-ui-block-panel"; import { ChatEmptyState } from "@/features/chat/components/sections/chat-empty"; import { ChatInput } from "@/features/chat/components/sections/chat-input"; import { ChatScreenshotPreviewDialog } from "@/features/chat/components/sections/chat-screenshot-preview-dialog"; @@ -31,7 +32,7 @@ import { useChatConversationActions } from "@/features/chat/hooks/use-chat-conve import { useChatFileDrag } from "@/features/chat/hooks/use-chat-file-drag"; import { useChatMCPTools } from "@/features/chat/hooks/use-chat-mcp-tools"; import { useChatUIComponents } from "@/features/chat/hooks/use-chat-ui-components"; -import { UIBlockRegistryProvider } from "@/shared/components/markdown/ui-blocks"; +import { UIBlockActionsProvider, UIBlockRegistryProvider } from "@/shared/components/markdown/ui-blocks"; import { useChatMediaAttachmentActions } from "@/features/chat/hooks/use-chat-media-attachment-actions"; import { useChatModelControlState } from "@/features/chat/hooks/use-chat-model-control-state"; import { useChatModelOptionState } from "@/features/chat/hooks/use-chat-model-option-state"; @@ -50,6 +51,7 @@ import { useChatViewerProfile } from "@/features/chat/hooks/use-chat-viewer-prof import { useChatVisualPrompt } from "@/features/chat/hooks/use-chat-visual-prompt"; import { useChatConversationDefaults } from "@/features/chat/hooks/use-chat-conversation-defaults"; import { useChatTemporaryRuntime } from "@/features/chat/hooks/use-chat-temporary-runtime"; +import { useChatUIBlockStateRuntime } from "@/features/chat/hooks/use-chat-ui-block-state"; import { filterAvailableMCPToolIDs } from "@/features/chat/model/chat-mcp-tool-defaults"; import type { ChatRegenerateReasoningOptions, @@ -428,6 +430,9 @@ export function AppChatArea() { setAttachments((current) => current.filter((item) => !consumedIDs.has(item.fileID))); }, [setAttachments, transferAttachments]); + // Interactive component state lives above both runtimes: persistent chats write + // it per message, temporary chats only send it with the next turn. + const uiBlockState = useChatUIBlockStateRuntime(); const { currentLeafMessage, onCycleMessageBranch, @@ -439,6 +444,7 @@ export function AppChatArea() { onRetryAssistantMessage, onRetryUserMessage, onSendMessage, + onSendText, onStopMessage, onDeleteQueuedMessage, onEditQueuedMessage, @@ -485,6 +491,7 @@ export function AppChatArea() { onConversationRunStarted: registerConversationRun, resumingActivityLabel, resumingRunID, + collectUIStates: uiBlockState.collectTurnUIStates, }); const generating = sending; const uploadDropDisabled = loading || uploading; @@ -669,15 +676,30 @@ export function AppChatArea() { onDraftChange: setDraft, onAttachmentsConsumed: onTemporaryAttachmentsConsumed, releaseAttachments, + collectUIStates: uiBlockState.collectTurnUIStates, }); const displayMessages = temporaryMode ? temporaryRuntime.messages : messagesWithInlineError; + const chatUIBlockState = React.useMemo( + () => ({ store: uiBlockState.store, conversationPublicID: temporaryMode ? null : conversationID }), + [conversationID, temporaryMode, uiBlockState.store], + ); const artifactWorkspace = useChatArtifacts({ scopeKey: conversationID, transient: temporaryMode, messages: displayMessages, }); const { workspaceRef, artifactResizing, onArtifactResizeStart } = useChatArtifactResize(artifactWorkspace); - const hasInlineArtifact = Boolean(artifactWorkspace.activeArtifact && artifactWorkspace.isInlineViewport); + const activeBlock = artifactWorkspace.activeBlock; + const activeBlockMessage = activeBlock ? displayMessages.find((message) => message.key === activeBlock.messageKey) : undefined; + const blockPanel = activeBlock ? ( + + ) : null; + const hasInlineArtifact = Boolean((artifactWorkspace.activeArtifact || activeBlock) && artifactWorkspace.isInlineViewport); const workspaceGridColumns = hasInlineArtifact ? `minmax(0, ${1 - artifactWorkspace.artifactRatio}fr) minmax(0, ${artifactWorkspace.artifactRatio}fr)` : "minmax(0, 1fr) minmax(0, 0fr)"; @@ -697,6 +719,35 @@ export function AppChatArea() { }); const composerSending = temporaryMode ? temporaryRuntime.sending : generating; + const sendTemporaryText = temporaryRuntime.sendText; + // A custom component's ask fills the composer. It may replace an empty draft + // or text a component put there, never what the user typed. + const draftRef = React.useRef(draft); + draftRef.current = draft; + const prefilledRef = React.useRef(""); + const prefillComposer = React.useCallback( + (text: string) => { + const current = draftRef.current; + if (current.trim() && current !== prefilledRef.current) { + toast.info(t("markdown.uiBlock.prefillSkipped")); + return; + } + prefilledRef.current = text; + setDraft(text); + toast.success(t("markdown.uiBlock.prefilled")); + }, + [setDraft, t], + ); + const uiBlockActions = React.useMemo( + () => ({ + ask: (text: string) => { + void (temporaryMode ? sendTemporaryText(text) : onSendText(text)); + }, + prefill: prefillComposer, + busy: composerSending, + }), + [composerSending, onSendText, prefillComposer, sendTemporaryText, temporaryMode], + ); const composerConversationMode = temporaryMode ? temporaryRuntime.messages.length > 0 : isConversationMode; const composerLoading = !temporaryMode && @@ -773,6 +824,7 @@ export function AppChatArea() { return ( +
) : null}
+
); } diff --git a/apps/web/features/chat/components/message/message-bot.tsx b/apps/web/features/chat/components/message/message-bot.tsx index ec9493081..40c3ca08e 100644 --- a/apps/web/features/chat/components/message/message-bot.tsx +++ b/apps/web/features/chat/components/message/message-bot.tsx @@ -36,6 +36,7 @@ import { cn } from "@/lib/utils"; import { type FileContentLoader, PreviewMedia } from "@/entities/file"; import { type MarkdownArtifactActions, MarkdownImage } from "@/shared/components/markdown/streamdown-components"; import { StreamdownRender } from "@/shared/components/markdown/streamdown-render"; +import type { UIBlockStateBinding } from "@/shared/components/markdown/ui-blocks/ui-block-state"; import { MediaActionBar, MediaActionButton } from "@/shared/components/media-action-bar"; import { useBranding } from "@/shared/config/branding-provider"; import { useCopyAction } from "@/shared/components/copy-action"; @@ -164,6 +165,8 @@ type ChatMessageBotProps = { onEditImageAttachment?: (attachment: MessageAttachment, sourceModelName?: string) => void; onExtendVideoAttachment?: (attachment: MessageAttachment, sourceModelName?: string) => void; artifactActions?: MarkdownArtifactActions; + // Persisted state of the interactive component blocks in this reply. + uiBlockState?: UIBlockStateBinding; showBranchNavigator?: boolean; contentWidthClassName?: string; screenshotMeta?: React.ReactNode; @@ -199,6 +202,7 @@ export function ChatMessageBot({ onEditImageAttachment, onExtendVideoAttachment, artifactActions, + uiBlockState, showBranchNavigator = true, contentWidthClassName = "max-w-[1080px]", screenshotMeta, @@ -418,6 +422,7 @@ export function ChatMessageBot({ autoExpandThinking={autoExpandThinking} imageActions={markdownImageActions} artifactActions={artifactActions} + uiBlockState={uiBlockState} /> ) : hasInlineContent ? (

{streamdownContent}

@@ -430,6 +435,7 @@ export function ChatMessageBot({ autoExpandThinking={autoExpandThinking} imageActions={markdownImageActions} artifactActions={artifactActions} + uiBlockState={uiBlockState} /> ) : hasStreamdownContent ? (

{item.content}

diff --git a/apps/web/features/chat/components/sections/chat-area.tsx b/apps/web/features/chat/components/sections/chat-area.tsx index a0644d077..528665adc 100644 --- a/apps/web/features/chat/components/sections/chat-area.tsx +++ b/apps/web/features/chat/components/sections/chat-area.tsx @@ -29,7 +29,9 @@ import { import { ChatResponseOutlineRail } from "@/features/chat/components/sections/chat-response-outline-rail"; import { ChatScreenshotSelectionBar } from "@/features/chat/components/sections/chat-screenshot-selection-bar"; import { useChatMessageFeedback } from "@/features/chat/hooks/use-chat-message-feedback"; +import { useChatUIBlockStateBinding } from "@/features/chat/hooks/use-chat-ui-block-state"; import type { OpenCodeArtifactInput } from "@/features/chat/model/chat-artifacts"; +import type { ChatUIBlockStateStore } from "@/features/chat/model/chat-ui-block-state-store"; import { areChatAreaMessagesRenderEqual } from "@/features/chat/model/chat-message-render"; import { MAX_SCREENSHOT_MESSAGES } from "@/features/chat/model/conversation-screenshot"; import type { ChatRegenerateReasoningOptions } from "@/features/chat/model/chat-reasoning-effort"; @@ -41,6 +43,8 @@ import { ConversationShareExportIconDropdown } from "@/entities/conversation"; import { useCopyAction } from "@/shared/components/copy-action"; import type { FileContentLoader } from "@/entities/file"; import { StreamdownRender } from "@/shared/components/markdown/streamdown-render"; +import { messageUIBlocksText, useUIBlockRegistry } from "@/shared/components/markdown/ui-blocks"; +import { isUIBlockState } from "@/shared/components/markdown/ui-blocks/ui-block-state"; import { PoweredByDeeix } from "@/shared/components/powered-by-deeix"; import { useBranding } from "@/shared/config/branding-provider"; import type { BillingDisplayCurrency } from "@/entities/billing"; @@ -92,6 +96,12 @@ type ChatAreaProps = { messages: ChatAreaMessage[]; messagesReadOnly?: boolean; persistMessageFeedback?: boolean; + // Interactive component state. Absent: blocks keep plain local state. + uiBlockState?: { + store: ChatUIBlockStateStore; + // Conversation the state is written to; null keeps it in memory only. + conversationPublicID: string | null; + }; busy: boolean; messageContentRef: React.RefObject; onScroll: (event: React.UIEvent) => void; @@ -112,6 +122,8 @@ type ChatAreaProps = { onEditImageAttachment?: (attachment: MessageAttachment, sourceModelName?: string) => void; onExtendVideoAttachment?: (attachment: MessageAttachment, sourceModelName?: string) => void; onOpenCodeArtifact?: (message: ChatAreaMessage, artifact: OpenCodeArtifactInput) => void; + // Opens an interactive block of the message in the side panel. + onOpenUIBlock?: (message: ChatAreaMessage, blockKey: string) => void; onCycleMessageBranch: (parentPublicID: string | null, direction: "previous" | "next") => void; onToggleStar?: () => void | Promise; onRename?: (title: string) => void | Promise; @@ -265,6 +277,8 @@ function useStableEvent(callback: (...args: Args const ChatMessageRow = React.memo(function ChatMessageRow({ item, + uiBlockStateStore, + uiBlockStateConversationID, busy, readOnly, reaction, @@ -286,6 +300,7 @@ const ChatMessageRow = React.memo(function ChatMessageRow({ onCycleMessageBranch, onReactAssistantMessage, onOpenCodeArtifact, + onOpenUIBlock, markdownRender, autoExpandThinking, autoExpandToolCalls, @@ -303,6 +318,8 @@ const ChatMessageRow = React.memo(function ChatMessageRow({ screenshotMetaTimestamp, }: { item: ChatAreaMessage; + uiBlockStateStore?: ChatUIBlockStateStore; + uiBlockStateConversationID: string | null; busy: boolean; readOnly: boolean; reaction: AssistantReaction; @@ -325,6 +342,7 @@ const ChatMessageRow = React.memo(function ChatMessageRow({ onCycleMessageBranch: (parentPublicID: string | null, direction: "previous" | "next") => void; onReactAssistantMessage: (publicID: string, reaction: AssistantReaction) => void; onOpenCodeArtifact?: (message: ChatAreaMessage, artifact: OpenCodeArtifactInput) => void; + onOpenUIBlock?: (message: ChatAreaMessage, blockKey: string) => void; markdownRender: boolean; autoExpandThinking: boolean; autoExpandToolCalls: boolean; @@ -359,14 +377,17 @@ const ChatMessageRow = React.memo(function ChatMessageRow({ }); const isUser = item.role === "user"; const isAssistant = item.role === "assistant"; + // Read-only rows (screenshots of a shared view) keep block changes local. + const uiBlockStateBinding = useChatUIBlockStateBinding(uiBlockStateStore, item, uiBlockStateConversationID, !readOnly); const artifactActions = React.useMemo( () => isAssistant && onOpenCodeArtifact ? { onOpenCodeArtifact: (artifact: OpenCodeArtifactInput) => onOpenCodeArtifact(item, artifact), + onOpenUIBlock: onOpenUIBlock ? (blockKey: string) => onOpenUIBlock(item, blockKey) : undefined, } : undefined, - [isAssistant, item, onOpenCodeArtifact], + [isAssistant, item, onOpenCodeArtifact, onOpenUIBlock], ); const sourceSupportsVideoExtension = React.useMemo( () => @@ -382,9 +403,18 @@ const ChatMessageRow = React.memo(function ChatMessageRow({ ); const copyKey = item.publicID || item.key; + const uiBlockRegistry = useUIBlockRegistry(); + // Interactive blocks are copied as readable text in their current state, not + // as block source: the copy is for pasting where blocks do not render. const onCopy = React.useCallback(async () => { - await copy(item.content, { key: copyKey }); - }, [copy, copyKey, item.content]); + const text = isUser + ? item.content + : messageUIBlocksText(item.content, uiBlockRegistry, (blockKey) => { + const saved = uiBlockStateBinding ? uiBlockStateBinding.read(blockKey) : item.uiState?.[blockKey]; + return isUIBlockState(saved) ? saved : undefined; + }); + await copy(text, { key: copyKey }); + }, [copy, copyKey, isUser, item.content, item.uiState, uiBlockRegistry, uiBlockStateBinding]); if (isUser) { return ( @@ -429,6 +459,7 @@ const ChatMessageRow = React.memo(function ChatMessageRow({ sourceSupportsVideoExtension ? onExtendVideoAttachment : undefined } artifactActions={artifactActions} + uiBlockState={uiBlockStateBinding} markdownRender={markdownRender} autoExpandThinking={autoExpandThinking} autoExpandToolCalls={autoExpandToolCalls} @@ -463,6 +494,8 @@ const ChatMessageRow = React.memo(function ChatMessageRow({ }, (previous, next) => ( previous.busy === next.busy && previous.readOnly === next.readOnly && + previous.uiBlockStateStore === next.uiBlockStateStore && + previous.uiBlockStateConversationID === next.uiBlockStateConversationID && previous.reaction === next.reaction && previous.markdownRender === next.markdownRender && previous.autoExpandThinking === next.autoExpandThinking && @@ -488,6 +521,7 @@ const ChatMessageRow = React.memo(function ChatMessageRow({ previous.onEditImageAttachment === next.onEditImageAttachment && previous.onExtendVideoAttachment === next.onExtendVideoAttachment && previous.onOpenCodeArtifact === next.onOpenCodeArtifact && + previous.onOpenUIBlock === next.onOpenUIBlock && areChatAreaMessagesRenderEqual(previous.item, next.item) )); @@ -498,6 +532,7 @@ export function ChatArea({ messages, messagesReadOnly = false, persistMessageFeedback = true, + uiBlockState, busy, messageContentRef, onScroll, @@ -517,6 +552,7 @@ export function ChatArea({ onEditImageAttachment, onExtendVideoAttachment, onOpenCodeArtifact, + onOpenUIBlock, onCycleMessageBranch, onToggleStar, onRename, @@ -697,6 +733,8 @@ export function ChatArea({ const row = ( void; @@ -64,6 +67,7 @@ const workspaceTransition = { ease: [0.16, 1, 0.3, 1] as const, }; const DESKTOP_SHELL_GUTTER_PX = 16; +const LIVE_SOURCE_FOLLOW_THRESHOLD_PX = 24; function artifactLanguageLabel(artifact: ChatArtifact): string { if (artifact.kind === "javascript") return "JS"; @@ -129,6 +133,41 @@ function ArtifactPreviewFrame({ documentHTML, title }: ArtifactPreviewFrameProps ); } +// Shown in the preview tab while the code block is still streaming. Follows the newest line +// unless the user has scrolled up to read. +function ArtifactLiveSource({ code, notice }: { code: string; notice: string }) { + const scrollRef = React.useRef(null); + const followRef = React.useRef(true); + + React.useEffect(() => { + const node = scrollRef.current; + if (node && followRef.current) { + node.scrollTop = node.scrollHeight; + } + }, [code]); + + const handleScroll = React.useCallback((event: React.UIEvent) => { + const node = event.currentTarget; + followRef.current = node.scrollHeight - node.scrollTop - node.clientHeight < LIVE_SOURCE_FOLLOW_THRESHOLD_PX; + }, []); + + return ( +
+

+ + {notice} +

+
+        {code}
+      
+
+ ); +} + function ChatArtifactPanel({ artifact, artifacts, @@ -150,21 +189,18 @@ function ChatArtifactPanel({ setPreviewTheme(captureHTMLVisualThemeSnapshot(resolvedTheme)); }, [chatFont, chatFontWeight, fontSize, preset, resolvedTheme]); - const artifactPreview = React.useMemo( - () => - artifact.kind === "svg" - ? ({ mode: "svg" } as const) - : ({ - documentHTML: buildArtifactPreviewDocument( - artifact.kind, - artifact.code, - previewTheme, - ), - mode: "frame", - } as const), - [artifact.code, artifact.kind, previewTheme], - ); + // A document is only built once its code block has closed: rebuilding srcdoc on every + // streamed chunk reloads the frame and re-runs a half-written page each time. + const artifactPreview = React.useMemo(() => { + if (!artifact.complete) return { mode: "pending" } as const; + if (artifact.kind === "svg") return { mode: "svg" } as const; + return { + documentHTML: buildArtifactPreviewDocument(artifact.kind, artifact.code, previewTheme), + mode: "frame", + } as const; + }, [artifact.code, artifact.complete, artifact.kind, previewTheme]); const canPreview = artifact.code.trim().length > 0; + const canDownload = canPreview && artifactPreview.mode !== "pending"; const artifactOptions = React.useMemo( () => artifacts.map((item, index) => ({ item, label: artifactLabel(item, index) })), [artifacts], @@ -179,10 +215,12 @@ function ChatArtifactPanel({ ); return; } - downloadBlob( - new Blob([artifactPreview.documentHTML], { type: "text/html;charset=utf-8" }), - resolveArtifactDownloadName(artifact.kind), - ); + if (artifactPreview.mode === "frame") { + downloadBlob( + new Blob([artifactPreview.documentHTML], { type: "text/html;charset=utf-8" }), + resolveArtifactDownloadName(artifact.kind), + ); + } }, [artifact.kind, artifact.code, artifactPreview, canPreview]); return ( @@ -239,7 +277,7 @@ function ChatArtifactPanel({ @@ -251,26 +289,26 @@ function ChatArtifactPanel({ - {canPreview ? ( - artifactPreview.mode === "svg" ? ( - - ) : ( - - ) - ) : ( + {!canPreview ? (
{t("empty")}
+ ) : artifactPreview.mode === "pending" ? ( + + ) : artifactPreview.mode === "svg" ? ( + + ) : ( + )}
@@ -286,6 +324,7 @@ function ChatArtifactPanel({ export function ChatArtifactWorkspace({ artifact, + blockPanel = null, artifacts, isInlineViewport, onArtifactChange, @@ -294,7 +333,8 @@ export function ChatArtifactWorkspace({ onResizeStart, }: ChatArtifactWorkspaceProps) { const t = useTranslations("chat.artifacts"); - const isDesktopOpen = Boolean(artifact && isInlineViewport); + const open = Boolean(artifact || blockPanel); + const isDesktopOpen = open && isInlineViewport; const desktopBleedWidth = `calc(100% + ${DESKTOP_SHELL_GUTTER_PX}px)`; return ( @@ -314,7 +354,7 @@ export function ChatArtifactWorkspace({ }} > - {artifact ? ( + {open ? ( - + {blockPanel ?? (artifact ? ( + + ) : null)} ) : null} - {artifact ? ( + {open ? ( - + {blockPanel ?? (artifact ? ( + + ) : null)} ) : null} diff --git a/apps/web/features/chat/components/sections/chat-ui-block-panel.tsx b/apps/web/features/chat/components/sections/chat-ui-block-panel.tsx new file mode 100644 index 000000000..dee946766 --- /dev/null +++ b/apps/web/features/chat/components/sections/chat-ui-block-panel.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { parseBlockSource, scanUIBlockFences, widgetKey, widgetTitle } from "@deeix/core"; +import { X } from "lucide-react"; +import { useTranslations } from "next-intl"; +import * as React from "react"; + +import { Button } from "@/components/ui/button"; +import type { ChatUIBlockStateStore } from "@/features/chat/model/chat-ui-block-state-store"; +import type { ChatAreaMessage } from "@/features/chat/types/messages"; +import { UIBlockHost, UIWidgetHost } from "@/shared/components/markdown/ui-blocks"; +import { UIBlockStateProvider, useMessageUIBlockStateBinding } from "@/shared/components/markdown/ui-blocks/ui-block-state"; + +type ChatUIBlockPanelProps = { + // Undefined once the message is no longer shown (other branch, deleted). + message: ChatAreaMessage | undefined; + blockKey: string; + store: ChatUIBlockStateStore; + onClose: () => void; +}; + +// An interactive block of a reply, opened in the side panel so it stays in view +// while the conversation goes on. It reads and writes the message's block state +// through the same store as the inline copy, so both always show the same. +export function ChatUIBlockPanel({ message, blockKey, store, onClose }: ChatUIBlockPanelProps) { + const t = useTranslations("chat.markdown.uiBlock"); + const content = message?.content ?? ""; + const located = React.useMemo(() => findBlock(content, blockKey), [blockKey, content]); + const serverStateRef = React.useRef(message?.uiState); + serverStateRef.current = message?.uiState; + const messageKey = message?.key ?? ""; + const binding = React.useMemo( + () => (messageKey ? store.bindingFor(messageKey, () => serverStateRef.current) : undefined), + [messageKey, store], + ); + const guardedBinding = useMessageUIBlockStateBinding(binding, content); + + // The block left the conversation on screen (branch switch, deletion, edit). + const missing = !located && !message?.isStreaming; + React.useEffect(() => { + if (missing) { + onClose(); + } + }, [missing, onClose]); + + const title = located?.title || t("panelTitle"); + const host = !located ? null : located.kind === "widget" ? ( + + ) : ( + + ); + return ( + + ); +} + +function findBlock(content: string, blockKey: string): { kind: "block" | "widget"; raw: string; title: string } | null { + for (const fence of scanUIBlockFences(content)) { + if (!fence.closed) { + continue; + } + if (fence.kind === "widget") { + if (widgetKey(fence.body) === blockKey) { + return { kind: "widget", raw: fence.body, title: widgetTitle(fence.body) }; + } + continue; + } + const parsed = parseBlockSource(fence.body, false); + if (parsed.status === "ok" && parsed.block.key === blockKey) { + const title = parsed.block.root.props.title; + return { kind: "block", raw: fence.body, title: typeof title === "string" ? title.trim() : "" }; + } + } + return null; +} diff --git a/apps/web/features/chat/hooks/use-chat-artifacts.ts b/apps/web/features/chat/hooks/use-chat-artifacts.ts index c90184dc1..30f11b783 100644 --- a/apps/web/features/chat/hooks/use-chat-artifacts.ts +++ b/apps/web/features/chat/hooks/use-chat-artifacts.ts @@ -124,6 +124,10 @@ export function useChatArtifacts({ scopeKey, transient = false, messages }: UseC const [dismissedArtifactID, setDismissedArtifactID] = React.useState(null); const [lastActiveArtifact, setLastActiveArtifact] = React.useState(null); const [customArtifactRatio, setCustomArtifactRatio] = React.useState(null); + // An interactive block opened in the panel. The panel shows one thing at a + // time: opening a code artifact (or one auto-opening while it streams) + // replaces the block, and opening a block closes the artifact. + const [activeBlock, setActiveBlock] = React.useState<{ messageKey: string; blockKey: string } | null>(null); const dismissedArtifactRef = React.useRef(null); const previousScopeRef = React.useRef({ key: scopeKey, transient }); const artifactRatio = customArtifactRatio ?? resolveDefaultRatio(inlineLayout); @@ -150,6 +154,7 @@ export function useChatArtifacts({ scopeKey, transient = false, messages }: UseC return; } previousScopeRef.current = { key: scopeKey, transient }; + setActiveBlock(null); setLastActiveArtifact(null); dismissedArtifactRef.current = null; setActiveArtifactID(null); @@ -210,6 +215,26 @@ export function useChatArtifacts({ scopeKey, transient = false, messages }: UseC setActiveArtifactID(null); }, [activeArtifact, latestArtifact]); + React.useEffect(() => { + if (activeArtifactID) { + setActiveBlock(null); + } + }, [activeArtifactID]); + + const openBlock = React.useCallback( + (message: ChatAreaMessage, blockKey: string) => { + if (activeArtifactID) { + closeArtifact(); + } + setActiveBlock({ messageKey: message.key, blockKey }); + }, + [activeArtifactID, closeArtifact], + ); + + const closeBlock = React.useCallback(() => { + setActiveBlock(null); + }, []); + const selectArtifact = React.useCallback((artifactID: string) => { setActiveArtifactID(artifactID); }, []); @@ -224,6 +249,9 @@ export function useChatArtifacts({ scopeKey, transient = false, messages }: UseC return { activeArtifact, + activeBlock: activeArtifact ? null : activeBlock, + closeBlock, + openBlock, artifactRatio, artifacts, closeArtifact, diff --git a/apps/web/features/chat/hooks/use-chat-message-submit.ts b/apps/web/features/chat/hooks/use-chat-message-submit.ts index 1e1ef57f2..7aefcb4ef 100644 --- a/apps/web/features/chat/hooks/use-chat-message-submit.ts +++ b/apps/web/features/chat/hooks/use-chat-message-submit.ts @@ -63,6 +63,7 @@ import type { ConversationDTO, ConversationOptions, MessageDTO, + MessageUIStateInput, SendMessageResult, StreamMessageEvent, } from "@/shared/api/conversation-types"; @@ -124,6 +125,7 @@ export function useChatMessageSubmit({ onConversationRunFinished, onConversationRunStarted, resumeGenerationActive = false, + collectUIStates, }: { conversationID: string | null; conversationScopeKey: string; @@ -177,6 +179,8 @@ export function useChatMessageSubmit({ onConversationRunFinished?: (runID: string) => void; onConversationRunStarted?: (runID: string, conversationPublicID: string) => void; resumeGenerationActive?: boolean; + // Interactive component state on the branch being continued, attached to chat turns. + collectUIStates?: (messages: readonly ChatAreaMessage[]) => MessageUIStateInput[]; }) { const t = useTranslations("chat.submit"); const activeStreamsRef = React.useRef(new Map()); @@ -342,6 +346,10 @@ export function useChatMessageSubmit({ return false; } const { plan } = planResult; + // Snapshot before the optimistic exchange joins the branch: the server keeps + // only blocks on the new message's own ancestor chain, so retries and edits + // can send the whole visible branch. + const turnUIStates = plan.submitTask === "chat" ? collectUIStates?.(visibleMessagesRef.current) : undefined; const { payloadContent, platformModelName, @@ -551,6 +559,7 @@ export function useChatMessageSubmit({ selectedSkills: plan.selectedSkills, selectedKnowledgeBaseIDs: plan.selectedKnowledgeBaseIDs, uiComponentIDs: plan.uiComponentIDs, + uiStates: turnUIStates, htmlVisualPromptEnabled: plan.htmlVisualPromptEnabled, parentMessagePublicID: resolvedParentPublicID, sourceMessagePublicID: plan.resolvedSourcePublicID, @@ -773,6 +782,7 @@ export function useChatMessageSubmit({ [ activeGenerationRunsRef, autoGenerateLabels, + collectUIStates, combinedMessages, flushStreamTextNow, flushUpstreamThinkNow, @@ -945,6 +955,32 @@ export function useChatMessageSubmit({ }); }, [attachments, currentLeafMessage?.publicID, draft, enqueueSubmission, resumeGenerationActive, sending, submitMessage, visibleMessages]); + // Sends text that did not come from the composer (an interactive block's + // `ask` button) as the next turn of the visible branch. The draft and its + // attachments stay as they are. Nothing is queued: while a reply is still + // running the button is disabled, and a stale click is ignored. + const onSendText = React.useCallback( + async (text: string) => { + const content = text.trim(); + if (!content || sending || resumeGenerationActive) { + return false; + } + const parentMessagePublicID = + resolvePersistedPublicID(currentLeafMessage?.publicID) ?? + resolveDefaultSubmissionParentMessage(visibleMessages)?.publicID ?? + null; + await submitMessage({ + content, + currentAttachments: [], + resetComposer: false, + parentMessagePublicID, + branchReason: "default", + }); + return true; + }, + [currentLeafMessage?.publicID, resumeGenerationActive, sending, submitMessage, visibleMessages], + ); + useChatQueueDispatch({ queuedSubmissions, setQueuedSubmissions, @@ -996,6 +1032,7 @@ export function useChatMessageSubmit({ onRetryAssistantMessage, onRetryUserMessage, onSendMessage, + onSendText, onStopMessage, onDeleteQueuedMessage, onEditQueuedMessage, diff --git a/apps/web/features/chat/hooks/use-chat-run-stream.ts b/apps/web/features/chat/hooks/use-chat-run-stream.ts index 809ea940c..d64c2b6af 100644 --- a/apps/web/features/chat/hooks/use-chat-run-stream.ts +++ b/apps/web/features/chat/hooks/use-chat-run-stream.ts @@ -1,5 +1,6 @@ "use client"; +import { UI_BLOCK_FORMAT_VERSION } from "@deeix/core"; import { splitControlSelections } from "@/features/chat/model/chat-model-controls"; import { useTranslations } from "next-intl"; import * as React from "react"; @@ -26,6 +27,7 @@ import type { MediaImageRequest, MediaVideoExtensionRequest, MediaVideoRequest, + MessageUIStateInput, SendMessageRequest, SendMessageResult, StreamMessageEvent, @@ -75,6 +77,7 @@ export function useChatRunStream({ selectedSkills, selectedKnowledgeBaseIDs, uiComponentIDs, + uiStates, htmlVisualPromptEnabled, parentMessagePublicID, sourceMessagePublicID, @@ -95,6 +98,7 @@ export function useChatRunStream({ selectedSkills: SkillSummaryDTO[]; selectedKnowledgeBaseIDs: string[]; uiComponentIDs: number[]; + uiStates?: MessageUIStateInput[]; htmlVisualPromptEnabled: boolean; parentMessagePublicID: string | null; sourceMessagePublicID: string | null; @@ -259,6 +263,8 @@ export function useChatRunStream({ knowledgeBaseIDs: selectedKnowledgeBaseIDs, htmlVisualPrompt: htmlVisualPromptEnabled || undefined, uiComponentIDs: uiComponentIDs.length > 0 ? uiComponentIDs : undefined, + uiBlockVersion: uiComponentIDs.length > 0 ? UI_BLOCK_FORMAT_VERSION : undefined, + uiStates: uiStates && uiStates.length > 0 ? uiStates : undefined, }; completed = await streamConversationMessage(token, conversationID, chatPayload, streamOptions); } else if (submitTask === "video_generation") { diff --git a/apps/web/features/chat/hooks/use-chat-runtime.ts b/apps/web/features/chat/hooks/use-chat-runtime.ts index dc4a1e28c..e3590ccde 100644 --- a/apps/web/features/chat/hooks/use-chat-runtime.ts +++ b/apps/web/features/chat/hooks/use-chat-runtime.ts @@ -4,10 +4,12 @@ import * as React from "react"; import { useChatBranchState } from "@/features/chat/hooks/use-chat-branch-state"; import { useChatSubmitStream } from "@/features/chat/hooks/use-chat-submit-stream"; import type { ChatModelOption, PendingAttachment, PendingExchangeMap } from "@/features/chat/types/chat-runtime"; +import type { ChatAreaMessage } from "@/features/chat/types/messages"; import type { ConversationDTO, ConversationOptions, MessageDTO, + MessageUIStateInput, } from "@/shared/api/conversation-types"; import type { SkillSummaryDTO } from "@/shared/api/skills-types"; @@ -121,6 +123,7 @@ export function useChatRuntime({ onConversationRunStarted, resumingRunID = "", resumingActivityLabel = "", + collectUIStates, }: { conversationID: string | null; resetToken: number; @@ -158,6 +161,8 @@ export function useChatRuntime({ onConversationRunStarted?: (runID: string, conversationPublicID: string) => void; resumingRunID?: string; resumingActivityLabel?: string; + // Interactive component state on the branch being continued, attached to chat turns. + collectUIStates?: (messages: readonly ChatAreaMessage[]) => MessageUIStateInput[]; }) { const [showConversationLayout, setShowConversationLayout] = React.useState(false); const previousResetTokenRef = React.useRef(resetToken); @@ -247,6 +252,7 @@ export function useChatRuntime({ onConversationRunFinished, onConversationRunStarted, resumeGenerationActive: visibleResumeGenerationActive, + collectUIStates, }); React.useEffect(() => { @@ -278,6 +284,7 @@ export function useChatRuntime({ onRetryAssistantMessage: submitState.onRetryAssistantMessage, onRetryUserMessage: submitState.onRetryUserMessage, onSendMessage: submitState.onSendMessage, + onSendText: submitState.onSendText, onStopMessage: submitState.onStopMessage, onDeleteQueuedMessage: submitState.onDeleteQueuedMessage, onEditQueuedMessage: submitState.onEditQueuedMessage, diff --git a/apps/web/features/chat/hooks/use-chat-submit-stream.ts b/apps/web/features/chat/hooks/use-chat-submit-stream.ts index 9ed4c2cb2..62154a365 100644 --- a/apps/web/features/chat/hooks/use-chat-submit-stream.ts +++ b/apps/web/features/chat/hooks/use-chat-submit-stream.ts @@ -14,6 +14,7 @@ import type { ConversationDTO, ConversationOptions, MessageDTO, + MessageUIStateInput, } from "@/shared/api/conversation-types"; import type { SkillSummaryDTO } from "@/shared/api/skills-types"; @@ -63,6 +64,7 @@ export function useChatSubmitStream({ onConversationRunFinished, onConversationRunStarted, resumeGenerationActive, + collectUIStates, }: { conversationID: string | null; conversationScopeKey: string; @@ -109,6 +111,7 @@ export function useChatSubmitStream({ onConversationRunFinished?: (runID: string) => void; onConversationRunStarted?: (runID: string, conversationPublicID: string) => void; resumeGenerationActive?: boolean; + collectUIStates?: (messages: readonly ChatAreaMessage[]) => MessageUIStateInput[]; }) { const streamBuffer = useChatStreamBuffer({ setPendingExchanges, @@ -167,6 +170,7 @@ export function useChatSubmitStream({ onConversationRunFinished, onConversationRunStarted, resumeGenerationActive, + collectUIStates, }); return messageSubmit; diff --git a/apps/web/features/chat/hooks/use-chat-temporary-runtime.ts b/apps/web/features/chat/hooks/use-chat-temporary-runtime.ts index 4e534b230..bdcfb0a76 100644 --- a/apps/web/features/chat/hooks/use-chat-temporary-runtime.ts +++ b/apps/web/features/chat/hooks/use-chat-temporary-runtime.ts @@ -1,5 +1,6 @@ "use client"; +import { UI_BLOCK_FORMAT_VERSION } from "@deeix/core"; import { useTranslations } from "next-intl"; import * as React from "react"; import { toast } from "sonner"; @@ -26,7 +27,12 @@ import { TEMPORARY_CHAT_MAX_IMAGE_ATTACHMENTS, } from "@/shared/api/conversation"; import type { TemporaryChatRequestAttachment } from "@/shared/api/conversation"; -import type { ConversationOptions, TemporaryChatHistoryMessage } from "@/shared/api/conversation-types"; +import type { + ConversationOptions, + MessageUIStateInput, + TemporaryChatHistoryMessage, + TemporaryChatUIStateInput, +} from "@/shared/api/conversation-types"; import type { FileContentLoader } from "@/entities/file"; import type { ReasoningEffortLevel } from "@/entities/model"; import { resolveAccessToken } from "@/shared/auth/resolve-access-token"; @@ -70,6 +76,9 @@ type TemporaryChatRuntimeInput = { onDraftChange: (value: string) => void; onAttachmentsConsumed: (items: PendingAttachment[]) => void; releaseAttachments: (items: PendingAttachment[]) => void; + // Interactive component state of earlier replies, attached to the next turn. + // Temporary chats are never stored, so this is the only place it goes. + collectUIStates?: (messages: readonly ChatAreaMessage[]) => MessageUIStateInput[]; }; type SubmitTemporaryTurnInput = { @@ -102,6 +111,58 @@ function toMessageAttachment(item: PendingAttachment): MessageAttachment { }; } +function toAreaMessage(message: TemporaryMessage): ChatAreaMessage { + return { + key: message.id, + publicID: message.id, + parentPublicID: message.parentID ?? null, + sourcePublicID: null, + role: message.role, + content: message.content, + branchReason: "default", + status: message.failed ? "failed" : message.streaming ? "processing" : "completed", + isStreaming: message.streaming, + platformModelName: message.model, + runID: message.runID, + inputTokens: message.inputTokens, + outputTokens: message.outputTokens, + latencyMS: message.latencyMS, + reasoningEffort: message.reasoningEffort, + activityLabel: message.activityLabel, + processTrace: message.processTrace, + knowledgeSources: message.knowledgeSources, + inlineAlert: message.inlineAlert, + attachments: message.attachments, + }; +} + +// Temporary chats have no message ids on the server: a block is located by the +// index of its reply in the request's `messages`. A reply sits right after its +// user turn in the history; the request may drop the oldest entries, so indexes +// shift by however many were dropped. +function toTemporaryUIStates( + entries: readonly MessageUIStateInput[], + messages: readonly TemporaryMessage[], + fullHistory: readonly TemporaryHistoryMessage[], + requestLength: number, +): TemporaryChatUIStateInput[] { + const dropped = fullHistory.length - requestLength; + const result: TemporaryChatUIStateInput[] = []; + for (const entry of entries) { + const message = messages.find((item) => item.id === entry.messagePublicID && item.role === "assistant"); + if (!message) { + continue; + } + const historyIndex = message.historyOffset + 1; + const messageIndex = historyIndex - dropped; + if (messageIndex < 0 || messageIndex >= requestLength || fullHistory[historyIndex]?.role !== "assistant") { + continue; + } + result.push({ messageIndex, blockID: entry.blockID, title: entry.title, state: entry.state }); + } + return result; +} + function isImageAttachment(item: PendingAttachment): boolean { return item.fileCategory === "image" || item.mimeType.startsWith("image/"); } @@ -182,6 +243,7 @@ export function useChatTemporaryRuntime({ onDraftChange, onAttachmentsConsumed, releaseAttachments, + collectUIStates, }: TemporaryChatRuntimeInput) { const t = useTranslations("chat.temporary"); const tSubmit = useTranslations("chat.submit"); @@ -400,7 +462,16 @@ export function useChatTemporaryRuntime({ content: normalizedContent, attachments: localAttachments, }; - const preparedRequest = prepareTemporaryRequestHistory([...baseHistory, userHistory]); + const fullHistory = [...baseHistory, userHistory]; + const preparedRequest = prepareTemporaryRequestHistory(fullHistory); + const turnUIStates = collectUIStates + ? toTemporaryUIStates( + collectUIStates(currentMessages.slice(0, insertionIndex).map(toAreaMessage)), + currentMessages, + fullHistory, + preparedRequest.messages.length, + ) + : []; let streamedAssistantText = ""; let moderationBlocked = false; @@ -419,6 +490,8 @@ export function useChatTemporaryRuntime({ knowledgeBaseIDs: selectedKnowledgeBaseIDs.length > 0 ? selectedKnowledgeBaseIDs : undefined, htmlVisualPrompt: htmlVisualPromptEnabled || undefined, uiComponentIDs: uiComponentIDs.length > 0 ? uiComponentIDs : undefined, + uiBlockVersion: uiComponentIDs.length > 0 ? UI_BLOCK_FORMAT_VERSION : undefined, + uiStates: turnUIStates.length > 0 ? turnUIStates : undefined, messages: preparedRequest.messages, }, { @@ -542,6 +615,7 @@ export function useChatTemporaryRuntime({ return true; }, [ active, + collectUIStates, finishSending, htmlVisualPromptEnabled, model, @@ -573,6 +647,24 @@ export function useChatTemporaryRuntime({ }); }, [attachments, draft, submitTurn, t]); + // An interactive block's `ask`: the text becomes the next turn; the draft and + // its attachments are left alone. + const sendText = React.useCallback( + async (text: string) => { + const content = text.trim(); + if (!content || sendingRef.current) { + return false; + } + return submitTurn({ + content, + localAttachments: [], + baseHistory: [...historyRef.current], + consumeComposer: false, + }); + }, + [submitTurn], + ); + const resolveUserTurn = React.useCallback((message: ChatAreaMessage) => { const messageIndex = messagesRef.current.findIndex((item) => item.id === message.publicID); if (messageIndex < 0) { @@ -672,36 +764,13 @@ export function useChatTemporaryRuntime({ return true; }, [replaceMessageTail, resolveUserTurn, t, updateMessage]); - const areaMessages = React.useMemo( - () => messages.map((message): ChatAreaMessage => ({ - key: message.id, - publicID: message.id, - parentPublicID: message.parentID ?? null, - sourcePublicID: null, - role: message.role, - content: message.content, - branchReason: "default", - status: message.failed ? "failed" : message.streaming ? "processing" : "completed", - isStreaming: message.streaming, - platformModelName: message.model, - runID: message.runID, - inputTokens: message.inputTokens, - outputTokens: message.outputTokens, - latencyMS: message.latencyMS, - reasoningEffort: message.reasoningEffort, - activityLabel: message.activityLabel, - processTrace: message.processTrace, - knowledgeSources: message.knowledgeSources, - inlineAlert: message.inlineAlert, - attachments: message.attachments, - })), - [messages], - ); + const areaMessages = React.useMemo(() => messages.map(toAreaMessage), [messages]); return { messages: areaMessages, sending, send, + sendText, stop, loadAttachmentContent, onRetryUserMessage: retryMessage, diff --git a/apps/web/features/chat/hooks/use-chat-ui-block-state.ts b/apps/web/features/chat/hooks/use-chat-ui-block-state.ts new file mode 100644 index 000000000..c288a1367 --- /dev/null +++ b/apps/web/features/chat/hooks/use-chat-ui-block-state.ts @@ -0,0 +1,160 @@ +"use client"; + +import { readMessageUIBlocks } from "@deeix/core"; +import * as React from "react"; + +import { + type ChatUIBlockStateStore, + createChatUIBlockStateStore, + type UIBlockStateTarget, +} from "@/features/chat/model/chat-ui-block-state-store"; +import type { ChatAreaMessage } from "@/features/chat/types/messages"; +import { setMessageUIState } from "@/shared/api/conversation"; +import type { MessageUIStateInput } from "@/shared/api/conversation-types"; +import { resolveAccessToken } from "@/shared/auth/resolve-access-token"; +import type { UIBlockState, UIBlockStateBinding } from "@/shared/components/markdown/ui-blocks/ui-block-state"; + +// How many blocks one turn carries. The server keeps the most recent few within +// its own budget; sending more only spends request bytes. +const MAX_TURN_UI_STATES = 32; + +type ChatTurnUIState = { + blockID: string; + title?: string; + state: UIBlockState; +}; + +type ChatUIBlockStateRuntime = { + store: ChatUIBlockStateStore; + // Block state of one message as the user currently sees it. + statesFor: (message: Pick) => ChatTurnUIState[]; + // Block state of the messages on the branch being continued, for the turn + // being sent. The server keeps only messages on the new message's own branch. + collectTurnUIStates: (messages: readonly ChatAreaMessage[]) => MessageUIStateInput[]; +}; + +// One store for the lifetime of the chat area. Changes stay in memory after they +// are written, so switching away and back never shows an older server snapshot. +export function useChatUIBlockStateRuntime(): ChatUIBlockStateRuntime { + const [store] = React.useState(() => + createChatUIBlockStateStore({ + persist: async (target, blocks, { keepalive }) => { + const token = await resolveAccessToken(); + if (!token) { + return; + } + await setMessageUIState( + token, + target.conversationPublicID, + target.messagePublicID, + { blocks: Object.fromEntries(Object.entries(blocks).map(([blockID, state]) => [blockID, state ?? null])) }, + { keepalive }, + ); + }, + }), + ); + + // Pending changes are written when the page is hidden or unloaded, with + // keepalive so the browser does not cancel them; a reload then restores the + // same view. Each request carries one message's blocks (a few KB at most), + // well under the browser's keepalive budget. + React.useEffect(() => { + const flushOnLeave = () => { + void store.flush({ keepalive: true }); + }; + const onVisibilityChange = () => { + if (document.visibilityState === "hidden") { + flushOnLeave(); + } + }; + document.addEventListener("visibilitychange", onVisibilityChange); + window.addEventListener("pagehide", flushOnLeave); + return () => { + document.removeEventListener("visibilitychange", onVisibilityChange); + window.removeEventListener("pagehide", flushOnLeave); + // Leaving the chat area: the page stays alive, a normal write is enough. + // The store itself is reused if React re-runs this effect in development. + void store.flush(); + }; + }, [store]); + + const statesFor = React.useCallback( + (message: Pick): ChatTurnUIState[] => { + const states = store.stateFor(message.key, message.uiState); + if (states.size === 0) { + return []; + } + // A block the message no longer contains (the reply was edited) is not + // something the model can relate the state to; neither is a key that + // several of its blocks share. + const blocks = new Map(readMessageUIBlocks(message.content).blocks.map((block) => [block.key, block])); + const result: ChatTurnUIState[] = []; + states.forEach(({ state, title }, blockID) => { + const block = blocks.get(blockID); + if (block) { + result.push({ blockID, title: title ?? block.title, state }); + } + }); + return result; + }, + [store], + ); + + const collectTurnUIStates = React.useCallback( + (messages: readonly ChatAreaMessage[]): MessageUIStateInput[] => { + const entries: MessageUIStateInput[] = []; + for (const message of messages) { + if (message.role !== "assistant" || message.isPending || message.isStreaming) { + continue; + } + const messagePublicID = message.publicID.trim(); + if (!messagePublicID) { + continue; + } + for (const { blockID, title, state } of statesFor(message)) { + entries.push({ messagePublicID, blockID, title, state }); + } + } + // Closest to the turn first when the budget is exceeded. + return entries.length > MAX_TURN_UI_STATES ? entries.slice(entries.length - MAX_TURN_UI_STATES) : entries; + }, + [statesFor], + ); + + return React.useMemo(() => ({ store, statesFor, collectTurnUIStates }), [collectTurnUIStates, statesFor, store]); +} + +// The binding a message row hands to its blocks, plus where the message's state +// is written. A message still streaming has no row yet: its changes wait in +// memory until it settles. Without a conversation (temporary chat) or without +// `persist`, changes stay local and only feed the next turn. +export function useChatUIBlockStateBinding( + store: ChatUIBlockStateStore | undefined, + message: Pick, + conversationPublicID: string | null | undefined, + persist: boolean, +): UIBlockStateBinding | undefined { + const serverStateRef = React.useRef(message.uiState); + serverStateRef.current = message.uiState; + + const settled = !message.isPending && !message.isStreaming && message.status !== "pending"; + const target = React.useMemo(() => { + const conversationID = conversationPublicID?.trim() ?? ""; + const messagePublicID = message.publicID.trim(); + if (!persist || !settled || message.role !== "assistant" || !conversationID || !messagePublicID) { + return undefined; + } + return { conversationPublicID: conversationID, messagePublicID }; + }, [conversationPublicID, message.publicID, message.role, persist, settled]); + + React.useEffect(() => { + store?.setTarget(message.key, target); + }, [message.key, store, target]); + + // Stable per message: StreamdownRender is memoized, and a new binding would + // re-render every block on each stream tick. + return React.useMemo( + () => (store && message.role === "assistant" ? store.bindingFor(message.key, () => serverStateRef.current) : undefined), + [message.key, message.role, store], + ); +} diff --git a/apps/web/features/chat/model/chat-message-render.ts b/apps/web/features/chat/model/chat-message-render.ts index 44603a7f7..619c7719f 100644 --- a/apps/web/features/chat/model/chat-message-render.ts +++ b/apps/web/features/chat/model/chat-message-render.ts @@ -5,6 +5,7 @@ import type { ChatMessageProcessTrace, MessageAttachment, } from "@/features/chat/types/messages"; +import { deepEquals } from "@/shared/components/markdown/ui-blocks/ui-block-state"; function areBranchNavigatorsEqual( previous: ChatMessageBranchNavigator | undefined, @@ -186,6 +187,8 @@ export function areChatAreaMessagesRenderEqual( areAttachmentsEqual(previous.attachments, next.attachments) && areProcessTracesEqual(previous.processTrace, next.processTrace) && areInlineAlertsEqual(previous.inlineAlert, next.inlineAlert) && - areCompactDoneEqual(previous.compactDone, next.compactDone) + areCompactDoneEqual(previous.compactDone, next.compactDone) && + // A reloaded message list brings a new snapshot object with the same content. + deepEquals(previous.uiState, next.uiState) ); } diff --git a/apps/web/features/chat/model/chat-thread.ts b/apps/web/features/chat/model/chat-thread.ts index b9b7ea806..49d5431b8 100644 --- a/apps/web/features/chat/model/chat-thread.ts +++ b/apps/web/features/chat/model/chat-thread.ts @@ -2,6 +2,7 @@ import { MODERATION_BLOCKED_BILLED_REASON, parseBillingSnapshot } from "@/featur import type { ChatAreaMessage, MessageAttachment } from "@/features/chat/types/messages"; import type { MessageDTO, UpstreamDebugInfo } from "@/shared/api/conversation-types"; import { normalizeMessageReasoningEffort } from "@/features/chat/model/chat-reasoning-effort"; +import { isUIBlockState } from "@/shared/components/markdown/ui-blocks/ui-block-state"; import { isRecord, parseJSON } from "@/shared/lib/type-guards"; function parseAttachmentDurationSeconds(value: unknown): number | undefined { @@ -228,6 +229,12 @@ export function mapServerMessage( msg.cacheWriteTokens = item.cacheWriteTokens ?? 0; } if (item.role === "assistant") { + // Only the keys the user changed are sent back; unknown shapes are dropped so + // a malformed payload never reaches a block. + const uiState = isUIBlockState(item.uiState) ? item.uiState : undefined; + if (uiState && Object.keys(uiState).length > 0) { + msg.uiState = uiState; + } msg.inputTokens = item.inputTokens ?? 0; msg.outputTokens = item.outputTokens ?? 0; msg.cacheReadTokens = item.cacheReadTokens ?? 0; diff --git a/apps/web/features/chat/model/chat-ui-block-state-store.ts b/apps/web/features/chat/model/chat-ui-block-state-store.ts new file mode 100644 index 000000000..6126332bd --- /dev/null +++ b/apps/web/features/chat/model/chat-ui-block-state-store.ts @@ -0,0 +1,237 @@ +import type { UIBlockState, UIBlockStateBinding } from "@/shared/components/markdown/ui-blocks/ui-block-state"; +import { isPersistableUIBlockName, isUIBlockState } from "@/shared/components/markdown/ui-blocks/ui-block-state"; + +// Per-block state of the interactive components in the conversation on screen. +// +// The store holds only local changes, keyed by message and block id; the server +// snapshot arrives with each message and is merged at read time, so restoring +// never has to wait for the store and the store never copies server data. +// +// Writes are debounced per message and merged server-side per block, so two tabs +// editing different blocks of one message do not overwrite each other. A message +// without a row yet (still streaming, or a temporary chat) keeps its changes in +// memory; they are written once the settled message gets a target. + +const UI_BLOCK_STATE_DEBOUNCE_MS = 1000; + +export type UIBlockStateTarget = { + conversationPublicID: string; + messagePublicID: string; +}; + +// `undefined` clears that block's stored state. +type UIBlockStatePatch = Record; + +// `keepalive` is set when the write happens because the page is going away. +type UIBlockStatePersist = (target: UIBlockStateTarget, blocks: UIBlockStatePatch, options: { keepalive: boolean }) => Promise; + +type LocalChange = { + // undefined: the user put the block back to its defaults. + state: UIBlockState | undefined; + title?: string; +}; + +export type ChatUIBlockStateStore = { + // The binding a message's blocks read and write through. `serverState` is the + // snapshot that came with the message; reads fall back to it. + bindingFor: (messageKey: string, getServerState: () => UIBlockState | undefined) => UIBlockStateBinding; + // Where a message's changes are written; undefined keeps them local. + setTarget: (messageKey: string, target: UIBlockStateTarget | undefined) => void; + // A message's current block state: server snapshot with local changes applied. + stateFor: (messageKey: string, serverState: UIBlockState | undefined) => Map; + // Writes everything pending now. Safe to call repeatedly; the store stays usable. + // `keepalive` lets the requests outlive a page that is being hidden or unloaded. + flush: (options?: { keepalive?: boolean }) => Promise; +}; + +export function createChatUIBlockStateStore({ + persist, + debounceMs = UI_BLOCK_STATE_DEBOUNCE_MS, +}: { + persist: UIBlockStatePersist; + debounceMs?: number; +}): ChatUIBlockStateStore { + // Every change the user made in this session, kept after it is written so the + // view and the next turn see it even before the message list is reloaded. + const changes = new Map>(); + // Changes not yet acknowledged by the server, per message. + const unsent = new Map>(); + const targets = new Map(); + const timers = new Map>(); + const inFlight = new Set(); + // Views of a block (inline and in the side panel) re-read after each write. + const listeners = new Map void>>(); + const listenerKey = (messageKey: string, blockID: string) => `${messageKey}\u0000${blockID}`; + + function schedule(messageKey: string) { + if (!targets.has(messageKey)) { + return; + } + const existing = timers.get(messageKey); + if (existing) { + clearTimeout(existing); + } + timers.set( + messageKey, + setTimeout(() => { + timers.delete(messageKey); + void writeNow(messageKey, false); + }, debounceMs), + ); + } + + async function writeNow(messageKey: string, keepalive: boolean): Promise { + const target = targets.get(messageKey); + const pending = unsent.get(messageKey); + const messageChanges = changes.get(messageKey); + if (!target || !pending?.size || !messageChanges || inFlight.has(messageKey)) { + return; + } + + const sent = new Map(); + const blocks: UIBlockStatePatch = {}; + for (const blockID of pending) { + const change = messageChanges.get(blockID); + if (!change) { + continue; + } + sent.set(blockID, change); + blocks[blockID] = change.state; + } + if (sent.size === 0) { + unsent.delete(messageKey); + return; + } + + inFlight.add(messageKey); + try { + await persist(target, blocks, { keepalive }); + } catch { + // Block state is a convenience: a failed write never surfaces to the user + // or interrupts the conversation. The next change retries it. + return; + } finally { + inFlight.delete(messageKey); + } + + // Only acknowledge what was sent: a block changed again while the request + // was in flight stays unsent and goes out on the next pass. + const stillPending = unsent.get(messageKey); + if (stillPending) { + sent.forEach((change, blockID) => { + if (changes.get(messageKey)?.get(blockID) === change) { + stillPending.delete(blockID); + } + }); + if (stillPending.size === 0) { + unsent.delete(messageKey); + } else { + schedule(messageKey); + } + } + } + + function record(messageKey: string, blockID: string, change: LocalChange) { + let messageChanges = changes.get(messageKey); + if (!messageChanges) { + messageChanges = new Map(); + changes.set(messageKey, messageChanges); + } + messageChanges.set(blockID, change); + let pending = unsent.get(messageKey); + if (!pending) { + pending = new Set(); + unsent.set(messageKey, pending); + } + pending.add(blockID); + schedule(messageKey); + // A copy: a listener may unsubscribe while being notified. + for (const listener of Array.from(listeners.get(listenerKey(messageKey, blockID)) ?? [])) { + listener(); + } + } + + return { + bindingFor(messageKey, getServerState) { + return { + read: (blockID) => { + const change = changes.get(messageKey)?.get(blockID); + if (change) { + return change.state; + } + const saved = getServerState()?.[blockID]; + return isUIBlockState(saved) ? saved : undefined; + }, + write: (blockID, state, title) => { + // Ids the server would reject stay local instead of failing the + // write for every other block of the message. + if (!isPersistableUIBlockName(blockID)) { + return; + } + if (state !== undefined && !isUIBlockState(state)) { + return; + } + record(messageKey, blockID, { state, title }); + }, + subscribe: (blockID, listener) => { + const key = listenerKey(messageKey, blockID); + let set = listeners.get(key); + if (!set) { + set = new Set(); + listeners.set(key, set); + } + set.add(listener); + return () => { + set.delete(listener); + if (set.size === 0) { + listeners.delete(key); + } + }; + }, + }; + }, + setTarget(messageKey, target) { + if (!target) { + targets.delete(messageKey); + return; + } + const previous = targets.get(messageKey); + if (previous?.conversationPublicID === target.conversationPublicID && previous.messagePublicID === target.messagePublicID) { + return; + } + targets.set(messageKey, target); + // Changes made while the message was streaming go out now that it has a row. + if (unsent.get(messageKey)?.size) { + schedule(messageKey); + } + }, + stateFor(messageKey, serverState) { + const result = new Map(); + for (const [blockID, state] of Object.entries(serverState ?? {})) { + if (isPersistableUIBlockName(blockID) && isUIBlockState(state) && Object.keys(state).length > 0) { + result.set(blockID, { state }); + } + } + changes.get(messageKey)?.forEach((change, blockID) => { + if (!change.state || Object.keys(change.state).length === 0) { + result.delete(blockID); + return; + } + result.set(blockID, { state: change.state, title: change.title }); + }); + return result; + }, + async flush({ keepalive = false } = {}) { + const keys = Array.from(unsent.keys()).filter((key) => targets.has(key)); + for (const key of keys) { + const timer = timers.get(key); + if (timer) { + clearTimeout(timer); + timers.delete(key); + } + } + // One request per message: a failure on one must not block the others. + await Promise.all(keys.map((key) => writeNow(key, keepalive))); + }, + }; +} diff --git a/apps/web/features/chat/model/ui-component-icons.ts b/apps/web/features/chat/model/ui-component-icons.ts index 576e48256..f4091ac4e 100644 --- a/apps/web/features/chat/model/ui-component-icons.ts +++ b/apps/web/features/chat/model/ui-component-icons.ts @@ -7,6 +7,8 @@ import { type LucideIcon, Network, Sheet, + SlidersHorizontal, + Sparkles, SquareChartGantt, SquareFunction, WalletCards, @@ -14,6 +16,7 @@ import { // Icon per builtin component name; custom components fall back to LayoutGrid. // stat-grid wants PlayingCardsFan, which lands in a newer lucide release. +// widget shares Sparkles with the illustration's loading placeholder. const BUILTIN_ICONS: Record = { "card-grid": LayoutGrid, chart: ChartPie, @@ -25,6 +28,8 @@ const BUILTIN_ICONS: Record = { "decision-tree": Network, diff: FileDiff, quiz: FileQuestionMark, + panel: SlidersHorizontal, + widget: Sparkles, }; export function uiComponentIcon(name: string): LucideIcon { diff --git a/apps/web/features/chat/types/messages.ts b/apps/web/features/chat/types/messages.ts index 758caa17b..bd6584527 100644 --- a/apps/web/features/chat/types/messages.ts +++ b/apps/web/features/chat/types/messages.ts @@ -1,4 +1,5 @@ import type { UpstreamDebugInfo } from "@/shared/api/conversation-types"; +import type { UIBlockState } from "@/shared/components/markdown/ui-blocks/ui-block-state"; import type { ReasoningEffortLevel } from "@/entities/model"; export type MessageAttachment = { @@ -160,6 +161,9 @@ export type ChatAreaMessage = { activityLabel?: string; imageAspectRatio?: ImageLoadingAspectRatio; myFeedback?: "up" | "down" | null; + // Persisted per-block state of the interactive components in this message, + // only the keys the user changed; absent when nothing was changed. + uiState?: UIBlockState; thumbsUpCount?: number; thumbsDownCount?: number; branchNavigator?: ChatMessageBranchNavigator; diff --git a/apps/web/i18n/messages/en-US/chat.json b/apps/web/i18n/messages/en-US/chat.json index 66cd95df7..fcb12a768 100644 --- a/apps/web/i18n/messages/en-US/chat.json +++ b/apps/web/i18n/messages/en-US/chat.json @@ -154,6 +154,7 @@ "itemCount": "{count} items", "unknownComponent": "Unsupported component {component}", "invalidProps": "Component {component} data is incomplete; showing the raw content", + "renderFailed": "Component {component} failed to render; showing the raw content", "reset": "Reset", "back": "Back", "missingNode": "Node {id} not found", @@ -193,7 +194,30 @@ "diffPrevChange": "Previous change", "diffNextChange": "Next change", "diffCopyAfter": "Copy result", - "quizNext": "Next" + "quizNext": "Next", + "askHint": "Send this follow-up (Enter to send, Shift+Enter for a new line)", + "askBusy": "Available when the current reply finishes", + "askUnavailable": "Messages can't be sent here", + "copy": "Copy", + "copied": "Copied", + "copyFailed": "Copy failed", + "formulaIssues": "{count, plural, one {# formula or data issue} other {# formula or data issues}}", + "prefilled": "Added to the message box; review it and send", + "prefillSkipped": "The message box already has text, so the component's text was not added", + "openInPanel": "Open in side panel", + "panelTitle": "Interactive block", + "closePanel": "Close side panel", + "widgetGenerating": "Drawing the illustration…", + "widgetGeneratingTitled": "Drawing “{title}”…", + "widgetTitle": "Interactive illustration", + "widgetUnavailable": "Illustrations can't run here; showing the source", + "widgetTooLarge": "This illustration is too large to run; showing the source", + "viewSource": "View source", + "hideSource": "Hide source", + "askLabel": "Follow-up question", + "askPlaceholder": "Type a follow-up question", + "askSend": "Send follow-up", + "askRestore": "Restore the suggested question" }, "copyLatex": "Copy formula source", "latexCopied": "Formula source copied", @@ -257,6 +281,7 @@ "close": "Close", "resize": "Resize Artifact", "empty": "This artifact is empty and cannot be previewed yet.", + "generating": "Generating… the preview appears once the code is complete.", "security": "The preview runs in an isolated sandbox with external network, forms, popups, parent page access, and storage access restricted." }, "messages": { diff --git a/apps/web/i18n/messages/en-US/errors.json b/apps/web/i18n/messages/en-US/errors.json index f12b44d09..d98e05c32 100644 --- a/apps/web/i18n/messages/en-US/errors.json +++ b/apps/web/i18n/messages/en-US/errors.json @@ -313,6 +313,8 @@ "invalidContent": "Invalid message content.", "invalidFeedback": "Invalid message feedback.", "invalidId": "Invalid message ID.", + "invalidUiState": "The component state is invalid or too large.", + "uiStateTargetInvalid": "Component state cannot be saved for this message right now.", "multipleImageProcessors": "Select only one image attachment processor.", "notFound": "Message not found.", "tooManyFiles": "Too many files in one message.", diff --git a/apps/web/i18n/messages/en-US/ui-components.json b/apps/web/i18n/messages/en-US/ui-components.json index 6d2406bd5..84c1a30df 100644 --- a/apps/web/i18n/messages/en-US/ui-components.json +++ b/apps/web/i18n/messages/en-US/ui-components.json @@ -31,7 +31,7 @@ "propsSummary": "Props summary", "propsSummaryHint": "Compact props description for the model, e.g. '{'symbol, name?'}'", "rendererSource": "Renderer", - "rendererSourceHint": "HTML and script run in a sandboxed iframe with no network access; receive data with deeix.onProps and theme variables with deeix.onTheme", + "rendererSourceHint": "HTML and script run in a sandboxed iframe with no network access; receive data with deeix.onProps and theme variables with deeix.onTheme; read and write state saved with the conversation (up to 4 KB) with deeix.onState / setState; deeix.ask(text) puts text in the message box for the user to send", "propsSchema": "Props schema", "propsSchemaHint": "Optional JSON Schema used to validate model output; leave empty to skip validation", "enabled": "Enabled" @@ -97,6 +97,14 @@ "quiz": { "title": "Quiz", "summary": "Instant grading with explanations; branch on answers" + }, + "panel": { + "title": "Interactive panel", + "summary": "Formulas, sliders and diagrams that recompute as you drag; replaces the calculator" + }, + "widget": { + "title": "Interactive illustration", + "summary": "Animations, 3D sketches and processes you can play, pause and scrub" } } } diff --git a/apps/web/i18n/messages/zh-CN/chat.json b/apps/web/i18n/messages/zh-CN/chat.json index 49fa2a8cf..745f721da 100644 --- a/apps/web/i18n/messages/zh-CN/chat.json +++ b/apps/web/i18n/messages/zh-CN/chat.json @@ -154,6 +154,7 @@ "itemCount": "{count} 条", "unknownComponent": "不支持的组件 {component}", "invalidProps": "组件 {component} 的数据不完整,已显示原始内容", + "renderFailed": "组件 {component} 渲染出错,已显示原始内容", "reset": "重置", "back": "返回", "missingNode": "找不到节点 {id}", @@ -193,7 +194,30 @@ "diffPrevChange": "上一处修改", "diffNextChange": "下一处修改", "diffCopyAfter": "复制修改后内容", - "quizNext": "下一题" + "quizNext": "下一题", + "askHint": "发送这条追问(Enter 发送,Shift+Enter 换行)", + "askBusy": "等当前回复完成后再发送", + "askUnavailable": "这里不能发送消息", + "copy": "复制", + "copied": "已复制", + "copyFailed": "复制失败", + "formulaIssues": "{count} 处公式或数据未能处理", + "prefilled": "已填入输入框,确认后发送", + "prefillSkipped": "输入框里已有内容,组件的文字没有填入", + "openInPanel": "在侧栏打开", + "panelTitle": "交互组件", + "closePanel": "关闭侧栏", + "widgetGenerating": "正在绘制交互图解…", + "widgetGeneratingTitled": "正在绘制「{title}」…", + "widgetTitle": "交互图解", + "widgetUnavailable": "当前环境无法运行交互图解,以下是源码", + "widgetTooLarge": "交互图解过大,未运行,以下是源码", + "viewSource": "查看源码", + "hideSource": "收起源码", + "askLabel": "追问", + "askPlaceholder": "输入要追问的内容", + "askSend": "发送追问", + "askRestore": "恢复为建议的问题" }, "copyLatex": "复制公式源码", "latexCopied": "已复制公式源码", @@ -257,6 +281,7 @@ "close": "关闭", "resize": "调整 Artifact 宽度", "empty": "当前 Artifact 为空,暂无法预览。", + "generating": "正在生成,代码完成后显示预览。", "security": "预览运行在隔离沙箱中,外部网络、表单、弹窗、父页面访问和存储访问均受限制。" }, "messages": { diff --git a/apps/web/i18n/messages/zh-CN/errors.json b/apps/web/i18n/messages/zh-CN/errors.json index 344e7c71e..f1fe006f0 100644 --- a/apps/web/i18n/messages/zh-CN/errors.json +++ b/apps/web/i18n/messages/zh-CN/errors.json @@ -313,6 +313,8 @@ "invalidContent": "消息内容无效。", "invalidFeedback": "消息反馈无效。", "invalidId": "消息 ID 无效。", + "invalidUiState": "组件状态无效或超出大小限制。", + "uiStateTargetInvalid": "该消息暂时无法保存组件状态。", "notFound": "消息不存在。", "multipleImageProcessors": "一条消息只能选择一个图片处理器。", "tooManyFiles": "单条消息包含的文件过多。", diff --git a/apps/web/i18n/messages/zh-CN/ui-components.json b/apps/web/i18n/messages/zh-CN/ui-components.json index 976ab973a..0b967c97c 100644 --- a/apps/web/i18n/messages/zh-CN/ui-components.json +++ b/apps/web/i18n/messages/zh-CN/ui-components.json @@ -31,7 +31,7 @@ "propsSummary": "入参摘要", "propsSummaryHint": "给模型看的紧凑入参说明,如 '{'symbol, name?'}'", "rendererSource": "渲染代码", - "rendererSourceHint": "HTML 与脚本在沙箱 iframe 中运行,无网络访问;通过 deeix.onProps 接收数据,deeix.onTheme 接收主题变量", + "rendererSourceHint": "HTML 与脚本在沙箱 iframe 中运行,无网络访问;通过 deeix.onProps 接收数据,deeix.onTheme 接收主题变量;deeix.onState / setState 读写会随对话保存的状态(最多 4 KB),deeix.ask(文字) 把文字填入输入框,由用户确认发送", "propsSchema": "入参 Schema", "propsSchemaHint": "可选的 JSON Schema,前端据此校验模型输出;留空则不校验", "enabled": "启用" @@ -97,6 +97,14 @@ "quiz": { "title": "测验 / 问答", "summary": "即时判对错与解析,计分;支持按选择跳题" + }, + "panel": { + "title": "可交互面板", + "summary": "公式、滑块与示意图,拖动参数实时重算;勾选后取代交互式计算器" + }, + "widget": { + "title": "交互图解", + "summary": "动画、三维示意与过程演示,可播放、暂停、拖动进度" } } } diff --git a/apps/web/shared/api/conversation-types.ts b/apps/web/shared/api/conversation-types.ts index fdfa2f410..5dccd10cb 100644 --- a/apps/web/shared/api/conversation-types.ts +++ b/apps/web/shared/api/conversation-types.ts @@ -16,6 +16,8 @@ import type { SetConversationProjectRequest as ContractSetConversationProjectRequest, SetConversationStarRequest as ContractSetConversationStarRequest, SetMessageFeedbackRequest as ContractSetMessageFeedbackRequest, + MessageUIStateInputRequest as ContractMessageUIStateInputRequest, + TemporaryChatUIStateRequest as ContractTemporaryChatUIStateRequest, UpdateConversationLabelsRequest as ContractUpdateConversationLabelsRequest, UpdateConversationProjectRequest as ContractUpdateConversationProjectRequest, UpdateMessageRequest as ContractUpdateMessageRequest, @@ -32,6 +34,7 @@ import type { MessageBillingCostResponse, MessageDeleteResponse, MessageFeedbackResponse, + MessageUIStateResponse, MessageProcessTraceResponse, MessagePromptTraceBlockResponse, MessagePromptTraceResponse, @@ -214,8 +217,30 @@ export type MessageFeedbackResult = Omit myFeedback: "up" | "down" | ""; }; -export type SendMessageRequest = Omit & { +// Block state is a JSON object per block id: only the keys the user changed. +// The contract types it as `object`; the client narrows it with isUIBlockState. +type MessageUIStateValue = Record; + +// `null` clears a block; blocks left out of the map are not touched. +export type SetMessageUIStateRequest = { + blocks: Record; +}; + +export type MessageUIStateResult = Omit & { + uiState: Record; +}; + +export type MessageUIStateInput = Omit & { + state: MessageUIStateValue; +}; + +export type TemporaryChatUIStateInput = Omit & { + state: MessageUIStateValue; +}; + +export type SendMessageRequest = Omit & { options?: ConversationOptions; + uiStates?: MessageUIStateInput[]; }; export type MediaImageRequest = { @@ -256,9 +281,10 @@ export type TemporaryChatHistoryMessage = Omit & { +export type TemporaryChatMessageRequest = Omit & { options?: ConversationOptions; messages: TemporaryChatHistoryMessage[]; + uiStates?: TemporaryChatUIStateInput[]; }; export type StreamMessageEvent = diff --git a/apps/web/shared/api/conversation.ts b/apps/web/shared/api/conversation.ts index 8de60fb1b..6c7af666e 100644 --- a/apps/web/shared/api/conversation.ts +++ b/apps/web/shared/api/conversation.ts @@ -32,6 +32,7 @@ import type { MessageDeleteDTO, MessageDTO, MessageFeedbackResult, + MessageUIStateResult, MessageProcessTraceDTO, PromptTraceBlockDTO, PromptTraceDTO, @@ -47,6 +48,7 @@ import type { SetConversationProjectRequest, SetConversationStarRequest, SetMessageFeedbackRequest, + SetMessageUIStateRequest, StreamMessageEvent, TemporaryChatMessageRequest, TraceBlockDTO, @@ -1238,6 +1240,27 @@ export async function deleteConversationMessage( ); } +// Saves the interactive component state of one assistant message. Blocks in the +// map replace their stored state, `null` clears one, absent blocks are untouched. +export async function setMessageUIState( + accessToken: string, + conversationPublicID: string, + messagePublicID: string, + payload: SetMessageUIStateRequest, + { keepalive = false }: { keepalive?: boolean } = {}, +): Promise { + return authedRequest( + `/api/v1/conversations/${pathParam(conversationPublicID)}/messages/${pathParam(messagePublicID)}/ui-state`, + { + method: "PUT", + accessToken, + body: payload, + keepalive, + }, + true, + ); +} + export type CompactDoneEvent = { method: string; freed_tokens: number; diff --git a/apps/web/shared/api/http-client.ts b/apps/web/shared/api/http-client.ts index 35d0146e4..c14c872ae 100644 --- a/apps/web/shared/api/http-client.ts +++ b/apps/web/shared/api/http-client.ts @@ -11,6 +11,9 @@ export type ApiRequestOptions = { body?: unknown; headers?: Record; signal?: AbortSignal; + // Lets a small write outlive the page (sent while it is being hidden or + // unloaded). Browsers cap keepalive bodies at 64 KB in total. + keepalive?: boolean; }; export class ApiError extends Error { @@ -163,6 +166,7 @@ function buildRequestInit(options: ApiRequestOptions): RequestInit { headers, body, signal: options.signal, + keepalive: options.keepalive, credentials: "include", cache: "no-store", }; diff --git a/apps/web/shared/components/markdown/streamdown-components.tsx b/apps/web/shared/components/markdown/streamdown-components.tsx index ba0795807..8ac310d4b 100644 --- a/apps/web/shared/components/markdown/streamdown-components.tsx +++ b/apps/web/shared/components/markdown/streamdown-components.tsx @@ -28,7 +28,8 @@ import { MarkdownFootnotesContext } from "./streamdown-html"; import { StreamdownCheckIcon, StreamdownCopyIcon } from "./streamdown-icons"; import { MarkdownMermaidLoading, MarkdownMermaidPendingContext } from "./streamdown-mermaid"; import { sanitizeHTMLStyle } from "./streamdown-style"; -import { UI_BLOCK_FENCE_LANGUAGE, UIBlockHost } from "./ui-blocks"; +import { UI_WIDGET_FENCE_LANGUAGE } from "@deeix/core"; +import { MarkdownWidgetHost, UI_BLOCK_FENCE_LANGUAGE, UIBlockHost } from "./ui-blocks"; // Fences without a language are treated as plain text: the label shows text as-is, so content isn't wrongly highlighted as Markdown. const DEFAULT_CODE_BLOCK_LANGUAGE = "text"; @@ -76,6 +77,8 @@ export type MarkdownArtifactActions = { language: string; kind: ArtifactPreviewKind; }) => void; + // Opens an interactive block of this message in the side panel, by block key. + onOpenUIBlock?: (blockKey: string) => void; }; type MarkdownParagraphProps = React.HTMLAttributes & { @@ -525,6 +528,14 @@ export function MarkdownCodePre({ children, node: _node, "data-markdown-source-l ); } + if (language === UI_WIDGET_FENCE_LANGUAGE) { + return ( +
+ +
+ ); + } + const codeBlock = React.cloneElement(childElement, { "data-block": "true" }); // Only `mermaid` fences become diagrams in Streamdown; `mmd` stays a plain code block. diff --git a/apps/web/shared/components/markdown/streamdown-raw-html.ts b/apps/web/shared/components/markdown/streamdown-raw-html.ts new file mode 100644 index 000000000..c3fb3de08 --- /dev/null +++ b/apps/web/shared/components/markdown/streamdown-raw-html.ts @@ -0,0 +1,34 @@ +// Inline HTML in Markdown is a single tag; block HTML (html-visual) spans whole lines. +const SINGLE_HTML_TAG_RE = /^<\/?([A-Za-z][A-Za-z0-9-]*)(?:\s[^<>]*)?\/?>$/; +// The sanitizer keeps only as a disabled checkbox (for GFM task lists), +// so a raw would turn into one. +const RAW_TAGS_REWRITTEN_BY_SANITIZER = new Set(["input"]); + +type HASTNode = { + type?: string; + value?: string; + children?: HASTNode[]; +}; + +function visitRawHTMLNodes(node: HASTNode, allowedTags: ReadonlySet) { + for (const child of node.children ?? []) { + if (child.type !== "raw") { + visitRawHTMLNodes(child, allowedTags); + continue; + } + const tagName = SINGLE_HTML_TAG_RE.exec(child.value?.trim() ?? "")?.[1]?.toLowerCase(); + if (tagName && !allowedTags.has(tagName)) { + child.type = "text"; + } + } +} + +// Shows a lone tag the sanitizer would not render ("use ) : null} {path.length > 1 ? ( - @@ -70,16 +102,16 @@ function DecisionTree({ id, props }: UIBlockRenderProps) { {path.length > 1 ? (
    - {path.slice(0, -1).map((step, index) => ( + {path.slice(0, -1).map((stepID, index) => (
  1. - {step.choice} + {choiceLabel(byID, stepID, path[index + 1])}
  2. ))}
) : null} - + {node ? (
@@ -106,7 +138,7 @@ function DecisionTree({ id, props }: UIBlockRenderProps) { ) : null}
) : ( -

{t("missingNode", { id: current?.nodeID ?? "" })}

+

{t("missingNode", { id: currentID })}

)} @@ -127,6 +159,32 @@ function DecisionTreeSkeleton() { ); } +// The path the user took: each step with the choice made, then where it ended +// (or the open question with its options). +function decisionTreeText(props: DecisionTreeProps, state: UIBlockState | undefined): string { + const byID = new Map(props.nodes.map((node) => [node.id, node])); + const startID = props.start ?? props.nodes[0]?.id ?? ""; + const path = restoreDecisionPath(byID, startID, state); + const lines: string[] = []; + path.forEach((nodeID, index) => { + const node = byID.get(nodeID); + if (!node) { + return; + } + const next = path[index + 1]; + lines.push(next ? `${index + 1}. ${node.text} → **${choiceLabel(byID, nodeID, next)}**` : `${index + 1}. ${node.text}`); + if (!next) { + if (node.detail?.trim()) { + lines.push(` ${node.detail.trim()}`); + } + for (const option of node.options ?? []) { + lines.push(` - ${option.label}`); + } + } + }); + return joinTextBlocks([...textTitle(props.title), lines.join("\n")]); +} + export const decisionTreeDefinition: UIBlockDefinition = { name: "decision-tree", version: 1, @@ -146,4 +204,5 @@ export const decisionTreeDefinition: UIBlockDefinition = { ), Component: DecisionTree, Skeleton: DecisionTreeSkeleton, + toText: decisionTreeText, }; diff --git a/apps/web/shared/components/markdown/ui-blocks/diff.tsx b/apps/web/shared/components/markdown/ui-blocks/diff.tsx index f342a9875..81842290f 100644 --- a/apps/web/shared/components/markdown/ui-blocks/diff.tsx +++ b/apps/web/shared/components/markdown/ui-blocks/diff.tsx @@ -9,6 +9,7 @@ import { cn } from "@/lib/utils"; import { writeClipboardText } from "@/shared/lib/clipboard"; import { type DiffLine, type DiffSegment, diffLines, diffWords, lineSimilarity } from "@/shared/lib/line-diff"; import type { UIBlockDefinition, UIBlockRenderProps } from "./block"; +import { joinTextBlocks, textTitle } from "./block-text"; import { s } from "./schema"; import { UIBlockFrame } from "./ui-block-frame"; @@ -326,6 +327,14 @@ function DiffSkeleton() { ); } +// A unified diff in a fence longer than any backtick run in the text. +function diffText(props: DiffProps): string { + const lines = diffLines(props.before, props.after).map((line) => `${line.kind === "added" ? "+" : line.kind === "removed" ? "-" : " "} ${line.text}`); + const longest = Math.max(2, ...Array.from(`${props.before}\n${props.after}`.matchAll(/`+/g), (match) => match[0].length)); + const fence = "`".repeat(longest + 1); + return joinTextBlocks([...textTitle(props.title), `${fence}diff\n${lines.join("\n")}\n${fence}`]); +} + export const diffDefinition: UIBlockDefinition = { name: "diff", version: 1, @@ -342,4 +351,5 @@ export const diffDefinition: UIBlockDefinition = { ), Component: Diff, Skeleton: DiffSkeleton, + toText: diffText, }; diff --git a/apps/web/shared/components/markdown/ui-blocks/function-plot.tsx b/apps/web/shared/components/markdown/ui-blocks/function-plot.tsx index c88996ac5..3fb2ff269 100644 --- a/apps/web/shared/components/markdown/ui-blocks/function-plot.tsx +++ b/apps/web/shared/components/markdown/ui-blocks/function-plot.tsx @@ -8,10 +8,12 @@ import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; import katex from "katex"; -import { type CompiledExpression, compileExpression, expressionToLatex } from "@/shared/lib/safe-expression"; +import { type CompiledExpression, compileExpression, expressionToLatex } from "@deeix/core"; import type { UIBlockDefinition, UIBlockRenderProps } from "./block"; +import { joinTextBlocks, textTitle } from "./block-text"; import { s } from "./schema"; import { UIBlockFrame } from "./ui-block-frame"; +import { type UIBlockState, useUIBlockStatePersistence } from "./ui-block-state"; type FunctionPlotProps = { title?: string; @@ -140,6 +142,40 @@ function rowsFromProps(functions: FunctionPlotProps["functions"]): FunctionRow[] return functions.slice(0, MAX_FUNCTIONS).map((fn, index) => ({ key: index, expr: fn.expr, hidden: false })); } +// Stored state: the expressions as the user left them and which rows are hidden +// (by position). The viewport is not stored: zoom is a way of looking, not a choice. +type FunctionPlotState = { expressions: string[]; hidden: number[] }; + +const MAX_STORED_EXPRESSION_LENGTH = 200; + +function functionPlotDefaults(functions: FunctionPlotProps["functions"]): FunctionPlotState { + return { expressions: functions.slice(0, MAX_FUNCTIONS).map((fn) => fn.expr), hidden: [] }; +} + +function restoreFunctionPlotState(saved: UIBlockState | undefined, defaults: FunctionPlotState): FunctionPlotState { + const storedExpressions = saved?.expressions; + const expressions = + Array.isArray(storedExpressions) && storedExpressions.length <= MAX_FUNCTIONS && storedExpressions.every((expr) => typeof expr === "string") + ? storedExpressions.filter((expr): expr is string => typeof expr === "string").map((expr) => expr.slice(0, MAX_STORED_EXPRESSION_LENGTH)) + : defaults.expressions; + const storedHidden = saved?.hidden; + const hidden = Array.isArray(storedHidden) + ? storedHidden.filter((index): index is number => typeof index === "number" && Number.isInteger(index) && index >= 0 && index < expressions.length) + : defaults.hidden; + return { expressions, hidden }; +} + +function rowsFromState(state: FunctionPlotState): FunctionRow[] { + return state.expressions.map((expr, index) => ({ key: index, expr, hidden: state.hidden.includes(index) })); +} + +function stateFromRows(rows: FunctionRow[]): FunctionPlotState { + return { + expressions: rows.map((row) => row.expr.slice(0, MAX_STORED_EXPRESSION_LENGTH)), + hidden: rows.flatMap((row, index) => (row.hidden ? [index] : [])), + }; +} + function rangeOr(range: number[] | undefined, fallback: readonly [number, number]): [number, number] { if (range && range.length === 2 && Number.isFinite(range[0]) && Number.isFinite(range[1]) && range[1] > range[0]) { return [range[0], range[1]]; @@ -298,7 +334,23 @@ function FunctionPlot({ id, props }: UIBlockRenderProps) { const t = useTranslations("chat.markdown.uiBlock"); const containerRef = React.useRef(null); const [width, setWidth] = React.useState(0); - const [rows, setRows] = React.useState(() => rowsFromProps(props.functions)); + const { initial, save } = useUIBlockStatePersistence(id, { + defaults: functionPlotDefaults(props.functions), + restore: restoreFunctionPlotState, + title: props.title, + }); + const [rows, setRowsState] = React.useState(() => rowsFromState(initial)); + // Rows are stored only after the user edits them; restoring alone writes nothing. + const rowsChangedRef = React.useRef(false); + const setRows = React.useCallback((update: React.SetStateAction) => { + rowsChangedRef.current = true; + setRowsState(update); + }, []); + React.useEffect(() => { + if (rowsChangedRef.current) { + save(stateFromRows(rows)); + } + }, [rows, save]); const nextKeyRef = React.useRef(rows.length); const compiledRows = React.useMemo(() => rows.map(compileRow), [rows]); const [view, setView] = React.useState(null); @@ -648,6 +700,13 @@ function FunctionPlotSkeleton() { ); } +// The expressions as the user left them; hidden ones are left out. +function functionPlotText(props: FunctionPlotProps, state: UIBlockState | undefined): string { + const { expressions, hidden } = restoreFunctionPlotState(state, functionPlotDefaults(props.functions)); + const lines = expressions.flatMap((expr, index) => (hidden.includes(index) || !expr.trim() ? [] : [`- \`${expr.trim()}\``])); + return joinTextBlocks([...textTitle(props.title), lines.join("\n")]); +} + export const functionPlotDefinition: UIBlockDefinition = { name: "function-plot", version: 1, @@ -662,4 +721,5 @@ export const functionPlotDefinition: UIBlockDefinition = { ), Component: FunctionPlot, Skeleton: FunctionPlotSkeleton, + toText: functionPlotText, }; diff --git a/apps/web/shared/components/markdown/ui-blocks/gantt.tsx b/apps/web/shared/components/markdown/ui-blocks/gantt.tsx index 8bf8cfbde..59f06b971 100644 --- a/apps/web/shared/components/markdown/ui-blocks/gantt.tsx +++ b/apps/web/shared/components/markdown/ui-blocks/gantt.tsx @@ -9,6 +9,7 @@ import { HeightTransition } from "@/components/ui/height-transition"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; import type { UIBlockDefinition, UIBlockRenderProps, UIBlockSkeletonProps } from "./block"; +import { joinTextBlocks, textPair, textTitle } from "./block-text"; import { s } from "./schema"; import { UIBlockFrame } from "./ui-block-frame"; @@ -494,6 +495,18 @@ function GanttSkeleton({ items = 0 }: UIBlockSkeletonProps) { ); } +function ganttText(props: GanttProps): string { + const day = (time: number) => new Date(time).toISOString().slice(0, 10); + const { items } = resolveTasks(props.tasks); + const lines = items.map((item) => { + const name = item.group ? `${item.group} · ${item.name}` : item.name; + const span = item.milestone ? `${day(item.start)} ◆` : `${day(item.start)} → ${day(item.end)}`; + const progress = item.progress > 0 ? ` (${item.progress}%)` : ""; + return `- ${textPair(name, span)}${progress}`; + }); + return joinTextBlocks([...textTitle(props.title), lines.join("\n")]); +} + export const ganttDefinition: UIBlockDefinition = { name: "gantt", version: 1, @@ -522,4 +535,5 @@ export const ganttDefinition: UIBlockDefinition = { ), Component: Gantt, Skeleton: GanttSkeleton, + toText: ganttText, }; diff --git a/apps/web/shared/components/markdown/ui-blocks/index.ts b/apps/web/shared/components/markdown/ui-blocks/index.ts index 294a2583a..39fdbdb3b 100644 --- a/apps/web/shared/components/markdown/ui-blocks/index.ts +++ b/apps/web/shared/components/markdown/ui-blocks/index.ts @@ -1,3 +1,6 @@ -export { UI_BLOCK_FENCE_LANGUAGE } from "./block"; +export { UI_BLOCK_FENCE_LANGUAGE } from "@deeix/core"; +export { UIBlockActionsProvider } from "./ui-block-actions"; export { BUILTIN_UI_BLOCKS, builtinUIBlockRegistry, createCatalogRegistry, UIBlockRegistryProvider, useUIBlockRegistry } from "./registry"; +export { messageUIBlocksText } from "./message-text"; export { UIBlockHost } from "./ui-block-host"; +export { MarkdownWidgetHost, UIWidgetCompletionProvider, UIWidgetHost } from "./widget-host"; diff --git a/apps/web/shared/components/markdown/ui-blocks/live/live-block.tsx b/apps/web/shared/components/markdown/ui-blocks/live/live-block.tsx new file mode 100644 index 000000000..438776fe7 --- /dev/null +++ b/apps/web/shared/components/markdown/ui-blocks/live/live-block.tsx @@ -0,0 +1,180 @@ +"use client"; + +import type { BlockIssue, BlockSource, ResolvedNode, UIValue } from "@deeix/core"; +import { + compileBlock, + expandPointProps, + isUIContainer, + isUIPrimitive, + mergeState, + resolveBlock, + stateDiff, + takeWhere, + WHERE_LIST_PROP, +} from "@deeix/core"; +import { useTranslations } from "next-intl"; +import * as React from "react"; + +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; +import { LIVE_COMPILE_OPTIONS, resolveLatestDefinition } from "../block"; +import { useUIBlockRegistry } from "../registry"; +import { UIBlockErrorBoundary } from "../ui-block-error-boundary"; +import { deepEquals, type UIBlockState, UIBlockLocalState, useUIBlockStateChanges, useUIBlockStatePersistence } from "../ui-block-state"; +import { LiveBlockContext, type LiveBlockContextValue, useLiveBlock } from "./live-context"; +import { LivePrimitive } from "./live-primitives"; + +// A live block: state, computed values and a tree of primitives and catalog +// components, re-resolved on every change. The model writes data and formulas; +// everything here is interpretation, no model code runs. + +const NO_OVERRIDES: UIBlockState = {}; + +const SHOW_ISSUES = process.env.NODE_ENV !== "production"; + +export function LiveBlock({ block, streaming }: { block: BlockSource; streaming: boolean }) { + const compiled = React.useMemo(() => compileBlock(block, LIVE_COMPILE_OPTIONS), [block]); + const title = typeof block.root.props.title === "string" ? block.root.props.title : undefined; + const { state, update, changed, reset } = useLiveBlockState(block.key, compiled.initialState, title); + const resolved = React.useMemo(() => resolveBlock(compiled, state), [compiled, state]); + const stateNames = React.useMemo(() => new Set(Object.keys(compiled.initialState)), [compiled.initialState]); + + const context = React.useMemo( + () => ({ + blockKey: block.key || "live", + streaming, + state, + values: resolved.values, + stateNames, + update, + changed, + reset, + renderNode: (node) => , + }), + [block.key, changed, reset, resolved.values, state, stateNames, streaming, update], + ); + + const issues = SHOW_ISSUES ? [...compiled.issues, ...resolved.issues] : []; + return ( + + {resolved.root.component === "panel" ? :
} + {issues.length > 0 && !streaming ? : null} +
+ ); +} + +// Only what the user changed is kept, as a diff against the declared state; +// it is merged over the latest declaration on every render, so a block that +// is still streaming in (or was edited) picks up new defaults. +function useLiveBlockState(key: string, initialState: Readonly>, title: string | undefined) { + const { initial, save } = useUIBlockStatePersistence(key, { + defaults: NO_OVERRIDES, + restore: (saved) => saved ?? NO_OVERRIDES, + title, + }); + const [overrides, setOverrides] = React.useState(initial); + const changedRef = React.useRef(false); + const initialRef = React.useRef(initialState); + initialRef.current = initialState; + // The same block open in another view (the side panel) changed it. + useUIBlockStateChanges(key, (saved) => { + const next = saved ?? NO_OVERRIDES; + setOverrides((current) => (deepEquals(current, next) ? current : next)); + }); + + React.useEffect(() => { + if (changedRef.current) { + save(overrides); + } + }, [overrides, save]); + + const state = React.useMemo(() => mergeState(initialState, overrides), [initialState, overrides]); + const update = React.useCallback((patch: Record) => { + changedRef.current = true; + setOverrides((current) => { + const declared = initialRef.current; + return stateDiff(declared, mergeState(declared, { ...current, ...patch })) ?? NO_OVERRIDES; + }); + }, []); + const reset = React.useCallback(() => { + changedRef.current = true; + setOverrides(NO_OVERRIDES); + }, []); + // Overrides that no longer differ from a re-declared state do not count. + const changed = React.useMemo(() => stateDiff(initialState, state) !== undefined, [initialState, state]); + return { state, update, changed, reset }; +} + +// Each node is its own error boundary: a component that throws disappears +// alone. While the message streams, a leaf waits for its closing brace (a +// placeholder keeps the layout); containers show as soon as they open. +function LiveNode({ node }: { node: ResolvedNode }) { + const { streaming } = useLiveBlock(); + if (streaming && !node.closed && !isUIContainer(node.component)) { + return ; + } + return ( + // Every resolution yields new node objects, so a node that threw is retried + // as soon as the state or the streamed source changes. + + {isUIPrimitive(node.component) ? : } + + ); +} + +// A catalog component inside a live block: per-point formulas are expanded, +// `where` filters its list, then it validates and renders as usual. Its own +// state (sort, filters…) stays local: the block's state is what is stored. +function LiveCatalogNode({ node }: { node: ResolvedNode }) { + const t = useTranslations("chat.markdown.uiBlock"); + const registry = useUIBlockRegistry(); + const { blockKey } = useLiveBlock(); + const definition = resolveLatestDefinition(registry, node.component); + if (!definition) { + return ; + } + // Per-point problems already show as gaps or a notice; they are not listed. + const issues: BlockIssue[] = []; + const { props, where } = takeWhere(node.component, expandPointProps(node.component, node.props, issues), issues); + let finalProps = props; + if (where) { + const listProp = WHERE_LIST_PROP[node.component] ?? ""; + const list = props[listProp]; + const kept = Array.isArray(list) ? list.filter((row, index) => where(row, index)) : []; + if (kept.length === 0) { + return ; + } + finalProps = { ...props, [listProp]: kept }; + } + // Ids must stay valid CSS identifiers: charts use them in selectors. + const rendered = definition.render(`${blockKey}-n${node.path.replaceAll(".", "-") || "root"}`, finalProps); + if (!rendered.ok) { + return ; + } + return ( +
+ {rendered.element} +
+ ); +} + +function LiveNotice({ text }: { text: string }) { + return

{text}

; +} + +// Development aid: what the engine dropped or could not compute. +function LiveIssues({ issues }: { issues: readonly BlockIssue[] }) { + const t = useTranslations("chat.markdown.uiBlock"); + return ( +
+ {t("formulaIssues", { count: issues.length })} +
    + {issues.slice(0, 12).map((issue, index) => ( +
  • + {issue.path || "$"}: {issue.message} +
  • + ))} +
+
+ ); +} diff --git a/apps/web/shared/components/markdown/ui-blocks/live/live-context.tsx b/apps/web/shared/components/markdown/ui-blocks/live/live-context.tsx new file mode 100644 index 000000000..ad83a0664 --- /dev/null +++ b/apps/web/shared/components/markdown/ui-blocks/live/live-context.tsx @@ -0,0 +1,155 @@ +"use client"; + +import type { ResolvedNode, UIValue } from "@deeix/core"; +import { formatTemplateNumber, valuesEqual } from "@deeix/core"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; +import { isRecord } from "@/shared/lib/type-guards"; + +// What every node of one live block shares. Containers render their children +// through `renderNode`, so primitives and the block host do not import each +// other. +export type LiveBlockContextValue = { + // Stable part of the ids handed to nested catalog components. + blockKey: string; + streaming: boolean; + // The block's state after the user's changes; controls read their binding here. + state: Readonly>; + // State plus computed values, for primitives that show a formula's inputs. + values: Readonly>; + stateNames: ReadonlySet; + update: (patch: Record) => void; + // The user changed something; the root panel then offers a reset. + changed: boolean; + reset: () => void; + renderNode: (node: ResolvedNode) => React.ReactNode; +}; + +export const LiveBlockContext = React.createContext(null); + +export function useLiveBlock(): LiveBlockContextValue { + const value = React.useContext(LiveBlockContext); + if (!value) { + throw new Error("live block nodes must render inside a LiveBlock"); + } + return value; +} + +export function LiveChildren({ node }: { node: ResolvedNode }) { + const { renderNode } = useLiveBlock(); + return node.children.map((child) => {renderNode(child)}); +} + +// ---- Reading resolved props ---- +// Props are model output after formula evaluation: anything may be missing or +// of the wrong type, and every reader falls back instead of throwing. + +export function readText(props: Record, key: string): string | undefined { + const value = props[key]; + if (typeof value === "string") { + return value; + } + return typeof value === "number" ? formatTemplateNumber(value) : undefined; +} + +export function readNumber(props: Record, key: string): number | undefined { + const value = props[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +export function readBoolean(props: Record, key: string): boolean | undefined { + const value = props[key]; + return typeof value === "boolean" ? value : undefined; +} + +export function readEnum(props: Record, key: string, allowed: readonly T[]): T | undefined { + const value = props[key]; + return allowed.find((item) => item === value); +} + +// How a resolved value reads as text: numbers grouped with at most two +// decimals, lists and objects as JSON, null as nothing. +export function displayValue(value: unknown): string { + if (typeof value === "number") { + return formatTemplateNumber(value); + } + if (typeof value === "string") { + return value; + } + if (typeof value === "boolean") { + return String(value); + } + if (value === null || value === undefined) { + return ""; + } + return JSON.stringify(value); +} + +type LiveOption = { label: string; value: UIValue }; + +// Options written as plain values (`["a", "b"]`) or as `{label, value}`. +export function readOptions(props: Record, key: string): LiveOption[] { + const value = props[key]; + if (!Array.isArray(value)) { + return []; + } + const options: LiveOption[] = []; + for (const item of value) { + if (isRecord(item)) { + const optionValue = toOptionValue(item.value ?? item.label); + if (optionValue !== undefined) { + options.push({ label: displayValue(item.label ?? item.value), value: optionValue }); + } + continue; + } + const optionValue = toOptionValue(item); + if (optionValue !== undefined) { + options.push({ label: displayValue(item), value: optionValue }); + } + } + return options; +} + +function toOptionValue(value: unknown): UIValue | undefined { + if (typeof value === "string" || typeof value === "boolean" || value === null) { + return value; + } + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +export function optionIndex(options: readonly LiveOption[], value: UIValue | undefined): number { + return value === undefined ? -1 : options.findIndex((option) => valuesEqual(option.value, value)); +} + +export type LiveTone = "default" | "muted" | "positive" | "negative" | "warning" | "info"; + +export const LIVE_TONES: readonly LiveTone[] = ["default", "muted", "positive", "negative", "warning", "info"]; + +export const TONE_TEXT_CLASS: Record = { + default: "text-foreground", + muted: "text-muted-foreground", + positive: "text-green-600 dark:text-green-400", + negative: "text-red-600 dark:text-red-400", + warning: "text-amber-600 dark:text-amber-400", + info: "text-sky-600 dark:text-sky-400", +}; + +// A pill toggle, shared by tabs and segmented controls. +export function ChipButton({ active, onClick, disabled, children }: { active: boolean; onClick: () => void; disabled?: boolean; children: React.ReactNode }) { + return ( + + ); +} diff --git a/apps/web/shared/components/markdown/ui-blocks/live/live-controls.tsx b/apps/web/shared/components/markdown/ui-blocks/live/live-controls.tsx new file mode 100644 index 000000000..f6b392f1f --- /dev/null +++ b/apps/web/shared/components/markdown/ui-blocks/live/live-controls.tsx @@ -0,0 +1,252 @@ +"use client"; + +import type { ResolvedNode, UIValue } from "@deeix/core"; +import { readAction } from "@deeix/core"; +import { Copy } from "lucide-react"; +import { useTranslations } from "next-intl"; +import * as React from "react"; + +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Slider } from "@/components/ui/slider"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import { useCopyAction } from "@/shared/components/copy-action"; +import { useUIBlockActions } from "../ui-block-actions"; +import { UIBlockAsk } from "../ui-block-ask"; +import { ChipButton, displayValue, optionIndex, readEnum, readNumber, readOptions, readText, useLiveBlock } from "./live-context"; + +// Controls write to the state named by `bind`; one without a binding has +// nothing to change and is not shown. + +const INPUT_MAX_LENGTH = 200; + +function ControlLabel({ htmlFor, label, value }: { htmlFor?: string; label?: string; value?: React.ReactNode }) { + if (!label && value === undefined) { + return null; + } + return ( +
+ + {value !== undefined ? {value} : null} +
+ ); +} + +export function LiveSlider({ node }: { node: ResolvedNode }) { + const { state, update } = useLiveBlock(); + const id = React.useId(); + const bind = node.bind; + if (!bind) { + return null; + } + const min = readNumber(node.props, "min") ?? 0; + const declaredMax = readNumber(node.props, "max") ?? 100; + const max = declaredMax > min ? declaredMax : min + 1; + const declaredStep = readNumber(node.props, "step"); + const step = declaredStep !== undefined && declaredStep > 0 ? declaredStep : max - min >= 10 ? 1 : (max - min) / 100; + const current = state[bind]; + const value = typeof current === "number" && Number.isFinite(current) ? Math.min(max, Math.max(min, current)) : min; + const unit = readText(node.props, "unit"); + return ( +
+ + {displayValue(value)} + {unit ? {unit} : null} + + } + /> + update({ [bind]: next ?? value })} /> +
+ ); +} + +export function LiveSelect({ node }: { node: ResolvedNode }) { + const { state, update } = useLiveBlock(); + const id = React.useId(); + const bind = node.bind; + const options = readOptions(node.props, "options"); + if (!bind || options.length === 0) { + return null; + } + const index = optionIndex(options, state[bind]); + return ( +
+ + {/* Radix needs string values; the option index stands in for the real value. */} + +
+ ); +} + +function selectOption(options: ReturnType, next: string, bind: string, update: (patch: Record) => void) { + const option = options[Number.parseInt(next, 10)]; + if (option) { + update({ [bind]: option.value }); + } +} + +export function LiveSegmented({ node }: { node: ResolvedNode }) { + const { state, update } = useLiveBlock(); + const bind = node.bind; + const options = readOptions(node.props, "options"); + if (!bind || options.length === 0) { + return null; + } + const index = optionIndex(options, state[bind]); + const label = readText(node.props, "label"); + return ( +
+ {label ?

{label}

: null} +
+ {options.map((option, optionIndexValue) => ( + update({ [bind]: option.value })}> + {option.label} + + ))} +
+
+ ); +} + +export function LiveSwitch({ node }: { node: ResolvedNode }) { + const { state, update } = useLiveBlock(); + const id = React.useId(); + const bind = node.bind; + if (!bind) { + return null; + } + return ( +
+ + update({ [bind]: checked })} /> +
+ ); +} + +const INPUT_TYPES = ["text", "number"] as const; + +export function LiveInput({ node }: { node: ResolvedNode }) { + const { state, update } = useLiveBlock(); + const id = React.useId(); + const bind = node.bind; + const numeric = readEnum(node.props, "type", INPUT_TYPES) === "number"; + const current = bind ? state[bind] : undefined; + // A number field keeps what is typed ("1." or "-") until it parses. + const [draft, setDraft] = React.useState(() => displayInput(current)); + const committed = displayInput(current); + const [lastCommitted, setLastCommitted] = React.useState(committed); + if (committed !== lastCommitted) { + setLastCommitted(committed); + setDraft(committed); + } + if (!bind) { + return null; + } + const onChange = (text: string) => { + setDraft(text); + if (!numeric) { + update({ [bind]: text }); + return; + } + const value = Number(text); + if (text.trim() !== "" && Number.isFinite(value)) { + update({ [bind]: value }); + } + }; + return ( +
+ + onChange(event.target.value)} + /> +
+ ); +} + +function displayInput(value: UIValue | undefined): string { + if (typeof value === "number") { + return Number.isFinite(value) ? String(value) : ""; + } + return typeof value === "string" ? value : ""; +} + +const BUTTON_VARIANTS = ["default", "outline"] as const; +const COPY_PREVIEW_LENGTH = 120; + +function truncate(text: string, length: number): string { + const characters = Array.from(text); + return characters.length > length ? `${characters.slice(0, length).join("")}…` : text; +} + +// `ask` becomes a follow-up composer, `set` changes block state, `copy` copies +// text; a button without an action renders disabled. Set and copy buttons stay +// neutral so the accent colour marks the one thing that leaves the block. +export function LiveButton({ node }: { node: ResolvedNode }) { + const t = useTranslations("chat.markdown.uiBlock"); + const { stateNames, streaming, update } = useLiveBlock(); + const actions = useUIBlockActions(); + const { copy } = useCopyAction({ messages: { copied: t("copied"), failed: t("copyFailed") } }); + const action = readAction(node.props, stateNames); + const variant = readEnum(node.props, "variant", BUTTON_VARIANTS) ?? "default"; + const className = cn( + "inline-flex min-h-8 w-fit max-w-full items-center gap-1.5 rounded-md px-3 py-1.5 text-left text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50", + variant === "outline" + ? "border-[0.5px] border-border bg-background hover:bg-accent hover:text-accent-foreground" + : "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ); + + if (action?.kind === "ask") { + // `variant` does not apply: an ask is a small composer, not a button. + return ; + } + + const label = readText(node.props, "label") ?? ""; + if (action?.kind === "copy") { + return ( + // The toast repeats what was copied, so a block cannot fill the clipboard unseen. + + ); + } + if (action?.kind === "set") { + const patch = action.patch; + return ( + + ); + } + return label ? ( + + ) : null; +} diff --git a/apps/web/shared/components/markdown/ui-blocks/live/live-primitives.tsx b/apps/web/shared/components/markdown/ui-blocks/live/live-primitives.tsx new file mode 100644 index 000000000..7e9202172 --- /dev/null +++ b/apps/web/shared/components/markdown/ui-blocks/live/live-primitives.tsx @@ -0,0 +1,336 @@ +"use client"; + +import type { ResolvedNode, UIValue } from "@deeix/core"; +import { ChevronDown, CircleAlert, Info, RotateCcw, TriangleAlert } from "lucide-react"; +import { useTranslations } from "next-intl"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; +import { CollapsibleMotionContent } from "@/shared/components/collapsible-motion-content"; +import { + ChipButton, + displayValue, + LIVE_TONES, + LiveChildren, + type LiveTone, + optionIndex, + readBoolean, + readEnum, + readNumber, + readOptions, + readText, + TONE_TEXT_CLASS, + useLiveBlock, +} from "./live-context"; +import { LiveButton, LiveInput, LiveSegmented, LiveSelect, LiveSlider, LiveSwitch } from "./live-controls"; +import { LiveFormula, LiveScene } from "./live-visuals"; + +// Renders one primitive. Unknown props are ignored; missing ones fall back to +// a sensible default, so a model slip changes the look, never breaks the block. +export function LivePrimitive({ node }: { node: ResolvedNode }) { + switch (node.component) { + case "panel": + return ; + case "stack": + return ( +
+ +
+ ); + case "row": + return ( +
+ +
+ ); + case "grid": + return ; + case "card": + return ; + case "tabs": + return ; + case "collapse": + return ; + case "divider": + return
; + case "text": + return ; + case "stat": + return ; + case "badge": + return ; + case "progress": + return ; + case "callout": + return ; + case "formula": + return ; + case "scene": + return ; + case "slider": + return ; + case "select": + return ; + case "segmented": + return ; + case "switch": + return ; + case "input": + return ; + case "button": + return ; + default: + return null; + } +} + +// The root panel carries the block's heading and, once the user changed +// something, a reset back to the declared state; a nested one is a titled stack. +function LivePanel({ node }: { node: ResolvedNode }) { + const t = useTranslations("chat.markdown.uiBlock"); + const { changed, reset } = useLiveBlock(); + const title = readText(node.props, "title"); + const description = readText(node.props, "description"); + const root = node.path === ""; + const showReset = root && changed; + return ( +
+ {title || showReset ? ( +
+ {title ?

{title}

: } + {showReset ? ( + + ) : null} +
+ ) : null} + {description ?

{description}

: null} + +
+ ); +} + +const VISUAL_PRIMITIVES = new Set(["scene"]); + +// The children of a panel or card. When they include a diagram next to a +// formula or controls, the section becomes an explorable: the diagram on the +// right, everything else on the left (stacked when the block is narrow), and a +// send button below both. Models write a flat list and still get a balanced +// layout; a row they write themselves is left alone. +function LiveFlow({ node, className }: { node: ResolvedNode; className?: string }) { + const { renderNode } = useLiveBlock(); + const visuals = node.children.filter((child) => VISUAL_PRIMITIVES.has(child.component)); + const asks = node.children.filter((child) => child.component === "button" && typeof child.props.ask === "string"); + const main = node.children.filter((child) => !visuals.includes(child) && !asks.includes(child)); + const render = (child: ResolvedNode) => {renderNode(child)}; + if (visuals.length === 0 || main.length === 0) { + return ( +
+ +
+ ); + } + return ( +
+
+
{main.map(render)}
+ {/* The diagram sits on its own tile, so it reads as one picture, not loose lines on the card. */} +
+ {visuals.map(render)} +
+
+ {asks.map(render)} +
+ ); +} + +const GRID_COLUMNS: Record = { + 1: "grid-cols-1", + 2: "grid-cols-1 sm:grid-cols-2", + 3: "grid-cols-1 sm:grid-cols-2 md:grid-cols-3", + 4: "grid-cols-2 md:grid-cols-4", +}; + +function LiveGrid({ node }: { node: ResolvedNode }) { + const columns = Math.round(readNumber(node.props, "columns") ?? 0); + return ( +
+ +
+ ); +} + +function LiveCard({ node }: { node: ResolvedNode }) { + const title = readText(node.props, "title"); + return ( +
+ {title ?

{title}

: null} + +
+ ); +} + +// Tab i shows child i. With `bind` the open tab is block state (the model sees +// which one the user looked at); without it the choice stays local. +function LiveTabs({ node }: { node: ResolvedNode }) { + const { state, update, renderNode } = useLiveBlock(); + const options = readOptions(node.props, "items"); + const [localIndex, setLocalIndex] = React.useState(0); + const bound = node.bind ? optionIndex(options, state[node.bind]) : -1; + const active = node.bind ? Math.max(0, bound) : Math.min(localIndex, Math.max(0, options.length - 1)); + const select = (index: number, value: UIValue) => { + if (node.bind) { + update({ [node.bind]: value }); + } else { + setLocalIndex(index); + } + }; + const child = node.children[active]; + return ( +
+
+ {options.map((option, index) => ( + select(index, option.value)}> + {option.label} + + ))} +
+ {child ?
{renderNode(child)}
: null} +
+ ); +} + +function LiveCollapse({ node }: { node: ResolvedNode }) { + const [open, setOpen] = React.useState(() => readBoolean(node.props, "open") ?? false); + const contentID = React.useId(); + return ( +
+ + +
+ +
+
+
+ ); +} + +const TEXT_SIZE = { sm: "text-xs leading-5", base: "text-sm leading-6", lg: "text-base font-medium leading-7" } as const; +const TEXT_SIZES = ["sm", "base", "lg"] as const; + +function LiveText({ node }: { node: ResolvedNode }) { + const text = displayValue(node.props.text); + if (!text) { + return null; + } + const tone = readEnum(node.props, "tone", LIVE_TONES) ?? "default"; + const size = readEnum(node.props, "size", TEXT_SIZES) ?? "base"; + return

{text}

; +} + +function LiveStat({ node }: { node: ResolvedNode }) { + const label = readText(node.props, "label"); + const unit = readText(node.props, "unit"); + const note = readText(node.props, "note"); + const tone = readEnum(node.props, "tone", LIVE_TONES) ?? "default"; + const highlight = readBoolean(node.props, "highlight") ?? false; + const precision = readNumber(node.props, "precision"); + const raw = node.props.value; + // A value that failed to compute shows a dash rather than disappearing. + const value = raw === undefined ? "—" : typeof raw === "number" && precision !== undefined ? formatFixed(raw, precision) : displayValue(raw); + return ( +
+ {label ?

{label}

: null} +

+ {value} + {unit ? {unit} : null} +

+ {note ?

{note}

: null} +
+ ); +} + +function formatFixed(value: number, precision: number): string { + if (!Number.isFinite(value)) { + return "—"; + } + const digits = Math.min(6, Math.max(0, Math.round(precision))); + return new Intl.NumberFormat("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits }).format(value); +} + +const BADGE_TONE_CLASS: Record = { + default: "border-border bg-muted text-foreground", + muted: "border-border text-muted-foreground", + positive: "border-green-600/30 bg-green-600/10 text-green-700 dark:text-green-400", + negative: "border-red-600/30 bg-red-600/10 text-red-700 dark:text-red-400", + warning: "border-amber-600/30 bg-amber-600/10 text-amber-700 dark:text-amber-400", + info: "border-sky-600/30 bg-sky-600/10 text-sky-700 dark:text-sky-400", +}; + +function LiveBadge({ node }: { node: ResolvedNode }) { + const text = displayValue(node.props.text ?? node.props.label); + if (!text) { + return null; + } + const tone = readEnum(node.props, "tone", LIVE_TONES) ?? "default"; + return ( + {text} + ); +} + +function LiveProgress({ node }: { node: ResolvedNode }) { + const max = readNumber(node.props, "max") ?? 100; + const value = readNumber(node.props, "value") ?? 0; + const ratio = max > 0 ? Math.min(1, Math.max(0, value / max)) : 0; + const label = readText(node.props, "label"); + return ( +
+
+ {label ?? ""} + {Math.round(ratio * 100)}% +
+
+
+
+
+ ); +} + +const CALLOUT_TONES = ["info", "positive", "warning", "negative"] as const; +const CALLOUT_CLASS: Record<(typeof CALLOUT_TONES)[number], string> = { + info: "border-sky-600/25 bg-sky-600/5", + positive: "border-green-600/25 bg-green-600/5", + warning: "border-amber-600/25 bg-amber-600/5", + negative: "border-red-600/25 bg-red-600/5", +}; + +function LiveCallout({ node }: { node: ResolvedNode }) { + const tone = readEnum(node.props, "tone", CALLOUT_TONES) ?? "info"; + const title = readText(node.props, "title"); + const text = displayValue(node.props.text); + const Icon = tone === "warning" ? TriangleAlert : tone === "negative" ? CircleAlert : Info; + return ( +
+ +
+ {title ?

{title}

: null} + {text ?

{text}

: null} +
+
+ ); +} diff --git a/apps/web/shared/components/markdown/ui-blocks/live/live-text.ts b/apps/web/shared/components/markdown/ui-blocks/live/live-text.ts new file mode 100644 index 000000000..31cb95522 --- /dev/null +++ b/apps/web/shared/components/markdown/ui-blocks/live/live-text.ts @@ -0,0 +1,145 @@ +// Readable Markdown of a live block, in the state the user left it: the same +// resolution as on screen, written out as text. Leaves that read as one line +// (stats, control values, badges) are gathered into a list; text, callouts and +// catalog components stay paragraphs. Buttons are left out: they do nothing +// outside the block. + +import type { BlockIssue, BlockSource, ResolvedNode, UIValue } from "@deeix/core"; +import { compileBlock, expandPointProps, formatTemplateNumber, formulaToLatex, mergeState, resolveBlock, takeWhere, WHERE_LIST_PROP } from "@deeix/core"; + +import { LIVE_COMPILE_OPTIONS, resolveLatestDefinition, type UIBlockRegistry } from "../block"; +import { joinTextBlocks, textPair, textTitle, textValue } from "../block-text"; +import type { UIBlockState } from "../ui-block-state"; +import { optionIndex, readNumber, readOptions, readText } from "./live-context"; + +type TextPart = { kind: "line" | "block"; text: string }; + +export function liveBlockText(block: BlockSource, saved: UIBlockState | undefined, registry: UIBlockRegistry): string { + const compiled = compileBlock(block, LIVE_COMPILE_OPTIONS); + const state = mergeState(compiled.initialState, saved); + const { root, values } = resolveBlock(compiled, state); + return joinParts(nodeText(root, state, values, registry)); +} + +function nodeText(node: ResolvedNode, state: Readonly>, values: Readonly>, registry: UIBlockRegistry): TextPart[] { + const children = () => node.children.flatMap((child) => nodeText(child, state, values, registry)); + const line = (text: string): TextPart[] => (text.trim() ? [{ kind: "line", text }] : []); + const block = (text: string): TextPart[] => (text.trim() ? [{ kind: "block", text }] : []); + const titled = (title: string | undefined, parts: TextPart[]): TextPart[] => [...textTitle(title).flatMap(block), ...parts]; + + switch (node.component) { + case "panel": + return titled(readText(node.props, "title"), [...block(readText(node.props, "description") ?? ""), ...children()]); + case "card": + case "collapse": + return titled(readText(node.props, "title"), children()); + case "stack": + case "row": + case "grid": + return children(); + case "tabs": { + const options = readOptions(node.props, "items"); + return node.children.flatMap((child, index) => titled(options[index]?.label, nodeText(child, state, values, registry))); + } + case "divider": + return block("---"); + case "text": + return block(textValue(node.props.text)); + case "callout": { + const title = readText(node.props, "title"); + const text = textValue(node.props.text); + return block([title ? `**${title}**` : "", text].filter(Boolean).map((part) => `> ${part}`).join("\n>\n")); + } + case "stat": + return line(textPair(readText(node.props, "label"), node.props.value === undefined ? "—" : textValue(node.props.value), readText(node.props, "unit"))); + case "badge": + return line(textValue(node.props.text ?? node.props.label)); + case "progress": { + const max = readNumber(node.props, "max") ?? 100; + const value = readNumber(node.props, "value") ?? 0; + const percent = max > 0 ? Math.round(Math.min(1, Math.max(0, value / max)) * 100) : 0; + return line(textPair(readText(node.props, "label"), `${percent}%`)); + } + case "slider": + case "switch": + case "input": + return node.bind ? line(textPair(readText(node.props, "label"), textValue(state[node.bind]), readText(node.props, "unit"))) : []; + case "select": + case "segmented": { + if (!node.bind) { + return []; + } + const options = readOptions(node.props, "options"); + const index = optionIndex(options, state[node.bind]); + return line(textPair(readText(node.props, "label"), index >= 0 ? (options[index]?.label ?? "") : textValue(state[node.bind]))); + } + case "formula": + return line(formulaText(node, values)); + case "scene": + case "button": + // A diagram and a button have no text of their own. + return []; + default: + return block(catalogText(node, registry)); + } +} + +// `F_g = m * g = 88.2 N`: the formula as written, then its current result. +function formulaText(node: ResolvedNode, values: Readonly>): string { + const expr = readText(node.props, "expr"); + if (!expr) { + return ""; + } + const name = readText(node.props, "name"); + const unit = readText(node.props, "unit"); + let result = ""; + try { + const value = formulaToLatex(expr, { values, with: node.props.with }).result; + if (value !== undefined) { + const digits = Math.min(6, Math.max(0, Math.round(readNumber(node.props, "precision") ?? 2))); + result = ` = ${formatTemplateNumber(Number(value.toFixed(digits)))}${unit ? ` ${unit}` : ""}`; + } + } catch { + // An invalid formula is copied as written. + } + return `${name ? `${name} = ` : ""}${expr}${result}`; +} + +// A nested catalog component, with per-point data expanded and `where` +// applied; its own state is local to the screen, so it reads as declared. +function catalogText(node: ResolvedNode, registry: UIBlockRegistry): string { + const definition = resolveLatestDefinition(registry, node.component); + if (!definition) { + return ""; + } + const issues: BlockIssue[] = []; + const { props, where } = takeWhere(node.component, expandPointProps(node.component, node.props, issues), issues); + let finalProps = props; + if (where) { + const listProp = WHERE_LIST_PROP[node.component] ?? ""; + const list = props[listProp]; + finalProps = { ...props, [listProp]: Array.isArray(list) ? list.filter((row, index) => where(row, index)) : [] }; + } + return definition.toText(finalProps, undefined) ?? ""; +} + +function joinParts(parts: TextPart[]): string { + const blocks: string[] = []; + let lines: string[] = []; + const flush = () => { + if (lines.length > 0) { + blocks.push(lines.map((text) => `- ${text}`).join("\n")); + lines = []; + } + }; + for (const part of parts) { + if (part.kind === "line") { + lines.push(part.text); + } else { + flush(); + blocks.push(part.text); + } + } + flush(); + return joinTextBlocks(blocks); +} diff --git a/apps/web/shared/components/markdown/ui-blocks/live/live-visuals.tsx b/apps/web/shared/components/markdown/ui-blocks/live/live-visuals.tsx new file mode 100644 index 000000000..d17e87314 --- /dev/null +++ b/apps/web/shared/components/markdown/ui-blocks/live/live-visuals.tsx @@ -0,0 +1,281 @@ +"use client"; + +import type { ResolvedNode } from "@deeix/core"; +import { formulaToLatex } from "@deeix/core"; +import katex from "katex"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; +import { isRecord } from "@/shared/lib/type-guards"; +import { LIVE_TONES, type LiveTone, readNumber, readText, useLiveBlock } from "./live-context"; + +// Two primitives for explaining a relationship rather than listing data: +// `formula` writes a formula out with the current values, and `scene` draws a +// small diagram whose geometry follows the state. Neither takes markup from the +// model: the formula is LaTeX built from the parsed expression, the scene is a +// fixed set of SVG shapes. + +// ---- formula ---- + +// `F_g = m g` large, then the worked line `= (9)(9.8) = 88.2 N` that follows +// every change of the inputs. +export function LiveFormula({ node }: { node: ResolvedNode }) { + const { values } = useLiveBlock(); + const expr = readText(node.props, "expr"); + const latex = React.useMemo(() => { + if (!expr) { + return null; + } + try { + return formulaToLatex(expr, { + name: readText(node.props, "name"), + unit: readText(node.props, "unit"), + precision: readNumber(node.props, "precision"), + values, + with: node.props.with, + }); + } catch { + return null; + } + }, [expr, node.props, values]); + if (!latex) { + return null; + } + const label = readText(node.props, "label"); + return ( +
+ {label ?

{label}

: null} + + {latex.worked ? : null} +
+ ); +} + +function KatexLine({ latex, className }: { latex: string; className?: string }) { + const html = React.useMemo(() => katex.renderToString(latex, { throwOnError: false, output: "html", strict: "ignore" }), [latex]); + // The LaTeX comes from formulaToLatex: built from a parsed AST, with names + // restricted to identifiers and units escaped. + // biome-ignore lint/security/noDangerouslySetInnerHtml: KaTeX markup built from a parsed AST + return ; +} + +// ---- scene ---- + +const MAX_SHAPES = 60; +const MAX_SIZE = 1000; +const LABEL_MAX_LENGTH = 40; + +// Strokes and fills per tone; only semantic colours, the model never picks one. +// Kept soft: a diagram sits next to text and controls and should not shout. +const TONE_STROKE: Record = { + default: "stroke-foreground/55", + muted: "stroke-muted-foreground/40", + positive: "stroke-emerald-500/85 dark:stroke-emerald-400/85", + negative: "stroke-rose-500/85 dark:stroke-rose-400/85", + warning: "stroke-amber-500/85 dark:stroke-amber-400/85", + info: "stroke-sky-500/85 dark:stroke-sky-400/85", +}; +const TONE_FILL: Record = { + default: "fill-foreground/[0.04]", + muted: "fill-muted/60", + positive: "fill-emerald-500/10", + negative: "fill-rose-500/10", + warning: "fill-amber-500/10", + info: "fill-sky-500/10", +}; +const TONE_TEXT: Record = { + default: "fill-foreground/80", + muted: "fill-muted-foreground", + positive: "fill-emerald-700 dark:fill-emerald-300", + negative: "fill-rose-700 dark:fill-rose-300", + warning: "fill-amber-700 dark:fill-amber-300", + info: "fill-sky-700 dark:fill-sky-300", +}; +const TONE_MARKER: Record = { + default: "fill-foreground/55", + muted: "fill-muted-foreground/40", + positive: "fill-emerald-500/85 dark:fill-emerald-400/85", + negative: "fill-rose-500/85 dark:fill-rose-400/85", + warning: "fill-amber-500/85 dark:fill-amber-400/85", + info: "fill-sky-500/85 dark:fill-sky-400/85", +}; + +const LABEL_SIZE = 12; +const LABEL_GAP = 8; + +type Shape = Record; + +// A diagram in its own coordinates (`width` × `height`, origin top left), drawn +// at that size and only ever scaled down: stretched to a wide column, labels and +// strokes would grow with it. Shapes: rect {x, y, w, h}, circle {cx, cy, r}, +// line and arrow {x1, y1, x2, y2}, text {x, y, text}; each may have `label` and +// `tone`. A shape with a missing or non-finite coordinate is skipped, not drawn at 0. +export function LiveScene({ node }: { node: ResolvedNode }) { + const markerID = `scene-arrow-${React.useId().replace(/[^a-zA-Z0-9_-]/g, "")}`; + const width = clampSize(readNumber(node.props, "width") ?? 400); + const height = clampSize(readNumber(node.props, "height") ?? 240); + const shapes = Array.isArray(node.props.shapes) ? node.props.shapes.filter(isRecord).slice(0, MAX_SHAPES) : []; + const usedTones = new Set(shapes.filter((shape) => shape.type === "arrow").map(toneOf)); + return ( +
+ + + {Array.from(usedTones, (tone) => ( + // In user units, so the head does not grow with the stroke. + + + + ))} + + {shapes.map((shape, index) => ( + + ))} + +
+ ); +} + +function SceneShape({ shape, markerID }: { shape: Shape; markerID: string }) { + const tone = toneOf(shape); + const label = shapeLabel(shape); + const n = (key: string) => finite(shape[key]); + const labelClass = TONE_TEXT[tone]; + switch (shape.type) { + case "rect": { + const [x, y, w, h] = [n("x"), n("y"), n("w") ?? n("width"), n("h") ?? n("height")]; + if (x === undefined || y === undefined || w === undefined || h === undefined) { + return null; + } + const left = Math.min(x, x + w); + const top = Math.min(y, y + h); + return ( + + + {label ? ( + + {label} + + ) : null} + + ); + } + case "circle": { + const [cx, cy, r] = [n("cx") ?? n("x"), n("cy") ?? n("y"), n("r")]; + if (cx === undefined || cy === undefined || r === undefined) { + return null; + } + return ( + + + {label ? ( + + {label} + + ) : null} + + ); + } + case "line": + case "arrow": { + const [x1, y1, x2, y2] = [n("x1"), n("y1"), n("x2"), n("y2")]; + if (x1 === undefined || y1 === undefined || x2 === undefined || y2 === undefined) { + return null; + } + const arrow = shape.type === "arrow"; + const placement = lineLabelPlacement(x1, y1, x2, y2); + return ( + + + {label ? ( + + {label} + + ) : null} + + ); + } + case "text": { + const [x, y] = [n("x"), n("y")]; + const text = label || shapeText(shape.text); + if (x === undefined || y === undefined || !text) { + return null; + } + return ( + + {text} + + ); + } + default: + return null; + } +} + +// A line's label sits beside its midpoint, off the line: to the right of a +// vertical line, below a horizontal one, never on the stroke or the arrowhead. +function lineLabelPlacement(x1: number, y1: number, x2: number, y2: number) { + const length = Math.hypot(x2 - x1, y2 - y1) || 1; + let nx = -(y2 - y1) / length; + let ny = (x2 - x1) / length; + if (nx < -0.01 || (Math.abs(nx) <= 0.01 && ny < 0)) { + nx = -nx; + ny = -ny; + } + return { + x: (x1 + x2) / 2 + nx * LABEL_GAP, + y: (y1 + y2) / 2 + ny * LABEL_GAP, + anchor: nx > 0.3 ? "start" : nx < -0.3 ? "end" : "middle", + baseline: ny > 0.3 ? "hanging" : ny < -0.3 ? "auto" : "central", + } as const; +} + +function toneOf(shape: Shape): LiveTone { + return LIVE_TONES.find((tone) => tone === shape.tone) ?? "default"; +} + +function shapeLabel(shape: Shape): string { + return shapeText(shape.label); +} + +function shapeText(value: unknown): string { + if (typeof value === "number") { + return Number.isFinite(value) ? String(Number(value.toFixed(2))) : ""; + } + return typeof value === "string" ? value.slice(0, LABEL_MAX_LENGTH) : ""; +} + +// Models sometimes quote coordinates ("120"); a numeric string is accepted. +function finite(value: unknown): number | undefined { + const number = typeof value === "string" && value.trim() !== "" ? Number(value) : value; + return typeof number === "number" && Number.isFinite(number) ? number : undefined; +} + +function clampSize(value: number): number { + return Math.min(MAX_SIZE, Math.max(40, value)); +} diff --git a/apps/web/shared/components/markdown/ui-blocks/message-text.ts b/apps/web/shared/components/markdown/ui-blocks/message-text.ts new file mode 100644 index 000000000..55ddaa947 --- /dev/null +++ b/apps/web/shared/components/markdown/ui-blocks/message-text.ts @@ -0,0 +1,39 @@ +// A reply as text for the clipboard: each interactive block becomes readable +// Markdown in the state the user left it; everything else is the reply as +// written. A block that cannot be described (unknown or invalid component, +// a custom sandbox component, still streaming) is kept as source. + +import { parseBlockSource, readMessageUIBlocks, replaceUIBlockFences, UI_BLOCK_FENCE_LANGUAGE } from "@deeix/core"; + +import { resolveDefinition, type UIBlockRegistry } from "./block"; +import { liveBlockText } from "./live/live-text"; +import type { UIBlockState } from "./ui-block-state"; + +export function messageUIBlocksText(content: string, registry: UIBlockRegistry, stateFor: (blockKey: string) => UIBlockState | undefined): string { + if (!content.includes(UI_BLOCK_FENCE_LANGUAGE)) { + return content; + } + // Repeated keys never store state (see useMessageUIBlockStateBinding). + const { duplicates } = readMessageUIBlocks(content); + return replaceUIBlockFences(content, (fence) => { + // Illustrations are program source: kept as written. + if (!fence.closed || fence.kind === "widget") { + return null; + } + const parsed = parseBlockSource(fence.body, false); + if (parsed.status !== "ok" || !parsed.block.complete) { + return null; + } + const { block } = parsed; + const state = block.key && !duplicates.has(block.key) ? stateFor(block.key) : undefined; + try { + if (block.live) { + return liveBlockText(block, state, registry) || null; + } + return resolveDefinition(registry, block.component, block.version)?.toText(block.root.props, state) || null; + } catch { + // Copying must never fail because one block could not be described. + return null; + } + }); +} diff --git a/apps/web/shared/components/markdown/ui-blocks/quiz.tsx b/apps/web/shared/components/markdown/ui-blocks/quiz.tsx index c90590654..5658022d2 100644 --- a/apps/web/shared/components/markdown/ui-blocks/quiz.tsx +++ b/apps/web/shared/components/markdown/ui-blocks/quiz.tsx @@ -10,8 +10,10 @@ import { cn } from "@/lib/utils"; import { CollapsibleMotionContent } from "@/shared/components/collapsible-motion-content"; import { SlideSwitch } from "@/shared/components/slide-switch"; import type { UIBlockDefinition, UIBlockRenderProps, UIBlockSkeletonProps } from "./block"; +import { joinTextBlocks, textTitle } from "./block-text"; import { s } from "./schema"; import { UIBlockFrame } from "./ui-block-frame"; +import { type UIBlockState, useUIBlockState } from "./ui-block-state"; // Options are plain strings or objects that can route to another question. type QuizOption = string | { text: string; next?: string; explanation?: string }; @@ -55,27 +57,62 @@ function questionID(question: QuizQuestion, index: number): string { return question.id ?? `q${index + 1}`; } +// Stored state: the picked option per question (null = unanswered), plus, for +// branching quizzes, the visited questions and whether the end was reached. The +// score is derived, never stored. +type QuizState = { answers: (number | null)[]; path: number[]; ended: boolean }; + +const MAX_PATH = 64; + +function quizDefaults(questions: QuizQuestion[]): QuizState { + return { answers: questions.map(() => null), path: [0], ended: false }; +} + +function restoreQuizState(questions: QuizQuestion[], saved: UIBlockState | undefined, defaults: QuizState): QuizState { + const storedAnswers = saved?.answers; + const answers = questions.map((question, index) => { + const stored = Array.isArray(storedAnswers) ? storedAnswers[index] : undefined; + return typeof stored === "number" && Number.isInteger(stored) && stored >= 0 && stored < question.options.length ? stored : null; + }); + const storedPath = saved?.path; + const path = + Array.isArray(storedPath) && + storedPath.length > 0 && + storedPath.length <= MAX_PATH && + storedPath[0] === 0 && + storedPath.every((step) => typeof step === "number" && Number.isInteger(step) && step >= 0 && step < questions.length) + ? storedPath.filter((step): step is number => typeof step === "number") + : defaults.path; + return { answers, path, ended: saved?.ended === true }; +} + +function withAnswer(answers: (number | null)[], index: number, value: number | null): (number | null)[] { + return answers.map((answer, current) => (current === index ? value : answer)); +} + function Quiz({ id, props }: UIBlockRenderProps) { const t = useTranslations("chat.markdown.uiBlock"); const questions = props.questions; const branching = React.useMemo(() => isBranching(questions), [questions]); const indexByID = React.useMemo(() => new Map(questions.map((question, index) => [questionID(question, index), index])), [questions]); - // Visited questions in order (branching) — the last entry is the current one. - const [path, setPath] = React.useState([0]); - const [picked, setPicked] = React.useState>({}); + const defaults = React.useMemo(() => quizDefaults(questions), [questions]); + // `path` lists visited questions in order (branching) — the last entry is the current one. + const [{ answers, path, ended }, setState] = useUIBlockState(id, { + defaults, + restore: (saved, initial) => restoreQuizState(questions, saved, initial), + title: props.title, + }); const [direction, setDirection] = React.useState<1 | -1>(1); - const [ended, setEnded] = React.useState(false); + const pickedOf = (qIndex: number): number | undefined => answers[qIndex] ?? undefined; const scored = questions.filter((question) => question.answer !== undefined); const visibleScored = branching ? scored.filter((question) => path.includes(questions.indexOf(question))) : scored; - const correct = visibleScored.reduce((count, question) => count + (picked[questions.indexOf(question)] === question.answer ? 1 : 0), 0); - const answered = Object.keys(picked).length; + const correct = visibleScored.reduce((count, question) => count + (pickedOf(questions.indexOf(question)) === question.answer ? 1 : 0), 0); + const answered = answers.filter((answer) => answer !== null).length; const finished = branching ? ended : answered === questions.length; const reset = () => { - setPicked({}); - setPath([0]); - setEnded(false); + setState(null); setDirection(-1); }; @@ -94,46 +131,42 @@ function Quiz({ id, props }: UIBlockRenderProps) { }; const choose = (qIndex: number, oIndex: number) => { - setPicked((current) => ({ ...current, [qIndex]: oIndex })); + setState((current) => ({ ...current, answers: withAnswer(current.answers, qIndex, oIndex) })); }; const advance = () => { const qIndex = path[path.length - 1]; const question = questions[qIndex]; - const option = question.options[picked[qIndex] ?? 0]; + const option = question.options[pickedOf(qIndex) ?? 0]; const next = resolveNext(question, qIndex, option); setDirection(1); if (next === END) { - setEnded(true); - } else { - // Stepping into a question again (loops) clears its previous pick. - setPicked((current) => { - const { [next]: _dropped, ...rest } = current; - return rest; - }); - setPath((current) => [...current, next]); + setState({ ended: true }); + return; } + // Stepping into a question again (loops) clears its previous pick. + setState((current) => + current.path.length >= MAX_PATH + ? current + : { ...current, answers: withAnswer(current.answers, next, null), path: [...current.path, next] }, + ); }; const back = () => { setDirection(-1); if (ended) { - setEnded(false); + setState({ ended: false }); return; } - setPath((current) => { - if (current.length <= 1) { + setState((current) => { + if (current.path.length <= 1) { return current; } - const dropped = current[current.length - 1]; - setPicked((picks) => { - const { [dropped]: _removed, ...rest } = picks; - return rest; - }); - return current.slice(0, -1); + const dropped = current.path[current.path.length - 1]; + return { ...current, answers: withAnswer(current.answers, dropped, null), path: current.path.slice(0, -1) }; }); }; const renderQuestion = (question: QuizQuestion, qIndex: number, ordinal: number) => { - const chosen = picked[qIndex]; + const chosen = pickedOf(qIndex); const revealed = chosen !== undefined; const graded = question.answer !== undefined; const answerIndex = question.answer !== undefined ? Math.min(Math.max(0, Math.trunc(question.answer)), question.options.length - 1) : -1; @@ -223,7 +256,7 @@ function Quiz({ id, props }: UIBlockRenderProps) {