diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d60bc9bd..94845bb9 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 @@ -22,13 +24,17 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v2 with: - version: latest + version: latest-9 - 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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8297d45a..3c697687 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 @@ -25,7 +27,16 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v2 with: - version: latest + version: latest-9 + + - 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: @@ -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/package.json b/package.json index d2cf5fc2..96a8ede6 100644 --- a/package.json +++ b/package.json @@ -5,22 +5,27 @@ "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:chatbox build:plugin", - "build:chatbox": "pnpm --filter chatbox build", - "build:webdav-explorer": "pnpm --filter webdav-explorer build", + "build": "run-s build:plugin", "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" + "test": "vitest", + "update:models-api": "node scripts/update-models-api.cjs", + "postinstall": "lefthook install" }, "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", + "@eslint/compat": "^2.1.0", + "@eslint/css": "^1.2.0", + "@eslint/js": "^10.0.1", + "@eslint/json": "^1.2.0", + "@eslint/markdown": "^8.0.1", + "@iconify-json/lucide": "^1.2.114", + "@nutstore/sso-js": "^0.0.9", + "@solid-primitives/i18n": "^2.2.0", "@swc/cli": "^0.7.9", "@swc/core": "^1.15.6", + "@tanstack/solid-table": "^8.21.3", "@types/diff-match-patch": "^1.0.36", "@types/glob-to-regexp": "^0.4.4", "@types/lodash-es": "^4.17.12", @@ -34,12 +39,12 @@ "ai": "^6.0.149", "assert": "^2.1.0", "async-mutex": "^0.5.0", + "baseline-browser-mapping": "^2.10.33", "blob-polyfill": "^9.0.20240710", "bottleneck": "^2.19.5", "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", @@ -47,15 +52,22 @@ "dotenv": "^16.4.7", "esbuild": "0.17.3", "esbuild-sass-plugin": "^3.3.1", + "eslint": "^10.4.0", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-solid": "^0.14.5", + "eslint-plugin-unused-imports": "^4.4.1", "fast-xml-parser": "^4.5.1", "fflate": "^0.8.2", "glob-to-regexp": "^0.4.1", + "globals": "^17.6.0", "hash-wasm": "^4.12.0", "html-entities": "^2.6.0", "http-status-codes": "^2.3.0", "i18next": "^24.2.2", + "jiti": "^2.7.0", "js-base64": "^3.7.7", "just-bash": "^2.14.0", + "lefthook": "^2.1.8", "localforage": "^1.10.0", "lodash-es": "^4.17.21", "node-diff3": "^3.1.2", @@ -63,19 +75,22 @@ "obsidian": "latest", "ohash": "^1.1.4", "path-browserify": "^1.0.1", + "postcss": "^8.5.15", "postcss-merge-rules": "^7.0.4", "prettier": "^3.5.0", "ramda": "^0.30.1", "rxjs": "^7.8.1", + "solid-js": "^1.9.5", "stream-browserify": "^3.0.0", "superjson": "^2.2.2", "tslib": "2.4.0", "typescript": "^5.9.3", + "typescript-eslint": "^8.59.4", "unocss": "66.1.0-beta.3", + "unplugin-solid": "^1.0.0", "uuid": "^13.0.0", "vitest": "^3.1.2", "webdav": "^5.7.1", - "webdav-explorer": "workspace: *", "zod": "^4.3.6" }, "browser": { @@ -84,4 +99,4 @@ "buffer": "buffer", "crypto": "crypto-browserify" } -} \ No newline at end of file +} 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/App.tsx b/packages/chatbox/src/App.tsx deleted file mode 100644 index 29b95333..00000000 --- a/packages/chatbox/src/App.tsx +++ /dev/null @@ -1,706 +0,0 @@ -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 */} -
-