From 23717e0ecdc471ebba2b1e2a8e4f2f773c9b7087 Mon Sep 17 00:00:00 2001 From: Cloud Bot Date: Fri, 20 Mar 2026 07:33:46 +0000 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E6=A8=A1=E7=89=88?= =?UTF-8?q?=E5=B7=A5=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 9 + .gitignore | 50 + .nova/config.json | 9 + Dockerfile | 34 + README.md | 213 + app/RouteChange.tsx | 20 + app/api/chat/event/route.ts | 18 + app/api/chat/oss_url/route.ts | 14 + app/api/chat/stop/route.ts | 11 + app/api/conversation/info/route.ts | 7 + app/api/conversation/route.ts | 12 + app/api/file/record/route.ts | 7 + app/api/file/sign/route.ts | 12 + app/api/file/upload/route.ts | 8 + app/api/health/route.ts | 24 + app/api/info/route.ts | 68 + app/api/llm-client.ts | 6 + app/api/nova-config.ts | 38 + app/api/oapi-client.ts | 85 + app/api/oapi-wrapper-client.ts | 55 + app/api/oss/upload-sts/route.ts | 6 + app/api/plugins/skill/upload/route.ts | 7 + app/api/remote-control/agent-info/route.ts | 41 + app/api/remote-control/config/route.ts | 109 + app/api/remote-control/logs/route.ts | 33 + app/api/remote-control/status/route.ts | 71 + app/api/remote-control/test/route.ts | 147 + app/api/team/[teamId]/plugins/route.ts | 11 + app/api/v1/[...path]/route.ts | 133 + app/api/websocket/index.ts | 570 + app/favicon.ico | Bin 0 -> 4286 bytes app/globals.css | 594 + app/layout.tsx | 34 + app/page.tsx | 33 + app/settings/remote-control/page.tsx | 797 ++ app/share/page.tsx | 32 + components/AgentationGuard.tsx | 12 + components/base/color-picker/index.tsx | 87 + components/html-editor/README.md | 292 + .../assets/images/align-center.svg | 1 + .../html-editor/assets/images/align-left.svg | 1 + .../html-editor/assets/images/align-right.svg | 1 + components/html-editor/assets/images/blod.svg | 1 + .../html-editor/assets/images/delete.svg | 21 + .../html-editor/assets/images/dropdown.svg | 1 + .../html-editor/assets/images/duplicate.svg | 1 + .../assets/images/image-replace.svg | 1 + .../html-editor/assets/images/italic.svg | 1 + .../html-editor/assets/images/underline.svg | 1 + .../components/html-render/task-html.tsx | 126 + .../toolbar-doc/components/TextToolbar.tsx | 354 + .../components/toolbar-doc/components/loco.ts | 54 + .../hooks/useSelectionFormatting.ts | 231 + .../toolbar-doc/hooks/useStopPopEvent.ts | 19 + .../components/toolbar-doc/index.tsx | 22 + .../components/toolbar-doc/toolbar.tsx | 72 + .../toolbar-web/assets/align-center.svg | 1 + .../toolbar-web/assets/align-left.svg | 1 + .../toolbar-web/assets/align-right.svg | 1 + .../components/toolbar-web/assets/blod.svg | 1 + .../components/toolbar-web/assets/delete.svg | 21 + .../toolbar-web/assets/dropdown.svg | 1 + .../toolbar-web/assets/duplicate.svg | 1 + .../toolbar-web/assets/image-replace.svg | 1 + .../components/toolbar-web/assets/italic.svg | 1 + .../toolbar-web/assets/underline.svg | 1 + .../toolbar-web/components/BlockToolbar.tsx | 185 + .../toolbar-web/components/ImageToolbar.tsx | 159 + .../toolbar-web/components/TextToolbar.tsx | 392 + .../toolbar-web/hooks/useElementStyles.ts | 369 + .../toolbar-web/hooks/useStopPopEvent.ts | 19 + .../components/toolbar-web/index.tsx | 23 + .../components/toolbar-web/loco.ts | 47 + .../components/toolbar-web/styles.ts | 175 + .../components/toolbar-web/toolbar.tsx | 73 + .../components/toolbar-web/utils/upload.ts | 153 + components/html-editor/context/index.tsx | 56 + components/html-editor/hooks/useDiff.ts | 35 + components/html-editor/hooks/useEditState.ts | 42 + components/html-editor/hooks/useIframeMode.ts | 261 + .../html-editor/hooks/useLoadContent.ts | 25 + .../html-editor/hooks/useToolPostion.ts | 82 + components/html-editor/index.tsx | 23 + components/html-editor/lib/config/styles.ts | 178 + .../html-editor/lib/core/editor/index.ts | 754 + .../lib/core/editorRegistry/index.ts | 122 + .../lib/core/eventManager/index.ts | 202 + .../lib/core/globalEditable/index.ts | 243 + .../core/globalEditable/markEngine/index.ts | 1378 ++ .../core/globalEditable/markEngine/type.ts | 30 + .../lib/core/helperBoxManager/index.ts | 82 + .../lib/core/historyManager/commands.ts | 293 + .../lib/core/historyManager/index.ts | 233 + .../lib/core/historyManager/types.ts | 119 + .../lib/core/moveableManager/events.ts | 167 + .../lib/core/moveableManager/guidelines.ts | 74 + .../lib/core/moveableManager/index.ts | 193 + .../lib/core/styleManager/index.ts | 281 + components/html-editor/lib/core/utils.ts | 160 + components/html-editor/lib/index.ts | 5 + .../html-editor/lib/types/core/editor.ts | 40 + .../html-editor/lib/types/core/events.ts | 28 + .../html-editor/lib/types/core/history.ts | 5 + .../html-editor/lib/types/core/moveable.ts | 32 + .../html-editor/lib/types/core/style.ts | 52 + components/html-editor/lib/types/index.ts | 9 + components/html-editor/mode/baseEdit.tsx | 121 + components/html-editor/mode/html-doc.tsx | 39 + components/html-editor/mode/html-web.tsx | 39 + components/html-editor/server/index.ts | 9 + components/html-editor/types/index.ts | 7 + .../components/canvas/constants.ts | 1 + .../components/canvas/dom-utils.ts | 10 + .../components/canvas/domino-anchor.tsx | 236 + .../components/canvas/domino-canvas.tsx | 280 + .../components/canvas/domino-hooks.ts | 112 + .../components/canvas/domino-provider.tsx | 21 + .../components/canvas/domino-store.ts | 637 + .../image-editor/components/canvas/domino.ts | 251 + .../components/canvas/element-content.tsx | 85 + .../components/canvas/element-renderer.tsx | 189 + .../image-editor/components/canvas/index.ts | 12 + .../canvas/interactions/resizing.ts | 184 + .../canvas/interactions/snapping.ts | 156 + .../image-editor/components/canvas/math.ts | 227 + .../components/canvas/placeholder.tsx | 30 + .../components/canvas/selection-overlay.tsx | 484 + .../components/canvas/text-render.tsx | 108 + .../components/canvas/use-domino-anchor.ts | 130 + .../components/canvas/use-domino-container.ts | 13 + .../components/canvas/use-domino-instance.ts | 115 + .../canvas/use-domino-scroll-into-view.ts | 57 + .../canvas/use-domino-zoom-to-fit.ts | 48 + .../components/canvas/use-gestures.ts | 115 + .../components/canvas/use-interactions.ts | 839 ++ .../components/canvas/use-transform.ts | 33 + .../editor/artboard-actions-toolbar.tsx | 116 + .../components/editor/bottom-toolbar.tsx | 142 + .../components/editor/domi-board.tsx | 396 + .../components/editor/draggable.tsx | 145 + .../components/editor/element-metadata.tsx | 60 + .../editor/image-actions-toolbar.tsx | 215 + .../components/editor/image-group-toolbar.tsx | 124 + .../components/editor/image-redraw-editor.tsx | 447 + .../components/editor/image-text-editor.tsx | 169 + .../components/editor/inline-mark-editor.tsx | 209 + .../components/editor/loading.tsx | 23 + .../image-editor/components/editor/main.tsx | 232 + .../components/editor/selection-overlay.tsx | 170 + .../components/editor/text-toolbar.tsx | 382 + .../components/editor/top-bar.tsx | 115 + .../image-editor/components/ui/button.tsx | 57 + .../components/ui/dropdown-menu.tsx | 209 + .../components/ui/error-boundary.tsx | 37 + .../components/ui/icon-mapping.ts | 48 + .../image-editor/components/ui/icon.tsx | 108 + .../image-editor/components/ui/input.tsx | 25 + .../image-editor/components/ui/local-icon.tsx | 48 + .../image-editor/components/ui/tooltip.tsx | 39 + components/image-editor/consts.ts | 6 + .../hooks/use-download-image-group.ts | 265 + .../image-editor/hooks/use-element-actions.ts | 227 + .../hooks/use-image-edit-actions.ts | 436 + components/image-editor/hooks/use-layout.ts | 162 + .../image-editor/hooks/use-local-fonts.ts | 83 + .../image-editor/hooks/use-paste-handler.ts | 80 + .../image-editor/hooks/use-persistence.ts | 156 + .../image-editor/hooks/use-shortcuts.ts | 200 + .../image-editor/hooks/use-zoom-actions.ts | 35 + .../image-editor/icons/artboard-gray.svg | 1 + .../image-editor/icons/artboard-mode.svg | 1 + components/image-editor/icons/auto.svg | 1 + components/image-editor/icons/back.svg | 5 + components/image-editor/icons/board-close.svg | 1 + .../image-editor/icons/board-zoom-in.svg | 1 + .../image-editor/icons/board-zoom-out.svg | 1 + components/image-editor/icons/bold.svg | 1 + components/image-editor/icons/check.svg | 1 + .../image-editor/icons/chevron-right.svg | 1 + components/image-editor/icons/circle.svg | 1 + components/image-editor/icons/close.svg | 1 + components/image-editor/icons/download.svg | 1 + components/image-editor/icons/dropdown.svg | 1 + .../image-editor/icons/edit-elements.svg | 1 + components/image-editor/icons/edit-text.svg | 1 + components/image-editor/icons/error.svg | 1 + .../image-editor-image-download-compose.svg | 1 + .../image-editor-image-download-muti.svg | 1 + .../icons/image-editor-text-align-center.svg | 1 + .../icons/image-editor-text-align-left.svg | 1 + .../icons/image-editor-text-align-right.svg | 1 + .../icons/image-editor-text-layer-down.svg | 1 + .../icons/image-editor-text-layer-up.svg | 1 + .../icons/image-editor-text-more.svg | 1 + .../image-editor/icons/image-matting.svg | 1 + components/image-editor/icons/image-mode.svg | 1 + components/image-editor/icons/italic.svg | 1 + components/image-editor/icons/layer-down.svg | 1 + components/image-editor/icons/layer-up.svg | 1 + components/image-editor/icons/more.svg | 24 + .../image-editor/icons/move-to-bottom.svg | 1 + components/image-editor/icons/move-to-top.svg | 1 + components/image-editor/icons/ne-rotate.svg | 1 + components/image-editor/icons/nw-rotate.svg | 1 + components/image-editor/icons/pan-mode.svg | 1 + .../image-editor/icons/partial-redraw.svg | 1 + components/image-editor/icons/redo.svg | 1 + components/image-editor/icons/se-rotate.svg | 1 + components/image-editor/icons/select-mode.svg | 1 + components/image-editor/icons/sw-rotate.svg | 1 + components/image-editor/icons/text-mode.svg | 1 + components/image-editor/icons/text.svg | 1 + components/image-editor/icons/undo.svg | 1 + components/image-editor/icons/zoom-in.svg | 1 + components/image-editor/icons/zoom-out.svg | 1 + components/image-editor/index.ts | 5 + components/image-editor/lib/utils.ts | 6 + components/image-editor/service/api.ts | 181 + components/image-editor/service/type.ts | 42 + components/image-editor/styles/icons.css | 208 + components/image-editor/styles/index.css | 7 + components/image-editor/utils/filename.ts | 8 + components/image-editor/utils/helper.ts | 485 + components/image-editor/utils/mark-image.ts | 188 + components/image-editor/utils/upload.ts | 21 + .../nova-sdk/context/NovaKitProvider.tsx | 80 + components/nova-sdk/context/context.ts | 25 + components/nova-sdk/context/useNovaKit.ts | 15 + components/nova-sdk/hooks/index.ts | 10 + .../nova-sdk/hooks/useArtifactsExtractor.ts | 117 + .../nova-sdk/hooks/useAttachmentHandlers.ts | 113 + .../hooks/useBuildConversationConnect.ts | 93 + .../nova-sdk/hooks/useEventProcessor.ts | 323 + components/nova-sdk/hooks/useFileUploader.ts | 166 + components/nova-sdk/hooks/useMessageScroll.ts | 25 + components/nova-sdk/hooks/useMessageSender.ts | 51 + components/nova-sdk/hooks/useNovaChatLogic.ts | 232 + components/nova-sdk/hooks/useNovaEvents.ts | 370 + components/nova-sdk/hooks/useNovaService.ts | 159 + components/nova-sdk/hooks/usePanelState.ts | 40 + components/nova-sdk/hooks/useSize.ts | 32 + components/nova-sdk/index.ts | 37 + .../message-input/FilePreviewList.tsx | 114 + components/nova-sdk/message-input/index.tsx | 443 + components/nova-sdk/message-list/index.tsx | 229 + .../message-item/AttachmentItem.tsx | 41 + .../message-item/BrowserUseAction.tsx | 133 + .../message-item/ContentMessage.tsx | 72 + .../message-list/message-item/FactCheck.tsx | 52 + .../message-item/ImageAttachmentItem.tsx | 101 + .../message-item/MessageFooter.tsx | 105 + .../message-item/MessageHeader.tsx | 19 + .../message-item/SlideOutlineAction.tsx | 122 + .../message-item/SystemMessage.tsx | 129 + .../message-list/message-item/TodoList.tsx | 156 + .../message-item/ToolCallAction.tsx | 131 + .../message-list/message-item/UserMessage.tsx | 80 + .../message-list/message-item/index.tsx | 225 + .../BrowserTakeoverInteraction.tsx | 30 + .../user-interaction/ChoiceInteraction.tsx | 31 + .../user-interaction/InteractionButtons.tsx | 61 + .../user-interaction/InteractionWrapper.tsx | 33 + .../user-interaction/SlideFormInteraction.tsx | 166 + .../message-item/user-interaction/index.tsx | 83 + .../message-list/message-item/utils.ts | 258 + .../nova-sdk/nova-chat/ChatHeader.test.tsx | 33 + components/nova-sdk/nova-chat/ChatHeader.tsx | 27 + .../nova-sdk/nova-chat/ChatInputArea.tsx | 41 + components/nova-sdk/nova-chat/index.tsx | 250 + components/nova-sdk/store/useEventStore.ts | 48 + components/nova-sdk/store/useImages.ts | 32 + .../nova-sdk/task-panel/ArtifactList.tsx | 347 + .../nova-sdk/task-panel/ArtifactPreview.tsx | 364 + .../task-panel/Preview/CsvPreview.tsx | 171 + .../Preview/HighlighterProvider.tsx | 64 + .../task-panel/Preview/MarkdownPreview.tsx | 65 + .../task-panel/Preview/PptPreview.tsx | 211 + .../task-panel/Preview/ScriptPreview.tsx | 162 + .../Preview/ShellExecutePreview.tsx | 110 + .../task-panel/Preview/ToolCallPreview.tsx | 420 + .../Preview/ToolOutputArtifactPreview.tsx | 233 + .../task-panel/Preview/UrlScriptPreview.tsx | 70 + .../task-panel/Preview/VirtualPdfPreview.tsx | 217 + .../task-panel/Preview/WebSearchPreview.tsx | 211 + .../nova-sdk/task-panel/Preview/index.ts | 31 + .../task-panel/Preview/previewUtils.test.ts | 82 + .../task-panel/Preview/previewUtils.ts | 392 + .../task-panel/Preview/useHighlighter.ts | 49 + components/nova-sdk/task-panel/index.tsx | 257 + components/nova-sdk/task-panel/utils.ts | 24 + components/nova-sdk/tools/README.md | 150 + components/nova-sdk/tools/REPLACE_SUMMARY.md | 61 + components/nova-sdk/tools/components/index.ts | 7 + .../tools/components/mcp-json-editor.tsx | 148 + .../nova-sdk/tools/components/skill-form.tsx | 147 + components/nova-sdk/tools/hooks/index.ts | 2 + components/nova-sdk/tools/index.ts | 8 + components/nova-sdk/tools/settings/index.ts | 2 + .../tools/settings/mcp-store-popover.tsx | 68 + components/nova-sdk/types.test.ts | 58 + components/nova-sdk/types.ts | 300 + .../nova-sdk/utils/event-render-props.ts | 420 + components/nova-sdk/utils/fileIcons.ts | 47 + .../nova-sdk/utils/slideEventHelpers.ts | 87 + components/ppt-editor/FloatingToolbar.css | 117 + components/ppt-editor/FloatingToolbar.tsx | 78 + components/ppt-editor/README.md | 70 + components/ppt-editor/hooks/usePPTEditor.ts | 121 + components/ppt-editor/hooks/useSize.ts | 32 + components/ppt-editor/index.tsx | 270 + components/ppt-editor/server/index.ts | 10 + components/provider/Theme/theme-provider.tsx | 17 + components/provider/Theme/theme-toggle.tsx | 35 + components/ui/avatar.tsx | 53 + components/ui/badge.tsx | 59 + components/ui/button.test.tsx | 70 + components/ui/button.tsx | 92 + components/ui/card.tsx | 96 + components/ui/dialog.tsx | 123 + components/ui/dropdown-menu.tsx | 255 + components/ui/image-preview.tsx | 68 + components/ui/image.tsx | 47 + components/ui/index.ts | 12 + components/ui/input.test.tsx | 46 + components/ui/input.tsx | 35 + components/ui/label.tsx | 24 + components/ui/popover.tsx | 87 + components/ui/scroll-area.tsx | 53 + components/ui/separator.tsx | 30 + components/ui/skeleton.tsx | 19 + components/ui/sonner.tsx | 40 + components/ui/table.tsx | 114 + components/ui/textarea.tsx | 36 + components/ui/tooltip.tsx | 36 + db/index.ts | 19 + drizzle.config.ts | 10 + eslint.config.mjs | 32 + http/http.ts | 59 + http/index.ts | 60 + http/request.ts | 22 + http/type.ts | 141 + instrumentation.ts | 34 + llm/client.ts | 225 + llm/index.ts | 29 + llm/models.ts | 189 + next.config.ts | 43 + package.json | 86 + package/apis/oss.ts | 43 + package/constant/index.ts | 7 + package/uploader/src/OssSingleton.ts | 84 + package/uploader/src/adapter/alioss.ts | 115 + package/uploader/src/adapter/base.ts | 128 + package/uploader/src/adapter/minio.ts | 207 + package/uploader/src/index.ts | 53 + package/uploader/src/type.ts | 35 + package/utils/crypto.ts | 80 + package/utils/parse.ts | 10 + pnpm-lock.yaml | 11562 ++++++++++++++++ pnpm-workspace.yaml | 3 + postcss.config.mjs | 7 + remote-control/bots/dingtalk/handlers.ts | 276 + remote-control/bots/dingtalk/index.ts | 142 + remote-control/bots/dingtalk/user-store.ts | 47 + remote-control/bots/discord/handlers.ts | 158 + remote-control/bots/discord/index.ts | 147 + remote-control/bots/discord/user-store.ts | 45 + remote-control/bots/lark/handlers.ts | 354 + remote-control/bots/lark/index.ts | 146 + remote-control/bots/lark/user-store.ts | 47 + remote-control/bots/slack/handlers.ts | 285 + remote-control/bots/slack/index.ts | 143 + remote-control/bots/slack/user-store.ts | 47 + remote-control/bots/telegram/handlers.ts | 251 + remote-control/bots/telegram/index.ts | 185 + remote-control/bots/telegram/user-store.ts | 47 + remote-control/config/manager.ts | 233 + remote-control/config/types.ts | 40 + remote-control/shared/file-types.ts | 60 + remote-control/shared/file-uploader.ts | 163 + remote-control/shared/logger.ts | 75 + remote-control/shared/nova-bridge.ts | 423 + tsconfig.json | 37 + use-llm.md | 295 + utils/cn.ts | 6 + utils/getAuth.ts | 18 + utils/logger.ts | 35 + 386 files changed, 51675 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .nova/config.json create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/RouteChange.tsx create mode 100644 app/api/chat/event/route.ts create mode 100644 app/api/chat/oss_url/route.ts create mode 100644 app/api/chat/stop/route.ts create mode 100644 app/api/conversation/info/route.ts create mode 100644 app/api/conversation/route.ts create mode 100644 app/api/file/record/route.ts create mode 100644 app/api/file/sign/route.ts create mode 100644 app/api/file/upload/route.ts create mode 100644 app/api/health/route.ts create mode 100644 app/api/info/route.ts create mode 100644 app/api/llm-client.ts create mode 100644 app/api/nova-config.ts create mode 100644 app/api/oapi-client.ts create mode 100644 app/api/oapi-wrapper-client.ts create mode 100644 app/api/oss/upload-sts/route.ts create mode 100644 app/api/plugins/skill/upload/route.ts create mode 100644 app/api/remote-control/agent-info/route.ts create mode 100644 app/api/remote-control/config/route.ts create mode 100644 app/api/remote-control/logs/route.ts create mode 100644 app/api/remote-control/status/route.ts create mode 100644 app/api/remote-control/test/route.ts create mode 100644 app/api/team/[teamId]/plugins/route.ts create mode 100644 app/api/v1/[...path]/route.ts create mode 100644 app/api/websocket/index.ts create mode 100644 app/favicon.ico create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 app/settings/remote-control/page.tsx create mode 100644 app/share/page.tsx create mode 100644 components/AgentationGuard.tsx create mode 100644 components/base/color-picker/index.tsx create mode 100644 components/html-editor/README.md create mode 100644 components/html-editor/assets/images/align-center.svg create mode 100644 components/html-editor/assets/images/align-left.svg create mode 100644 components/html-editor/assets/images/align-right.svg create mode 100644 components/html-editor/assets/images/blod.svg create mode 100644 components/html-editor/assets/images/delete.svg create mode 100644 components/html-editor/assets/images/dropdown.svg create mode 100644 components/html-editor/assets/images/duplicate.svg create mode 100644 components/html-editor/assets/images/image-replace.svg create mode 100644 components/html-editor/assets/images/italic.svg create mode 100644 components/html-editor/assets/images/underline.svg create mode 100644 components/html-editor/components/html-render/task-html.tsx create mode 100644 components/html-editor/components/toolbar-doc/components/TextToolbar.tsx create mode 100644 components/html-editor/components/toolbar-doc/components/loco.ts create mode 100644 components/html-editor/components/toolbar-doc/hooks/useSelectionFormatting.ts create mode 100644 components/html-editor/components/toolbar-doc/hooks/useStopPopEvent.ts create mode 100644 components/html-editor/components/toolbar-doc/index.tsx create mode 100644 components/html-editor/components/toolbar-doc/toolbar.tsx create mode 100644 components/html-editor/components/toolbar-web/assets/align-center.svg create mode 100644 components/html-editor/components/toolbar-web/assets/align-left.svg create mode 100644 components/html-editor/components/toolbar-web/assets/align-right.svg create mode 100644 components/html-editor/components/toolbar-web/assets/blod.svg create mode 100644 components/html-editor/components/toolbar-web/assets/delete.svg create mode 100644 components/html-editor/components/toolbar-web/assets/dropdown.svg create mode 100644 components/html-editor/components/toolbar-web/assets/duplicate.svg create mode 100644 components/html-editor/components/toolbar-web/assets/image-replace.svg create mode 100644 components/html-editor/components/toolbar-web/assets/italic.svg create mode 100644 components/html-editor/components/toolbar-web/assets/underline.svg create mode 100644 components/html-editor/components/toolbar-web/components/BlockToolbar.tsx create mode 100644 components/html-editor/components/toolbar-web/components/ImageToolbar.tsx create mode 100644 components/html-editor/components/toolbar-web/components/TextToolbar.tsx create mode 100644 components/html-editor/components/toolbar-web/hooks/useElementStyles.ts create mode 100644 components/html-editor/components/toolbar-web/hooks/useStopPopEvent.ts create mode 100644 components/html-editor/components/toolbar-web/index.tsx create mode 100644 components/html-editor/components/toolbar-web/loco.ts create mode 100644 components/html-editor/components/toolbar-web/styles.ts create mode 100644 components/html-editor/components/toolbar-web/toolbar.tsx create mode 100644 components/html-editor/components/toolbar-web/utils/upload.ts create mode 100644 components/html-editor/context/index.tsx create mode 100644 components/html-editor/hooks/useDiff.ts create mode 100644 components/html-editor/hooks/useEditState.ts create mode 100644 components/html-editor/hooks/useIframeMode.ts create mode 100644 components/html-editor/hooks/useLoadContent.ts create mode 100644 components/html-editor/hooks/useToolPostion.ts create mode 100644 components/html-editor/index.tsx create mode 100644 components/html-editor/lib/config/styles.ts create mode 100644 components/html-editor/lib/core/editor/index.ts create mode 100644 components/html-editor/lib/core/editorRegistry/index.ts create mode 100644 components/html-editor/lib/core/eventManager/index.ts create mode 100644 components/html-editor/lib/core/globalEditable/index.ts create mode 100644 components/html-editor/lib/core/globalEditable/markEngine/index.ts create mode 100644 components/html-editor/lib/core/globalEditable/markEngine/type.ts create mode 100644 components/html-editor/lib/core/helperBoxManager/index.ts create mode 100644 components/html-editor/lib/core/historyManager/commands.ts create mode 100644 components/html-editor/lib/core/historyManager/index.ts create mode 100644 components/html-editor/lib/core/historyManager/types.ts create mode 100644 components/html-editor/lib/core/moveableManager/events.ts create mode 100644 components/html-editor/lib/core/moveableManager/guidelines.ts create mode 100644 components/html-editor/lib/core/moveableManager/index.ts create mode 100644 components/html-editor/lib/core/styleManager/index.ts create mode 100644 components/html-editor/lib/core/utils.ts create mode 100644 components/html-editor/lib/index.ts create mode 100644 components/html-editor/lib/types/core/editor.ts create mode 100644 components/html-editor/lib/types/core/events.ts create mode 100644 components/html-editor/lib/types/core/history.ts create mode 100644 components/html-editor/lib/types/core/moveable.ts create mode 100644 components/html-editor/lib/types/core/style.ts create mode 100644 components/html-editor/lib/types/index.ts create mode 100644 components/html-editor/mode/baseEdit.tsx create mode 100644 components/html-editor/mode/html-doc.tsx create mode 100644 components/html-editor/mode/html-web.tsx create mode 100644 components/html-editor/server/index.ts create mode 100644 components/html-editor/types/index.ts create mode 100644 components/image-editor/components/canvas/constants.ts create mode 100644 components/image-editor/components/canvas/dom-utils.ts create mode 100644 components/image-editor/components/canvas/domino-anchor.tsx create mode 100644 components/image-editor/components/canvas/domino-canvas.tsx create mode 100644 components/image-editor/components/canvas/domino-hooks.ts create mode 100644 components/image-editor/components/canvas/domino-provider.tsx create mode 100644 components/image-editor/components/canvas/domino-store.ts create mode 100644 components/image-editor/components/canvas/domino.ts create mode 100644 components/image-editor/components/canvas/element-content.tsx create mode 100644 components/image-editor/components/canvas/element-renderer.tsx create mode 100644 components/image-editor/components/canvas/index.ts create mode 100644 components/image-editor/components/canvas/interactions/resizing.ts create mode 100644 components/image-editor/components/canvas/interactions/snapping.ts create mode 100644 components/image-editor/components/canvas/math.ts create mode 100644 components/image-editor/components/canvas/placeholder.tsx create mode 100644 components/image-editor/components/canvas/selection-overlay.tsx create mode 100644 components/image-editor/components/canvas/text-render.tsx create mode 100644 components/image-editor/components/canvas/use-domino-anchor.ts create mode 100644 components/image-editor/components/canvas/use-domino-container.ts create mode 100644 components/image-editor/components/canvas/use-domino-instance.ts create mode 100644 components/image-editor/components/canvas/use-domino-scroll-into-view.ts create mode 100644 components/image-editor/components/canvas/use-domino-zoom-to-fit.ts create mode 100644 components/image-editor/components/canvas/use-gestures.ts create mode 100644 components/image-editor/components/canvas/use-interactions.ts create mode 100644 components/image-editor/components/canvas/use-transform.ts create mode 100644 components/image-editor/components/editor/artboard-actions-toolbar.tsx create mode 100644 components/image-editor/components/editor/bottom-toolbar.tsx create mode 100644 components/image-editor/components/editor/domi-board.tsx create mode 100644 components/image-editor/components/editor/draggable.tsx create mode 100644 components/image-editor/components/editor/element-metadata.tsx create mode 100644 components/image-editor/components/editor/image-actions-toolbar.tsx create mode 100644 components/image-editor/components/editor/image-group-toolbar.tsx create mode 100644 components/image-editor/components/editor/image-redraw-editor.tsx create mode 100644 components/image-editor/components/editor/image-text-editor.tsx create mode 100644 components/image-editor/components/editor/inline-mark-editor.tsx create mode 100644 components/image-editor/components/editor/loading.tsx create mode 100644 components/image-editor/components/editor/main.tsx create mode 100644 components/image-editor/components/editor/selection-overlay.tsx create mode 100644 components/image-editor/components/editor/text-toolbar.tsx create mode 100644 components/image-editor/components/editor/top-bar.tsx create mode 100644 components/image-editor/components/ui/button.tsx create mode 100644 components/image-editor/components/ui/dropdown-menu.tsx create mode 100644 components/image-editor/components/ui/error-boundary.tsx create mode 100644 components/image-editor/components/ui/icon-mapping.ts create mode 100644 components/image-editor/components/ui/icon.tsx create mode 100644 components/image-editor/components/ui/input.tsx create mode 100644 components/image-editor/components/ui/local-icon.tsx create mode 100644 components/image-editor/components/ui/tooltip.tsx create mode 100644 components/image-editor/consts.ts create mode 100644 components/image-editor/hooks/use-download-image-group.ts create mode 100644 components/image-editor/hooks/use-element-actions.ts create mode 100644 components/image-editor/hooks/use-image-edit-actions.ts create mode 100644 components/image-editor/hooks/use-layout.ts create mode 100644 components/image-editor/hooks/use-local-fonts.ts create mode 100644 components/image-editor/hooks/use-paste-handler.ts create mode 100644 components/image-editor/hooks/use-persistence.ts create mode 100644 components/image-editor/hooks/use-shortcuts.ts create mode 100644 components/image-editor/hooks/use-zoom-actions.ts create mode 100644 components/image-editor/icons/artboard-gray.svg create mode 100644 components/image-editor/icons/artboard-mode.svg create mode 100644 components/image-editor/icons/auto.svg create mode 100644 components/image-editor/icons/back.svg create mode 100644 components/image-editor/icons/board-close.svg create mode 100644 components/image-editor/icons/board-zoom-in.svg create mode 100644 components/image-editor/icons/board-zoom-out.svg create mode 100644 components/image-editor/icons/bold.svg create mode 100644 components/image-editor/icons/check.svg create mode 100644 components/image-editor/icons/chevron-right.svg create mode 100644 components/image-editor/icons/circle.svg create mode 100644 components/image-editor/icons/close.svg create mode 100644 components/image-editor/icons/download.svg create mode 100644 components/image-editor/icons/dropdown.svg create mode 100644 components/image-editor/icons/edit-elements.svg create mode 100644 components/image-editor/icons/edit-text.svg create mode 100644 components/image-editor/icons/error.svg create mode 100644 components/image-editor/icons/image-editor-image-download-compose.svg create mode 100644 components/image-editor/icons/image-editor-image-download-muti.svg create mode 100644 components/image-editor/icons/image-editor-text-align-center.svg create mode 100644 components/image-editor/icons/image-editor-text-align-left.svg create mode 100644 components/image-editor/icons/image-editor-text-align-right.svg create mode 100644 components/image-editor/icons/image-editor-text-layer-down.svg create mode 100644 components/image-editor/icons/image-editor-text-layer-up.svg create mode 100644 components/image-editor/icons/image-editor-text-more.svg create mode 100644 components/image-editor/icons/image-matting.svg create mode 100644 components/image-editor/icons/image-mode.svg create mode 100644 components/image-editor/icons/italic.svg create mode 100644 components/image-editor/icons/layer-down.svg create mode 100644 components/image-editor/icons/layer-up.svg create mode 100644 components/image-editor/icons/more.svg create mode 100644 components/image-editor/icons/move-to-bottom.svg create mode 100644 components/image-editor/icons/move-to-top.svg create mode 100644 components/image-editor/icons/ne-rotate.svg create mode 100644 components/image-editor/icons/nw-rotate.svg create mode 100644 components/image-editor/icons/pan-mode.svg create mode 100644 components/image-editor/icons/partial-redraw.svg create mode 100644 components/image-editor/icons/redo.svg create mode 100644 components/image-editor/icons/se-rotate.svg create mode 100644 components/image-editor/icons/select-mode.svg create mode 100644 components/image-editor/icons/sw-rotate.svg create mode 100644 components/image-editor/icons/text-mode.svg create mode 100644 components/image-editor/icons/text.svg create mode 100644 components/image-editor/icons/undo.svg create mode 100644 components/image-editor/icons/zoom-in.svg create mode 100644 components/image-editor/icons/zoom-out.svg create mode 100644 components/image-editor/index.ts create mode 100644 components/image-editor/lib/utils.ts create mode 100644 components/image-editor/service/api.ts create mode 100644 components/image-editor/service/type.ts create mode 100644 components/image-editor/styles/icons.css create mode 100644 components/image-editor/styles/index.css create mode 100644 components/image-editor/utils/filename.ts create mode 100644 components/image-editor/utils/helper.ts create mode 100644 components/image-editor/utils/mark-image.ts create mode 100644 components/image-editor/utils/upload.ts create mode 100644 components/nova-sdk/context/NovaKitProvider.tsx create mode 100644 components/nova-sdk/context/context.ts create mode 100644 components/nova-sdk/context/useNovaKit.ts create mode 100644 components/nova-sdk/hooks/index.ts create mode 100644 components/nova-sdk/hooks/useArtifactsExtractor.ts create mode 100644 components/nova-sdk/hooks/useAttachmentHandlers.ts create mode 100644 components/nova-sdk/hooks/useBuildConversationConnect.ts create mode 100644 components/nova-sdk/hooks/useEventProcessor.ts create mode 100644 components/nova-sdk/hooks/useFileUploader.ts create mode 100644 components/nova-sdk/hooks/useMessageScroll.ts create mode 100644 components/nova-sdk/hooks/useMessageSender.ts create mode 100644 components/nova-sdk/hooks/useNovaChatLogic.ts create mode 100644 components/nova-sdk/hooks/useNovaEvents.ts create mode 100644 components/nova-sdk/hooks/useNovaService.ts create mode 100644 components/nova-sdk/hooks/usePanelState.ts create mode 100644 components/nova-sdk/hooks/useSize.ts create mode 100644 components/nova-sdk/index.ts create mode 100644 components/nova-sdk/message-input/FilePreviewList.tsx create mode 100644 components/nova-sdk/message-input/index.tsx create mode 100644 components/nova-sdk/message-list/index.tsx create mode 100644 components/nova-sdk/message-list/message-item/AttachmentItem.tsx create mode 100644 components/nova-sdk/message-list/message-item/BrowserUseAction.tsx create mode 100644 components/nova-sdk/message-list/message-item/ContentMessage.tsx create mode 100644 components/nova-sdk/message-list/message-item/FactCheck.tsx create mode 100644 components/nova-sdk/message-list/message-item/ImageAttachmentItem.tsx create mode 100644 components/nova-sdk/message-list/message-item/MessageFooter.tsx create mode 100644 components/nova-sdk/message-list/message-item/MessageHeader.tsx create mode 100644 components/nova-sdk/message-list/message-item/SlideOutlineAction.tsx create mode 100644 components/nova-sdk/message-list/message-item/SystemMessage.tsx create mode 100644 components/nova-sdk/message-list/message-item/TodoList.tsx create mode 100644 components/nova-sdk/message-list/message-item/ToolCallAction.tsx create mode 100644 components/nova-sdk/message-list/message-item/UserMessage.tsx create mode 100644 components/nova-sdk/message-list/message-item/index.tsx create mode 100644 components/nova-sdk/message-list/message-item/user-interaction/BrowserTakeoverInteraction.tsx create mode 100644 components/nova-sdk/message-list/message-item/user-interaction/ChoiceInteraction.tsx create mode 100644 components/nova-sdk/message-list/message-item/user-interaction/InteractionButtons.tsx create mode 100644 components/nova-sdk/message-list/message-item/user-interaction/InteractionWrapper.tsx create mode 100644 components/nova-sdk/message-list/message-item/user-interaction/SlideFormInteraction.tsx create mode 100644 components/nova-sdk/message-list/message-item/user-interaction/index.tsx create mode 100644 components/nova-sdk/message-list/message-item/utils.ts create mode 100644 components/nova-sdk/nova-chat/ChatHeader.test.tsx create mode 100644 components/nova-sdk/nova-chat/ChatHeader.tsx create mode 100644 components/nova-sdk/nova-chat/ChatInputArea.tsx create mode 100644 components/nova-sdk/nova-chat/index.tsx create mode 100644 components/nova-sdk/store/useEventStore.ts create mode 100644 components/nova-sdk/store/useImages.ts create mode 100644 components/nova-sdk/task-panel/ArtifactList.tsx create mode 100644 components/nova-sdk/task-panel/ArtifactPreview.tsx create mode 100644 components/nova-sdk/task-panel/Preview/CsvPreview.tsx create mode 100644 components/nova-sdk/task-panel/Preview/HighlighterProvider.tsx create mode 100644 components/nova-sdk/task-panel/Preview/MarkdownPreview.tsx create mode 100644 components/nova-sdk/task-panel/Preview/PptPreview.tsx create mode 100644 components/nova-sdk/task-panel/Preview/ScriptPreview.tsx create mode 100644 components/nova-sdk/task-panel/Preview/ShellExecutePreview.tsx create mode 100644 components/nova-sdk/task-panel/Preview/ToolCallPreview.tsx create mode 100644 components/nova-sdk/task-panel/Preview/ToolOutputArtifactPreview.tsx create mode 100644 components/nova-sdk/task-panel/Preview/UrlScriptPreview.tsx create mode 100644 components/nova-sdk/task-panel/Preview/VirtualPdfPreview.tsx create mode 100644 components/nova-sdk/task-panel/Preview/WebSearchPreview.tsx create mode 100644 components/nova-sdk/task-panel/Preview/index.ts create mode 100644 components/nova-sdk/task-panel/Preview/previewUtils.test.ts create mode 100644 components/nova-sdk/task-panel/Preview/previewUtils.ts create mode 100644 components/nova-sdk/task-panel/Preview/useHighlighter.ts create mode 100644 components/nova-sdk/task-panel/index.tsx create mode 100644 components/nova-sdk/task-panel/utils.ts create mode 100644 components/nova-sdk/tools/README.md create mode 100644 components/nova-sdk/tools/REPLACE_SUMMARY.md create mode 100644 components/nova-sdk/tools/components/index.ts create mode 100644 components/nova-sdk/tools/components/mcp-json-editor.tsx create mode 100644 components/nova-sdk/tools/components/skill-form.tsx create mode 100644 components/nova-sdk/tools/hooks/index.ts create mode 100644 components/nova-sdk/tools/index.ts create mode 100644 components/nova-sdk/tools/settings/index.ts create mode 100644 components/nova-sdk/tools/settings/mcp-store-popover.tsx create mode 100644 components/nova-sdk/types.test.ts create mode 100644 components/nova-sdk/types.ts create mode 100644 components/nova-sdk/utils/event-render-props.ts create mode 100644 components/nova-sdk/utils/fileIcons.ts create mode 100644 components/nova-sdk/utils/slideEventHelpers.ts create mode 100644 components/ppt-editor/FloatingToolbar.css create mode 100644 components/ppt-editor/FloatingToolbar.tsx create mode 100644 components/ppt-editor/README.md create mode 100644 components/ppt-editor/hooks/usePPTEditor.ts create mode 100644 components/ppt-editor/hooks/useSize.ts create mode 100644 components/ppt-editor/index.tsx create mode 100644 components/ppt-editor/server/index.ts create mode 100644 components/provider/Theme/theme-provider.tsx create mode 100644 components/provider/Theme/theme-toggle.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/button.test.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/image-preview.tsx create mode 100644 components/ui/image.tsx create mode 100644 components/ui/index.ts create mode 100644 components/ui/input.test.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/popover.tsx create mode 100644 components/ui/scroll-area.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/sonner.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 db/index.ts create mode 100644 drizzle.config.ts create mode 100644 eslint.config.mjs create mode 100644 http/http.ts create mode 100644 http/index.ts create mode 100644 http/request.ts create mode 100644 http/type.ts create mode 100644 instrumentation.ts create mode 100644 llm/client.ts create mode 100644 llm/index.ts create mode 100644 llm/models.ts create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 package/apis/oss.ts create mode 100644 package/constant/index.ts create mode 100644 package/uploader/src/OssSingleton.ts create mode 100644 package/uploader/src/adapter/alioss.ts create mode 100644 package/uploader/src/adapter/base.ts create mode 100644 package/uploader/src/adapter/minio.ts create mode 100644 package/uploader/src/index.ts create mode 100644 package/uploader/src/type.ts create mode 100644 package/utils/crypto.ts create mode 100644 package/utils/parse.ts create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 postcss.config.mjs create mode 100644 remote-control/bots/dingtalk/handlers.ts create mode 100644 remote-control/bots/dingtalk/index.ts create mode 100644 remote-control/bots/dingtalk/user-store.ts create mode 100644 remote-control/bots/discord/handlers.ts create mode 100644 remote-control/bots/discord/index.ts create mode 100644 remote-control/bots/discord/user-store.ts create mode 100644 remote-control/bots/lark/handlers.ts create mode 100644 remote-control/bots/lark/index.ts create mode 100644 remote-control/bots/lark/user-store.ts create mode 100644 remote-control/bots/slack/handlers.ts create mode 100644 remote-control/bots/slack/index.ts create mode 100644 remote-control/bots/slack/user-store.ts create mode 100644 remote-control/bots/telegram/handlers.ts create mode 100644 remote-control/bots/telegram/index.ts create mode 100644 remote-control/bots/telegram/user-store.ts create mode 100644 remote-control/config/manager.ts create mode 100644 remote-control/config/types.ts create mode 100644 remote-control/shared/file-types.ts create mode 100644 remote-control/shared/file-uploader.ts create mode 100644 remote-control/shared/logger.ts create mode 100644 remote-control/shared/nova-bridge.ts create mode 100644 tsconfig.json create mode 100644 use-llm.md create mode 100644 utils/cn.ts create mode 100644 utils/getAuth.ts create mode 100644 utils/logger.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1ef7535 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +DATABASE_URL=postgres://postgres:postgres@localhost:5432/vibe_next_template +LOG_DIR=./vibe-next-template + +NOVA_BASE_URL=https://dev-nova-api.betteryeah.com +NOVA_TENANT_ID=tenant_xxx +NOVA_ACCESS_KEY=access_key_xxx + +LLM_BASE_URL=https://dev-llm-server-internal.betteryeah.com +LLM_API_KEY=sk-skills-default-dev-key \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70fd0d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# vscode +.vscode/* +.idea/* + +# Remote Control 运行时数据 +/remote-control/data/ +!/remote-control/data/.gitkeep + diff --git a/.nova/config.json b/.nova/config.json new file mode 100644 index 0000000..04040fb --- /dev/null +++ b/.nova/config.json @@ -0,0 +1,9 @@ +{ + "agents": [ + { + "agent_id": "d730c266fe6748839d9c93ece8e58b84", + "agent_name": "Agent", + "agent_description": "" + } + ] +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..eaae9df --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM node:22-slim AS builder + +WORKDIR /app +ENV CI=true + +COPY .npmrc /root/.npmrc +RUN corepack enable && corepack prepare pnpm@latest --activate + +COPY . . + +RUN pnpm install --frozen-lockfile && pnpm run build && (test -d public || mkdir public) + +FROM node:22-slim AS runner + +WORKDIR /app + +ENV NODE_ENV=production +ENV CI=true + +COPY .npmrc /root/.npmrc +RUN corepack enable && corepack prepare pnpm@latest --activate + +COPY --from=builder /app/package.json ./ +COPY --from=builder /app/pnpm-lock.yaml ./ +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/.nova ./.nova +COPY --from=builder /app/.env ./.env +COPY --from=builder /app/public* ./public + +RUN pnpm install --frozen-lockfile --prod + +EXPOSE 13000 + +CMD ["pnpm", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..51e3a47 --- /dev/null +++ b/README.md @@ -0,0 +1,213 @@ +# vibe-next-template + +Nova Agent 示例模板工程。[Nova](https://nova.betteryeah.com) 是一个创建 Agent 的 SaaS 产品,可以通过 Nova 创建能够操作沙箱、浏览器、文件系统的专业 Agent。本项目基于 Next.js 提供完整的 Agent 聊天前端,包含消息流、事件列表、会话管理与文件上传能力。 + +项目中 `.nova/config.json` 内置了一个示例 Agent,如需基于本模板开发自己的应用,请在 Nova 平台创建 Agent 并替换配置。 + +## 技术栈 + +- Next.js 16(App Router) / React 19 / TypeScript 5 +- Tailwind CSS 4 / Radix UI / Lucide React Icons +- Zustand(状态管理) +- 自定义 WebSocket 客户端(实时通信) +- 自定义 HTTPClient(Fetch 封装,支持拦截器) +- Drizzle ORM + PostgreSQL +- Winston(日志,生产环境按天轮转) +- Shiki(代码高亮)/ React Markdown + Remark GFM + +## 快速开始 + +1. 安装依赖:`pnpm install` +2. `.env`环境变量文件已经初始化完成了,所以设计环境变量的需要在这里修改 +3. (可选)修改 `.nova/config.json`,替换为你自己的 Agent +4. 启动开发服务器:`pnpm dev`(端口 13000) + +## Agent 配置 + +Agent 配置存放在 `.nova/config.json`: + +```json +{ + "agents": [ + { + "agent_id": "70da765f2d42490ca574d72dce4d24fe", + "agent_name": "Nova示例Agent", + "agent_description": "一个Nova的Agent示例,包含Agent具备的通用功能,文件操作、沙箱环境执行、浏览器操作等等。" + } + ] +} +``` + +- `agents` 数组中的第一个 Agent 作为默认使用的 Agent +- 在 Nova 平台创建新 Agent 后,将 `agent_id`、`agent_name`、`agent_description` 替换为新 Agent 的信息 +- 配置通过 `app/api/nova-config.ts` 的 `getDefaultAgentId()` 在服务端读取,启动后缓存 + +## 目录结构 + +``` +.nova/ + config.json # Agent 配置(agent_id, agent_name, agent_description) + +app/ + layout.tsx # 根布局(Geist Sans/Mono 字体) + page.tsx # 主入口,初始化聊天连接 + globals.css # 全局样式 + api/ # Next.js API 路由(BFF 层,代理 Nova 平台 API) + oapi-client.ts # 共享的 Nova OpenAPI HTTPClient 实例 + nova-config.ts # 读取 .nova/config.json,提供 getDefaultAgentId() + info/route.ts # 返回客户端配置:agentId, conversationId, wssUrl, token + chat/event/route.ts # 代理获取聊天事件历史 + chat/stop/route.ts # 停止当前任务 + conversation/route.ts # 会话列表 + file/upload/route.ts # 文件上传 + file/sign/route.ts # 文件签名 URL + health/route.ts # 健康检查 + +components/ + nova-sdk/ # 核心聊天 SDK + types.ts # 核心类型定义(ApiEvent, PlatformConfig, TaskArtifact 等) + websocket.ts # WebSocket 客户端(自动重连、心跳、离线队列) + store/ + useNovaStore.ts # Zustand 全局状态(events, artifacts, ws 状态, loading) + context/ + context.ts # NovaContext 定义(HTTPClient + getArtifactUrl) + Provider.tsx # Context Provider + useNova.ts # useContext hook + hooks/ + useNovaChatLogic.ts # 主编排 hook,组合所有子 hook + useNovaEvents.ts # WebSocket + 历史事件获取 + useNovaService.ts # 从 platformConfig 创建 HTTPClient + useMessageSender.ts # 通过 WebSocket 发送消息 + useEventProcessor.ts # ApiEvent → UI 消息转换 + useBuildConversationConnect.ts # 初始化:获取 agentId、conversationId + useFileUploader.ts # 文件上传逻辑 + useAttachmentHandlers.ts # 附件点击处理 + useMessageScroll.ts # 消息列表自动滚动 + usePanelState.ts # 产物面板开关 + useSize.ts # 响应式尺寸 + nova-chat/ # 聊天主组件 + index.tsx # NovaChat(包裹 Provider、Header、MessageList、Input、TaskPanel) + ChatHeader.tsx # 聊天头部 + ChatInputArea.tsx # 输入区域 + message-list/ # 消息列表 + index.tsx # MessageList + MessageItem.tsx # 单条消息 + AttachmentItem.tsx # 文件附件 + ImageAttachmentItem.tsx # 图片附件 + ToolCallAction.tsx # 工具调用展示 + message-input/ # 消息输入 + index.tsx # 输入组件 + FilePreviewList.tsx # 文件预览列表 + task-panel/ # 产物预览面板 + index.tsx # TaskPanel(侧边栏,50% 宽度) + ArtifactList.tsx # 产物列表 + ArtifactPreview.tsx # 产物预览 + Preview/ # 各类预览组件(Markdown、代码、PPT、工具调用) + ui/ # 基础 UI 组件(Shadcn 风格) + button.tsx, dialog.tsx, scroll-area.tsx, image.tsx, image-preview.tsx + +http/ + index.ts # HTTPClient 类(Fetch 封装) + http.ts # 底层 fetch 包装,支持 onRequest/onResponse/onError 钩子 + type.ts # HTTP 类型定义 + +db/ + index.ts # Drizzle ORM 连接(server-only,全局单例) + +utils/ + logger.ts # Winston 日志(开发→终端,生产→按天文件轮转) + cn.ts # clsx + tailwind-merge 工具函数 +``` + +## 数据流 + +``` +app/page.tsx + │ useBuildConversationConnect() → GET /api/info + │ 获取 agentId(来自 .nova/config.json), conversationId, platformConfig + ▼ +NovaChat 组件 + │ useNovaChatLogic() 编排所有子 hook + ├── useNovaEvents() → WebSocket 连接 + GET /api/chat/event 加载历史 + ├── useMessageSender() → WebSocket 发送消息 + └── useEventProcessor()→ 事件转 UI 消息 + ▼ +Zustand Store (useNovaStore) + │ events[], artifacts[](从 events 自动提取), loading, ws 状态 + ▼ +MessageList / TaskPanel 渲染 +``` + +## API 路由 + +| 路由 | 方法 | 说明 | +|------|------|------| +| `/api/info` | GET | 返回客户端配置:agentId, conversationId, wssUrl, token | +| `/api/chat/event` | GET | 代理 Nova `/chat/event_list`,获取事件历史 | +| `/api/chat/stop` | POST | 停止当前任务 | +| `/api/conversation` | GET | 会话列表 | +| `/api/file/upload` | POST | 上传文件 | +| `/api/file/sign` | POST | 获取文件签名 URL | +| `/api/health` | GET | 健康检查 | + +所有 API 路由通过 `app/api/oapi-client.ts` 中的共享 HTTPClient 代理到 Nova 平台,请求头自动注入 `Tenant-Id` 和 `Authorization`。Agent ID 从 `.nova/config.json` 读取,不再依赖环境变量。 + +## WebSocket 消息格式 + +- 心跳:`{ message_type: 'ping' }` +- 聊天消息:`{ message_type: 'chat', conversation_id, content, ... }` +- 切换会话:`{ message_type: 'switch_conversation', conversation_id }` + +WebSocket 客户端内置自动重连(可配置次数/间隔)、心跳检测、网络状态监听、页面可见性恢复。 + +## 常用命令 + +```bash +pnpm install # 安装依赖 +pnpm dev # 启动开发服务器(端口 13000) +pnpm build # 生产构建 +pnpm start # 生产运行(端口 13000) +pnpm lint # ESLint 检查并自动修复 +pnpm db:generate # 生成 Drizzle 迁移 +pnpm db:migrate # 执行数据库迁移 +pnpm db:studio # 打开 Drizzle Studio GUI +``` + +## 环境变量 + +参考 `.env.example`,在项目根目录创建 `.env.local`: + +| 变量名 | 说明 | +|--------|------| +| `DATABASE_URL` | PostgreSQL 连接地址(Drizzle 使用) | +| `LOG_DIR` | 生产环境日志目录(默认 `./logs`,按天切分) | +| `NOVA_BASE_URL` | Nova OpenAPI 服务地址 | +| `NOVA_TENANT_ID` | 租户 ID(请求头 `Tenant-Id`) | +| `NOVA_ACCESS_KEY` | 鉴权密钥(请求头 `Authorization`) | + +> Agent ID 已从环境变量迁移至 `.nova/config.json`,无需在 `.env.local` 中配置。 + +## 代码约定 + +- **路径别名**:`@/*` → `./*`(tsconfig paths) +- **组件**:PascalCase 文件名,使用 `React.memo()` 优化 +- **Hooks**:camelCase,`use*` 前缀 +- **工具函数**:camelCase +- **常量枚举**:UPPER_SNAKE_CASE(如 `EventType`, `TaskStatus`) +- **样式**:Tailwind CSS 工具类,通过 `cn()` 函数(`utils/cn.ts`)合并类名 +- **回调稳定性**:使用 ahooks 的 `useMemoizedFn()` 或 `useCallback()` +- **异步状态**:`queueMicrotask()` 延迟更新,避免 React 批量更新冲突 +- **Store 去重**:Zustand store 按 `event_id` 去重事件 +- **日志**:开发环境输出到终端,生产环境按天写入 `${LOG_DIR}/app-YYYY-MM-DD.log` +- **数据库**:所有操作通过 Drizzle ORM,`import { db } from '@/db'`,仅限 server 端 +- **UI 错误提示**:使用中文(如 "Chat 不可用,请检查项目 .env 配置") +- **请求头约定**:`X-Locale=zh`, `X-Region=CN` + +## 注意事项 + +- `next.config.ts` 中 `reactStrictMode: false` +- `.next/` 目录为构建产物,不要手动修改 +- 数据库连接使用全局单例模式,`db/index.ts` 标记了 `server-only` +- 聊天初始化流程:先请求 `/api/info` 获取配置 → 再建立 WebSocket → 加载历史事件 +- 产物提取来源:`attachments`, `attachment_files`, `files`, `generated_files`,按文件 key 去重 +- 文件签名通过 `getArtifactUrl()` 懒加载,POST `/file/sign`,失败时 fallback 到原始路径 diff --git a/app/RouteChange.tsx b/app/RouteChange.tsx new file mode 100644 index 0000000..b905dfa --- /dev/null +++ b/app/RouteChange.tsx @@ -0,0 +1,20 @@ +// components/RouterListener.tsx +"use client"; + +import { useEffect } from "react"; +import { usePathname, useSearchParams } from "next/navigation"; + +export default function RouteChange() { + const pathname = usePathname(); + const searchParams = useSearchParams(); + + useEffect(() => { + const url = searchParams.toString() + ? `${pathname}?${searchParams.toString()}` + : pathname; + + window.parent.postMessage({ type: "ROUTE_CHANGE", path: url }, "*"); + }, [pathname, searchParams]); + + return null; +} diff --git a/app/api/chat/event/route.ts b/app/api/chat/event/route.ts new file mode 100644 index 0000000..68fc9fc --- /dev/null +++ b/app/api/chat/event/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from 'next/server' +import { oapiClient, sendResponse } from '../../oapi-client' +import { getDefaultAgentId } from '../../nova-config' + +export async function GET(req: NextRequest) { + const conversationId = req.nextUrl.searchParams.get('conversation_id') + const pageNo = req.nextUrl.searchParams.get('page_no') + const pageSize = req.nextUrl.searchParams.get('page_size') + + const res = await oapiClient.get('/v1/oapi/super_agent/chat/event_list', { + agent_id: getDefaultAgentId(), + conversation_id: conversationId, + page_no: pageNo, + page_size: pageSize, + }) + + return sendResponse(res) +} diff --git a/app/api/chat/oss_url/route.ts b/app/api/chat/oss_url/route.ts new file mode 100644 index 0000000..b43e010 --- /dev/null +++ b/app/api/chat/oss_url/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from 'next/server' +import { oapiClient, sendResponse } from '../../oapi-client' + +export async function POST(req: NextRequest) { + const body = req.body ? await req.json() : {} + const { file_path, task_id } = body + + const res = await oapiClient.post('/v1/super_agent/chat/oss_url', { + file_path: file_path, + task_id: task_id, + }) + + return sendResponse(res) +} diff --git a/app/api/chat/stop/route.ts b/app/api/chat/stop/route.ts new file mode 100644 index 0000000..f153f65 --- /dev/null +++ b/app/api/chat/stop/route.ts @@ -0,0 +1,11 @@ +import { NextRequest } from 'next/server' +import { oapiClient, sendResponse } from '../../oapi-client' + +export async function GET(req: NextRequest) { + const conversationId = req.nextUrl.searchParams.get('conversation_id') + const res = await oapiClient.post('/v1/oapi/super_agent/stop_chat', { + conversation_id: conversationId, + }) + + return sendResponse(res) +} diff --git a/app/api/conversation/info/route.ts b/app/api/conversation/info/route.ts new file mode 100644 index 0000000..7464739 --- /dev/null +++ b/app/api/conversation/info/route.ts @@ -0,0 +1,7 @@ +import { oapiClient, sendResponse } from '../../oapi-client' + +export async function POST(req: Request) { + const body = req.body ? await req.json() : {} + const res = await oapiClient.post('/v1/super_agent/chat/get_conversation_info_list', body) + return sendResponse(res) +} diff --git a/app/api/conversation/route.ts b/app/api/conversation/route.ts new file mode 100644 index 0000000..838ff4d --- /dev/null +++ b/app/api/conversation/route.ts @@ -0,0 +1,12 @@ +import { oapiClient, sendResponse } from '../oapi-client' +import { getDefaultAgentId } from '../nova-config' + +export async function GET() { + const res = await oapiClient.get('/v1/oapi/super_agent/chat/conversation_list', { + page_no: 1, + page_size: 10, + agent_id: getDefaultAgentId(), + }) + + return sendResponse(res) +} diff --git a/app/api/file/record/route.ts b/app/api/file/record/route.ts new file mode 100644 index 0000000..7b09d69 --- /dev/null +++ b/app/api/file/record/route.ts @@ -0,0 +1,7 @@ +import { oapiClient, sendResponse } from '../../oapi-client' + +export async function POST(req: Request) { + const body = req.body ? await req.json() : {} + const res = await oapiClient.post('/v1/super_agent/file_upload_record/create', body) + return sendResponse(res) +} diff --git a/app/api/file/sign/route.ts b/app/api/file/sign/route.ts new file mode 100644 index 0000000..f62dc5d --- /dev/null +++ b/app/api/file/sign/route.ts @@ -0,0 +1,12 @@ +import { oapiClient, sendResponse } from '../../oapi-client' + +export async function POST(req: Request) { + const body = req.body ? await req.json() : {} + const key = body.file_path || body.key + const res = await oapiClient.post('v1/oss/sign_url', { + key, + params: body.params, + }) + + return sendResponse(res) +} diff --git a/app/api/file/upload/route.ts b/app/api/file/upload/route.ts new file mode 100644 index 0000000..432bfda --- /dev/null +++ b/app/api/file/upload/route.ts @@ -0,0 +1,8 @@ +import { oapiClient, sendResponse } from '../../oapi-client' + +export async function POST(req: Request) { + const body = await req.formData() + const res = await oapiClient.post('/v1/oapi/super_agent/chat/file_upload', body) + + return sendResponse(res) +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..1a9b9e5 --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server' + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET,OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', +} + +export async function GET() { + return NextResponse.json( + { success: true, message: 'ok' }, + { + status: 200, + headers: corsHeaders, + } + ) +} + +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: corsHeaders, + }) +} diff --git a/app/api/info/route.ts b/app/api/info/route.ts new file mode 100644 index 0000000..a1577be --- /dev/null +++ b/app/api/info/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from 'next/server' +import { oapiClient } from '../oapi-client' +import { getDefaultAgentId, getDefaultAgentName } from '../nova-config' +import { getProjectId, getUserId } from '@/utils/getAuth' + +const buildWssUrl = () => { + const baseUrl = process.env.NOVA_BASE_URL! + const wssBase = baseUrl.replace('https://', 'wss://').replace('http://', 'ws://') + const authorization = process.env.NOVA_ACCESS_KEY + const tenantId = process.env.NOVA_TENANT_ID + return `${wssBase}/v1/super_agent/chat/completions?Authorization=${authorization}&X-Locale=zh&X-Region=CN&Tenant-Id=${tenantId}` +} + +export async function GET() { + const agentId = getDefaultAgentId() + const agentName = getDefaultAgentName() + + const list = await oapiClient.get('/v1/oapi/super_agent/chat/conversation_list', { + page_no: 1, + page_size: 10, + agent_id: agentId, + }) + + const conversationId = list.data?.[0]?.conversation_id + + if (!conversationId) { + const res = await oapiClient.post('/v1/oapi/super_agent/chat/create_conversation', { + agent_id: agentId, + title: 'new conversation', + conversation_type: 'REACTUS', + external_app_id: getProjectId(), + external_user_id: getUserId(), + }) + + return NextResponse.json( + { + code: 0, + message: 'ok', + data: { + apiBaseUrl: '/api', + agent_id: agentId, + agent_name: agentName, + conversation_id: res.conversation_id, + wssUrl: buildWssUrl(), + token: process.env.NOVA_ACCESS_KEY, + tenantId: process.env.NOVA_TENANT_ID, + }, + }, + { status: 200 } + ) + } + + return NextResponse.json( + { + success: true, + data: { + apiBaseUrl: '/api', + agent_id: agentId, + agent_name: agentName, + conversation_id: conversationId, + wssUrl: buildWssUrl(), + token: process.env.NOVA_ACCESS_KEY, + tenantId: process.env.NOVA_TENANT_ID, + }, + }, + { status: 200 } + ) +} diff --git a/app/api/llm-client.ts b/app/api/llm-client.ts new file mode 100644 index 0000000..1e941ce --- /dev/null +++ b/app/api/llm-client.ts @@ -0,0 +1,6 @@ +import { SkillsClient } from '@/llm' + +export const llmClient = new SkillsClient({ + apiKey: process.env.LLM_API_KEY!, + baseUrl: process.env.LLM_BASE_URL!, +}) \ No newline at end of file diff --git a/app/api/nova-config.ts b/app/api/nova-config.ts new file mode 100644 index 0000000..9de0719 --- /dev/null +++ b/app/api/nova-config.ts @@ -0,0 +1,38 @@ +import { readFileSync } from 'fs' +import { join } from 'path' + +interface NovaAgent { + agent_id: string + agent_name: string + agent_description: string +} + +interface NovaConfig { + agents: NovaAgent[] +} + +let _config: NovaConfig | null = null + +export function getNovaConfig(): NovaConfig { + if (!_config) { + const configPath = join(process.cwd(), '.nova', 'config.json') + _config = JSON.parse(readFileSync(configPath, 'utf-8')) + } + return _config! +} + +export function getDefaultAgentId(): string { + const config = getNovaConfig() + if (!config.agents.length) { + throw new Error('No agents configured in .nova/config.json') + } + return config.agents[0].agent_id +} + +export function getDefaultAgentName(): string { + const config = getNovaConfig() + if (!config.agents.length) { + throw new Error('No agents configured in .nova/config.json') + } + return config.agents[0].agent_name +} \ No newline at end of file diff --git a/app/api/oapi-client.ts b/app/api/oapi-client.ts new file mode 100644 index 0000000..f704948 --- /dev/null +++ b/app/api/oapi-client.ts @@ -0,0 +1,85 @@ +import { NextResponse } from 'next/server'; +import { HTTPClient } from '@/http'; +import { logger } from '@/utils/logger' + +export const oapiClient = new HTTPClient({ + baseURL: process.env.NOVA_BASE_URL, + headers: { + 'Content-Type': 'application/json', + 'Tenant-Id': process.env.NOVA_TENANT_ID, + 'Authorization': process.env.NOVA_ACCESS_KEY, + } +}, { + onRequest: async (config) => { + logger.info('oapi request start', { + method: config.method, + url: config.url, + body: config.body, + query: config.query, + headers: config.headers, + }) + + return config + }, + onResponse: async (response, config) => { + logger.info('oapi response received', { + method: config.method, + url: config.url, + }) + }, + onError: (error, config) => { + logger.error('oapi request error', { + method: config.method, + url: config.url, + error + }) + } +}) + + +export const sendResponse = (res: any) => { + // 兼容 HTTP 层 defaultGetResult 的解包结果: + // 若上游返回 { success: true, data: ... },此处可能拿到 data 本身(含 null) + if (res == null || typeof res !== 'object' || !('success' in res)) { + return NextResponse.json( + { + success: true, + data: res, + }, + { status: 200 } + ) + } + + if (res.success === false) { + logger.error('oapi request failed', { + code: res.code, + message: res.message, + request_id: res.request_id, + }) + + return NextResponse.json( + { + code: res.code, + message: res.message, + request_id: res.request_id, + success: false, + }, + { status: 500 } + ) + } + + logger.info('oapi request success', { + code: res.code, + request_id: res.request_id, + }) + + return NextResponse.json( + { + code: res.code, + request_id: res.request_id, + success: true, + data: res, + }, + { status: 200 } + ) +} \ No newline at end of file diff --git a/app/api/oapi-wrapper-client.ts b/app/api/oapi-wrapper-client.ts new file mode 100644 index 0000000..60c7ec1 --- /dev/null +++ b/app/api/oapi-wrapper-client.ts @@ -0,0 +1,55 @@ +import type { HttpDefine } from '@/http/type' +import { oapiClient } from './oapi-client' + +export type DataWrapped = { data: T } + +async function dataInterceptor(promise: Promise): Promise> { + const result = await promise + return { data: result } +} + +export const oapiDataClient = { + request(config: HttpDefine): Promise> { + return dataInterceptor(oapiClient.request(config)) + }, + + get( + url: string, + query?: Record, + config?: HttpDefine, + ): Promise> { + return dataInterceptor(oapiClient.get(url, query, config)) + }, + + post( + url: string, + body?: Record | FormData, + config?: HttpDefine, + ): Promise> { + return dataInterceptor(oapiClient.post(url, body, config)) + }, + + put( + url: string, + body?: Record | FormData, + config?: HttpDefine, + ): Promise> { + return dataInterceptor(oapiClient.put(url, body, config)) + }, + + patch( + url: string, + body?: Record | FormData, + config?: HttpDefine, + ): Promise> { + return dataInterceptor(oapiClient.patch(url, body, config)) + }, + + delete( + url: string, + query?: Record, + config?: HttpDefine, + ): Promise> { + return dataInterceptor(oapiClient.delete(url, query, config)) + }, +} diff --git a/app/api/oss/upload-sts/route.ts b/app/api/oss/upload-sts/route.ts new file mode 100644 index 0000000..3b21fb1 --- /dev/null +++ b/app/api/oss/upload-sts/route.ts @@ -0,0 +1,6 @@ +import { oapiClient, sendResponse } from '../../oapi-client' + +export async function GET() { + const res = await oapiClient.get('/v1/oss/upload_sts') + return sendResponse(res) +} diff --git a/app/api/plugins/skill/upload/route.ts b/app/api/plugins/skill/upload/route.ts new file mode 100644 index 0000000..8dd7bff --- /dev/null +++ b/app/api/plugins/skill/upload/route.ts @@ -0,0 +1,7 @@ +import { oapiClient, sendResponse } from '../../../oapi-client' + +export async function POST(req: Request) { + const body = await req.formData() + const res = await oapiClient.post('/v1/plugins/skill/upload', body) + return sendResponse(res) +} diff --git a/app/api/remote-control/agent-info/route.ts b/app/api/remote-control/agent-info/route.ts new file mode 100644 index 0000000..f5f80be --- /dev/null +++ b/app/api/remote-control/agent-info/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from 'next/server' +import { getDefaultAgentId } from '@/app/api/nova-config' + +export async function GET() { + const baseUrl = process.env.NOVA_BASE_URL || '' + const agentId = getDefaultAgentId() + const tenantId = process.env.NOVA_TENANT_ID || '' + + let stats = { + activeConnections: 0, + totalMessages: 0, + averageResponseTime: 0, + } + + try { + const { getStats } = await import('@/remote-control/shared/nova-bridge') + stats = getStats() + } catch { + // nova-bridge 未加载 + } + + let status: 'connected' | 'disconnected' = 'disconnected' + try { + const response = await fetch(`${baseUrl}/health`, { + signal: AbortSignal.timeout(5000), + }) + if (response.ok) { + status = 'connected' + } + } catch { + // 连接失败 + } + + return NextResponse.json({ + baseUrl, + agentId, + tenantId, + status, + ...stats, + }) +} diff --git a/app/api/remote-control/config/route.ts b/app/api/remote-control/config/route.ts new file mode 100644 index 0000000..9112a89 --- /dev/null +++ b/app/api/remote-control/config/route.ts @@ -0,0 +1,109 @@ +import { NextRequest, NextResponse } from 'next/server' +import { ConfigManager, reconnectAllPlatforms } from '@/remote-control/config/manager' + +export async function GET() { + const configManager = ConfigManager.getInstance() + await configManager.ensureLoaded() + const config = configManager.getMasked() + return NextResponse.json(config) +} + +export async function PUT(request: NextRequest) { + try { + const body = await request.json() + const configManager = ConfigManager.getInstance() + await configManager.ensureLoaded() + + // Replace masked values with existing real values so we don't overwrite secrets + const merged = stripMaskedValues(body, configManager.get()) + + const validationError = validateConfig(merged) + if (validationError) { + return NextResponse.json( + { success: false, error: validationError }, + { status: 400 } + ) + } + + // skipEmit: lifecycle is managed explicitly below, avoid double-triggering + await configManager.update(merged, { skipEmit: true }) + + // After saving, explicitly manage bot lifecycle: + // - Stop all disabled bots + // - Reconnect (stop + start) all enabled bots + const config = configManager.get() + const errors: string[] = [] + + await reconnectAllPlatforms(config, errors) + + return NextResponse.json({ + success: true, + message: errors.length > 0 + ? `配置已保存,部分渠道连接异常: ${errors.join('; ')}` + : '配置已保存并生效', + }) + } catch (error) { + return NextResponse.json( + { success: false, error: '保存配置失败' }, + { status: 500 } + ) + } +} + +const MASK_PREFIX = '••••' + +/** + * If the frontend sends back a masked value (e.g. "••••pbYI"), replace it + * with the real value from the current config so we never overwrite secrets. + */ +function stripMaskedValues( + incoming: Record>, + current: Record>, +): Record> { + const result: Record> = {} + for (const platform of Object.keys(incoming)) { + const incomingPlatform = incoming[platform] + const currentPlatform = current[platform] ?? {} + const merged: Record = {} + for (const key of Object.keys(incomingPlatform)) { + const val = incomingPlatform[key] + if (typeof val === 'string' && val.startsWith(MASK_PREFIX)) { + // Keep the real value + merged[key] = currentPlatform[key] ?? '' + } else { + merged[key] = val + } + } + result[platform] = merged + } + return result +} + +function validateConfig(config: Record): string | null { + const discord = config.discord as Record | undefined + const dingtalk = config.dingtalk as Record | undefined + const lark = config.lark as Record | undefined + + if (discord?.enabled && !discord?.botToken) { + return 'Discord Bot Token 不能为空' + } + if (dingtalk?.enabled && (!dingtalk?.clientId || !dingtalk?.clientSecret)) { + return '钉钉 Client ID 和 Client Secret 不能为空' + } + if (lark?.enabled) { + if (!lark?.appId || !lark?.appSecret) { + return '飞书 App ID 和 App Secret 不能为空' + } + } + + const telegram = config.telegram as Record | undefined + const slack = config.slack as Record | undefined + + if (telegram?.enabled && !telegram?.botToken) { + return 'Telegram Bot Token 不能为空' + } + if (slack?.enabled && (!slack?.botToken || !slack?.appToken)) { + return 'Slack Bot Token 和 App Token 不能为空' + } + return null +} diff --git a/app/api/remote-control/logs/route.ts b/app/api/remote-control/logs/route.ts new file mode 100644 index 0000000..0b5a97c --- /dev/null +++ b/app/api/remote-control/logs/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server' +import { BotLogger } from '@/remote-control/shared/logger' + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams + const limit = parseInt(searchParams.get('limit') || '100') + const offset = parseInt(searchParams.get('offset') || '0') + const platform = searchParams.get('platform') as 'discord' | 'dingtalk' | null + const eventType = searchParams.get('eventType') || null + const severity = searchParams.get('severity') as 'info' | 'warning' | 'error' | null + + const { logs, total } = BotLogger.getLogs({ + limit, + offset, + platform: platform || undefined, + eventType: eventType || undefined, + severity: severity || undefined, + }) + + return NextResponse.json({ + logs, + total, + hasMore: offset + logs.length < total, + }) +} + +export async function DELETE() { + BotLogger.clear() + return NextResponse.json({ + success: true, + message: '日志已清空', + }) +} diff --git a/app/api/remote-control/status/route.ts b/app/api/remote-control/status/route.ts new file mode 100644 index 0000000..96fa658 --- /dev/null +++ b/app/api/remote-control/status/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from 'next/server' +import { ConfigManager } from '@/remote-control/config/manager' + +export async function GET() { + const mgr = ConfigManager.getInstance() + await mgr.ensureLoaded() + const config = mgr.get() + const statuses: Record = {} + + // Discord Bot 状态 + if (config.discord.enabled) { + try { + const discordBot = await import('@/remote-control/bots/discord') + statuses.discord = discordBot.getStatus() + } catch { + statuses.discord = { platform: 'discord', status: 'disconnected', error: 'Bot 模块未加载' } + } + } else { + statuses.discord = { platform: 'discord', status: 'disconnected' } + } + + // 钉钉 Bot 状态 + if (config.dingtalk.enabled) { + try { + const dingtalkBot = await import('@/remote-control/bots/dingtalk') + statuses.dingtalk = dingtalkBot.getStatus() + } catch { + statuses.dingtalk = { platform: 'dingtalk', status: 'disconnected', error: 'Bot 模块未加载' } + } + } else { + statuses.dingtalk = { platform: 'dingtalk', status: 'disconnected' } + } + + // 飞书 Bot 状态 + if (config.lark.enabled) { + try { + const larkBot = await import('@/remote-control/bots/lark') + statuses.lark = larkBot.getStatus() + } catch { + statuses.lark = { platform: 'lark', status: 'disconnected', error: 'Bot 模块未加载' } + } + } else { + statuses.lark = { platform: 'lark', status: 'disconnected' } + } + + // Telegram Bot 状态 + if (config.telegram.enabled) { + try { + const telegramBot = await import('@/remote-control/bots/telegram') + statuses.telegram = telegramBot.getStatus() + } catch { + statuses.telegram = { platform: 'telegram', status: 'disconnected', error: 'Bot 模块未加载' } + } + } else { + statuses.telegram = { platform: 'telegram', status: 'disconnected' } + } + + // Slack Bot 状态 + if (config.slack.enabled) { + try { + const slackBot = await import('@/remote-control/bots/slack') + statuses.slack = slackBot.getStatus() + } catch { + statuses.slack = { platform: 'slack', status: 'disconnected', error: 'Bot 模块未加载' } + } + } else { + statuses.slack = { platform: 'slack', status: 'disconnected' } + } + + return NextResponse.json(statuses) +} diff --git a/app/api/remote-control/test/route.ts b/app/api/remote-control/test/route.ts new file mode 100644 index 0000000..6e33d8f --- /dev/null +++ b/app/api/remote-control/test/route.ts @@ -0,0 +1,147 @@ +import { NextRequest, NextResponse } from 'next/server' +import { ConfigManager } from '@/remote-control/config/manager' + +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +/** + * Poll a bot's getStatus() until it leaves 'connecting' state, + * or until timeout (default 10s). Returns the final status. + */ +async function waitForConnection( + getStatus: () => { status: string; error?: string }, + timeoutMs = 10000, + intervalMs = 500, +): Promise<{ status: string; error?: string }> { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const s = getStatus() + // 'connected' or 'disconnected' (with error) means we have a definitive answer + if (s.status !== 'connecting') return s + await sleep(intervalMs) + } + return getStatus() +} + +export async function POST(request: NextRequest) { + try { + const { platform } = await request.json() + + if (platform !== 'discord' && platform !== 'dingtalk' && platform !== 'lark' && platform !== 'telegram' && platform !== 'slack') { + return NextResponse.json({ success: false, error: '无效的平台' }, { status: 400 }) + } + + const mgr = ConfigManager.getInstance() + await mgr.ensureLoaded() + const config = mgr.get() + + // Reject test for disabled platforms + const platformConfig = config[platform as keyof typeof config] as { enabled: boolean } + if (!platformConfig?.enabled) { + return NextResponse.json({ success: false, error: '该渠道已禁用,请先启用后再测试' }, { status: 400 }) + } + + if (platform === 'discord') { + if (!config.discord.botToken) { + return NextResponse.json({ success: false, error: 'Discord Bot Token 未配置' }) + } + try { + const bot = await import('@/remote-control/bots/discord') + await bot.stopBot() + await bot.startBot(config.discord.botToken) + const status = await waitForConnection(() => bot.getStatus()) + return NextResponse.json({ + success: status.status === 'connected', + status, + error: status.status !== 'connected' ? (status.error || '连接超时,请检查 Token 是否正确') : undefined, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return NextResponse.json({ success: false, error: `Discord 连接失败: ${msg}` }) + } + } + + if (platform === 'dingtalk') { + if (!config.dingtalk.clientId || !config.dingtalk.clientSecret) { + return NextResponse.json({ success: false, error: '钉钉 Client ID 或 Client Secret 未配置' }) + } + try { + const bot = await import('@/remote-control/bots/dingtalk') + await bot.stopBot() + await bot.startBot(config.dingtalk.clientId, config.dingtalk.clientSecret) + const status = await waitForConnection(() => bot.getStatus()) + return NextResponse.json({ + success: status.status === 'connected', + status, + error: status.status !== 'connected' ? (status.error || '连接超时,请检查凭证是否正确') : undefined, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return NextResponse.json({ success: false, error: `钉钉连接失败: ${msg}` }) + } + } + + if (platform === 'lark') { + if (!config.lark.appId || !config.lark.appSecret) { + return NextResponse.json({ success: false, error: '飞书 App ID 或 App Secret 未配置' }) + } + try { + const bot = await import('@/remote-control/bots/lark') + await bot.stopBot() + await bot.startBot(config.lark.appId, config.lark.appSecret) + const status = await waitForConnection(() => bot.getStatus()) + return NextResponse.json({ + success: status.status === 'connected', + status, + error: status.status !== 'connected' ? (status.error || '连接超时,请检查凭证是否正确') : undefined, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return NextResponse.json({ success: false, error: `飞书连接失败: ${msg}` }) + } + } + + if (platform === 'telegram') { + if (!config.telegram.botToken) { + return NextResponse.json({ success: false, error: 'Telegram Bot Token 未配置' }) + } + try { + const bot = await import('@/remote-control/bots/telegram') + await bot.stopBot() + await bot.startBot(config.telegram.botToken) + const status = await waitForConnection(() => bot.getStatus()) + return NextResponse.json({ + success: status.status === 'connected', + status, + error: status.status !== 'connected' ? (status.error || '连接超时,请检查 Token 是否正确') : undefined, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return NextResponse.json({ success: false, error: `Telegram 连接失败: ${msg}` }) + } + } + + if (platform === 'slack') { + if (!config.slack.botToken || !config.slack.appToken) { + return NextResponse.json({ success: false, error: 'Slack Bot Token 或 App Token 未配置' }) + } + try { + const bot = await import('@/remote-control/bots/slack') + await bot.stopBot() + await bot.startBot(config.slack.botToken, config.slack.appToken) + const status = await waitForConnection(() => bot.getStatus()) + return NextResponse.json({ + success: status.status === 'connected', + status, + error: status.status !== 'connected' ? (status.error || '连接超时,请检查凭证是否正确') : undefined, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return NextResponse.json({ success: false, error: `Slack 连接失败: ${msg}` }) + } + } + } catch { + return NextResponse.json({ success: false, error: '请求解析失败' }, { status: 400 }) + } +} diff --git a/app/api/team/[teamId]/plugins/route.ts b/app/api/team/[teamId]/plugins/route.ts new file mode 100644 index 0000000..6f19af1 --- /dev/null +++ b/app/api/team/[teamId]/plugins/route.ts @@ -0,0 +1,11 @@ +import { oapiClient, sendResponse } from '@/app/api/oapi-client' + +export async function POST( + req: Request, + { params }: { params: Promise<{ teamId: string }> }, +) { + const body = req.body ? await req.json() : {} + const { teamId } = await params + const res = await oapiClient.post(`/v1/team/${teamId}/plugins`, body) + return sendResponse(res) +} diff --git a/app/api/v1/[...path]/route.ts b/app/api/v1/[...path]/route.ts new file mode 100644 index 0000000..e4723c2 --- /dev/null +++ b/app/api/v1/[...path]/route.ts @@ -0,0 +1,133 @@ +import { NextRequest } from 'next/server' +import { oapiClient, sendResponse } from '../../oapi-client' + +function buildUrl(path: string[]) { + return `/v1/${path.join('/')}` +} + +function buildFullForwardUrl(path: string[], query?: Record) { + const base = process.env.NOVA_BASE_URL || '' + const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base + const pathname = buildUrl(path) + const url = new URL(`${normalizedBase}${pathname}`) + Object.entries(query || {}).forEach(([key, value]) => { + url.searchParams.set(key, value) + }) + return url.toString() +} + +function logForwardRequest(args: { + method: string + path: string[] + query?: Record + body?: unknown +}) { + const { method, path, query, body } = args + console.log('[api/v1 proxy] forward', { + method, + url: buildFullForwardUrl(path, query), + query: query || {}, + body: body ?? null, + }) +} + +function logForwardResponse(method: string, path: string[], res: unknown) { + const url = buildUrl(path) + try { + console.log('[api/v1 proxy] response', { + method, + url, + raw: JSON.stringify(res, null, 2), + }) + } catch { + console.log('[api/v1 proxy] response', { + method, + url, + raw: res, + }) + } +} + +function normalizeQuery(searchParams: URLSearchParams) { + const query: Record = {} + + searchParams.forEach((value, key) => { + // params[task_id] -> task_id + if (key.startsWith('params[') && key.endsWith(']')) { + const realKey = key.slice(7, -1) + if (realKey) query[realKey] = value + return + } + query[key] = value + }) + + return query +} + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +) { + const { path } = await params + const query = normalizeQuery(req.nextUrl.searchParams) + logForwardRequest({ method: 'GET', path, query }) + const res = await oapiClient.get(buildUrl(path), query) + logForwardResponse('GET', path, res) + return sendResponse(res) +} + +export async function POST( + req: Request, + { params }: { params: Promise<{ path: string[] }> }, +) { + const body = req.body ? await req.json() : {} + const { path } = await params + logForwardRequest({ method: 'POST', path, body }) + const res = await oapiClient.post(buildUrl(path), body) + logForwardResponse('POST', path, res) + return sendResponse(res) +} + +export async function PUT( + req: Request, + { params }: { params: Promise<{ path: string[] }> }, +) { + const body = req.body ? await req.json() : {} + const { path } = await params + logForwardRequest({ method: 'PUT', path, body }) + const res = await oapiClient.request({ + url: buildUrl(path), + method: 'put', + body, + }) + logForwardResponse('PUT', path, res) + return sendResponse(res) +} + +export async function PATCH( + req: Request, + { params }: { params: Promise<{ path: string[] }> }, +) { + const body = req.body ? await req.json() : {} + const { path } = await params + logForwardRequest({ method: 'PATCH', path, body }) + const res = await oapiClient.request({ + url: buildUrl(path), + method: 'patch', + body, + }) + logForwardResponse('PATCH', path, res) + return sendResponse(res) +} + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +) { + const { path } = await params + const query = normalizeQuery(req.nextUrl.searchParams) + logForwardRequest({ method: 'DELETE', path, query }) + const res = await oapiClient.delete(buildUrl(path), query) + logForwardResponse('DELETE', path, res) + return sendResponse(res) +} diff --git a/app/api/websocket/index.ts b/app/api/websocket/index.ts new file mode 100644 index 0000000..9b3cd7a --- /dev/null +++ b/app/api/websocket/index.ts @@ -0,0 +1,570 @@ +/** + * WebSocket 客户端封装 + * + * 提供自动重连、心跳检测、网络状态监听等功能 + */ + +function latest(value: T) { + const ref = { current: value } + return ref +} + +export const ReadyState = { + Connecting: 0, + Open: 1, + Closing: 2, + Closed: 3, +} as const + +export type ReadyState = (typeof ReadyState)[keyof typeof ReadyState] + +export interface Result { + sendMessage: WebSocket['send'] + disconnect: () => void + connect: () => void + readyState: ReadyState + webSocketIns?: WebSocket + clearHeartbeat: () => void + switchConversation: (conversationId: string) => void + cleanup: () => void +} + +export interface HeartbeatOptions { + /** 心跳间隔,默认 20000ms */ + heartbeatInterval?: number + /** 心跳超时,默认 22000ms */ + heartbeatTimeout?: number + /** 心跳消息,默认 { message_type: 'ping' } */ + heartbeatMessage?: string | object | (() => string | object) + /** 心跳响应类型,默认 'pong' */ + heartbeatResponseType?: string +} + +export interface ApiEvent { + event_id: string + event_type?: string + role?: 'user' | 'assistant' | 'system' + content?: { + text?: string + content?: string + [key: string]: unknown + } + created_at?: string + task_id?: string + is_display?: boolean + metadata?: Record + stream?: boolean + event_status?: string + [key: string]: unknown +} + +// WebSocket 事件类型定义 +type WSOpenEvent = Event +interface WSCloseEvent { + code?: number + reason?: string + wasClean?: boolean +} +interface WSMessageEvent { + data: string | ArrayBuffer | Blob +} +type WSErrorEvent = Event + +export interface Options { + /** 重连次数限制,默认 3 */ + reconnectLimit?: number + /** 重连间隔,默认 3000ms */ + reconnectInterval?: number + /** 是否手动连接,默认 false */ + manual?: boolean + /** 连接成功回调 */ + onOpen?: (event: WSOpenEvent, instance: WebSocket) => void + /** 连接关闭回调 */ + onClose?: (event: WSCloseEvent, instance: WebSocket) => void + /** 收到消息回调 */ + onMessage?: (message: ApiEvent, instance: WebSocket) => void + /** 连接错误回调 */ + onError?: (event: WSErrorEvent, instance: WebSocket) => void + /** WebSocket 协议 */ + protocols?: string | string[] + /** 心跳配置,传 false 禁用心跳 */ + heartbeat?: HeartbeatOptions | boolean + /** 是否监听网络状态变化,默认 true */ + enableNetworkListener?: boolean + /** 是否监听文档可见性变化,默认 true */ + enableVisibilityListener?: boolean + /** 重连延迟,默认 300ms */ + reconnectDelay?: number + /** 重连防抖时间,默认 1000ms */ + reconnectDebounce?: number + /** 获取认证 Token */ + getToken?: () => string | undefined + /** 获取租户 ID */ + getTenantId?: () => string | undefined +} + +/** + * 创建 WebSocket 客户端 + */ +export function createWebSocketClient( + socketUrl: string, + options: Options = {}, +): Result { + const { + reconnectLimit = 3, + reconnectInterval = 3 * 1000, + manual = false, + onOpen, + onClose, + onMessage, + onError, + protocols, + heartbeat: enableHeartbeat = true, + enableNetworkListener = true, + enableVisibilityListener = true, + reconnectDelay = 300, + reconnectDebounce = 1000, + getToken, + getTenantId, + } = options + + const heartbeatOptions: HeartbeatOptions = + typeof enableHeartbeat === 'object' ? enableHeartbeat : {} + const { + heartbeatInterval = 20000, + heartbeatTimeout = 22000, + heartbeatMessage = { message_type: 'ping' }, + heartbeatResponseType = 'pong', + } = heartbeatOptions + + // 提前声明函数,避免使用前未定义 + let disconnectFn: () => void = () => { + throw new Error('disconnectFn not initialized') + } + let connectWsFn: () => void = () => { + throw new Error('connectWsFn not initialized') + } + + const onOpenRef = latest(onOpen) + const onCloseRef = latest(onClose) + const onMessageRef = latest(onMessage) + const onErrorRef = latest(onError) + + // 确保 ref 始终指向最新的回调 + if (onMessage) { + onMessageRef.current = onMessage + } + + const reconnectTimesRef = latest(0) + const reconnectTimerRef = latest | undefined>(undefined) + const websocketRef = latest(undefined) + const readyStateRef = latest(ReadyState.Closed) + + // 心跳相关 + const heartbeatTimerRef = latest | undefined>(undefined) + const heartbeatTimeoutTimerRef = latest | undefined>(undefined) + const waitingForPongRef = latest(false) + + // 网络和可见性状态 + const isOnlineRef = latest(typeof navigator !== 'undefined' ? navigator.onLine : true) + const isVisibleRef = latest(typeof document !== 'undefined' ? !document.hidden : true) + + // 重连防抖定时器 + const reconnectDebounceTimerRef = latest | undefined>(undefined) + + // 更新 readyState 的辅助函数 + const setReadyState = (state: ReadyState) => { + readyStateRef.current = state + } + + // 获取当前 readyState + const getReadyState = (): ReadyState => { + if (websocketRef.current) { + const wsState = websocketRef.current.readyState + if (wsState === WebSocket.CONNECTING) return ReadyState.Connecting + if (wsState === WebSocket.OPEN) return ReadyState.Open + if (wsState === WebSocket.CLOSING) return ReadyState.Closing + if (wsState === WebSocket.CLOSED) return ReadyState.Closed + } + return readyStateRef.current + } + + // 清除心跳相关定时器 + const clearHeartbeat = () => { + if (heartbeatTimerRef.current) { + clearTimeout(heartbeatTimerRef.current) + heartbeatTimerRef.current = undefined + } + if (heartbeatTimeoutTimerRef.current) { + clearTimeout(heartbeatTimeoutTimerRef.current) + heartbeatTimeoutTimerRef.current = undefined + } + waitingForPongRef.current = false + } + + // 处理心跳超时 + const handlePingTimeout = () => { + if (!isOnlineRef.current) { + waitingForPongRef.current = false + clearHeartbeat() + return + } + waitingForPongRef.current = false + clearHeartbeat() + disconnectFn() + } + + // 发送心跳 + const sendHeartbeat = () => { + if (!isOnlineRef.current) { + return + } + if (waitingForPongRef.current) { + return + } + if (websocketRef.current && getReadyState() === ReadyState.Open) { + try { + const message = + typeof heartbeatMessage === 'function' + ? heartbeatMessage() + : heartbeatMessage + websocketRef.current.send( + typeof message === 'string' ? message : JSON.stringify(message), + ) + waitingForPongRef.current = true + + heartbeatTimeoutTimerRef.current = setTimeout( + handlePingTimeout, + heartbeatTimeout, + ) + } catch { + clearHeartbeat() + } + } else { + clearHeartbeat() + } + } + + // 处理心跳响应 + const handlePongReceived = () => { + if (!waitingForPongRef.current) { + return + } + waitingForPongRef.current = false + + if (heartbeatTimeoutTimerRef.current) { + clearTimeout(heartbeatTimeoutTimerRef.current) + heartbeatTimeoutTimerRef.current = undefined + } + + heartbeatTimerRef.current = setTimeout(sendHeartbeat, heartbeatInterval) + } + + // 启动心跳 + const startHeartbeat = () => { + if (!enableHeartbeat) return + clearHeartbeat() + heartbeatTimerRef.current = setTimeout(sendHeartbeat, 1000) + } + + // 处理原始消息,检查是否是心跳响应 + const handleRawMessage = (messageData: string): boolean => { + try { + const rawMessage = JSON.parse(messageData) + if ( + rawMessage.data?.message_type === heartbeatResponseType || + rawMessage.message_type === heartbeatResponseType + ) { + handlePongReceived() + return true + } + return false + } catch { + return false + } + } + + const reconnect = () => { + if ( + reconnectTimesRef.current < reconnectLimit && + getReadyState() !== ReadyState.Open + ) { + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current) + } + + reconnectTimerRef.current = setTimeout(() => { + connectWsFn() + reconnectTimesRef.current++ + }, reconnectInterval) + } + } + + connectWsFn = () => { + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current) + } + + if (websocketRef.current) { + websocketRef.current.close() + } + + // 构建 WebSocket URL + const url = new URL(socketUrl) + + // 添加认证信息 + const token = getToken?.() + const tenantId = getTenantId?.() + + if (token) { + url.searchParams.set('Authorization', token) + } + if (tenantId) { + url.searchParams.set('Tenant-Id', tenantId) + } + + const ws = new WebSocket(url.toString(), protocols) + setReadyState(ReadyState.Connecting) + + ws.onerror = event => { + if (websocketRef.current !== ws) { + return + } + reconnect() + onErrorRef.current?.(event, ws) + setReadyState(ReadyState.Closed) + } + + ws.onopen = event => { + if (websocketRef.current !== ws) { + return + } + onOpenRef.current?.(event, ws) + reconnectTimesRef.current = 0 + setReadyState(ReadyState.Open) + startHeartbeat() + } + + ws.onmessage = (message: WSMessageEvent) => { + if (websocketRef.current !== ws) { + return + } + + const messageData = + typeof message.data === 'string' ? message.data : String(message.data) + + // 先检查是否是心跳响应 + if (enableHeartbeat && handleRawMessage(messageData)) { + return + } + + // 解析消息并触发回调 + try { + const parsedMessage: ApiEvent = JSON.parse(messageData) + onMessageRef.current?.(parsedMessage, ws) + } catch { + // 如果解析失败,尝试作为原始数据传递 + console.warn('[WebSocket] Failed to parse message:', messageData) + } + } + + ws.onclose = event => { + onCloseRef.current?.(event, ws) + clearHeartbeat() + // closed by server + if (websocketRef.current === ws) { + reconnect() + } + // closed by disconnect or closed by server + if (!websocketRef.current || websocketRef.current === ws) { + setReadyState(ReadyState.Closed) + } + } + + websocketRef.current = ws + } + + const sendMessage: WebSocket['send'] = message => { + const currentState = getReadyState() + if (currentState === ReadyState.Open) { + websocketRef.current?.send(message) + } else { + throw new Error('WebSocket disconnected') + } + } + + // 切换会话 + const switchConversation = (conversationId: string) => { + // 检查网络状态 + if (!isOnlineRef.current) { + throw new Error('网络连接异常,无法切换会话') + } + + // 检查 WebSocket 连接状态 + const currentState = getReadyState() + if (!websocketRef.current || currentState !== ReadyState.Open) { + throw new Error('WebSocket 未连接,无法切换会话') + } + + try { + const message = JSON.stringify({ + message_type: 'switch_conversation', + conversation_id: conversationId, + }) + websocketRef.current.send(message) + } catch (error) { + throw new Error(`切换会话失败: ${error}`) + } + } + + const connect = () => { + reconnectTimesRef.current = 0 + connectWsFn() + } + + disconnectFn = () => { + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current) + } + if (reconnectDebounceTimerRef.current) { + clearTimeout(reconnectDebounceTimerRef.current) + } + + reconnectTimesRef.current = reconnectLimit + clearHeartbeat() + websocketRef.current?.close(1000, '手动断开') + websocketRef.current = undefined + setReadyState(ReadyState.Closed) + } + + // 处理网络断开 + const handleNetworkOffline = () => { + clearHeartbeat() + const currentState = getReadyState() + if ( + currentState === ReadyState.Open || + currentState === ReadyState.Connecting + ) { + disconnectFn() + } + } + + // 重连函数 - 统一处理重连逻辑 + const attemptReconnect = () => { + // 清除之前的防抖定时器 + if (reconnectDebounceTimerRef.current) { + clearTimeout(reconnectDebounceTimerRef.current) + } + + reconnectDebounceTimerRef.current = setTimeout(() => { + const currentState = getReadyState() + // 已连接或正在连接时跳过 + if ( + currentState === ReadyState.Open || + currentState === ReadyState.Connecting + ) { + return + } + + clearHeartbeat() + + const isClosed = + currentState === ReadyState.Closed || + currentState === ReadyState.Closing + + if (isClosed) { + connect() + } else { + disconnectFn() + setTimeout(() => { + if ( + isOnlineRef.current && + getReadyState() !== ReadyState.Open && + getReadyState() !== ReadyState.Connecting + ) { + connect() + } + }, reconnectDelay) + } + }, reconnectDebounce) + } + + // 网络状态监听 + let cleanupNetworkListener: (() => void) | undefined + + if (enableNetworkListener && typeof window !== 'undefined') { + const handleOnline = () => { + isOnlineRef.current = true + attemptReconnect() + } + + const handleOffline = () => { + isOnlineRef.current = false + handleNetworkOffline() + } + + window.addEventListener('online', handleOnline) + window.addEventListener('offline', handleOffline) + + cleanupNetworkListener = () => { + window.removeEventListener('online', handleOnline) + window.removeEventListener('offline', handleOffline) + } + } + + // 文档可见性监听 + let cleanupVisibilityListener: (() => void) | undefined + + if (enableVisibilityListener && typeof document !== 'undefined') { + const handleVisibilityChange = () => { + const isVisible = !document.hidden + isVisibleRef.current = isVisible + if (isVisible && isOnlineRef.current) { + const currentState = getReadyState() + if ( + currentState === ReadyState.Closed || + currentState === ReadyState.Closing + ) { + attemptReconnect() + } + } + } + + document.addEventListener('visibilitychange', handleVisibilityChange) + + cleanupVisibilityListener = () => { + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + } + + // 自动连接 + if (!manual && socketUrl) { + connect() + } + + // 清理函数 + const cleanup = () => { + disconnectFn() + cleanupNetworkListener?.() + cleanupVisibilityListener?.() + } + + const result: Result = { + sendMessage, + connect, + disconnect: disconnectFn, + get readyState() { + return getReadyState() + }, + get webSocketIns() { + return websocketRef.current + }, + clearHeartbeat, + switchConversation, + cleanup, + } + + return result +} + +export default createWebSocketClient diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..de5d764fd3d4cf355926f06836d52b3fe24fd758 GIT binary patch literal 4286 zcmdUzdsJ0b9>pAH^+sz&c&?p57U2N}-U>bg-q;60;clg0(cSET(bOKCbLI%$3r2#4#P0)3|v* z&8teVXK$b-?rGg4{U1FRYYx8`H}rrdN!lrzdvbYajr!|Cd)zGSEFLN$%s%V?RBEf31fn%W|s5N59b)dSW<^(WpX&R|KvIe8YB51yi? z7HXxpt!NkFlOj2IsDipW>DRPk9+w?H>FV23SW2zgCmN2N!x=eG9+abX#)rz&P24~~ zUi$H)MKhLon(}tJv`wArYA4YNDg3T$7nUr0p89{$ua~!FxvSLImB^Sxs;V5V-sHQn zV866gL+f;hc-*Y-^)_rV(vCIB#uB5sXHx&c=dO9Q=|{ySyEGQ(JbTUjP5q_MNdK;` zL`;kSQC}YGKKh21sJY4@Q67?Vu_UX;iT_~%YqYeZe<6wqorWW25w%}5w9mg*b6K}> zJ0pXwyuQ2a8htbWz5Cx`WQdJ5>kBcjZL3Y~H%&M)mh1V5lYYW(Yzdvjb@Zd8ALSP0$DgbIroEKLtStMK6H z@;>Yc8$kY$fou(WfWuGs<@;~lv7a4;GitKdLG=4rr*#u9c}DAb4@>(cqmy6I+Q5+) zOhbbQ2Qz(G?;gmy9>ZDNBY-t-{$dz;{ro5y>%);H{W-nOQ}m{MNk4Xu9mv|=KCJBO z!-_7#wfEMsH?PO_cX@UcjF5hm>JXKSZAwSwE$wuJF_{+aYZ*5K`+GyFJn6~SK|yTl zHG=$JL2SJLPi(yJLGr`~57Sd1YrD(Wy#ey(&&%EXS>0_ItKIy>5MJsg?itLB_xQ5v zZeLd4>%%99@5g>-5QIlae@c~j$W`<3xm_4L=P+Yu!^AA?=Z0|MpcmT)Ji^v~qVK~L z^nHjeeI8=7=SVj79wCC-eVLx}dIqvVtoInsI`;t9-Rm!J%B#mPUhY1WmwWhe_BBuJ zpA3MSyIH7jKK5fle0jDHCBuU$dLWeT4}|c_fMB-uALXLZ z>(QI|QdY{V-Iwyp?-#(uir&~Od>~9~z-nd?aalw%G3Nvma&R#|2gkG}nZ0z*5QY>~1vQRWIRK7y4@nw1{^lHtkz|2coZRs=8l~;bx5$ui_N?mPF9Gf22 zGY~E9sbY$Ve4EM7UKY5R^eoO93%Oe9%U2)tY4K5SAupTU!vvt zOqy%F`0hddpaU@Mw zJXMeTsQSD&U!LsG-}m|OQQ=U|t_$GIs^OeoIh-?V{5ej_`E{CFZR~==|0`t z3;SP((XvFDo9lbbERm%(kReiQF`T;jJJ5}y!X@I7%`?WAtNxojzp zf^-~jPtsVAVy_I~>LqW@X#mZ&Uai#hSKC1BpZVz-3&Qc1P4AJZv}Eh|P}Bk#x7g0O z1vQMD_o<$>WAd?9&-^2L<~x}$A*L4@r0nQG(kr)%yTu?8PEyfw5{u6XCrR5Or2zDE z$~k*A&bcdaF380>f4R<|!I_Z@NzW_06%er!CTTzMxsRFn=UgT{J)XxF`!YV;jdAmT zOZfcTMBD54x_i<}46=$1QcK)Pd+iMYsiiQZ1g7tlRxu>XQ~WlR6~t~nMa9xd?HIlX0VAei^nR^E?YyYRr+y_~EAoC4K--XN5j92Anhy2J( zVxjzOk)QmpwUz>i%`=ECxS7li`+4$hgZ!%o^A2YUJ5L^hIS1j1H*xvN+}-NM%qB&- zBp0bx+ZJp7w-LYjhGXAqCyS07EIi(s#V1NxbON%E!TcjIS6*fxK-nmlb{_{xDZW$O z>hL$cj-{s!mYy={OV0Zru;e{h^saoogEB!G{gB7r^JKg+SY8a${&LfCf64_cJ7ci; zU4xvHK|ED)zFmKTnM0XblS + + + {props.children} + + + + + + + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..b36246b --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { ImageEditor, ImageEditorHandle } from '@/components/image-editor'; +import { NovaChat } from '@/components/nova-sdk'; +import { useBuildConversationConnect } from '@/components/nova-sdk/hooks'; +import { useImages } from '@/components/nova-sdk/store/useImages'; +import { useRef } from 'react'; + +const ChatWithImageEditor = () => { + const imageEditorRef = useRef(null); + const { conversationId, platformConfig } = useBuildConversationConnect(); + useImages(imageEditorRef) + + return ( +
+
+ {conversationId && ( + + )} +
+
+ +
+
+ ); +}; + +export default ChatWithImageEditor; diff --git a/app/settings/remote-control/page.tsx b/app/settings/remote-control/page.tsx new file mode 100644 index 0000000..e40daf9 --- /dev/null +++ b/app/settings/remote-control/page.tsx @@ -0,0 +1,797 @@ +'use client' + +import Link from 'next/link' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + ArrowLeft, + Bot, + CheckCircle2, + Eye, + EyeOff, + Info, + Loader2, + RefreshCw, + Save, + Trash2, + XCircle, +} from 'lucide-react' +import { toast } from 'sonner' + +// PLATFORM:TYPE_UNION:START +type Platform = 'discord' | 'dingtalk' | 'lark' | 'telegram' | 'slack' +// PLATFORM:TYPE_UNION:END +type ConnectionStatus = 'connected' | 'disconnected' | 'connecting' + +type CSSVarName = + | '--background' + | '--foreground' + | '--card' + | '--card-foreground' + | '--primary' + | '--primary-foreground' + | '--muted' + | '--muted-foreground' + | '--border' + | '--input' + | '--success' + | '--warning' + | '--destructive' + +const themeVar = (name: CSSVarName) => `var(${name})` + +interface RemoteControlConfig { + // PLATFORM:DINGTALK:CONFIG_INTERFACE:START + dingtalk: { + enabled: boolean + clientId: string + clientSecret: string + } + // PLATFORM:DINGTALK:CONFIG_INTERFACE:END + // PLATFORM:DISCORD:CONFIG_INTERFACE:START + discord: { + enabled: boolean + botToken: string + } + // PLATFORM:DISCORD:CONFIG_INTERFACE:END + // PLATFORM:LARK:CONFIG_INTERFACE:START + lark: { + enabled: boolean + appId: string + appSecret: string + } + // PLATFORM:LARK:CONFIG_INTERFACE:END + // PLATFORM:TELEGRAM:CONFIG_INTERFACE:START + telegram: { + enabled: boolean + botToken: string + } + // PLATFORM:TELEGRAM:CONFIG_INTERFACE:END + // PLATFORM:SLACK:CONFIG_INTERFACE:START + slack: { + enabled: boolean + botToken: string + appToken: string + } + // PLATFORM:SLACK:CONFIG_INTERFACE:END +} + +interface PlatformStatus { + platform?: Platform + status: ConnectionStatus + messagesProcessed?: number + activeSessions?: number + lastConnectedAt?: string + uptime?: number + error?: string +} + +interface LogsResponse { + logs: LogEntry[] +} + +interface LogEntry { + id?: string + timestamp: string + platform: Platform + eventType: string + severity: 'info' | 'warning' | 'error' + details?: string | Record + message?: string +} + +interface AgentInfoResponse { + agentId: string + baseUrl: string + stats?: { + totalMessages?: number + activeConnections?: number + avgResponseTime?: number + } + totalMessages?: number + activeConnections?: number + averageResponseTime?: number +} + +const DEFAULT_CONFIG: RemoteControlConfig = { + // PLATFORM:DINGTALK:DEFAULT_CONFIG:START + dingtalk: { enabled: false, clientId: '', clientSecret: '' }, + // PLATFORM:DINGTALK:DEFAULT_CONFIG:END + // PLATFORM:DISCORD:DEFAULT_CONFIG:START + discord: { enabled: false, botToken: '' }, + // PLATFORM:DISCORD:DEFAULT_CONFIG:END + // PLATFORM:LARK:DEFAULT_CONFIG:START + lark: { enabled: false, appId: '', appSecret: '' }, + // PLATFORM:LARK:DEFAULT_CONFIG:END + // PLATFORM:TELEGRAM:DEFAULT_CONFIG:START + telegram: { enabled: false, botToken: '' }, + // PLATFORM:TELEGRAM:DEFAULT_CONFIG:END + // PLATFORM:SLACK:DEFAULT_CONFIG:START + slack: { enabled: false, botToken: '', appToken: '' }, + // PLATFORM:SLACK:DEFAULT_CONFIG:END +} + +const DEFAULT_STATUS: Record = { + // PLATFORM:DINGTALK:DEFAULT_STATUS:START + dingtalk: { platform: 'dingtalk', status: 'disconnected', messagesProcessed: 0, activeSessions: 0 }, + // PLATFORM:DINGTALK:DEFAULT_STATUS:END + // PLATFORM:DISCORD:DEFAULT_STATUS:START + discord: { platform: 'discord', status: 'disconnected', messagesProcessed: 0, activeSessions: 0 }, + // PLATFORM:DISCORD:DEFAULT_STATUS:END + // PLATFORM:LARK:DEFAULT_STATUS:START + lark: { platform: 'lark', status: 'disconnected', messagesProcessed: 0, activeSessions: 0 }, + // PLATFORM:LARK:DEFAULT_STATUS:END + // PLATFORM:TELEGRAM:DEFAULT_STATUS:START + telegram: { platform: 'telegram', status: 'disconnected', messagesProcessed: 0, activeSessions: 0 }, + // PLATFORM:TELEGRAM:DEFAULT_STATUS:END + // PLATFORM:SLACK:DEFAULT_STATUS:START + slack: { platform: 'slack', status: 'disconnected', messagesProcessed: 0, activeSessions: 0 }, + // PLATFORM:SLACK:DEFAULT_STATUS:END +} + +function statusMeta(status: ConnectionStatus) { + if (status === 'connected') { + return { label: '已连接', dot: 'var(--success)' } + } + if (status === 'connecting') { + return { label: '连接中...', dot: 'var(--warning)' } + } + return { label: '断开连接', dot: 'var(--destructive)' } +} + +function formatDate(value?: string) { + if (!value) return '-' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return date.toLocaleString('zh-CN', { hour12: false }) +} + +function formatDetails(log: LogEntry) { + if (typeof log.details === 'string') return log.details + if (log.message) return log.message + if (log.details) return JSON.stringify(log.details) + return '-' +} + +function formatDuration(seconds?: number) { + if (!seconds || seconds <= 0) return '-' + const h = Math.floor(seconds / 3600) + const m = Math.floor((seconds % 3600) / 60) + const s = seconds % 60 + if (h > 0) return `${h}小时${m}分` + if (m > 0) return `${m}分${s}秒` + return `${s}秒` +} + +export default function RemoteControlPage() { + const [config, setConfig] = useState(DEFAULT_CONFIG) + const [status, setStatus] = useState>(DEFAULT_STATUS) + const [logs, setLogs] = useState([]) + const [agentInfo, setAgentInfo] = useState(null) + // PLATFORM:SHOW_SECRETS:START + const [showSecrets, setShowSecrets] = useState({ + // PLATFORM:DINGTALK:SHOW_SECRETS:START + dingtalkSecret: false, + // PLATFORM:DINGTALK:SHOW_SECRETS:END + // PLATFORM:DISCORD:SHOW_SECRETS:START + discordToken: false, + // PLATFORM:DISCORD:SHOW_SECRETS:END + // PLATFORM:LARK:SHOW_SECRETS:START + larkSecret: false, + // PLATFORM:LARK:SHOW_SECRETS:END + // PLATFORM:TELEGRAM:SHOW_SECRETS:START + telegramToken: false, + // PLATFORM:TELEGRAM:SHOW_SECRETS:END + // PLATFORM:SLACK:SHOW_SECRETS:START + slackBotToken: false, + slackAppToken: false, + // PLATFORM:SLACK:SHOW_SECRETS:END + }) + // PLATFORM:SHOW_SECRETS:END + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + // PLATFORM:TESTING:START + const [testing, setTesting] = useState>({ + // PLATFORM:DINGTALK:TESTING:START + dingtalk: false, + // PLATFORM:DINGTALK:TESTING:END + // PLATFORM:DISCORD:TESTING:START + discord: false, + // PLATFORM:DISCORD:TESTING:END + // PLATFORM:LARK:TESTING:START + lark: false, + // PLATFORM:LARK:TESTING:END + // PLATFORM:TELEGRAM:TESTING:START + telegram: false, + // PLATFORM:TELEGRAM:TESTING:END + // PLATFORM:SLACK:TESTING:START + slack: false, + // PLATFORM:SLACK:TESTING:END + }) + // PLATFORM:TESTING:END + const [refreshingLogs, setRefreshingLogs] = useState(false) + const [clearingLogs, setClearingLogs] = useState(false) + + const loadStatus = useCallback(async () => { + const response = await fetch('/api/remote-control/status', { cache: 'no-store' }) + if (!response.ok) { + throw new Error('状态加载失败') + } + const data = (await response.json()) as Partial> + setStatus({ + // PLATFORM:DINGTALK:LOAD_STATUS:START + dingtalk: { ...DEFAULT_STATUS.dingtalk, ...(data.dingtalk ?? {}) }, + // PLATFORM:DINGTALK:LOAD_STATUS:END + // PLATFORM:DISCORD:LOAD_STATUS:START + discord: { ...DEFAULT_STATUS.discord, ...(data.discord ?? {}) }, + // PLATFORM:DISCORD:LOAD_STATUS:END + // PLATFORM:LARK:LOAD_STATUS:START + lark: { ...DEFAULT_STATUS.lark, ...(data.lark ?? {}) }, + // PLATFORM:LARK:LOAD_STATUS:END + // PLATFORM:TELEGRAM:LOAD_STATUS:START + telegram: { ...DEFAULT_STATUS.telegram, ...(data.telegram ?? {}) }, + // PLATFORM:TELEGRAM:LOAD_STATUS:END + // PLATFORM:SLACK:LOAD_STATUS:START + slack: { ...DEFAULT_STATUS.slack, ...(data.slack ?? {}) }, + // PLATFORM:SLACK:LOAD_STATUS:END + }) + }, []) + + const loadLogs = useCallback(async () => { + const response = await fetch('/api/remote-control/logs?limit=50', { cache: 'no-store' }) + if (!response.ok) { + throw new Error('日志加载失败') + } + const data = (await response.json()) as LogsResponse + setLogs(Array.isArray(data.logs) ? data.logs : []) + }, []) + + const loadAgentInfo = useCallback(async () => { + const response = await fetch('/api/remote-control/agent-info', { cache: 'no-store' }) + if (!response.ok) { + throw new Error('Agent 信息加载失败') + } + const data = (await response.json()) as AgentInfoResponse + setAgentInfo(data) + }, []) + + const loadInitialData = useCallback(async () => { + setLoading(true) + try { + const configResponse = await fetch('/api/remote-control/config', { cache: 'no-store' }) + if (!configResponse.ok) { + throw new Error('配置加载失败') + } + const configData = (await configResponse.json()) as Partial + setConfig({ + // PLATFORM:DINGTALK:LOAD_INITIAL:START + dingtalk: { ...DEFAULT_CONFIG.dingtalk, ...(configData.dingtalk ?? {}) }, + // PLATFORM:DINGTALK:LOAD_INITIAL:END + // PLATFORM:DISCORD:LOAD_INITIAL:START + discord: { ...DEFAULT_CONFIG.discord, ...(configData.discord ?? {}) }, + // PLATFORM:DISCORD:LOAD_INITIAL:END + // PLATFORM:LARK:LOAD_INITIAL:START + lark: { ...DEFAULT_CONFIG.lark, ...(configData.lark ?? {}) }, + // PLATFORM:LARK:LOAD_INITIAL:END + // PLATFORM:TELEGRAM:LOAD_INITIAL:START + telegram: { ...DEFAULT_CONFIG.telegram, ...(configData.telegram ?? {}) }, + // PLATFORM:TELEGRAM:LOAD_INITIAL:END + // PLATFORM:SLACK:LOAD_INITIAL:START + slack: { ...DEFAULT_CONFIG.slack, ...(configData.slack ?? {}) }, + // PLATFORM:SLACK:LOAD_INITIAL:END + }) + await Promise.all([loadStatus(), loadLogs(), loadAgentInfo()]) + } catch { + toast.error('加载远程控制配置失败') + } finally { + setLoading(false) + } + }, [loadAgentInfo, loadLogs, loadStatus]) + + useEffect(() => { + void loadInitialData() + }, [loadInitialData]) + + const saveConfig = async () => { + setSaving(true) + try { + const response = await fetch('/api/remote-control/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }) + const data = (await response.json()) as { success?: boolean; message?: string; error?: string } + if (!response.ok || !data.success) { + throw new Error(data.error || '保存失败') + } + + toast.success(data.message || '配置已保存并生效') + + window.setTimeout(() => { + void Promise.all([loadStatus(), loadLogs(), loadAgentInfo()]) + }, 3000) + } catch (error) { + const message = error instanceof Error ? error.message : '保存失败' + toast.error(message) + } finally { + setSaving(false) + } + } + + const testConnection = async (platform: Platform) => { + setTesting(prev => ({ ...prev, [platform]: true })) + try { + const response = await fetch('/api/remote-control/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ platform }), + }) + + const data = (await response.json()) as { success?: boolean; message?: string; error?: string } + if (!response.ok || !data.success) { + throw new Error(data.error || data.message || '连接测试失败') + } + + // PLATFORM:TEST_CONNECTION_SUCCESS:START + const platformNames: Record = { + discord: 'Discord', + dingtalk: '钉钉', + lark: '飞书', + telegram: 'Telegram', + slack: 'Slack', + } + toast.success(`${platformNames[platform]}连接测试成功`) + // PLATFORM:TEST_CONNECTION_SUCCESS:END + await Promise.all([loadStatus(), loadLogs()]) + } catch (error) { + const message = error instanceof Error ? error.message : '连接测试失败' + toast.error(message) + await Promise.all([loadStatus(), loadLogs()]) + } finally { + setTesting(prev => ({ ...prev, [platform]: false })) + } + } + + const refreshLogs = async () => { + setRefreshingLogs(true) + try { + await loadLogs() + toast.success('日志已刷新') + } catch { + toast.error('刷新日志失败') + } finally { + setRefreshingLogs(false) + } + } + + const clearLogs = async () => { + setClearingLogs(true) + try { + const response = await fetch('/api/remote-control/logs', { method: 'DELETE' }) + if (!response.ok) { + throw new Error('清空失败') + } + setLogs([]) + toast.success('日志已清空') + } catch { + toast.error('清空日志失败') + } finally { + setClearingLogs(false) + } + } + + const mergedStats = useMemo(() => { + const totalMessages = agentInfo?.stats?.totalMessages ?? agentInfo?.totalMessages ?? 0 + const activeConnections = agentInfo?.stats?.activeConnections ?? agentInfo?.activeConnections ?? 0 + const avgResponseTime = agentInfo?.stats?.avgResponseTime ?? agentInfo?.averageResponseTime ?? 0 + return { totalMessages, activeConnections, avgResponseTime } + }, [agentInfo]) + + if (loading) { + return ( +
+
+ + 加载远程控制配置中... +
+
+ ) + } + + return ( +
+
+
+
+ + + 返回主页 + +

+ 远程控制配置 +

+
+ +
+ + {/* PLATFORM:DINGTALK:CARD:START */} + setConfig(prev => ({ ...prev, dingtalk: { ...prev.dingtalk, enabled } }))} + fields={[ + { + label: 'Client ID', + type: 'text', + value: config.dingtalk.clientId, + onChange: value => setConfig(prev => ({ ...prev, dingtalk: { ...prev.dingtalk, clientId: value } })), + placeholder: '请输入钉钉 Client ID', + }, + { + label: 'Client Secret', + type: showSecrets.dingtalkSecret ? 'text' : 'password', + value: config.dingtalk.clientSecret, + onChange: value => setConfig(prev => ({ ...prev, dingtalk: { ...prev.dingtalk, clientSecret: value } })), + placeholder: '请输入钉钉 Client Secret', + secretVisible: showSecrets.dingtalkSecret, + onToggleSecret: () => setShowSecrets(prev => ({ ...prev, dingtalkSecret: !prev.dingtalkSecret })), + }, + ]} + onTest={() => void testConnection('dingtalk')} + testing={testing.dingtalk} + docUrl="https://open.dingtalk.com/document/robots/custom-robot-access" + /> + {/* PLATFORM:DINGTALK:CARD:END */} + + {/* PLATFORM:DISCORD:CARD:START */} + setConfig(prev => ({ ...prev, discord: { ...prev.discord, enabled } }))} + fields={[ + { + label: 'Bot Token', + type: showSecrets.discordToken ? 'text' : 'password', + value: config.discord.botToken, + onChange: value => setConfig(prev => ({ ...prev, discord: { ...prev.discord, botToken: value } })), + placeholder: '请输入 Discord Bot Token', + secretVisible: showSecrets.discordToken, + onToggleSecret: () => setShowSecrets(prev => ({ ...prev, discordToken: !prev.discordToken })), + }, + ]} + onTest={() => void testConnection('discord')} + testing={testing.discord} + docUrl="https://docs.discord.com/developers/topics/oauth2#bots" + /> + {/* PLATFORM:DISCORD:CARD:END */} + + {/* PLATFORM:LARK:CARD:START */} + setConfig(prev => ({ ...prev, lark: { ...prev.lark, enabled } }))} + fields={[ + { + label: 'App ID', + type: 'text', + value: config.lark.appId, + onChange: value => setConfig(prev => ({ ...prev, lark: { ...prev.lark, appId: value } })), + placeholder: '请输入飞书 App ID', + }, + { + label: 'App Secret', + type: showSecrets.larkSecret ? 'text' : 'password', + value: config.lark.appSecret, + onChange: value => setConfig(prev => ({ ...prev, lark: { ...prev.lark, appSecret: value } })), + placeholder: '请输入飞书 App Secret', + secretVisible: showSecrets.larkSecret, + onToggleSecret: () => setShowSecrets(prev => ({ ...prev, larkSecret: !prev.larkSecret })), + }, + ]} + onTest={() => void testConnection('lark')} + testing={testing.lark} + docUrl="https://open.feishu.cn/document/home/introduction-to-custom-app-development/self-built-application-development-process" + /> + {/* PLATFORM:LARK:CARD:END */} + + {/* PLATFORM:TELEGRAM:CARD:START */} + setConfig(prev => ({ ...prev, telegram: { ...prev.telegram, enabled } }))} + fields={[ + { + label: 'Bot Token', + type: showSecrets.telegramToken ? 'text' : 'password', + value: config.telegram.botToken, + onChange: value => setConfig(prev => ({ ...prev, telegram: { ...prev.telegram, botToken: value } })), + placeholder: '请输入 Telegram Bot Token (通过 @BotFather 获取)', + secretVisible: showSecrets.telegramToken, + onToggleSecret: () => setShowSecrets(prev => ({ ...prev, telegramToken: !prev.telegramToken })), + }, + ]} + onTest={() => void testConnection('telegram')} + testing={testing.telegram} + docUrl="https://core.telegram.org/bots#how-do-i-create-a-bot" + /> + {/* PLATFORM:TELEGRAM:CARD:END */} + + {/* PLATFORM:SLACK:CARD:START */} + setConfig(prev => ({ ...prev, slack: { ...prev.slack, enabled } }))} + fields={[ + { + label: 'Bot Token (xoxb-)', + type: showSecrets.slackBotToken ? 'text' : 'password', + value: config.slack.botToken, + onChange: value => setConfig(prev => ({ ...prev, slack: { ...prev.slack, botToken: value } })), + placeholder: '请输入 Slack Bot Token (xoxb-...)', + secretVisible: showSecrets.slackBotToken, + onToggleSecret: () => setShowSecrets(prev => ({ ...prev, slackBotToken: !prev.slackBotToken })), + }, + { + label: 'App-Level Token (xapp-)', + type: showSecrets.slackAppToken ? 'text' : 'password', + value: config.slack.appToken, + onChange: value => setConfig(prev => ({ ...prev, slack: { ...prev.slack, appToken: value } })), + placeholder: '请输入 Slack App Token (xapp-...)', + secretVisible: showSecrets.slackAppToken, + onToggleSecret: () => setShowSecrets(prev => ({ ...prev, slackAppToken: !prev.slackAppToken })), + }, + ]} + onTest={() => void testConnection('slack')} + testing={testing.slack} + docUrl="https://api.slack.com/start/quickstart" + /> + {/* PLATFORM:SLACK:CARD:END */} + +
+
+

