diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 8ea19608..39ac0c21 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -64,7 +64,7 @@ jobs: RETRY_DELAY=5 ATTEMPT=0 SUCCESS=false - + while [ $ATTEMPT -lt $MAX_RETRIES ] && [ "$SUCCESS" = "false" ]; do ATTEMPT=$((ATTEMPT+1)) echo "Attempt $ATTEMPT of $MAX_RETRIES" @@ -144,9 +144,8 @@ jobs: exit 1 fi env: - GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} - COMMIT_MESSAGES_RAW: ${{ steps.commit_messages.outputs.commits }} - + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + COMMIT_MESSAGES_RAW: ${{ steps.commit_messages.outputs.commits }} - name: Update CHANGELOG.md # Only run if AI generated text @@ -194,7 +193,6 @@ jobs: echo "CHANGELOG.md updated." - - name: Commit and Push CHANGELOG.md # Only run if AI generated text if: steps.ai_changelog.outputs.changelog_entry != '' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3acc67d2..d60bc9bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,9 +15,9 @@ jobs: - name: Use Node.js uses: actions/setup-node@v3 with: - node-version: "18.x" - registry-url: "https://npm.pkg.github.com" - scope: "@nutstore" + node-version: '18.x' + registry-url: 'https://npm.pkg.github.com' + scope: '@nutstore' - name: Install pnpm uses: pnpm/action-setup@v2 diff --git a/.github/workflows/update-changelog-version.yml b/.github/workflows/update-changelog-version.yml index e07071e5..d957230d 100644 --- a/.github/workflows/update-changelog-version.yml +++ b/.github/workflows/update-changelog-version.yml @@ -14,7 +14,7 @@ jobs: - name: Checkout main branch uses: actions/checkout@v4 with: - ref: main # Explicitly checkout main branch + ref: main # Explicitly checkout main branch fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} @@ -31,31 +31,31 @@ jobs: echo "After removing refs/tags/: $VERSION" VERSION=${VERSION#v} echo "After removing v prefix: $VERSION" - + echo "Current content before replacement:" cat CHANGELOG.md - + # Only replace "Unreleased" in the heading, not in other places sed -i "s/## \[Unreleased\]/## [$VERSION]/" CHANGELOG.md - + echo "Content after replacement:" cat CHANGELOG.md - + # Check if there are changes if git diff --quiet CHANGELOG.md; then echo "No changes were made to CHANGELOG.md" exit 1 fi - + # Commit and push changes git add CHANGELOG.md git commit -m "docs: update changelog version to $VERSION [skip ci]" git push origin HEAD:main - + # Update release description with CHANGELOG link CHANGELOG_URL="https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md#$VERSION" RELEASE_ID=$(jq --raw-output .release.id "$GITHUB_EVENT_PATH") - + # Add CHANGELOG link to release description curl \ -X PATCH \ diff --git a/README.md b/README.md index 3dfba729..76c707f0 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,71 @@ # 🔄 Nutstore Sync -## Introduction | 简介 - -This plugin enables two-way synchronization between Obsidian notes and Nutstore via WebDAV protocol. +## 简介 | Introduction 此插件允许您通过 WebDAV 协议将 Obsidian 笔记与坚果云进行双向同步。 +_This plugin enables two-way synchronization between Obsidian notes and Nutstore via WebDAV protocol._ --- -## ✨ Key Features | 主要特性 - -- 🔄 **Two-way Sync**: Efficiently synchronize your notes across devices -- ⚡ **Incremental Sync**: Fast updates that only transfer changed files, making large vaults sync quickly -- 🔐 **Single Sign-On**: Connect to Nutstore with simple authorization instead of manually entering WebDAV credentials -- 📁 **WebDAV Explorer**: Visual file browser for remote file management -- 🔀 **Smart Conflict Resolution**: - - Character-level comparison to automatically merge changes when possible - - Option to use timestamp-based resolution (newest file wins) -- 🚀 **Loose Sync Mode**: Optimize performance for vaults with thousands of notes -- 📦 **Large File Handling**: Set size limits to skip large files for better performance -- 📊 **Sync Status Tracking**: Clear visual indicators of sync progress and completion -- 📝 **Detailed Logging**: Comprehensive logs for troubleshooting - -
- -- 🔄 **双向同步**: 高效地在多设备间同步笔记 -- ⚡ **增量同步**: 只传输更改过的文件,使大型笔记库也能快速同步 -- 🔐 **单点登录**: 通过简单授权连接坚果云,无需手动输入 WebDAV 凭据 -- 📁 **WebDAV 文件浏览器**: 远程文件管理的可视化界面 -- 🔀 **智能冲突解决**: - - 字符级比较自动合并可能的更改 - - 支持基于时间戳的解决方案(最新文件优先) -- 🚀 **宽松同步模式**: 优化对包含数千笔记的仓库的性能 -- 📦 **大文件处理**: 设置大小限制以跳过大文件,提升性能 -- 📊 **同步状态跟踪**: 清晰的同步进度和完成提示 -- 📝 **详细日志**: 全面的故障排查日志 +## ✨ 主要特性 | Key Features + +- 🔄 **双向同步 | Two-way Sync** + 高效地在多设备间同步笔记。 + _Efficiently synchronize your notes across devices._ +- ⚡ **增量同步 | Incremental Sync** + 只传输更改过的文件,使大型笔记库也能快速同步。 + _Fast updates that only transfer changed files, making large vaults sync quickly._ +- 🔐 **单点登录 | Single Sign-On** + 通过简单授权连接坚果云,无需手动输入 WebDAV 凭据。 + _Connect to Nutstore with simple authorization instead of manually entering WebDAV credentials._ +- 📁 **WebDAV 文件浏览器 | WebDAV Explorer** + 远程文件管理的可视化界面。 + _Visual file browser for remote file management._ +- 🔀 **智能冲突解决 | Smart Conflict Resolution** + 字符级比较自动合并可能的更改;支持基于时间戳的解决方案(最新文件优先)。 + _Character-level comparison to automatically merge changes when possible. Option to use timestamp-based resolution (newest file wins)._ +- 🚀 **宽松同步模式 | Loose Sync Mode** + 优化对包含数千笔记的仓库的性能。 + _Optimize performance for vaults with thousands of notes._ +- 📦 **大文件处理 | Large File Handling** + 设置大小限制以跳过大文件,提升性能。 + _Set size limits to skip large files for better performance._ +- 📊 **同步状态跟踪 | Sync Status Tracking** + 清晰的同步进度和完成提示。 + _Clear visual indicators of sync progress and completion._ +- 📝 **详细日志 | Detailed Logging** + 全面的故障排查日志。 + _Comprehensive logs for troubleshooting._ +- 🤖 **AI 智能助手 | AI Agent** + 内置 AI 助手,可通过自然语言读取、编辑和管理 Vault 中的文件。 + _Built-in AI assistant that can read, edit, and manage files in your vault through natural language._ --- -## ⚠️ Important Notes | 注意事项 +## 🤖 AI 智能助手 | AI Agent + +AI 助手是一个内置的智能代理,让你通过自然语言管理 Obsidian Vault。支持任意兼容 OpenAI 接口的服务商,可自主完成复杂的多步骤任务。 +_The AI Agent is a built-in assistant that lets you manage your Obsidian vault through natural language. It supports any OpenAI-compatible provider and can handle complex, multi-step tasks autonomously._ + +Agent 在做出任何更改前都会请求用户确认。你可以逐条批准、按操作类型批准当前会话,或在设置中开启 *YOLO 模式* 全部自动通过。 +_Before the agent makes any changes, it asks for your approval. You can approve individual operations, approve an operation type for the entire session, or enable _YOLO mode_ in settings to auto-approve everything._ -- ⏳ Initial sync may take longer (especially with many files) -- 💾 Please backup before syncing +**配置方法 | Setup:** + +1. 打开插件设置 → **AI** 标签页 + _Open plugin settings → **AI** tab_ +2. 添加 AI 服务商(支持任意兼容 OpenAI 接口的端点)并填写模型名称 + _Add an AI provider (any OpenAI-compatible endpoint) and fill in the model name_ +3. 从左侧边栏打开 AI 对话框,开始对话 + _Open the AI chat panel from the left sidebar and start chatting_ + +--- -
+## ⚠️ 注意事项 | Important Notes -- ⏳ 首次同步可能需要较长时间 (文件比较多时) -- 💾 请在同步之前备份 +- ⏳ **首次同步 | Initial Sync** + 首次同步可能需要较长时间(文件比较多时)。 + _Initial sync may take longer (especially with many files)._ +- 💾 **数据备份 | Backup** + 请在同步之前备份。 + _Please backup before syncing._ diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 9b3d8101..fda3cdb5 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -15,7 +15,10 @@ const renamePlugin = { name: 'rename-plugin', setup(build) { build.onEnd(async () => { - fs.renameSync(prod ? './dist/main.css' : './main.css', './styles.css') + const source = prod ? './dist/main.css' : './main.css' + if (fs.existsSync(source)) { + fs.renameSync(source, './styles.css') + } }) }, } @@ -60,6 +63,9 @@ const context = await esbuild.context({ }), renamePlugin, ], + alias: { + 'node:zlib': './src/shims/node-zlib.ts', + }, }) if (prod) { diff --git a/package.json b/package.json index 5637e30b..39c025c3 100644 --- a/package.json +++ b/package.json @@ -5,14 +5,17 @@ "scripts": { "dev": "run-p dev:*", "dev:plugin": "node esbuild.config.mjs", + "dev:chatbox": "pnpm --filter chatbox dev", "dev:webdav-explorer": "pnpm --filter webdav-explorer dev", - "build": "run-s build:webdav-explorer build:plugin", + "build": "run-s build:webdav-explorer build:chatbox build:plugin", + "build:chatbox": "pnpm --filter chatbox build", "build:webdav-explorer": "pnpm --filter webdav-explorer build", "build:plugin": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production && swc ./dist/main.js -o main.js", "version": "node version-bump.mjs && git add manifest.json versions.json", "test": "vitest" }, "devDependencies": { + "@ai-sdk/openai": "^3.0.48", "@deanc/esbuild-plugin-postcss": "^1.0.2", "@electron/remote": "^2.1.2", "@nutstore/sso-js": "^0.0.8", @@ -28,6 +31,7 @@ "@typescript-eslint/parser": "5.29.0", "@unocss/postcss": "66.1.0-beta.3", "@vitest/coverage-v8": "^3.1.2", + "ai": "^6.0.149", "assert": "^2.1.0", "async-mutex": "^0.5.0", "blob-polyfill": "^9.0.20240710", @@ -35,6 +39,7 @@ "buffer": "^6.0.3", "builtin-modules": "3.3.0", "bytes-iec": "^3.1.1", + "chatbox": "workspace: *", "consola": "^3.4.0", "core-js": "^3.41.0", "crypto-browserify": "^3.12.1", @@ -50,6 +55,7 @@ "http-status-codes": "^2.3.0", "i18next": "^24.2.2", "js-base64": "^3.7.7", + "just-bash": "^2.14.0", "localforage": "^1.10.0", "lodash-es": "^4.17.21", "node-diff3": "^3.1.2", @@ -69,7 +75,8 @@ "uuid": "^13.0.0", "vitest": "^3.1.2", "webdav": "^5.7.1", - "webdav-explorer": "workspace: *" + "webdav-explorer": "workspace: *", + "zod": "^4.3.6" }, "browser": { "path": "path-browserify", diff --git a/packages/chatbox/package.json b/packages/chatbox/package.json new file mode 100644 index 00000000..44104ed3 --- /dev/null +++ b/packages/chatbox/package.json @@ -0,0 +1,33 @@ +{ + "name": "chatbox", + "version": "0.0.0", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "rslib build", + "dev": "rslib build --watch" + }, + "devDependencies": { + "@rsbuild/plugin-babel": "^1.0.4", + "@rsbuild/plugin-solid": "^1.0.5", + "@rslib/core": "^0.5.3", + "@solid-primitives/i18n": "^2.2.0", + "@solid-primitives/media": "^2.3.0", + "@unocss/postcss": "66.1.0-beta.3", + "typescript": "^5.8.2", + "unocss": "66.1.0-beta.3" + }, + "dependencies": { + "solid-js": "^1.9.5" + } +} diff --git a/packages/chatbox/postcss.config.mjs b/packages/chatbox/postcss.config.mjs new file mode 100644 index 00000000..6d0228c7 --- /dev/null +++ b/packages/chatbox/postcss.config.mjs @@ -0,0 +1,5 @@ +import UnoCSS from '@unocss/postcss' + +export default { + plugins: [UnoCSS()], +} diff --git a/packages/chatbox/rslib.config.ts b/packages/chatbox/rslib.config.ts new file mode 100644 index 00000000..7b443f44 --- /dev/null +++ b/packages/chatbox/rslib.config.ts @@ -0,0 +1,32 @@ +import { pluginBabel } from '@rsbuild/plugin-babel' +import { pluginSolid } from '@rsbuild/plugin-solid' +import { defineConfig } from '@rslib/core' + +export default defineConfig({ + source: { + entry: { + index: ['./src/**'], + }, + }, + tools: { + rspack: { + plugins: [], + }, + }, + lib: [ + { + bundle: false, + dts: true, + format: 'esm', + }, + ], + output: { + target: 'web', + }, + plugins: [ + pluginBabel({ + include: /\.(?:jsx|tsx)$/, + }), + pluginSolid(), + ], +}) diff --git a/packages/chatbox/src/App.tsx b/packages/chatbox/src/App.tsx new file mode 100644 index 00000000..9193c49f --- /dev/null +++ b/packages/chatbox/src/App.tsx @@ -0,0 +1,706 @@ +import { + For, + Match, + Show, + Switch, + createEffect, + createSignal, + onCleanup, +} from 'solid-js' +import { ConfirmDialog } from './components/ConfirmDialog' +import { FragmentDivider } from './components/FragmentDivider' +import { MessageCard } from './components/MessageCard' +import { PaneResizer } from './components/PaneResizer' +import { PendingList } from './components/PendingList' +import { RunStateCard } from './components/RunStateCard' +import { SessionHistoryItem } from './components/SessionHistoryItem' +import { TasksPanel } from './components/TasksPanel' +import { t } from './i18n' +import type { + ChatTimelineFragmentItem, + ChatTimelineMessageItem, + ChatboxProps, +} from './types' + +export type AppProps = ChatboxProps + +const DESKTOP_RESIZE_MEDIA_QUERY = '(pointer: fine) and (min-width: 1024px)' +const INPUT_HEIGHT_STORAGE_KEY = 'nutstore-sync.chatbox.desktop-input-height' +const DEFAULT_DESKTOP_INPUT_HEIGHT = 184 +const DESKTOP_INPUT_MIN_HEIGHT = 120 +const DESKTOP_INPUT_ABSOLUTE_MIN_HEIGHT = 72 +const DESKTOP_MESSAGES_MIN_HEIGHT = 200 +const RESIZER_HITBOX_HEIGHT = 10 +const DESKTOP_INPUT_MAX_VIEWPORT_RATIO = 0.6 + +function App(props: AppProps) { + const [input, setInput] = createSignal('') + const [isComposing, setIsComposing] = createSignal(false) + const [historyOpen, setHistoryOpen] = createSignal(false) + const [tasksOpen, setTasksOpen] = createSignal(false) + const [modelPickerOpen, setModelPickerOpen] = createSignal(false) + const [sessionPendingDeleteId, setSessionPendingDeleteId] = + createSignal() + const [pendingDeleteMessage, setPendingDeleteMessage] = + createSignal() + const [pendingRegenerateMessage, setPendingRegenerateMessage] = + createSignal() + const [pendingRecallMessage, setPendingRecallMessage] = + createSignal() + const [desktopResizeEnabled, setDesktopResizeEnabled] = createSignal(false) + const [inputPaneHeight, setInputPaneHeight] = createSignal() + let messagesEl: HTMLDivElement | undefined + let splitLayoutEl: HTMLDivElement | undefined + let inputPaneEl: HTMLDivElement | undefined + let historyEl: HTMLDivElement | undefined + let modelPickerEl: HTMLDivElement | undefined + let previousActiveSessionId = props.activeSessionId + let defaultDesktopInputHeight = DEFAULT_DESKTOP_INPUT_HEIGHT + let dragStartHeight = 0 + + const hasTasks = () => + props.currentSessionTasks.length + props.otherSessionTasks.length > 0 + const runningTaskCount = () => + props.currentSessionTasks.filter((task) => task.status === 'running') + .length + + props.otherSessionTasks.filter((task) => task.status === 'running').length + const isBusy = () => props.runState !== 'idle' + const selectedProvider = () => + props.providers.find((provider) => provider.id === props.selectedProviderId) + const modelPickerLabel = () => { + const provider = selectedProvider() + const selectedModel = provider?.models.find( + (model) => model.id === props.selectedModelId, + ) + return ( + [provider?.name, selectedModel?.name].filter(Boolean).join('/') || + t('noModel') + ) + } + + function readStoredInputPaneHeight() { + try { + const raw = window.localStorage.getItem(INPUT_HEIGHT_STORAGE_KEY) + if (!raw) { + return undefined + } + const value = Number(raw) + return Number.isFinite(value) ? value : undefined + } catch { + return undefined + } + } + + function persistInputPaneHeight(height: number) { + try { + window.localStorage.setItem( + INPUT_HEIGHT_STORAGE_KEY, + String(Math.round(height)), + ) + } catch { + // Ignore storage errors, resize should still work. + } + } + + function getMaxInputPaneHeight() { + const viewportMax = Math.floor( + window.innerHeight * DESKTOP_INPUT_MAX_VIEWPORT_RATIO, + ) + const splitHeight = splitLayoutEl?.getBoundingClientRect().height ?? 0 + if (splitHeight <= 0) { + return Math.max(DESKTOP_INPUT_MIN_HEIGHT, viewportMax) + } + const messagesBound = Math.floor( + splitHeight - DESKTOP_MESSAGES_MIN_HEIGHT - RESIZER_HITBOX_HEIGHT, + ) + const maxHeight = Math.min(messagesBound, viewportMax) + return Math.max(DESKTOP_INPUT_ABSOLUTE_MIN_HEIGHT, maxHeight) + } + + function clampInputPaneHeight(height: number) { + const maxHeight = getMaxInputPaneHeight() + const minHeight = Math.min(DESKTOP_INPUT_MIN_HEIGHT, maxHeight) + return Math.round(Math.min(Math.max(height, minHeight), maxHeight)) + } + + function applyInputPaneHeight(height: number, persist = false) { + const next = clampInputPaneHeight(height) + setInputPaneHeight(next) + if (persist) { + persistInputPaneHeight(next) + } + return next + } + + function resetInputPaneHeight() { + if (!desktopResizeEnabled()) { + return + } + applyInputPaneHeight(defaultDesktopInputHeight, true) + } + + function onInputPaneResizeStart() { + if (!desktopResizeEnabled()) { + return + } + dragStartHeight = + inputPaneHeight() ?? clampInputPaneHeight(defaultDesktopInputHeight) + } + + function onInputPaneResize(deltaY: number) { + if (!desktopResizeEnabled()) { + return + } + applyInputPaneHeight(dragStartHeight + deltaY) + } + + function onInputPaneResizeEnd() { + const height = inputPaneHeight() + if (typeof height === 'number') { + persistInputPaneHeight(height) + } + } + + function scrollMessagesToBottom(behavior: ScrollBehavior = 'smooth') { + requestAnimationFrame(() => { + if (!messagesEl) { + return + } + messagesEl.scrollTo({ + top: messagesEl.scrollHeight, + behavior, + }) + }) + } + + createEffect(() => { + const activeSessionId = props.activeSessionId + props.timeline.length + props.currentSessionTasks.length + props.otherSessionTasks.length + props.pendingMessages.length + props.runState + const behavior = + previousActiveSessionId !== activeSessionId ? 'auto' : 'smooth' + previousActiveSessionId = activeSessionId + scrollMessagesToBottom(behavior) + }) + + createEffect(() => { + if (!hasTasks() && tasksOpen()) { + setTasksOpen(false) + } + }) + + createEffect(() => { + if (!historyOpen() && !modelPickerOpen()) { + return + } + + const onPointerDown = (event: PointerEvent) => { + const target = event.target + if (!(target instanceof Node)) { + return + } + if (historyEl?.contains(target) || modelPickerEl?.contains(target)) { + return + } + setHistoryOpen(false) + setModelPickerOpen(false) + } + + document.addEventListener('pointerdown', onPointerDown) + onCleanup(() => document.removeEventListener('pointerdown', onPointerDown)) + }) + + createEffect(() => { + const mediaQuery = window.matchMedia(DESKTOP_RESIZE_MEDIA_QUERY) + const update = () => setDesktopResizeEnabled(mediaQuery.matches) + update() + mediaQuery.addEventListener('change', update) + onCleanup(() => mediaQuery.removeEventListener('change', update)) + }) + + createEffect(() => { + if (!desktopResizeEnabled() || !inputPaneEl) { + return + } + defaultDesktopInputHeight = + Math.round(inputPaneEl.getBoundingClientRect().height) || + DEFAULT_DESKTOP_INPUT_HEIGHT + const storedHeight = readStoredInputPaneHeight() + applyInputPaneHeight(storedHeight ?? defaultDesktopInputHeight) + }) + + createEffect(() => { + if (desktopResizeEnabled()) { + return + } + setInputPaneHeight(undefined) + }) + + createEffect(() => { + if (!desktopResizeEnabled()) { + return + } + const onResize = () => { + const height = inputPaneHeight() + if (typeof height !== 'number') { + return + } + const clampedHeight = clampInputPaneHeight(height) + if (clampedHeight !== height) { + applyInputPaneHeight(clampedHeight, true) + } + } + window.addEventListener('resize', onResize) + onCleanup(() => window.removeEventListener('resize', onResize)) + }) + + async function submit() { + const text = input().trim() + if (!text || !props.canSend) { + return + } + setInput('') + scrollMessagesToBottom('auto') + await props.onSendMessage(text) + } + + async function confirmDeleteSession() { + const sessionId = sessionPendingDeleteId() + if (!sessionId) { + return + } + setSessionPendingDeleteId(undefined) + await props.onDeleteSession(sessionId) + } + + const requestDeleteMessage = props.onDeleteMessage + ? (messageId: string) => { + const item = props.timeline.find( + (i): i is ChatTimelineMessageItem => + i.kind === 'message' && i.message.id === messageId, + ) + if (!item) return + setPendingDeleteMessage(item) + } + : undefined + + const requestRegenerateMessage = props.onRegenerateMessage + ? (messageId: string) => { + const item = props.timeline.find( + (i): i is ChatTimelineMessageItem => + i.kind === 'message' && i.message.id === messageId, + ) + if (!item) return + setPendingRegenerateMessage(item) + } + : undefined + + const requestRecallMessage = props.onRecallMessage + ? (messageId: string) => { + const item = props.timeline.find( + (i): i is ChatTimelineMessageItem => + i.kind === 'message' && i.message.id === messageId, + ) + if (!item) return + setPendingRecallMessage(item) + } + : undefined + + async function doRecallMessage( + item: ChatTimelineMessageItem, + options?: { restoreFiles?: boolean }, + ) { + const text = (item.message.message.content ?? []) + .filter((p) => p.type === 'text') + .map((p) => (p as { type: 'text'; text: string }).text) + .join('\n') + setInput(text) + await props.onRecallMessage?.(item.message.id, options) + } + + async function confirmRecallMessage() { + const item = pendingRecallMessage() + if (!item) return + setPendingRecallMessage(undefined) + await doRecallMessage(item) + } + + function confirmRegenerateMessage() { + const item = pendingRegenerateMessage() + if (!item) return + setPendingRegenerateMessage(undefined) + props.onRegenerateMessage?.(item.message.id) + } + + function confirmDeleteMessage() { + const item = pendingDeleteMessage() + if (!item) return + setPendingDeleteMessage(undefined) + props.onDeleteMessage?.(item.message.id) + } + + const deleteMessageConfirmText = () => { + const item = pendingDeleteMessage() + if (!item) return '' + switch (item.message.message.role) { + case 'user': + return t('deleteUserMessageConfirm') + case 'tool': + return t('deleteToolMessageConfirm') + default: + return t('deleteAssistantMessageConfirm') + } + } + + const deleteMessageHasReversibleOps = () => + Boolean(pendingDeleteMessage()?.message.reversibleOps?.length) + + const recallHasReversibleOps = () => { + const item = pendingRecallMessage() + if (!item) return false + let seenTarget = false + for (const timelineItem of props.timeline) { + if (timelineItem.kind !== 'message') { + if (seenTarget) { + break + } + continue + } + if (!seenTarget) { + seenTarget = timelineItem.message.id === item.message.id + continue + } + if (timelineItem.message.reversibleOps?.length) { + return true + } + } + return item.message.reversibleOps?.length ? true : false + } + + async function confirmRecallAndRestoreMessage() { + const item = pendingRecallMessage() + if (!item) return + setPendingRecallMessage(undefined) + await doRecallMessage(item, { restoreFiles: true }) + } + + return ( +
+
+ {/* Header */} +
+ +
+ {props.title || t('newChat')} +
+ + + +
+ + +
+
+ {t('provider')} +
+ +
+ {t('model')} +
+ +
+
+
+ +
+
+
+
+
+ {t('history')} +
+
+ +
+
+
+
+ + {(session) => ( + { + props.onSwitchSession(sessionId) + setHistoryOpen(false) + }} + onDelete={(sessionId) => { + setSessionPendingDeleteId(sessionId) + }} + /> + )} + +
+
+
+
+
+ +
+ {/* Messages */} +
+ 0 || + props.pendingMessages.length > 0 || + isBusy() + } + fallback={ +
+ {t('empty')} +
+ } + > +
+ + {(item) => ( + + + + + + + + + )} + + + +
+
+
+ + + + + + {/* Input */} +
+