diff --git a/android/app/build.gradle b/android/app/build.gradle index fb9483bb..8c7984c5 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "app.aiaw.glass" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 20018 - versionName "2.0.8.9" + versionCode 20019 + versionName "2.0.8.12" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index beb983bf..a99cac76 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -2,8 +2,8 @@ android { compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4af8cc12..2764c064 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -28,6 +28,7 @@ android:name=".MainActivity" android:label="@string/title_activity_main" android:theme="@style/AppTheme.NoActionBarLaunch" + android:windowSoftInputMode="adjustResize" android:launchMode="singleTask" android:exported="true"> diff --git a/android/app/src/main/java/app/aiaw/MainActivity.java b/android/app/src/main/java/app/aiaw/MainActivity.java index 40efd56e..f96478ec 100644 --- a/android/app/src/main/java/app/aiaw/MainActivity.java +++ b/android/app/src/main/java/app/aiaw/MainActivity.java @@ -1,6 +1,7 @@ package app.aiaw; import android.os.Bundle; +import android.view.WindowManager; import com.getcapacitor.BridgeActivity; @@ -8,6 +9,9 @@ public class MainActivity extends BridgeActivity { @Override public void onCreate(Bundle savedInstanceState) { registerPlugin(LocalFsPlugin.class); + // Set this before Capacitor creates the WebView; its edge-to-edge + // initialization otherwise overwrites the manifest/runtime mode. + getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); super.onCreate(savedInstanceState); } } diff --git a/package.json b/package.json index 905c4504..0cbd65a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aiaw", - "version": "2.0.8.9", + "version": "2.0.8.12", "description": "精心设计的 AI (LLM) 客户端。全功能,轻量级;支持多工作区、插件系统、跨平台、本地优先+实时云同步", "productName": "AI as Workspace", "author": "NitroRCr ", diff --git a/src/App.vue b/src/App.vue index c4588f36..4594b71d 100644 --- a/src/App.vue +++ b/src/App.vue @@ -103,6 +103,7 @@ onMounted(() => { async function migrateBuiltinPluginsAtStartup() { try { + if (!db.isOpen()) await db.open() const assistants = await db.assistants.toArray() for (const assistant of assistants) { const beforeKeys = Object.keys(assistant.plugins || {}) diff --git a/src/components/MessageInfoDialog.vue b/src/components/MessageInfoDialog.vue index 1cf47961..c39256f9 100644 --- a/src/components/MessageInfoDialog.vue +++ b/src/components/MessageInfoDialog.vue @@ -75,8 +75,8 @@ const props = defineProps<{ message: Message }>() -const length = computed(() => props.message.contents.filter( - c => c.type === 'assistant-message' || c.type === 'user-message' +const length = computed(() => (props.message.contents || []).filter( + c => (c.type === 'assistant-message' || c.type === 'user-message') && typeof c.text === 'string' ).reduce((prev, cur) => prev + cur.text.length, 0)) const createdAt = computed(() => idDateString(props.message.id)) diff --git a/src/components/MessageItem.vue b/src/components/MessageItem.vue index c7b803b7..87882532 100644 --- a/src/components/MessageItem.vue +++ b/src/components/MessageItem.vue @@ -391,6 +391,7 @@ const props = defineProps<{ message: Message, childNum: number, scrollContainer: HTMLElement, + lazyPlainText?: boolean, branchControl?: { current: number, max: number, @@ -416,7 +417,7 @@ function moreInfo() { } const sourceCodeMode = ref(false) -const contents = computed(() => props.message.contents.map(x => { +const contents = computed(() => (props.message.contents || []).map(x => { if (x.type === 'assistant-message' || x.type === 'user-message') { return { ...x, @@ -445,7 +446,7 @@ watchEffect(async () => { generatingSession: null, status: 'failed', error: 'aborted', - contents: props.message.contents.map(content => { + contents: (props.message.contents || []).map(content => { if (content.type === 'assistant-tool' && content.status === 'calling') { return { ...content, @@ -459,8 +460,11 @@ watchEffect(async () => { } }) -const textIndex = computed(() => props.message.contents.findIndex(c => ['user-message', 'assistant-message'].includes(c.type))) -const textContent = computed(() => (props.message.contents[textIndex.value] as UserMessageContent | AssistantMessageContent)) +const textIndex = computed(() => (props.message.contents || []).findIndex(c => ['user-message', 'assistant-message'].includes(c.type))) +const textContent = computed(() => { + const content = (props.message.contents || [])[textIndex.value] + return (content || { type: 'assistant-message', text: '' }) as UserMessageContent | AssistantMessageContent +}) const { perfs } = useUserPerfsStore() const assistantsStore = useAssistantsStore() @@ -517,6 +521,10 @@ function isStreamingAssistantText(content: MessageContent) { function getStreamingRenderState(content: MessageContent) { if (isStreamingAssistantText(content)) return streamingRenderState.value + // Off-screen messages: skip expensive KaTeX/markdown rendering + if (props.lazyPlainText && (content.type === 'assistant-message' || content.type === 'user-message')) { + return { mode: 'plain-text' as const, text: content.text } + } if (content.type === 'assistant-message' || content.type === 'user-message') { return { mode: 'final' as const, text: content.text } } @@ -602,7 +610,9 @@ function onSelect(mode: 'mouse' | 'touch') { } const range = selection.getRangeAt(0) const targetRects = range.getBoundingClientRect() - const baseRects = textDiv.value[0].getBoundingClientRect() + const textElement = textDiv.value?.[0] as HTMLElement | undefined + if (!textElement) return + const baseRects = textElement.getBoundingClientRect() floatBtnStyle.top = targetRects.top < 48 || mode === 'touch' ? targetRects.bottom - baseRects.top + 12 + 'px' : targetRects.top - baseRects.top - 48 + 'px' @@ -776,7 +786,7 @@ function shouldPromoteInlineMath(inlineMath: HTMLElement) { } function injectDisplayMathScroll() { - const el: HTMLElement = textDiv.value[0] + const el = textDiv.value?.[0] as HTMLElement | undefined if (!el) return clearDisplayMathScroll(el) @@ -802,25 +812,30 @@ function injectDisplayMathScroll() { } function injectConvertArtifact() { if (!isPlatformEnabled(perfs.artifactsEnabled)) return - const el: HTMLElement = textDiv.value[0] + const el = textDiv.value?.[0] as HTMLElement | undefined + if (!el) return el.querySelectorAll('.md-editor-code').forEach(code => { if (code.querySelector('.md-editor-convert-artifact')) return const anchor = code.querySelector('.md-editor-collapse-tips') + const action = code.querySelector('.md-editor-code-action') + const source = code.querySelector('pre code') + if (!anchor || !action || !source) return const btn = document.createElement('span') btn.innerHTML = 'convert_to_text' btn.classList.add('md-editor-convert-artifact') btn.addEventListener('click', (ev) => { ev.preventDefault() ev.stopPropagation() - const text = code.querySelector('pre code').textContent - const lang = code.querySelector('pre code').getAttribute('language') + const text = source.textContent || '' + const lang = source.getAttribute('language') || '' const pattern = new RegExp(`\`{3,}.*\\n${escapeRegex(text)}\\s*\`{3,}`, 'g') convertArtifact(text, pattern, lang) }) btn.title = t('messageItem.convertToArtifactBtn') - code.querySelector('.md-editor-code-action').insertBefore(btn, anchor) - code.querySelector('.md-editor-copy-button').title = t('messageItem.copyCode') - code.querySelector('.md-editor-collapse-tips').title = t('messageItem.fold') + action.insertBefore(btn, anchor) + const copyButton = code.querySelector('.md-editor-copy-button') + if (copyButton) copyButton.title = t('messageItem.copyCode') + anchor.title = t('messageItem.fold') }) } const mdPreviewProps = useMdPreviewProps() diff --git a/src/components/ModelItem.vue b/src/components/ModelItem.vue index 8d1a19c3..e440eaa2 100644 --- a/src/components/ModelItem.vue +++ b/src/components/ModelItem.vue @@ -46,6 +46,6 @@ const avatar = computed(() => { if (m.startsWith('grok') || m.startsWith('xai')) return { type: 'svg', name: 'grok' } if (m.startsWith('kimi') || m.startsWith('moonshot')) return { type: 'svg', name: 'kimi-c' } if (m.startsWith('doubao')) return { type: 'svg', name: 'doubao-c' } - return defaultAvatar(m[0].toUpperCase()) + return defaultAvatar(m?.[0]?.toUpperCase() || '?') }) diff --git a/src/components/ParseFilesDialog.vue b/src/components/ParseFilesDialog.vue index 2cc68516..217698cb 100644 --- a/src/components/ParseFilesDialog.vue +++ b/src/components/ParseFilesDialog.vue @@ -164,6 +164,7 @@ async function parse() { if (!value) return [] const file = props.files[index] const fp = fileparsers.value.find(fp => fp.id === value) + if (!fp || !file) return [] try { const result = await fp.execute({ file, range: ranges[index] }, fp.settings) return result.map(r => ({ ...r, name: file.name })) @@ -178,7 +179,7 @@ async function parse() { } const ranges = reactive(props.files.map(() => null)) -const selected = reactive(props.files.map((val, index) => allOptions.value[index][0])) +const selected = reactive(props.files.map((val, index) => allOptions.value[index]?.[0]).filter(Boolean)) const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent() diff --git a/src/components/SearchDialog.vue b/src/components/SearchDialog.vue index 2a714313..3ab5be6f 100644 --- a/src/components/SearchDialog.vue +++ b/src/components/SearchDialog.vue @@ -102,28 +102,6 @@ interface Doc { content: string } -const global = ref(false) -const dialogs = ref(null) -const docs = ref(null) -watchEffect(async () => { - dialogs.value = global.value - ? await db.dialogs.toArray() - : await db.dialogs.where('workspaceId').equals(props.workspaceId).toArray() - const messages = global.value - ? await db.messages.toArray() - : await db.messages.where('dialogId').anyOf(dialogs.value.map(d => d.id)).toArray() - docs.value = messages.map(m => ({ - id: m.id, - dialogId: m.dialogId, - content: m.contents - .filter(c => c.type === 'assistant-message' || c.type === 'user-message') - .map(c => c.text) - .join('\n') - })) - search() -}) - -const q = ref('') interface Result { workspaceId: string dialogId: string @@ -131,11 +109,48 @@ interface Result { route: number[] preview?: string } -const results = ref(null) + +const global = ref(false) +const dialogs = ref([]) +const docs = ref([]) +const results = ref([]) +watchEffect(async () => { + try { + dialogs.value = global.value + ? await db.dialogs.toArray() + : await db.dialogs.where('workspaceId').equals(props.workspaceId).toArray() + const messages = global.value + ? await db.messages.toArray() + : dialogs.value.length + ? await db.messages.where('dialogId').anyOf(dialogs.value.map(d => d.id)).toArray() + : [] + docs.value = (messages || []).map(m => ({ + id: m.id, + dialogId: m.dialogId, + content: (Array.isArray(m.contents) ? m.contents : []) + .filter(c => c && (c.type === 'assistant-message' || c.type === 'user-message')) + .map(c => typeof c.text === 'string' ? c.text : '') + .join('\n') + })) + search() + } catch (error) { + // Search is a sidebar utility and must never take down the whole SPA when + // an older/malformed IndexedDB message is encountered. + console.error('[SearchDialog] failed to load dialogs', error) + dialogs.value = [] + docs.value = [] + results.value = [] + } +}) + +const q = ref('') const listRef = ref() function search() { - if (!q.value) return - const hits = docs.value.filter(d => caselessIncludes(d.content, q.value)).slice(0, 100) + if (!q.value || !docs.value || !dialogs.value) { + results.value = [] + return + } + const hits = docs.value.filter(d => d && caselessIncludes(d.content || '', q.value)).slice(0, 100) unmark() results.value = [ ...hits.map(h => { @@ -143,15 +158,15 @@ function search() { return dialog && { workspaceId: dialog.workspaceId, dialogId: dialog.id, - title: dialog.name, - preview: h.content.match(new RegExp(`^.*${escapeRegex(q.value)}.*$`, 'im'))[0], - route: getRoute(dialog.msgTree, h.id) + title: dialog.name || '', + preview: h.content.match(new RegExp(`^.*${escapeRegex(q.value)}.*$`, 'im'))?.[0] || h.content.slice(0, 240), + route: getRoute(dialog.msgTree, h.id) || [] } }).filter(Boolean), - ...dialogs.value.filter(d => caselessIncludes(d.name, q.value)).map(d => ({ + ...dialogs.value.filter(d => caselessIncludes(d.name || '', q.value)).map(d => ({ workspaceId: d.workspaceId, dialogId: d.id, - title: d.name, + title: d.name || '', route: [] })) ].reverse() @@ -179,10 +194,13 @@ watch(open, val => { }) }) -function getRoute(tree: Record, target: string, curr = '$root') { - for (const [i, v] of tree[curr].entries()) { +function getRoute(tree: Record | undefined, target: string, curr = '$root', seen = new Set()): number[] | undefined { + if (!tree || seen.has(curr)) return + seen.add(curr) + const children = Array.isArray(tree[curr]) ? tree[curr] : [] + for (const [i, v] of children.entries()) { if (v === target) return [i] - const route = getRoute(tree, target, v) + const route = getRoute(tree, target, v, seen) if (route) return [i, ...route] } } diff --git a/src/components/ViewImageDialog.vue b/src/components/ViewImageDialog.vue index 79b0dba2..97413554 100644 --- a/src/components/ViewImageDialog.vue +++ b/src/components/ViewImageDialog.vue @@ -132,6 +132,7 @@ const imageStyle = computed(() => ({ })) function onTouchStart(e: TouchEvent) { + if (e.touches.length === 0) return if (e.touches.length === 2) { wasMultiTouch = true const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2 @@ -154,6 +155,7 @@ function onTouchStart(e: TouchEvent) { } function onTouchMove(e: TouchEvent) { + if (e.touches.length === 0) return if (e.touches.length === 2) { const dist = Math.hypot( e.touches[0].clientX - e.touches[1].clientX, diff --git a/src/composables/set-theme.ts b/src/composables/set-theme.ts index 9b784a44..d085dd96 100644 --- a/src/composables/set-theme.ts +++ b/src/composables/set-theme.ts @@ -14,6 +14,7 @@ import { EdgeToEdge } from '@capawesome/capacitor-android-edge-to-edge-support' let cachedSafeAreaTop = 0 let cachedSafeAreaBottom = 0 + export function getSafeAreaTop(): number { return cachedSafeAreaTop } export function getSafeAreaBottom(): number { return cachedSafeAreaBottom } @@ -127,8 +128,11 @@ export function useSetTheme() { cachedSafeAreaTop = 24 writeInsets() }) - Keyboard.addListener('keyboardWillShow', (info) => { - cachedSafeAreaBottom = info.keyboardHeight + Keyboard.addListener('keyboardDidShow', (info) => { + // In the current Android edge-to-edge window the WebView is not + // resized, so bottom: 0 is physically underneath the IME. The + // Capacitor Android value is native pixels; convert it to CSS px. + cachedSafeAreaBottom = info.keyboardHeight / Math.max(window.devicePixelRatio || 1, 1) writeInsets() }) Keyboard.addListener('keyboardWillHide', () => { diff --git a/src/composables/use-dialog-artifact.ts b/src/composables/use-dialog-artifact.ts index d8f6434c..180b9b77 100644 --- a/src/composables/use-dialog-artifact.ts +++ b/src/composables/use-dialog-artifact.ts @@ -44,8 +44,10 @@ export function useDialogArtifact( }) if (options.reserveOriginal) return const to = `> ${t('dialogView.convertedToArtifact')}: ${name}\n` - const index = message.contents.findIndex(c => ['assistant-message', 'user-message'].includes(c.type)) - const content = message.contents[index] as UserMessageContent | AssistantMessageContent + const contents = message.contents || [] + const index = contents.findIndex(c => ['assistant-message', 'user-message'].includes(c.type)) + const content = contents[index] as UserMessageContent | AssistantMessageContent | undefined + if (!content) return await db.messages.update(message.id, { [`contents.${index}.text`]: content.text.replace(pattern, to) as any }) @@ -62,7 +64,9 @@ export function useDialogArtifact( const object: ExtractArtifactResult = JSON.parse(text) if (!object.found) return const reg = new RegExp(`(\`{3,}.*\\n)?(${object.regex})(\\s*\`{3,})?`) - const content = message.contents.find(c => c.type === 'assistant-message') + if (!message || !Array.isArray(message.contents)) return + const content = (message.contents || []).find(c => c.type === 'assistant-message') as AssistantMessageContent | undefined + if (!content || typeof content.text !== 'string') return const match = content.text.match(reg) if (!match) return await extractArtifact(message, match[2], reg, { diff --git a/src/composables/use-dialog-branch.ts b/src/composables/use-dialog-branch.ts index d541eb17..597f98b3 100644 --- a/src/composables/use-dialog-branch.ts +++ b/src/composables/use-dialog-branch.ts @@ -11,7 +11,7 @@ export function useDialogBranch( const parentId = chain.value[index - 1] const messageId = chain.value[index] - const branches = dialog.value.msgTree[parentId] + const branches = dialog.value.msgTree[parentId] || [] if (!Array.isArray(branches) || branches.length <= 1) return null const message = messageMap.value[messageId] diff --git a/src/composables/use-dialog-chain.ts b/src/composables/use-dialog-chain.ts index c272db07..86b2947b 100644 --- a/src/composables/use-dialog-chain.ts +++ b/src/composables/use-dialog-chain.ts @@ -13,14 +13,14 @@ export function useDialogChain( itemMap: Ref>, ) { const editingDraftState = ref<{ parentId: string, draftId: string } | null>(null) - const chain = computed(() => liveDialog.value ? getChain('$root', liveDialog.value.msgRoute)[0] : []) - const normalizedRoute = computed(() => liveDialog.value ? getChain('$root', liveDialog.value.msgRoute)[1] : []) + const chain = computed(() => liveDialog.value ? getChain('$root', Array.isArray(liveDialog.value.msgRoute) ? liveDialog.value.msgRoute : [])[0] : []) + const normalizedRoute = computed(() => liveDialog.value ? getChain('$root', Array.isArray(liveDialog.value.msgRoute) ? liveDialog.value.msgRoute : [])[1] : []) const historyChain = ref([]) function getChain(node, route: number[]) { const tree = liveDialog.value?.msgTree || {} const children = tree[node] - const r = route.at(0) || 0 + const r = Number.isInteger(route?.[0]) ? route[0] : 0 if (!Array.isArray(children) || children.length === 0) { return [[node], [r]] } @@ -64,7 +64,8 @@ export function useDialogChain( }) function expandMessageTree(root): string[] { - return [root, ...liveDialog.value.msgTree[root].flatMap(id => expandMessageTree(id))] + const children = liveDialog.value?.msgTree?.[root] || [] + return [root, ...children.flatMap(id => expandMessageTree(id))] } async function deleteMessageBranch(parent: string, anchor: string) { @@ -78,7 +79,7 @@ export function useDialogChain( references === 0 ? db.items.delete(id) : db.items.update(id, { references }) }) const msgTree = { ...toRaw(liveDialog.value.msgTree) } - msgTree[parent] = msgTree[parent].filter(id => id !== anchor) + msgTree[parent] = (msgTree[parent] || []).filter(id => id !== anchor) ids.forEach(id => { delete msgTree[id] }) @@ -89,8 +90,9 @@ export function useDialogChain( async function deleteBranch(index) { const parent = chain.value[index - 1] const anchor = chain.value[index] - const branch = liveDialog.value.msgRoute[index - 1] - branch === liveDialog.value.msgTree[parent].length - 1 && switchChain(index - 1, branch - 1) + const branch = liveDialog.value?.msgRoute?.[index - 1] + const siblingCount = liveDialog.value?.msgTree?.[parent]?.length ?? 0 + if (Number.isInteger(branch) && branch === siblingCount - 1 && branch > 0) switchChain(index - 1, branch - 1) await deleteMessageBranch(parent, anchor) } @@ -104,7 +106,8 @@ export function useDialogChain( ...info } as Message) const d = await db.dialogs.get(propsId.value) - const children = d.msgTree[target] + if (!d) return + const children = d.msgTree?.[target] || [] const changes = insert ? { [target]: [id], [id]: children @@ -113,7 +116,7 @@ export function useDialogChain( [id]: [] } await db.dialogs.update(propsId.value, { - msgTree: { ...d.msgTree, ...changes } + msgTree: { ...(d.msgTree || { $root: [] }), ...changes } }) }) return id diff --git a/src/composables/use-dialog-input.ts b/src/composables/use-dialog-input.ts index 6fa68891..5b68e9dc 100644 --- a/src/composables/use-dialog-input.ts +++ b/src/composables/use-dialog-input.ts @@ -38,7 +38,7 @@ export function useDialogInput( editingDraftState.value = null return false } - const content = draft.contents[0] as UserMessageContent + const content = draft.contents?.[0] as UserMessageContent | undefined if (content?.text || content?.items?.length) return false await deleteMessageBranch(state.parentId, state.draftId) editingDraftState.value = null @@ -55,7 +55,7 @@ export function useDialogInput( inputText.value = '' return } - const content = draft.contents[0] as UserMessageContent + const content = draft.contents?.[0] as UserMessageContent | undefined if (content?.text || content?.items?.length) { await deleteMessageBranch(state.parentId, state.draftId) } @@ -191,11 +191,11 @@ export function useDialogInput( onUnmounted(() => removeEventListener('paste', onPaste)) async function removeItem({ id, references }: StoredItem) { - const items = [...inputMessageContent.value.items] + const items = [...(inputMessageContent.value?.items || [])] items.splice(items.indexOf(id), 1) await db.transaction('rw', db.messages, db.items, () => { db.messages.update(activeInputMessageId.value, { - contents: [{ ...inputMessageContent.value, items }] + contents: [{ ...(inputMessageContent.value || { type: 'user-message', text: '' }), items }] }) references-- references === 0 ? db.items.delete(id) : db.items.update(id, { references }) @@ -233,7 +233,7 @@ export function useDialogInput( if (displayLength(item.contentText) > 200) { addInputItems([item]) } else { - const { text } = inputMessageContent.value + const text = inputMessageContent.value?.text || '' const content = wrapQuote(item.contentText) + '\n\n' updateInputText(text ? text + '\n' + content : content) } @@ -244,7 +244,7 @@ export function useDialogInput( const ids = storedItems.map(i => i.id) await db.transaction('rw', db.messages, db.items, () => { db.messages.update(activeInputMessageId.value, { - contents: [{ ...inputMessageContent.value, items: [...inputMessageContent.value.items, ...ids] }] + contents: [{ ...(inputMessageContent.value || { type: 'user-message', text: '' }), items: [...(inputMessageContent.value?.items || []), ...ids] }] }) saveItemsLocal(storedItems) }) diff --git a/src/composables/use-dialog-scroll.ts b/src/composables/use-dialog-scroll.ts index 45e094f3..05ded3f1 100644 --- a/src/composables/use-dialog-scroll.ts +++ b/src/composables/use-dialog-scroll.ts @@ -254,7 +254,8 @@ export function useDialogScroll( const id = chain.value[index] let to const curr = dialog.value.msgRoute[index] - const num = dialog.value.msgTree[id].length + const num = dialog.value.msgTree[id]?.length ?? 0 + if (num <= 0) return if (target === 'first') to = 0 else if (target === 'last') to = num - 1 else if (target === 'prev') to = curr - 1 diff --git a/src/composables/use-message-window.ts b/src/composables/use-message-window.ts new file mode 100644 index 00000000..439cdd90 --- /dev/null +++ b/src/composables/use-message-window.ts @@ -0,0 +1,131 @@ +import { computed, nextTick, onBeforeUnmount, ref, watch, type Ref } from 'vue' + +/** + * Renders only a sliding window of the message chain to prevent + * DOM bloat in long conversations. Older messages are loaded on + * scroll-up via IntersectionObserver sentinel placed at the top. + * + * Architecture: + * - Initial render: last INITIAL_LIMIT messages + * - Scroll to top → sentinel enters viewport → loadMore (LOAD_MORE_SIZE) + * - Scroll position is preserved by compensating scrollHeight delta + * - Observer is gated with cooldown to prevent rapid-fire loads + */ +export function useMessageWindow( + chain: Ref, + scrollContainer: Ref, +) { + const INITIAL_LIMIT = 100 + const LOAD_MORE_SIZE = 50 + const MAX_RENDER_CAP = 200 // hard limit: never render more than this + const SETTLE_DELAY_MS = 500 + const OBSERVER_COOLDOWN_MS = 600 + + const renderStart = ref(Math.max(0, chain.value.length - INITIAL_LIMIT)) + const renderEnd = ref(chain.value.length) + const isLoadingMore = ref(false) + + const reset = () => { + renderEnd.value = chain.value.length + renderStart.value = Math.max(0, renderEnd.value - INITIAL_LIMIT) + isLoadingMore.value = false + } + + // Stable-keyed visible slice — keys are message IDs so Vue won't remount + const visibleItems = computed(() => + chain.value + .slice(renderStart.value, renderEnd.value) + .map((id, i) => ({ + id, + originalIndex: renderStart.value + i, + })), + ) + + const hasMore = computed(() => renderStart.value > 0) + + function loadMore() { + if (isLoadingMore.value || !hasMore.value) return + + const container = scrollContainer.value + // Guard against false triggers: keyboard appearance shrinks viewport, + // which can make the sentinel appear "visible" even when user hasn't scrolled. + // Require scrollTop near 0 (user actually scrolled to top). + if (container && container.scrollTop > 60) return + + isLoadingMore.value = true + + const prevScrollHeight = container?.scrollHeight ?? 0 + const prevScrollTop = container?.scrollTop ?? 0 + + renderStart.value = Math.max(0, renderStart.value - LOAD_MORE_SIZE) + // Enforce max cap: if window exceeds limit, drop oldest messages + if (renderEnd.value - renderStart.value > MAX_RENDER_CAP) { + renderEnd.value = renderStart.value + MAX_RENDER_CAP + } + + // Restore scroll position after Vue re-renders expanded content + nextTick(() => { + if (container) { + const newScrollHeight = container.scrollHeight + container.scrollTop = prevScrollTop + (newScrollHeight - prevScrollHeight) + } + // Cooldown: don't let the observer fire again immediately + setTimeout(() => { + isLoadingMore.value = false + }, OBSERVER_COOLDOWN_MS) + }) + } + + // ── IntersectionObserver sentinel ── + const sentinelRef = ref() + let sentinelObserver: IntersectionObserver | null = null + let settleTimer: ReturnType | null = null + + function setupSentinel(el: HTMLElement) { + sentinelObserver?.disconnect() + sentinelObserver = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + loadMore() + } + }, + { root: scrollContainer.value ?? null, threshold: 0 }, + ) + sentinelObserver.observe(el) + } + + function teardownSentinel() { + sentinelObserver?.disconnect() + sentinelObserver = null + if (settleTimer) { + clearTimeout(settleTimer) + settleTimer = null + } + } + + // Watch the sentinel DOM element: set up observer with delay so + // initial scroll restoration has time to settle before detection. + watch(sentinelRef, (el) => { + teardownSentinel() + if (!el) return + settleTimer = setTimeout(() => { + setupSentinel(el) + settleTimer = null + }, SETTLE_DELAY_MS) + }) + + onBeforeUnmount(() => { + teardownSentinel() + }) + + return { + visibleItems, + renderStart, + renderEnd, + hasMore, + isLoadingMore, + loadMore, + reset, + sentinelRef, + } +} diff --git a/src/css/app.scss b/src/css/app.scss index 6df57ca4..6cfcad1a 100644 --- a/src/css/app.scss +++ b/src/css/app.scss @@ -144,14 +144,11 @@ body.body--light { } .dialog-composer-area { - position: absolute; + position: fixed !important; left: 0; right: 0; - bottom: 0; - // Bug #8: prefer --sab (set by set-theme.ts from Keyboard plugin) so the - // composer sits above the soft keyboard when it's open. env(safe-area-inset-bottom) - // covers iOS notch home indicator and Android nav bar when no keyboard is open. - padding: 0 12px calc(14px + max(var(--sab, 0px), env(safe-area-inset-bottom, 0px))); + bottom: var(--sab, 0px); + padding: 0 12px 14px; pointer-events: none; background: transparent !important; box-shadow: none !important; diff --git a/src/utils/db.ts b/src/utils/db.ts index 8a5f5d66..c83c1ea7 100644 --- a/src/utils/db.ts +++ b/src/utils/db.ts @@ -37,7 +37,7 @@ db.on('blocked', () => { // Dexie 4 把 schema version 乘 10 写入 IDB version(schema v7 = IDB v70), // 所以"IDB v70"是正常状态,**不要**迁移或删除。 // 保留这段仅作未来扩展用:只有当 IDB version > 70*10=700(schema v70+)才视为污染。 -const __migratePromise = (async () => { +void (async () => { if (DexieDBURL || typeof indexedDB === 'undefined') return try { const dbs = await indexedDB.databases?.() @@ -165,11 +165,13 @@ db.on.populate.subscribe(() => { // Migration db.assistants.hook('reading', assistant => { + if (!assistant || typeof assistant !== 'object') return assistant + assistant.modelSettings ||= {} assistant.promptRole ??= 'system' assistant.stream ??= true // Migration to v1.8 const { modelSettings } = assistant - if ('maxTokens' in modelSettings) { + if (modelSettings && 'maxTokens' in modelSettings) { modelSettings.maxOutputTokens = modelSettings.maxTokens as number delete modelSettings.maxTokens } @@ -187,7 +189,24 @@ db.workspaces.hook('reading', workspace => { return workspace }) +db.dialogs.hook('reading', dialog => { + if (!dialog || typeof dialog !== 'object') return dialog + if (!dialog.msgTree || typeof dialog.msgTree !== 'object' || Array.isArray(dialog.msgTree)) { + dialog.msgTree = { $root: [] } + } else { + for (const key of Object.keys(dialog.msgTree)) { + if (!Array.isArray(dialog.msgTree[key])) dialog.msgTree[key] = [] + } + dialog.msgTree.$root ??= [] + } + if (!Array.isArray(dialog.msgRoute)) dialog.msgRoute = [] + return dialog +}) + db.messages.hook('reading', message => { + if (!message || typeof message !== 'object') return message + if (!Array.isArray(message.contents)) message.contents = [] + else message.contents = message.contents.filter(content => content && typeof content === 'object') const usage = message.usage as any if (usage && 'promptTokens' in usage) { message.usage = { @@ -199,5 +218,40 @@ db.messages.hook('reading', message => { return message }) +// Persist normalization for records written by older builds. +let repairStarted = false +db.on('ready', () => { + if (repairStarted) return + repairStarted = true + void db.transaction('rw', db.dialogs, db.messages, async () => { + const dialogs = await db.dialogs.toArray() + for (const dialog of dialogs) { + const tree = dialog.msgTree && typeof dialog.msgTree === 'object' && !Array.isArray(dialog.msgTree) + ? dialog.msgTree + : { $root: [] } + let changed = !dialog.msgTree || Array.isArray(dialog.msgTree) + for (const key of Object.keys(tree)) { + if (!Array.isArray(tree[key])) { + tree[key] = [] + changed = true + } + } + if (!Array.isArray(tree.$root)) { + tree.$root = [] + changed = true + } + if (!Array.isArray(dialog.msgRoute)) { + dialog.msgRoute = [] + changed = true + } + if (changed) await db.dialogs.put({ ...dialog, msgTree: tree }) + } + const messages = await db.messages.toArray() + for (const message of messages) { + if (!Array.isArray(message.contents)) await db.messages.update(message.id, { contents: [] }) + } + }).catch(error => console.error('[db] legacy record repair failed', error)) +}) + export { schema, db, defaultModelSettings } export type { Db } diff --git a/src/utils/doc-parse-plugin.ts b/src/utils/doc-parse-plugin.ts index c7542f8b..2ad6c56b 100644 --- a/src/utils/doc-parse-plugin.ts +++ b/src/utils/doc-parse-plugin.ts @@ -106,7 +106,7 @@ async function parseDocx(file: Blob): Promise { } function rowsToMarkdown(rows) { - if (!rows.length) return '' + if (!Array.isArray(rows) || !rows.length || !Array.isArray(rows[0])) return '' const head = rows[0].map(c => c == null ? '' : String(c)) const body = rows.slice(1).map(r => r.map(c => c == null ? '' : String(c)) @@ -136,7 +136,11 @@ async function parsePptx(file: Blob, { targetPages }): Promise const slideFileNames = Object.keys(files).filter(name => /^ppt\/slides\/slide\d+\.xml$/.test(name) - ).sort((a, b) => parseInt(a.match(/\d+/)[0]) - parseInt(b.match(/\d+/)[0])) + ).sort((a, b) => { + const aNum = a.match(/\d+/)?.[0] + const bNum = b.match(/\d+/)?.[0] + return Number(aNum || 0) - Number(bNum || 0) + }) const texts = slideFileNames.map(name => { const xmlStr = strFromU8(files[name]) diff --git a/src/utils/plugins.ts b/src/utils/plugins.ts index 75135bf5..0a39f595 100644 --- a/src/utils/plugins.ts +++ b/src/utils/plugins.ts @@ -437,7 +437,9 @@ function buildMcpPlugin(dump: McpPluginDump, available: boolean): Plugin { return resourceToResultItem(i.resource) } else { const resource = await client.readResource({ uri: i.uri }, requestOptions(settings)) - return resourceToResultItem(resource.contents[0]) + const resourceContent = resource?.contents?.[0] + if (!resourceContent) return { type: 'text' as const, contentText: '' } + return resourceToResultItem(resourceContent) } })) } @@ -453,7 +455,7 @@ function buildMcpPlugin(dump: McpPluginDump, available: boolean): Plugin { async execute(args, settings) { const client = await getClient(id, applyDerivedProviderHeaders(settings, { type: transport.type, ...settings })) const res: ReadResourceResult = await client.readResource({ uri }, requestOptions(settings)) - return res.contents.map(c => resourceToResultItem(c, name)) + return res.contents?.map(c => resourceToResultItem(c, name)) || [] } } }) @@ -492,7 +494,9 @@ function buildMcpPlugin(dump: McpPluginDump, available: boolean): Plugin { return resourceToResultItem(content.resource, content.resource.uri) } else { const resource = await client.readResource({ uri: content.uri }, requestOptions(settings)) - return resourceToResultItem(resource.contents[0]) + const resourceContent = resource?.contents?.[0] + if (!resourceContent) return { type: 'text', name, contentText: '' } + return resourceToResultItem(resourceContent, content.resource?.uri) } })) } @@ -721,7 +725,9 @@ const videoTranscriptPlugin: Plugin = { if (!AudioEncoderSupported) throw new Error(t('plugins.videoTranscript.audioEncoderError')) const rg = range ? range.split('-').map(parseSeconds) : undefined const audioBlob = await extractAudioBlob(file, rg as [number, number]) - return await whisperPlugin.fileparsers[0].execute({ file: audioBlob }, settings) + const parser = whisperPlugin.fileparsers?.[0] + if (!parser) return [{ type: 'text', contentText: '' }] + return await parser.execute({ file: audioBlob }, settings) }, rangeInput: { label: t('plugins.videoTranscript.rangeInput.label'), diff --git a/src/views/DialogView.vue b/src/views/DialogView.vue index f019a396..4df46368 100644 --- a/src/views/DialogView.vue +++ b/src/views/DialogView.vue @@ -157,27 +157,42 @@ :class="['dialog-scroll-container', { 'rd-r-lg': rightDrawerAbove }]" @scroll="onScroll" > +