From 06cf1cdc2e6f281cb6a83056a27b3df60aec2cce Mon Sep 17 00:00:00 2001 From: miaobuao Date: Wed, 3 Jun 2026 10:17:41 +0800 Subject: [PATCH 01/24] chore: tooling and build config updates --- .github/workflows/ci.yml | 6 + .github/workflows/release.yml | 15 +- .gitignore | 1 + .vscode/settings.json | 9 +- esbuild.config.mjs | 46 +++++- eslint.config.mts | 118 +++++++++++++++ lefthook.yaml | 13 ++ src/assets/styles/global.css | 276 ++++++++++++++++++++++++++++++++++ tsconfig.json | 34 +++-- uno.config.ts | 33 +++- 10 files changed, 522 insertions(+), 29 deletions(-) create mode 100644 eslint.config.mts create mode 100644 lefthook.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d60bc9bd..c37d6446 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,8 @@ jobs: packages: read steps: - uses: actions/checkout@v3 + with: + lfs: true - name: Use Node.js uses: actions/setup-node@v3 @@ -29,6 +31,10 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: pnpm install + - name: Update models API catalog + run: pnpm run update:models-api + continue-on-error: true + - name: Build plugin env: NS_NSDAV_ENDPOINT: ${{ vars.NS_NSDAV_ENDPOINT }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8297d45a..76f4a410 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,8 @@ jobs: packages: read steps: - uses: actions/checkout@v3 + with: + lfs: true - name: Use Node.js uses: actions/setup-node@v3 @@ -27,6 +29,15 @@ jobs: with: version: latest + - name: Install dependencies + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: pnpm install + + - name: Update models API catalog + run: pnpm run update:models-api + continue-on-error: true + - name: Build plugin env: NS_NSDAV_ENDPOINT: ${{ vars.NS_NSDAV_ENDPOINT }} @@ -34,9 +45,7 @@ jobs: NUTSTORE_PAT: ${{ secrets.GITHUB_TOKEN }} NODE_ENV: production NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - pnpm install - pnpm run build + run: pnpm run build - name: Package plugin run: | diff --git a/.gitignore b/.gitignore index 0b83ae16..b4d9988b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ node_modules # obsidian data.json +data.local.json # Exclude macOS Finder (System Explorer) View States .DS_Store diff --git a/.vscode/settings.json b/.vscode/settings.json index 6b1a3baa..1dbe43e4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -32,10 +32,7 @@ "editor.defaultFormatter": "esbenp.prettier-vscode" }, "cSpell.words": ["fflate", "jedec", "Mkdirs", "Nutstore", "webdav"], - "i18n-ally.localesPaths": [ - "src/i18n", - "src/i18n/locales", - "packages/webdav-explorer/src/i18n", - "packages/webdav-explorer/src/i18n/locales" - ] + "i18n-ally.localesPaths": ["src/i18n/locales"], + "i18n-ally.displayLanguage": "zh", + "i18n-ally.keystyle": "nested" } diff --git a/esbuild.config.mjs b/esbuild.config.mjs index fda3cdb5..c3b4fee4 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -1,16 +1,50 @@ -import postcss from '@deanc/esbuild-plugin-postcss' import UnoCSS from '@unocss/postcss' import dotenv from 'dotenv' import esbuild from 'esbuild' import fs, { readFileSync } from 'fs' +import path from 'path' +import postcss from 'postcss' import postcssMergeRules from 'postcss-merge-rules' import process from 'process' +import solid from 'unplugin-solid/esbuild' const pkgJson = JSON.parse(readFileSync('./package.json', 'utf-8')) dotenv.config() const prod = process.argv[2] === 'production' +const postcssPlugin = { + name: 'postcss', + setup(build) { + build.onResolve({ filter: /\.css$/ }, (args) => ({ + path: path.resolve(args.resolveDir, args.path), + namespace: 'postcss', + pluginData: { + resolveDir: args.resolveDir || process.cwd(), + importer: args.importer, + }, + })) + + build.onLoad({ filter: /\.css$/, namespace: 'postcss' }, async (args) => { + const resolvedPath = args.path + const css = await fs.promises.readFile(resolvedPath, 'utf8') + const result = await postcss([UnoCSS(), postcssMergeRules()]).process( + css, + { from: resolvedPath }, + ) + const watchFiles = result.messages + .filter((m) => m.type === 'dependency') + .map((m) => m.file) + return { + contents: result.css, + loader: 'css', + watchFiles: [resolvedPath, ...watchFiles], + resolveDir: args.pluginData?.resolveDir, + } + }) + }, +} + const renamePlugin = { name: 'rename-plugin', setup(build) { @@ -46,6 +80,9 @@ const context = await esbuild.context({ process.env.NS_NSDAV_ENDPOINT, ), 'process.env.NS_DAV_ENDPOINT': JSON.stringify(process.env.NS_DAV_ENDPOINT), + 'process.env.LLM_GATEWAY_CLIENT_ID': JSON.stringify( + process.env.LLM_GATEWAY_CLIENT_ID || '', + ), 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || ''), 'process.env.PLUGIN_VERSION': JSON.stringify(pkgJson.version), }, @@ -57,12 +94,7 @@ const context = await esbuild.context({ outfile: prod ? 'dist/main.js' : 'main.js', minify: prod, platform: 'browser', - plugins: [ - postcss({ - plugins: [UnoCSS(), postcssMergeRules()], - }), - renamePlugin, - ], + plugins: [postcssPlugin, solid(), renamePlugin], alias: { 'node:zlib': './src/shims/node-zlib.ts', }, diff --git a/eslint.config.mts b/eslint.config.mts new file mode 100644 index 00000000..7de79f23 --- /dev/null +++ b/eslint.config.mts @@ -0,0 +1,118 @@ +import css from '@eslint/css' +import js from '@eslint/js' +import json from '@eslint/json' +import markdown from '@eslint/markdown' +import * as tsParser from '@typescript-eslint/parser' +import solid from 'eslint-plugin-solid/configs/typescript' +import { defineConfig } from 'eslint/config' +import globals from 'globals' +import tseslint from 'typescript-eslint' + +export default defineConfig([ + { + ignores: [ + 'node_modules/**', + 'dist/**', + 'main.js', + 'coverage/**', + '.codegraph/**', + ], + }, + { + files: ['**/*.{js,mjs,cjs,ts,mts,cts}'], + plugins: { js }, + extends: ['js/recommended'], + languageOptions: { globals: globals.browser }, + }, + { + files: ['scripts/**/*.cjs', '*.mjs'], + languageOptions: { globals: globals.node }, + rules: { + '@typescript-eslint/no-require-imports': 'off', + }, + }, + // @ts-ignore + { + files: ['**/*.{ts,tsx}'], + ...solid, + languageOptions: { + parser: tsParser, + parserOptions: { + project: 'tsconfig.json', + }, + }, + }, + tseslint.configs.recommended, + { + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/no-explicit-any': 'off', + }, + }, + { + files: ['**/*.test.{ts,tsx}', 'test/**/*.ts'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + }, + }, + { + files: ['**/*.json'], + ignores: ['tsconfig*.json'], + plugins: { json }, + language: 'json/json', + extends: ['json/recommended'], + }, + { + files: ['tsconfig*.json'], + plugins: { json }, + language: 'json/jsonc', + extends: ['json/recommended'], + }, + { + files: ['**/*.jsonc'], + plugins: { json }, + language: 'json/jsonc', + extends: ['json/recommended'], + }, + { + files: ['**/*.json5'], + plugins: { json }, + language: 'json/json5', + extends: ['json/recommended'], + }, + { + files: ['**/*.md'], + plugins: { markdown }, + language: 'markdown/gfm', + extends: ['markdown/recommended'], + rules: { + 'markdown/no-missing-label-refs': 'off', + }, + }, + { + files: ['**/*.css'], + plugins: { css }, + language: 'css/css', + extends: ['css/recommended'], + rules: { + 'css/no-invalid-at-rules': 'off', + 'css/no-invalid-properties': 'off', + 'css/no-important': 'off', + 'css/use-baseline': 'off', + }, + }, + { + files: ['scripts/**/*.cjs'], + rules: { + '@typescript-eslint/no-require-imports': 'off', + }, + }, +]) diff --git a/lefthook.yaml b/lefthook.yaml new file mode 100644 index 00000000..7349c042 --- /dev/null +++ b/lefthook.yaml @@ -0,0 +1,13 @@ +pre-commit: + jobs: + - name: prettier frontend + glob: '*.{js,jsx,ts,tsx,mjs,mts,json,md,css,scss,html,yml,yaml}' + exclude: + - 'pnpm-lock.yaml' + stage_fixed: true + run: pnpm prettier --write {staged_files} + + - name: lint frontend + glob: '*.{js,jsx,mjs,ts,tsx,mts}' + stage_fixed: true + run: pnpm eslint --fix {staged_files} diff --git a/src/assets/styles/global.css b/src/assets/styles/global.css index d6f19ae3..29505ff1 100644 --- a/src/assets/styles/global.css +++ b/src/assets/styles/global.css @@ -114,3 +114,279 @@ input.success { font-size: 0.85em; font-weight: normal; } + +.markdown-rendered { + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; +} + +.markdown-rendered > *:first-child { + margin-top: 0; +} + +.markdown-rendered > *:last-child { + margin-bottom: 0; +} + +.markdown-rendered table { + display: block; + max-width: 100%; + overflow-x: auto; + border-collapse: collapse; + white-space: nowrap; +} + +.markdown-rendered thead, +.markdown-rendered tbody, +.markdown-rendered tr { + width: max-content; + min-width: 100%; +} + +.markdown-rendered th, +.markdown-rendered td { + white-space: nowrap; +} + +.markdown-rendered pre, +.markdown-rendered code, +.markdown-rendered .internal-embed, +.markdown-rendered img { + max-width: 100%; +} + +.markdown-rendered pre { + overflow-x: auto; + white-space: pre; +} + +.chatbox-tag-button { + border-radius: 999px; +} + +.modal.provider-editor-modal { + width: min(720px, calc(100vw - 2rem)); + max-width: 720px; +} + +.modal-content.provider-editor-modal__content { + display: flex; + flex-direction: column; + max-height: 90vh; + min-height: 0; +} + +.provider-editor-modal__body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding-right: 0.25rem; +} + +.provider-editor-modal__footer { + flex: 0 0 auto; + padding-top: 0.75rem; + border-top: 1px solid var(--background-modifier-border); + background: var(--background-primary); +} + +.provider-editor-modal__footer .setting-item { + border-top: 0; + padding-top: 0; +} + +.provider-editor-modal__models { + padding-bottom: 0.5rem; +} + +.model-editor-modality-tags { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.model-editor-modality-tag { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: auto !important; + border: 1px solid var(--background-modifier-border); + border-radius: 999px; + padding: 0.25rem 0.625rem; + background: var(--background-primary); + color: var(--text-muted); + line-height: 1.2; + cursor: pointer; + transition: + background-color 120ms ease, + border-color 120ms ease, + color 120ms ease; +} + +.model-editor-modality-tag:hover { + border-color: var(--interactive-accent); + color: var(--text-normal); +} + +.model-editor-modality-tag.is-active { + background: var(--interactive-accent); + border-color: var(--interactive-accent); + color: var(--text-on-accent); +} + +.model-editor-modality-tag.is-active:hover { + background: var(--interactive-accent); + border-color: var(--interactive-accent); + color: var(--text-on-accent); +} + +.modality-badge-row { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: 0.2rem; +} + +.modality-badge { + display: inline-flex; + align-items: center; + font-size: 1em; + border: 1px solid var(--background-modifier-border); + border-radius: 999px; + padding: 0.1rem 0.5rem; + background: var(--background-primary); + color: var(--text-muted); + line-height: 1.4; + text-transform: capitalize; + user-select: none; +} + +.modality-badge-text { + background: color-mix( + in srgb, + var(--color-blue) 14%, + var(--background-primary) + ); + border-color: color-mix( + in srgb, + var(--color-blue) 38%, + var(--background-modifier-border) + ); + color: color-mix(in srgb, var(--color-blue) 78%, var(--text-normal)); +} + +.modality-badge-image { + background: color-mix( + in srgb, + var(--color-green) 14%, + var(--background-primary) + ); + border-color: color-mix( + in srgb, + var(--color-green) 38%, + var(--background-modifier-border) + ); + color: color-mix(in srgb, var(--color-green) 78%, var(--text-normal)); +} + +.modality-badge-audio { + background: color-mix( + in srgb, + var(--color-orange) 14%, + var(--background-primary) + ); + border-color: color-mix( + in srgb, + var(--color-orange) 38%, + var(--background-modifier-border) + ); + color: color-mix(in srgb, var(--color-orange) 82%, var(--text-normal)); +} + +.modality-badge-video { + background: color-mix( + in srgb, + var(--color-red) 14%, + var(--background-primary) + ); + border-color: color-mix( + in srgb, + var(--color-red) 38%, + var(--background-modifier-border) + ); + color: color-mix(in srgb, var(--color-red) 78%, var(--text-normal)); +} + +.modality-badge-pdf { + background: color-mix( + in srgb, + var(--color-purple) 14%, + var(--background-primary) + ); + border-color: color-mix( + in srgb, + var(--color-purple) 38%, + var(--background-modifier-border) + ); + color: color-mix(in srgb, var(--color-purple) 78%, var(--text-normal)); +} + +.chatbox-input { + height: 7rem; + padding: 0.75rem; + line-height: 1.5; +} + +.chatbox-input-pane { + display: flex; + flex-direction: column; +} + +.chatbox-resizer { + flex-shrink: 0; + cursor: ns-resize; + padding-bottom: 4px; + padding-top: 4px; + touch-action: none; + user-select: none; +} + +.chatbox-resizer-line { + height: 1px; + background: var(--background-modifier-border); + transition: background-color 120ms ease; +} + +.chatbox-resizer:hover .chatbox-resizer-line, +.chatbox-resizer.is-resizing .chatbox-resizer-line { + background: var(--interactive-accent); +} + +.chatbox-resize-active, +.chatbox-resize-active * { + cursor: ns-resize !important; + user-select: none !important; +} + +@media (pointer: fine) and (min-width: 1024px) { + .chatbox-input-pane--resizable { + border-top-width: 0; + } + + .chatbox-input-pane--resizable .chatbox-input { + flex: 1 1 auto; + height: auto; + min-height: 4rem; + min-width: 0; + } +} + +@media (max-width: 768px) { + .chatbox-input { + height: 5.5rem; + padding: 0.625rem; + line-height: 1.4; + } +} diff --git a/tsconfig.json b/tsconfig.json index ff75b684..57b847b6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,22 +1,36 @@ { "compilerOptions": { + "target": "ES6", + "strictNullChecks": true, + "allowSyntheticDefaultImports": true, + "lib": ["DOM", "ES5", "ES6", "ES7"], + + /* solid-js */ + "jsx": "preserve", + "noEmit": true, + "skipLibCheck": true, + "jsxImportSource": "solid-js", + "useDefineForClassFields": true, + "baseUrl": ".", + "module": "ESNext", + "moduleResolution": "bundler", "paths": { "~/*": ["./src/*"], "~": ["./src"] }, - "inlineSourceMap": true, - "inlineSources": true, - "module": "ESNext", - "target": "ES6", + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "allowJs": true, + "strict": true, "noImplicitAny": true, - "moduleResolution": "bundler", + "noUnusedLocals": true, + "noUnusedParameters": true, "importHelpers": true, - "isolatedModules": true, - "strictNullChecks": true, - "allowSyntheticDefaultImports": true, - "lib": ["DOM", "ES5", "ES6", "ES7"] + "inlineSourceMap": true, + "inlineSources": true, + "isolatedModules": true }, - "include": ["**/*.ts"] + "include": ["**/*.ts", "**/*.tsx", "**/*.mts"] } diff --git a/uno.config.ts b/uno.config.ts index 6b678318..9c22a655 100644 --- a/uno.config.ts +++ b/uno.config.ts @@ -1,9 +1,36 @@ -import { defineConfig, presetUno } from 'unocss' +import { defineConfig, presetIcons, presetUno } from 'unocss' export default defineConfig({ content: { filesystem: ['src/**/*.{html,js,ts,jsx,tsx,vue,svelte,astro}'], }, - rules: [[/^background-none$/, () => ({ background: 'none' })]], - presets: [presetUno()], + rules: [ + [/^background-none$/, () => ({ background: 'none' })], + [ + /^scrollbar-hide$/, + ([_]) => { + return `.scrollbar-hide{scrollbar-width:none} + .scrollbar-hide::-webkit-scrollbar{display:none}` + }, + ], + [ + /^scrollbar-default$/, + ([_]) => { + return `.scrollbar-default{scrollbar-width:auto} + .scrollbar-default::-webkit-scrollbar{display:block}` + }, + ], + ], + presets: [ + presetIcons({ + collections: { + custom: { + folder: + 'folder', + file: 'unknown', + }, + }, + }), + presetUno(), + ], }) From 708a60fdece424a006cbe1a5674afc4922200b5a Mon Sep 17 00:00:00 2001 From: miaobuao Date: Wed, 3 Jun 2026 10:17:59 +0800 Subject: [PATCH 02/24] refactor: migrate chatbox and webdav-explorer packages into src/components/solid-js --- packages/chatbox/package.json | 33 -- packages/chatbox/postcss.config.mjs | 5 - packages/chatbox/rslib.config.ts | 32 -- packages/chatbox/src/assets/styles/global.css | 109 ----- .../src/components/SessionHistoryItem.tsx | 58 --- packages/chatbox/src/i18n/index.ts | 30 -- packages/chatbox/src/index.tsx | 27 -- packages/chatbox/tsconfig.json | 24 -- packages/chatbox/unocss.config.ts | 35 -- packages/webdav-explorer/package.json | 33 -- packages/webdav-explorer/postcss.config.mjs | 5 - packages/webdav-explorer/rslib.config.ts | 32 -- .../src/assets/styles/global.css | 1 - packages/webdav-explorer/src/i18n/index.ts | 30 -- packages/webdav-explorer/src/index.tsx | 8 - packages/webdav-explorer/tsconfig.json | 24 -- packages/webdav-explorer/unocss.config.ts | 35 -- .../solid-js/components/Chatbox/Chatbox.tsx | 380 ++++++++++++++---- .../Chatbox}/components/ConfirmDialog.tsx | 8 +- .../Chatbox}/components/ContentParts.tsx | 0 .../Chatbox/components/ContextArea.tsx | 166 ++++++++ .../Chatbox}/components/CopyButton.tsx | 0 .../Chatbox}/components/FragmentDivider.tsx | 0 .../Chatbox}/components/MarkdownContent.tsx | 0 .../Chatbox}/components/MessageCard.tsx | 11 + .../Chatbox}/components/PaneResizer.tsx | 28 +- .../Chatbox}/components/PendingList.tsx | 0 .../Chatbox}/components/RunStateCard.tsx | 4 +- .../Chatbox/components/SessionHistoryItem.tsx | 119 ++++++ .../Chatbox}/components/TaskCard.tsx | 5 +- .../Chatbox}/components/TasksPanel.tsx | 8 +- .../solid-js/components/Chatbox/i18n/index.ts | 18 + .../components/Chatbox}/i18n/locales/en.ts | 4 + .../components/Chatbox}/i18n/locales/zh.ts | 4 + .../solid-js/components/Chatbox/index.tsx | 31 ++ .../solid-js/components/Chatbox}/types.ts | 43 +- .../solid-js/components/Chatbox}/utils.ts | 0 .../TaskSelectionVirtualList.tsx | 249 ++++++++++++ .../TaskSelectionVirtualList/i18n/index.ts | 18 + .../i18n/locales/en.ts | 14 + .../i18n/locales/zh.ts | 14 + .../TaskSelectionVirtualList/index.tsx | 34 ++ .../WebDAVExplorer/WebDAVExplorer.tsx | 24 +- .../WebDAVExplorer}/components/File.tsx | 0 .../WebDAVExplorer}/components/FileList.tsx | 6 +- .../WebDAVExplorer}/components/Folder.tsx | 0 .../WebDAVExplorer}/components/NewFolder.tsx | 2 +- .../components/WebDAVExplorer/fs.d.ts | 6 + .../components/WebDAVExplorer/i18n/index.ts | 18 + .../WebDAVExplorer}/i18n/locales/en.ts | 0 .../WebDAVExplorer}/i18n/locales/zh.ts | 0 .../components/WebDAVExplorer/index.tsx | 6 + src/components/solid-js/i18n.ts | 16 + src/components/solid-js/index.tsx | 3 + 54 files changed, 1121 insertions(+), 639 deletions(-) delete mode 100644 packages/chatbox/package.json delete mode 100644 packages/chatbox/postcss.config.mjs delete mode 100644 packages/chatbox/rslib.config.ts delete mode 100644 packages/chatbox/src/assets/styles/global.css delete mode 100644 packages/chatbox/src/components/SessionHistoryItem.tsx delete mode 100644 packages/chatbox/src/i18n/index.ts delete mode 100644 packages/chatbox/src/index.tsx delete mode 100644 packages/chatbox/tsconfig.json delete mode 100644 packages/chatbox/unocss.config.ts delete mode 100644 packages/webdav-explorer/package.json delete mode 100644 packages/webdav-explorer/postcss.config.mjs delete mode 100644 packages/webdav-explorer/rslib.config.ts delete mode 100644 packages/webdav-explorer/src/assets/styles/global.css delete mode 100644 packages/webdav-explorer/src/i18n/index.ts delete mode 100644 packages/webdav-explorer/src/index.tsx delete mode 100644 packages/webdav-explorer/tsconfig.json delete mode 100644 packages/webdav-explorer/unocss.config.ts rename packages/chatbox/src/App.tsx => src/components/solid-js/components/Chatbox/Chatbox.tsx (65%) rename {packages/chatbox/src => src/components/solid-js/components/Chatbox}/components/ConfirmDialog.tsx (87%) rename {packages/chatbox/src => src/components/solid-js/components/Chatbox}/components/ContentParts.tsx (100%) create mode 100644 src/components/solid-js/components/Chatbox/components/ContextArea.tsx rename {packages/chatbox/src => src/components/solid-js/components/Chatbox}/components/CopyButton.tsx (100%) rename {packages/chatbox/src => src/components/solid-js/components/Chatbox}/components/FragmentDivider.tsx (100%) rename {packages/chatbox/src => src/components/solid-js/components/Chatbox}/components/MarkdownContent.tsx (100%) rename {packages/chatbox/src => src/components/solid-js/components/Chatbox}/components/MessageCard.tsx (96%) rename {packages/chatbox/src => src/components/solid-js/components/Chatbox}/components/PaneResizer.tsx (60%) rename {packages/chatbox/src => src/components/solid-js/components/Chatbox}/components/PendingList.tsx (100%) rename {packages/chatbox/src => src/components/solid-js/components/Chatbox}/components/RunStateCard.tsx (90%) create mode 100644 src/components/solid-js/components/Chatbox/components/SessionHistoryItem.tsx rename {packages/chatbox/src => src/components/solid-js/components/Chatbox}/components/TaskCard.tsx (93%) rename {packages/chatbox/src => src/components/solid-js/components/Chatbox}/components/TasksPanel.tsx (90%) create mode 100644 src/components/solid-js/components/Chatbox/i18n/index.ts rename {packages/chatbox/src => src/components/solid-js/components/Chatbox}/i18n/locales/en.ts (94%) rename {packages/chatbox/src => src/components/solid-js/components/Chatbox}/i18n/locales/zh.ts (94%) create mode 100644 src/components/solid-js/components/Chatbox/index.tsx rename {packages/chatbox/src => src/components/solid-js/components/Chatbox}/types.ts (81%) rename {packages/chatbox/src => src/components/solid-js/components/Chatbox}/utils.ts (100%) create mode 100644 src/components/solid-js/components/TaskSelectionVirtualList/TaskSelectionVirtualList.tsx create mode 100644 src/components/solid-js/components/TaskSelectionVirtualList/i18n/index.ts create mode 100644 src/components/solid-js/components/TaskSelectionVirtualList/i18n/locales/en.ts create mode 100644 src/components/solid-js/components/TaskSelectionVirtualList/i18n/locales/zh.ts create mode 100644 src/components/solid-js/components/TaskSelectionVirtualList/index.tsx rename packages/webdav-explorer/src/App.tsx => src/components/solid-js/components/WebDAVExplorer/WebDAVExplorer.tsx (76%) rename {packages/webdav-explorer/src => src/components/solid-js/components/WebDAVExplorer}/components/File.tsx (100%) rename {packages/webdav-explorer/src => src/components/solid-js/components/WebDAVExplorer}/components/FileList.tsx (94%) rename {packages/webdav-explorer/src => src/components/solid-js/components/WebDAVExplorer}/components/Folder.tsx (100%) rename {packages/webdav-explorer/src => src/components/solid-js/components/WebDAVExplorer}/components/NewFolder.tsx (93%) create mode 100644 src/components/solid-js/components/WebDAVExplorer/fs.d.ts create mode 100644 src/components/solid-js/components/WebDAVExplorer/i18n/index.ts rename {packages/webdav-explorer/src => src/components/solid-js/components/WebDAVExplorer}/i18n/locales/en.ts (100%) rename {packages/webdav-explorer/src => src/components/solid-js/components/WebDAVExplorer}/i18n/locales/zh.ts (100%) create mode 100644 src/components/solid-js/components/WebDAVExplorer/index.tsx create mode 100644 src/components/solid-js/i18n.ts create mode 100644 src/components/solid-js/index.tsx diff --git a/packages/chatbox/package.json b/packages/chatbox/package.json deleted file mode 100644 index 44104ed3..00000000 --- a/packages/chatbox/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "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 deleted file mode 100644 index 6d0228c7..00000000 --- a/packages/chatbox/postcss.config.mjs +++ /dev/null @@ -1,5 +0,0 @@ -import UnoCSS from '@unocss/postcss' - -export default { - plugins: [UnoCSS()], -} diff --git a/packages/chatbox/rslib.config.ts b/packages/chatbox/rslib.config.ts deleted file mode 100644 index 7b443f44..00000000 --- a/packages/chatbox/rslib.config.ts +++ /dev/null @@ -1,32 +0,0 @@ -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/assets/styles/global.css b/packages/chatbox/src/assets/styles/global.css deleted file mode 100644 index 8d6d294d..00000000 --- a/packages/chatbox/src/assets/styles/global.css +++ /dev/null @@ -1,109 +0,0 @@ -@unocss; - -.markdown-rendered { - min-width: 0; - overflow-wrap: anywhere; - word-break: break-word; -} - -.markdown-rendered > *:first-child { - margin-top: 0; -} - -.markdown-rendered > *:last-child { - margin-bottom: 0; -} - -.markdown-rendered table { - display: block; - max-width: 100%; - overflow-x: auto; - border-collapse: collapse; - white-space: nowrap; -} - -.markdown-rendered thead, -.markdown-rendered tbody, -.markdown-rendered tr { - width: max-content; - min-width: 100%; -} - -.markdown-rendered th, -.markdown-rendered td { - white-space: nowrap; -} - -.markdown-rendered pre, -.markdown-rendered code, -.markdown-rendered .internal-embed, -.markdown-rendered img { - max-width: 100%; -} - -.markdown-rendered pre { - overflow-x: auto; - white-space: pre; -} - -.chatbox-tag-button { - border-radius: 999px; -} - -.chatbox-input { - height: 7rem; - padding: 0.75rem; - line-height: 1.5; -} - -.chatbox-input-pane { - display: flex; - flex-direction: column; -} - -.chatbox-resizer { - flex-shrink: 0; - cursor: ns-resize; - padding-bottom: 4px; - padding-top: 4px; - touch-action: none; - user-select: none; -} - -.chatbox-resizer-line { - height: 1px; - background: var(--background-modifier-border); - transition: background-color 120ms ease; -} - -.chatbox-resizer:hover .chatbox-resizer-line, -.chatbox-resizer.is-resizing .chatbox-resizer-line { - background: var(--interactive-accent); -} - -.chatbox-resize-active, -.chatbox-resize-active * { - cursor: ns-resize !important; - user-select: none !important; -} - -@media (pointer: fine) and (min-width: 1024px) { - .chatbox-input-pane--resizable { - border-top-width: 0; - } - - .chatbox-input-pane--resizable .chatbox-input { - flex: 1 1 auto; - height: auto; - min-height: 4rem; - min-width: 0; - } -} - -@media (max-width: 768px) { - .chatbox-input { - height: 5.5rem; - padding: 0.625rem; - line-height: 1.4; - } -} diff --git a/packages/chatbox/src/components/SessionHistoryItem.tsx b/packages/chatbox/src/components/SessionHistoryItem.tsx deleted file mode 100644 index d3c56472..00000000 --- a/packages/chatbox/src/components/SessionHistoryItem.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Show } from 'solid-js' -import type { ChatboxProps } from '../types' -import { t } from '../i18n' -import { formatTime } from '../utils' - -export function SessionHistoryItem(props: { - session: ChatboxProps['sessionHistory'][number] - isActive: boolean - onSelect: (sessionId: string) => void - onDelete: (sessionId: string) => void -}) { - const activate = () => props.onSelect(props.session.id) - - return ( -
{ - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault() - activate() - } - }} - > - -
- -
-
-
- {props.session.title} -
-
- {formatTime(props.session.createdAt)} -
-
- -
-
- ) -} diff --git a/packages/chatbox/src/i18n/index.ts b/packages/chatbox/src/i18n/index.ts deleted file mode 100644 index 76236853..00000000 --- a/packages/chatbox/src/i18n/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -import * as i18n from '@solid-primitives/i18n' -import { createResource, createSignal } from 'solid-js' -import en from './locales/en' -import zh from './locales/zh' - -export type Locale = 'zh' | 'en' - -export function toLocale(language: string) { - switch (language.split('-')[0].toLowerCase()) { - case 'zh': - return 'zh' - default: - return 'en' - } -} - -export const [locale, setLocale] = createSignal( - toLocale(navigator.language), -) - -const [dict] = createResource(locale, (locale) => { - switch (locale) { - case 'zh': - return i18n.flatten(zh) - default: - return i18n.flatten(en) - } -}) - -export const t = i18n.translator(dict) diff --git a/packages/chatbox/src/index.tsx b/packages/chatbox/src/index.tsx deleted file mode 100644 index 499c0bc0..00000000 --- a/packages/chatbox/src/index.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import './assets/styles/global.css' - -import { createStore, reconcile } from 'solid-js/store' -import { render } from 'solid-js/web' -import App, { AppProps } from './App' -export * from './types' - -export interface ChatboxController { - update: (props: AppProps) => void - destroy: () => void -} - -export function mount(el: Element, props: AppProps): ChatboxController { - let update = (_props: AppProps) => {} - const destroy = render(() => { - const [state, setState] = createStore(props) - update = (nextProps: AppProps) => { - setState(reconcile(nextProps)) - } - return - }, el) - - return { - update, - destroy, - } -} diff --git a/packages/chatbox/tsconfig.json b/packages/chatbox/tsconfig.json deleted file mode 100644 index cda6754a..00000000 --- a/packages/chatbox/tsconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "compilerOptions": { - "lib": ["DOM", "ES2020"], - "jsx": "preserve", - "target": "ES2020", - "noEmit": true, - "skipLibCheck": true, - "jsxImportSource": "solid-js", - "useDefineForClassFields": true, - - /* modules */ - "module": "ESNext", - "isolatedModules": true, - "resolveJsonModule": true, - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - - /* type checking */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true - }, - "include": ["src"] -} diff --git a/packages/chatbox/unocss.config.ts b/packages/chatbox/unocss.config.ts deleted file mode 100644 index a81233b3..00000000 --- a/packages/chatbox/unocss.config.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { defineConfig, presetIcons, presetUno } from 'unocss' - -export default defineConfig({ - content: { - filesystem: ['**/*.{html,js,ts,jsx,tsx,vue,svelte,astro}'], - }, - rules: [ - [ - /^scrollbar-hide$/, - ([_]) => { - return `.scrollbar-hide{scrollbar-width:none} - .scrollbar-hide::-webkit-scrollbar{display:none}` - }, - ], - [ - /^scrollbar-default$/, - ([_]) => { - return `.scrollbar-default{scrollbar-width:auto} - .scrollbar-default::-webkit-scrollbar{display:block}` - }, - ], - ], - presets: [ - presetIcons({ - collections: { - custom: { - folder: - 'folder', - file: 'unknown', - }, - }, - }), - presetUno(), - ], -}) diff --git a/packages/webdav-explorer/package.json b/packages/webdav-explorer/package.json deleted file mode 100644 index 38137a21..00000000 --- a/packages/webdav-explorer/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "webdav-explorer", - "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/webdav-explorer/postcss.config.mjs b/packages/webdav-explorer/postcss.config.mjs deleted file mode 100644 index 6d0228c7..00000000 --- a/packages/webdav-explorer/postcss.config.mjs +++ /dev/null @@ -1,5 +0,0 @@ -import UnoCSS from '@unocss/postcss' - -export default { - plugins: [UnoCSS()], -} diff --git a/packages/webdav-explorer/rslib.config.ts b/packages/webdav-explorer/rslib.config.ts deleted file mode 100644 index 7b443f44..00000000 --- a/packages/webdav-explorer/rslib.config.ts +++ /dev/null @@ -1,32 +0,0 @@ -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/webdav-explorer/src/assets/styles/global.css b/packages/webdav-explorer/src/assets/styles/global.css deleted file mode 100644 index 4612250a..00000000 --- a/packages/webdav-explorer/src/assets/styles/global.css +++ /dev/null @@ -1 +0,0 @@ -@unocss; diff --git a/packages/webdav-explorer/src/i18n/index.ts b/packages/webdav-explorer/src/i18n/index.ts deleted file mode 100644 index 76236853..00000000 --- a/packages/webdav-explorer/src/i18n/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -import * as i18n from '@solid-primitives/i18n' -import { createResource, createSignal } from 'solid-js' -import en from './locales/en' -import zh from './locales/zh' - -export type Locale = 'zh' | 'en' - -export function toLocale(language: string) { - switch (language.split('-')[0].toLowerCase()) { - case 'zh': - return 'zh' - default: - return 'en' - } -} - -export const [locale, setLocale] = createSignal( - toLocale(navigator.language), -) - -const [dict] = createResource(locale, (locale) => { - switch (locale) { - case 'zh': - return i18n.flatten(zh) - default: - return i18n.flatten(en) - } -}) - -export const t = i18n.translator(dict) diff --git a/packages/webdav-explorer/src/index.tsx b/packages/webdav-explorer/src/index.tsx deleted file mode 100644 index 4984aa9f..00000000 --- a/packages/webdav-explorer/src/index.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import './assets/styles/global.css' - -import { render } from 'solid-js/web' -import App, { AppProps } from './App' - -export function mount(el: Element, props: AppProps) { - return render(() => , el) -} diff --git a/packages/webdav-explorer/tsconfig.json b/packages/webdav-explorer/tsconfig.json deleted file mode 100644 index cda6754a..00000000 --- a/packages/webdav-explorer/tsconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "compilerOptions": { - "lib": ["DOM", "ES2020"], - "jsx": "preserve", - "target": "ES2020", - "noEmit": true, - "skipLibCheck": true, - "jsxImportSource": "solid-js", - "useDefineForClassFields": true, - - /* modules */ - "module": "ESNext", - "isolatedModules": true, - "resolveJsonModule": true, - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - - /* type checking */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true - }, - "include": ["src"] -} diff --git a/packages/webdav-explorer/unocss.config.ts b/packages/webdav-explorer/unocss.config.ts deleted file mode 100644 index a81233b3..00000000 --- a/packages/webdav-explorer/unocss.config.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { defineConfig, presetIcons, presetUno } from 'unocss' - -export default defineConfig({ - content: { - filesystem: ['**/*.{html,js,ts,jsx,tsx,vue,svelte,astro}'], - }, - rules: [ - [ - /^scrollbar-hide$/, - ([_]) => { - return `.scrollbar-hide{scrollbar-width:none} - .scrollbar-hide::-webkit-scrollbar{display:none}` - }, - ], - [ - /^scrollbar-default$/, - ([_]) => { - return `.scrollbar-default{scrollbar-width:auto} - .scrollbar-default::-webkit-scrollbar{display:block}` - }, - ], - ], - presets: [ - presetIcons({ - collections: { - custom: { - folder: - 'folder', - file: 'unknown', - }, - }, - }), - presetUno(), - ], -}) diff --git a/packages/chatbox/src/App.tsx b/src/components/solid-js/components/Chatbox/Chatbox.tsx similarity index 65% rename from packages/chatbox/src/App.tsx rename to src/components/solid-js/components/Chatbox/Chatbox.tsx index 29b95333..ac3c8bf2 100644 --- a/packages/chatbox/src/App.tsx +++ b/src/components/solid-js/components/Chatbox/Chatbox.tsx @@ -5,9 +5,12 @@ import { Switch, createEffect, createSignal, + on, onCleanup, } from 'solid-js' +import { createImageContextItem } from '~/chat/user-context' import { ConfirmDialog } from './components/ConfirmDialog' +import { ContextArea } from './components/ContextArea' import { FragmentDivider } from './components/FragmentDivider' import { MessageCard } from './components/MessageCard' import { PaneResizer } from './components/PaneResizer' @@ -22,7 +25,90 @@ import type { ChatboxProps, } from './types' -export type AppProps = ChatboxProps +function decodeURIComponentRepeatedly(value: string): string { + let current = value.trim() + for (let i = 0; i < 3; i += 1) { + try { + const decoded = decodeURIComponent(current) + if (decoded === current) break + current = decoded + } catch { + break + } + } + return current +} + +function normalizeDroppedPath(path: string): string | null { + const decoded = decodeURIComponentRepeatedly(path) + .replace(/^\[\[/, '') + .replace(/\]\]$/, '') + const withoutAlias = decoded.split('|')[0]?.trim() ?? '' + const normalized = withoutAlias.replace(/^\/+/, '').replace(/\/+$/, '').trim() + return normalized || null +} + +function addDroppedPathPayload(payload: string, parsed: Set) { + const trimmedPayload = payload.trim() + if (!trimmedPayload) return + if (trimmedPayload.startsWith('{') || trimmedPayload.startsWith('[')) { + try { + addDroppedJsonPayload(JSON.parse(trimmedPayload), parsed) + return + } catch { + // Fall through to plain text parsing. + } + } + for (const line of trimmedPayload.split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('http')) { + continue + } + if (trimmed.startsWith('obsidian://open?')) { + try { + const url = new URL(trimmed) + const file = url.searchParams.get('file') + if (file) { + const normalized = normalizeDroppedPath(file) + if (normalized) parsed.add(normalized) + } + continue + } catch { + // Fall through to plain path parsing. + } + } + const normalized = normalizeDroppedPath(trimmed) + if (normalized) parsed.add(normalized) + } +} + +function addDroppedJsonPayload(value: unknown, parsed: Set) { + if (typeof value === 'string') { + const normalized = normalizeDroppedPath(value) + if (normalized) parsed.add(normalized) + return + } + if (Array.isArray(value)) { + for (const item of value) addDroppedJsonPayload(item, parsed) + return + } + if (!value || typeof value !== 'object') return + const record = value as Record + for (const key of ['path', 'file', 'files']) { + if (key in record) addDroppedJsonPayload(record[key], parsed) + } +} + +function parseDroppedObsidianPaths(e: DragEvent): string[] { + const parsed = new Set() + const dataTransfer = e.dataTransfer + if (!dataTransfer) return [] + for (const type of Array.from(dataTransfer.types)) { + if (type === 'Files') continue + addDroppedPathPayload(dataTransfer.getData(type) ?? '', parsed) + } + return Array.from(parsed) +} const DESKTOP_RESIZE_MEDIA_QUERY = '(pointer: fine) and (min-width: 1024px)' const INPUT_HEIGHT_STORAGE_KEY = 'nutstore-sync.chatbox.desktop-input-height' @@ -33,7 +119,7 @@ const DESKTOP_MESSAGES_MIN_HEIGHT = 200 const RESIZER_HITBOX_HEIGHT = 10 const DESKTOP_INPUT_MAX_VIEWPORT_RATIO = 0.6 -function App(props: AppProps) { +function Chatbox(props: ChatboxProps) { const [input, setInput] = createSignal('') const [isComposing, setIsComposing] = createSignal(false) const [historyOpen, setHistoryOpen] = createSignal(false) @@ -49,15 +135,29 @@ function App(props: AppProps) { createSignal() const [desktopResizeEnabled, setDesktopResizeEnabled] = createSignal(false) const [inputPaneHeight, setInputPaneHeight] = createSignal() + const [stickToBottom, setStickToBottom] = createSignal(true) 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 previousActiveSessionId: string | undefined let defaultDesktopInputHeight = DEFAULT_DESKTOP_INPUT_HEIGHT let dragStartHeight = 0 + function getViewDocument() { + return ( + splitLayoutEl?.ownerDocument ?? + inputPaneEl?.ownerDocument ?? + messagesEl?.ownerDocument ?? + document + ) + } + + function getViewWindow() { + return getViewDocument().defaultView ?? window + } + const hasTasks = () => props.currentSessionTasks.length + props.otherSessionTasks.length > 0 const runningTaskCount = () => @@ -65,6 +165,34 @@ function App(props: AppProps) { .length + props.otherSessionTasks.filter((task) => task.status === 'running').length const isBusy = () => props.runState !== 'idle' + + function isMessagesNearBottom(threshold = 48) { + if (!messagesEl) { + return true + } + const remaining = + messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight + return remaining <= threshold + } + + const lastMessageFingerprint = () => { + for (let index = props.timeline.length - 1; index >= 0; index -= 1) { + const item = props.timeline[index] + if (item?.kind !== 'message') { + continue + } + const textLength = (item.message.message.content ?? []) + .filter((part) => part.type === 'text') + .reduce((total, part) => total + part.text.length, 0) + const toolCallsLength = + item.message.message.role === 'assistant' + ? (item.message.message.tool_calls?.length ?? 0) + : 0 + return `${item.message.id}:${textLength}:${toolCallsLength}:${item.message.message.role}` + } + return 'empty' + } + const selectedProvider = () => props.providers.find((provider) => provider.id === props.selectedProviderId) const modelPickerLabel = () => { @@ -80,7 +208,7 @@ function App(props: AppProps) { function readStoredInputPaneHeight() { try { - const raw = window.localStorage.getItem(INPUT_HEIGHT_STORAGE_KEY) + const raw = getViewWindow().localStorage.getItem(INPUT_HEIGHT_STORAGE_KEY) if (!raw) { return undefined } @@ -93,7 +221,7 @@ function App(props: AppProps) { function persistInputPaneHeight(height: number) { try { - window.localStorage.setItem( + getViewWindow().localStorage.setItem( INPUT_HEIGHT_STORAGE_KEY, String(Math.round(height)), ) @@ -104,7 +232,7 @@ function App(props: AppProps) { function getMaxInputPaneHeight() { const viewportMax = Math.floor( - window.innerHeight * DESKTOP_INPUT_MAX_VIEWPORT_RATIO, + getViewWindow().innerHeight * DESKTOP_INPUT_MAX_VIEWPORT_RATIO, ) const splitHeight = splitLayoutEl?.getBoundingClientRect().height ?? 0 if (splitHeight <= 0) { @@ -162,7 +290,7 @@ function App(props: AppProps) { } function scrollMessagesToBottom(behavior: ScrollBehavior = 'smooth') { - requestAnimationFrame(() => { + getViewWindow().requestAnimationFrame(() => { if (!messagesEl) { return } @@ -170,20 +298,56 @@ function App(props: AppProps) { top: messagesEl.scrollHeight, behavior, }) + setStickToBottom(true) }) } + createEffect( + on( + () => [ + props.activeSessionId, + props.timeline.length, + props.currentSessionTasks.length, + props.otherSessionTasks.length, + props.pendingMessages.length, + props.runState, + ], + ([activeSessionId]) => { + const behavior = + previousActiveSessionId !== activeSessionId ? 'auto' : 'smooth' + const shouldScroll = + previousActiveSessionId !== activeSessionId || stickToBottom() + previousActiveSessionId = activeSessionId?.toString() + if (shouldScroll) { + scrollMessagesToBottom(behavior) + } + }, + ), + ) + + createEffect( + on( + () => [props.activeSessionId, lastMessageFingerprint()], + ([activeSessionId], previous) => { + const previousActiveSessionId = previous?.[0] + if (activeSessionId !== previousActiveSessionId || !stickToBottom()) { + return + } + scrollMessagesToBottom('auto') + }, + ), + ) + 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) + if (!messagesEl) { + return + } + const onScroll = () => { + setStickToBottom(isMessagesNearBottom()) + } + onScroll() + messagesEl.addEventListener('scroll', onScroll, { passive: true }) + onCleanup(() => messagesEl?.removeEventListener('scroll', onScroll)) }) createEffect(() => { @@ -192,6 +356,15 @@ function App(props: AppProps) { } }) + createEffect( + on( + () => props.activeSessionId, + () => { + setInput(props.pendingInputDraft) + }, + ), + ) + createEffect(() => { if (!historyOpen() && !modelPickerOpen()) { return @@ -199,26 +372,34 @@ function App(props: AppProps) { const onPointerDown = (event: PointerEvent) => { const target = event.target - if (!(target instanceof Node)) { + if (!target || typeof target !== 'object' || !('nodeType' in target)) { return } - if (historyEl?.contains(target) || modelPickerEl?.contains(target)) { + const node = target as Node + if (historyEl?.contains(node) || modelPickerEl?.contains(node)) { return } setHistoryOpen(false) setModelPickerOpen(false) } - document.addEventListener('pointerdown', onPointerDown) - onCleanup(() => document.removeEventListener('pointerdown', onPointerDown)) + const viewDoc = getViewDocument() + viewDoc.addEventListener('pointerdown', onPointerDown) + onCleanup(() => viewDoc.removeEventListener('pointerdown', onPointerDown)) }) createEffect(() => { - const mediaQuery = window.matchMedia(DESKTOP_RESIZE_MEDIA_QUERY) + const viewWindow = getViewWindow() + const mediaQuery = viewWindow.matchMedia(DESKTOP_RESIZE_MEDIA_QUERY) const update = () => setDesktopResizeEnabled(mediaQuery.matches) update() - mediaQuery.addEventListener('change', update) - onCleanup(() => mediaQuery.removeEventListener('change', update)) + if (typeof mediaQuery.addEventListener === 'function') { + mediaQuery.addEventListener('change', update) + onCleanup(() => mediaQuery.removeEventListener('change', update)) + return + } + mediaQuery.addListener(update) + onCleanup(() => mediaQuery.removeListener(update)) }) createEffect(() => { @@ -243,6 +424,7 @@ function App(props: AppProps) { if (!desktopResizeEnabled()) { return } + const viewWindow = getViewWindow() const onResize = () => { const height = inputPaneHeight() if (typeof height !== 'number') { @@ -253,18 +435,31 @@ function App(props: AppProps) { applyInputPaneHeight(clampedHeight, true) } } - window.addEventListener('resize', onResize) - onCleanup(() => window.removeEventListener('resize', onResize)) + viewWindow.addEventListener('resize', onResize) + onCleanup(() => viewWindow.removeEventListener('resize', onResize)) }) async function submit() { const text = input().trim() - if (!text || !props.canSend) { + const hasPendingContext = props.pendingUserContext.length > 0 + if ((!text && !hasPendingContext) || !props.canSend) { return } + const previousInput = input() setInput('') + props.onUpdateInputDraft('') scrollMessagesToBottom('auto') - await props.onSendMessage(text) + try { + const accepted = await props.onSendMessage(text) + if (!accepted) { + setInput(previousInput) + props.onUpdateInputDraft(previousInput) + } + } catch (error) { + setInput(previousInput) + props.onUpdateInputDraft(previousInput) + throw error + } } async function confirmDeleteSession() { @@ -276,49 +471,52 @@ function App(props: AppProps) { 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 + function requestDeleteMessage(messageId: string) { + if (!props.onDeleteMessage) return + const item = props.timeline.find( + (i): i is ChatTimelineMessageItem => + i.kind === 'message' && i.message.id === messageId, + ) + if (!item) return + setPendingDeleteMessage(item) + } + + function requestRegenerateMessage(messageId: string) { + if (!props.onRegenerateMessage) return + const item = props.timeline.find( + (i): i is ChatTimelineMessageItem => + i.kind === 'message' && i.message.id === messageId, + ) + if (!item) return + setPendingRegenerateMessage(item) + } + + function requestRecallMessage(messageId: string) { + if (!props.onRecallMessage) return + const item = props.timeline.find( + (i): i is ChatTimelineMessageItem => + i.kind === 'message' && i.message.id === messageId, + ) + if (!item) return + setPendingRecallMessage(item) + } async function doRecallMessage( item: ChatTimelineMessageItem, options?: { restoreFiles?: boolean }, ) { - const text = (item.message.message.content ?? []) + const recalled = await props.onRecallMessage?.(item.message.id, options) + if (recalled?.text !== undefined) { + setInput(recalled.text) + props.onUpdateInputDraft(recalled.text) + return + } + const fallbackText = (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) + setInput(fallbackText) + props.onUpdateInputDraft(fallbackText) } async function confirmRecallMessage() { @@ -415,7 +613,7 @@ function App(props: AppProps) {