Bot 事件日志(最近 50 条)

+
+ + +
+
+ +
+ {logs.length === 0 ? ( +
暂无日志数据
+ ) : ( +
+ {logs.map((log, index) => ( +
+ {formatDate(log.timestamp)} + {log.platform} + {log.eventType} + + + + {formatDetails(log)} +
+ ))} +
+ )} +
+
+ +
+

Nova Agent 信息(只读)

+
+
+
Agent ID: {agentInfo?.agentId || '-'}
+
Base URL: {agentInfo?.baseUrl || '-'}
+
+
+
活跃连接数: {mergedStats.activeConnections}
+
消息总数: {mergedStats.totalMessages}
+
平均响应时间: {mergedStats.avgResponseTime} ms
+
+
+
+ +
+ +
+
+
+ ) +} + +interface PlatformField { + label: string + type: 'text' | 'password' + value: string + placeholder: string + onChange: (value: string) => void + secretVisible?: boolean + onToggleSecret?: () => void +} + +interface PlatformCardProps { + title: string + enabled: boolean + onEnabledChange: (enabled: boolean) => void + status: PlatformStatus + fields: PlatformField[] + onTest: () => void + testing: boolean + docUrl: string +} + +function PlatformCard({ title, enabled, onEnabledChange, status, fields, onTest, testing, docUrl }: PlatformCardProps) { + const meta = statusMeta(status.status) + + return ( +
+
+
+

{title}

+ + + +
+
+ +
+ + {meta.label} +
+
+
+ +
+ {fields.map(field => ( + + ))} +
+ +
+
+ 最后连接时间: {formatDate(status.lastConnectedAt)} + 运行时长: {formatDuration(status.uptime)} + 已处理消息数: {status.messagesProcessed ?? 0} + 活跃会话数: {status.activeSessions ?? 0} +
+ +
+ {status.error && ( +
+ + {status.error} +
+ )} + {!status.error && status.status === 'connected' && ( +
+ + 连接状态正常 +
+ )} +
+ ) +} + +function SeverityTag({ severity }: { severity: LogEntry['severity'] }) { + if (severity === 'error') { + return error + } + if (severity === 'warning') { + return warning + } + return info +} diff --git a/app/share/page.tsx b/app/share/page.tsx new file mode 100644 index 0000000..3355ba8 --- /dev/null +++ b/app/share/page.tsx @@ -0,0 +1,32 @@ +'use client' + +import { NovaChat } from '@/components/nova-sdk/nova-chat' +import { NovaState, useBuildConversationConnect } from '@/components/nova-sdk/hooks/useBuildConversationConnect' +import { Loader2 } from 'lucide-react' + +export default function NovaChatPage() { + const { chatEnabled, agentId, conversationId, platformConfig } = useBuildConversationConnect() + + if (chatEnabled === NovaState.Failed) { + return ( +
+

Chat 不可用,请检查项目 .env 配置

+
+ ) + } + + if (!agentId || !conversationId || !platformConfig) { + return ( +
+ +

正在连接中...

+
+ ) + } + + return ( +
+ +
+ ) +} diff --git a/components/AgentationGuard.tsx b/components/AgentationGuard.tsx new file mode 100644 index 0000000..1fed7cb --- /dev/null +++ b/components/AgentationGuard.tsx @@ -0,0 +1,12 @@ +'use client' + +import { Agentation } from 'agentation' + +export function AgentationGuard() { + if (process.env.NODE_ENV !== 'development') { + return null + } + + return +} + diff --git a/components/base/color-picker/index.tsx b/components/base/color-picker/index.tsx new file mode 100644 index 0000000..2aba677 --- /dev/null +++ b/components/base/color-picker/index.tsx @@ -0,0 +1,87 @@ +'use client' + +import * as React from 'react' +import { HexColorPicker } from 'react-colorful' +import { cn } from '@/utils/cn' +import { Input } from '@/components/ui/input' + +interface ColorPickerProps { + color: string + onChange: (color: string) => void + presets?: string[] + className?: string +} + +const defaultPresets = [ + '#171412', + '#2E241C', + '#8A6742', + '#B79267', + '#D0B08C', + '#E8DFD1', + '#FFFDF8', + '#8F7CFF', + '#45D4FF', + '#FF78B8', + '#48D7C2', + '#FFB86B', +] + +export function ColorPicker({ + color, + onChange, + presets = defaultPresets, + className, +}: ColorPickerProps) { + const [inputValue, setInputValue] = React.useState(color) + + React.useEffect(() => { + setInputValue(color) + }, [color]) + + const handleInputChange = (e: React.ChangeEvent) => { + const val = e.target.value + setInputValue(val) + if (/^#[0-9A-F]{3,6}$/i.test(val)) { + onChange(val) + } + } + + return ( +
+ +
+ + +
+ {presets.length > 0 && ( +
+ {presets.map((p) => ( +
+ )} +
+ ) +} diff --git a/components/html-editor/README.md b/components/html-editor/README.md new file mode 100644 index 0000000..2b6e62d --- /dev/null +++ b/components/html-editor/README.md @@ -0,0 +1,292 @@ +# TaskArtifactHtml + +HTML 产物渲染与编辑组件,支持 **文档模式(document)** 和 **网页模式(web)** 两种渲染方式。内容通过 iframe 沙箱加载,编辑态下提供所见即所得的工具栏。 + +--- + +## 快速开始 + +```tsx +import { TaskArtifactHtml } from "@/components/nova-sdk/html-editor"; + + { + // state.canUndo / state.canRedo / state.undo() / state.redo() + console.log(state); + }} +/>; +``` + +> **前置依赖**:组件内部通过 `useNovaKit()` 获取 API 实例来加载远程内容,因此使用前需确保外层已包裹 ``。 + +--- + +## Props + +| 属性 | 类型 | 必填 | 说明 | +| --------------- | ------------------------------------ | ---- | ----------------------------------------------------------------- | +| `taskId` | `string` | ✅ | 任务 ID,用于关联产物与任务,编辑时也用于保存接口 | +| `editable` | `boolean` | ❌ | 是否开启编辑模式。`false` 为只读预览,`true` 显示编辑工具栏 | +| `type` | `'document' \| 'web'` | ✅ | 渲染模式。`document` 适合富文本文档编辑,`web` 适合网页类产物编辑 | +| `taskArtifact` | `TaskArtifact` | ✅ | 产物数据对象,包含文件路径等信息 | +| `onStateChange` | `(state: ArtifactEditState) => void` | ❌ | 编辑状态变化回调,每次 undo/redo 历史变化时触发 | + +### TaskArtifact 类型定义 + +```ts +interface TaskArtifact { + path: string; // 产物文件路径 + file_name: string; // 文件名 + file_type: string; // 文件 MIME 类型 + last_modified?: number; // 最后修改时间戳 + url?: string; // 可选的直接访问 URL + content?: string; // 可选的内联内容 + task_id?: string; // 关联的任务 ID + event_type?: string; // 工具调用事件类型 + tool_name?: string; // 工具名称 + tool_input?: unknown; // 工具输入 + tool_output?: unknown; // 工具输出 +} +``` + +### ArtifactEditState 类型定义 + +当传入 `onStateChange` 后,编辑器内部的历史记录发生变化时会通过该回调通知父组件。父组件可据此实现外部的撤销/重做按钮。 + +```ts +interface ArtifactEditState { + canUndo: boolean; // 是否可以撤销 + canRedo: boolean; // 是否可以重做 + undo: () => void; // 执行撤销 + redo: () => void; // 执行重做 +} +``` + +--- + +## 两种模式对比 + +| 特性 | `document` 模式 | `web` 模式 | +| -------- | ------------------------------------------ | ------------------------------------------------------ | +| 适用场景 | 富文本文档(文章、报告等) | 网页类产物(落地页、网站等) | +| 编辑方式 | 全局 `contentEditable`,支持文本选区工具栏 | 元素级选取与属性编辑 | +| 工具栏 | `toolbar-doc` — 基于文本选区定位 | `toolbar-web` — 基于选中元素定位,更丰富的样式编辑能力 | +| 内部组件 | `TaskHtmlDoc` | `TaskHtmlWeb` | + +--- + +## 使用示例 + +### 只读预览 + +```tsx +// 简单预览,不显示任何编辑工具栏 + +``` + +### 文档编辑模式 + +```tsx +// 开启编辑,用户可以直接在文档中选中文本进行格式编辑 + +``` + +### 网页编辑模式 + +```tsx +// 开启编辑,用户可以点选网页中的元素进行样式调整 + +``` + +### 配合外部撤销/重做按钮 + +```tsx +const [editState, setEditState] = useState(null); + +<> +
+ + +
+ + +; +``` + +--- + +## 内部架构 + +``` +TaskArtifactHtml (入口) +├── type="document" → TaskHtmlDoc +│ ├── editable=false → Html (只读 iframe) +│ └── editable=true +│ └── PPTEditProvider (编辑状态上下文) +│ ├── PPTEditToolBar (toolbar-doc, 文本选区工具栏) +│ └── HtmlWithEditMode (基础编辑层) +│ ├── useIframeMode → HTMLEditor 实例 +│ ├── useDiff → 同步编辑状态到 Context +│ └── Html (iframe 渲染) +│ +└── type="web" → TaskHtmlWeb + ├── editable=false → Html (只读 iframe) + └── editable=true + └── PPTEditProvider + ├── PPTEditToolBar (toolbar-web, 元素工具栏) + └── HtmlWithEditMode (isDoc=false) +``` + +### 核心模块说明 + +#### `Html`(`components/html-render/task-html.tsx`) + +底层 iframe 渲染组件,接受 HTML 字符串作为 `srcDoc` 注入 iframe。配置了 `sandbox="allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox"`,并自动拦截 iframe 内的 `_blank` 链接和 `window.open` 调用,转发至父窗口打开。 + +#### `HtmlWithEditMode`(`mode/baseEdit.tsx`) + +编辑模式的基础封装层,负责: + +- 调用 `useIframeMode` 初始化 `HTMLEditor` 编辑器实例 +- 调用 `useDiff` 将编辑器状态同步到 `PPTEditContext` +- 监听历史记录变化,通过 `onStateChange` 向上传递 `ArtifactEditState`(canUndo/canRedo/undo/redo) +- 内容变更时自动防抖保存(1 秒),通过 `saveMarkdown` 接口持久化到服务端 + +#### `PPTEditProvider`(`context/index.tsx`) + +编辑状态上下文 Provider,维护: + +- `state` — 当前 `useIframeMode` 的返回值(编辑器实例、选中元素、位置等) +- `setState` — 由 `useDiff` hook 调用,将编辑状态同步至上下文 +- `originalSlide` — 原始幻灯片数据的引用(PPT 场景) + +工具栏组件通过 `usePPTEditContext()` 消费该上下文,获取编辑器实例和选中元素信息来渲染对应的编辑工具。 + +--- + +## Hooks + +### `useLoadContent(taskArtifact: TaskArtifact): string` + +通过 `useNovaKit()` 提供的 API 加载产物的远程 HTML 内容。先调用 `api.getArtifactUrl()` 获取签名 URL,再 `fetch` 获取文本内容。 + +### `useIframeMode(id, containerRef, options?, scale?): UseInjectModeReturn` + +核心编辑 hook,负责: + +- 创建 `HTMLEditor` 实例并注入到 iframe 或普通 DOM 容器 +- 管理元素选中状态和位置计算 +- 管理 undo/redo 历史记录 +- 绑定 `Cmd+Z` / `Cmd+Shift+Z` 快捷键 + +返回值: + +```ts +interface UseInjectModeReturn { + editor: HTMLEditor | null; // 编辑器实例(ref 值) + editorIns: HTMLEditor | null; // 编辑器实例(state 值,触发重渲染) + selectedElement: HTMLElement | null; // 当前选中的 DOM 元素 + position: Position | null; // 选中元素在 iframe 内的位置 + tipPosition: Position | null; // 经过缩放换算后用于定位工具栏的位置 + injectScript: (target: HTMLElement) => Promise; // 手动注入编辑器 + canUndo: boolean; + canRedo: boolean; + clearHistory: () => void; + loadSuccess: boolean; // iframe 内容是否加载/注入成功 +} +``` + +### `useDiff(useIframeReturn, isDoc?)` + +区分 document / web 两种模式的差异逻辑,将 `useIframeMode` 的状态同步到 `PPTEditContext`: + +- **document 模式**:使用 `useToolPosition` 基于文本选区末尾计算工具栏位置 +- **web 模式**:基于选中元素的 `getBoundingClientRect` 定位工具栏;失焦后清除状态 + +### `useToolPosition(editor, isDoc): Position | null` + +仅在 document 模式下生效。监听编辑器内的 `mousedown` / `mouseup` / `scroll` / `resize` 事件,根据当前文本选区(`Selection`)末尾位置计算工具栏坐标。 + +--- + +## 服务端接口 + +### `saveMarkdown`(`server/index.ts`) + +编辑模式下内容变更会自动防抖(1 秒)调用此函数保存到服务端: + +```ts +POST / v1 / super_agent / chat / write_file; +Body: { + task_id: string; + path: string; + content: string; +} +``` + +--- + +## 目录结构 + +``` +html-editor/ +├── index.tsx # 入口组件 TaskArtifactHtml +├── README.md # 本文档 +├── types/ +│ └── index.ts # ArtifactEditState 等类型定义 +├── server/ +│ └── index.ts # saveMarkdown 保存接口 +├── mode/ +│ ├── baseEdit.tsx # 编辑模式基础层 HtmlWithEditMode +│ ├── html-doc.tsx # 文档模式 TaskHtmlDoc +│ └── html-web.tsx # 网页模式 TaskHtmlWeb +├── context/ +│ └── index.tsx # PPTEditProvider / usePPTEditContext +├── hooks/ +│ ├── useDiff.ts # doc/web 差异逻辑 +│ ├── useIframeMode.ts # 核心编辑器 hook +│ ├── useLoadContent.ts # 远程内容加载 +│ └── useToolPostion.ts # 文本选区工具栏定位 +├── components/ +│ ├── html-render/ # iframe 渲染组件 +│ ├── toolbar-doc/ # 文档模式工具栏 +│ └── toolbar-web/ # 网页模式工具栏 +├── lib/ # HTMLEditor 核心库 +│ ├── core/ # 编辑器引擎、历史记录管理器 +│ ├── types/ # 内部类型定义 +│ └── config/ # 编辑器配置 +└── assets/ # 图标等静态资源 +``` diff --git a/components/html-editor/assets/images/align-center.svg b/components/html-editor/assets/images/align-center.svg new file mode 100644 index 0000000..d6a6346 --- /dev/null +++ b/components/html-editor/assets/images/align-center.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/html-editor/assets/images/align-left.svg b/components/html-editor/assets/images/align-left.svg new file mode 100644 index 0000000..5ba453c --- /dev/null +++ b/components/html-editor/assets/images/align-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/html-editor/assets/images/align-right.svg b/components/html-editor/assets/images/align-right.svg new file mode 100644 index 0000000..84ce842 --- /dev/null +++ b/components/html-editor/assets/images/align-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/html-editor/assets/images/blod.svg b/components/html-editor/assets/images/blod.svg new file mode 100644 index 0000000..7625e13 --- /dev/null +++ b/components/html-editor/assets/images/blod.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/html-editor/assets/images/delete.svg b/components/html-editor/assets/images/delete.svg new file mode 100644 index 0000000..b511e5e --- /dev/null +++ b/components/html-editor/assets/images/delete.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/components/html-editor/assets/images/dropdown.svg b/components/html-editor/assets/images/dropdown.svg new file mode 100644 index 0000000..b97253a --- /dev/null +++ b/components/html-editor/assets/images/dropdown.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/html-editor/assets/images/duplicate.svg b/components/html-editor/assets/images/duplicate.svg new file mode 100644 index 0000000..6fedc47 --- /dev/null +++ b/components/html-editor/assets/images/duplicate.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/html-editor/assets/images/image-replace.svg b/components/html-editor/assets/images/image-replace.svg new file mode 100644 index 0000000..2dc0a69 --- /dev/null +++ b/components/html-editor/assets/images/image-replace.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/html-editor/assets/images/italic.svg b/components/html-editor/assets/images/italic.svg new file mode 100644 index 0000000..a521b86 --- /dev/null +++ b/components/html-editor/assets/images/italic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/html-editor/assets/images/underline.svg b/components/html-editor/assets/images/underline.svg new file mode 100644 index 0000000..269cc78 --- /dev/null +++ b/components/html-editor/assets/images/underline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/html-editor/components/html-render/task-html.tsx b/components/html-editor/components/html-render/task-html.tsx new file mode 100644 index 0000000..93fc576 --- /dev/null +++ b/components/html-editor/components/html-render/task-html.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { cn } from '@/utils/cn'; + +interface HtmlProps { + className?: string; + content?: string; +} + +// 注入脚本,拦截 iframe 中的 _blank 链接和 window.open +function injectInterceptScript(iframeWindow: Window) { + try { + // 检查是否可以访问 iframe 的 document(避免跨域错误) + const iframeDocument = iframeWindow.document; + if (!iframeDocument) { + return; + } + + // 保存父窗口的 window.open 引用 + const parentWindowOpen = window.open.bind(window); + + // 重写 iframe 内的 window.open + iframeWindow.open = function (url, target, features) { + return parentWindowOpen(url, target, features); + }; + + // 拦截所有 标签的点击事件 + iframeDocument.addEventListener( + 'click', + (e: MouseEvent) => { + const target = e.target as HTMLElement; + const anchor = target.closest('a'); + + if (anchor && anchor.target === '_blank') { + e.preventDefault(); + const href = anchor.href; + if (href) { + parentWindowOpen(href, '_blank'); + } + } + }, + true, + ); + + // 监听动态添加的元素 + const observer = new MutationObserver(() => { + // 重新绑定 window.open (防止被覆盖) + if (iframeWindow.open !== parentWindowOpen) { + iframeWindow.open = function (url, target, features) { + return parentWindowOpen(url, target, features); + }; + } + }); + + observer.observe(iframeDocument.documentElement, { + childList: true, + subtree: true, + }); + } catch { + // 跨域或沙箱限制,无法注入脚本,静默失败 + // 这种情况下需要依赖 sandbox 属性中的 allow-popups-to-escape-sandbox + } +} + +export const Html = React.forwardRef( + ( + { className, content }: HtmlProps, + _ref: React.ForwardedRef, + ) => { + // 当 iframe 加载完成后注入拦截脚本 + const handleIframeLoad = React.useCallback( + (e: React.SyntheticEvent) => { + const iframe = e.currentTarget; + if (iframe?.contentWindow) { + injectInterceptScript(iframe.contentWindow); + } + }, + [], + ); + + // if (content) { + // // 在 content 中添加 base 标签,确保所有链接在 iframe 内部打开 + // const contentWithBase = content.includes("") + // ? content.replace("", '') + // : content; + + // return ( + //