fix: stabilize background operations and large embedding results

This commit is contained in:
2026-09-06 16:26:17 +08:00
parent 874e916106
commit 3b9490e3fb
73 changed files with 3847 additions and 149 deletions
@@ -1,6 +1,6 @@
theme_id: paper-moments
name: 纸间时光 · Paper Moments
version: 1.8.0
version: 1.8.1
author: NotesAgent
description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。
min_app_version: 0.2.0
@@ -155,9 +155,13 @@ license: MIT
padding: 44px 40px 60px 52px;
border: 1px solid #685949;
border-radius: 8px 16px 8px 8px;
outline: 1px dashed #c5b9a7;
outline-offset: -10px;
background: linear-gradient(90deg, transparent 32px, #e9cfc7 32px 34px, transparent 34px), #fffef8;
/* Small repeated tiles retain the stitching without a document-height dashed outline. */
background:
linear-gradient(#c5b9a7 50%, transparent 50%) 10px 0 / 1px 8px repeat-y,
linear-gradient(#c5b9a7 50%, transparent 50%) calc(100% - 10px) 0 / 1px 8px repeat-y,
linear-gradient(90deg, #c5b9a7 50%, transparent 50%) 0 10px / 8px 1px repeat-x,
linear-gradient(90deg, #c5b9a7 50%, transparent 50%) 0 calc(100% - 10px) / 8px 1px repeat-x,
linear-gradient(90deg, transparent 32px, #e9cfc7 32px 34px, transparent 34px), #fffef8;
box-shadow: 6px 6px 0 #d8e6e2, 12px 12px 0 #f0d8cf;
}
[data-theme="paper-moments"] .milkdown-host .ProseMirror::before {
+3 -2
View File
@@ -29,8 +29,9 @@ const router = useRouter()
let statusTimer: ReturnType<typeof setTimeout> | undefined
let disposed = false
async function pollIndex() {
try { settingsStore.indexStatus = await getIndexStatus() } catch { /* retain last status; retry */ }
if (!disposed) statusTimer = setTimeout(pollIndex, 5000)
try { const status = await getIndexStatus(); if (!disposed) settingsStore.indexStatus = status } catch { /* retain last status; retry */ }
const busy = settingsStore.indexStatus.status === 'indexing' || settingsStore.indexStatus.active_searches
if (!disposed) statusTimer = setTimeout(pollIndex, busy || route.name === 'settings' || route.name === 'search' ? 1000 : 5000)
}
onMounted(() => { void settingsStore.loadDiagnostics(); void pollIndex() })
onUnmounted(() => { disposed = true; clearTimeout(statusTimer) })
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { computed, ref } from 'vue'
import { ArrowLeftBold, ArrowRightBold, Brush, ChatDotRound, CircleCheck, Connection, Cpu, FolderOpened, Lightning, Monitor, Search, Setting } from '@element-plus/icons-vue'
import { ArrowLeftBold, ArrowRightBold, Brush, ChatDotRound, CircleCheck, Connection, Cpu, Document, FolderOpened, Lightning, Monitor, Search, Setting } from '@element-plus/icons-vue'
import AppIcon from './AppIcon.vue'
import { t } from '@/i18n'
@@ -20,6 +20,7 @@ const navItems = computed(() => [
{ name: 'plugins', icon: Connection, label: 'Plugin' },
{ name: 'mcp-servers', icon: Monitor, label: 'MCP' },
{ name: 'themes', icon: Brush, label: t('主题', 'Themes') },
{ name: 'logs', icon: Document, label: t('日志', 'Logs') },
{ name: 'settings', icon: Setting, label: t('设置', 'Settings') },
])
+2 -6
View File
@@ -38,11 +38,7 @@ const saveStatusColor = computed(() => {
return map[editorStore.saveStatus] || 'var(--color-text-tertiary)'
})
const indexStatusText = computed(() => {
const s = settingsStore.indexStatus.status
if (s === 'idle' && settingsStore.indexStatus.vector_refresh_required) return t('全文可用 · 向量待重建', 'Full text ready · vectors need rebuilding')
return s === 'unknown' ? t('索引状态未获取', 'Index status unavailable') : s === 'idle' ? t('索引就绪', 'Index ready') : s === 'indexing' ? t('后台计算索引', 'Indexing in background') : t('索引错误', 'Index error')
})
const indexStatusText = computed(() => settingsStore.indexStatusLabel)
const aiCoreStatusText = computed(() => {
const map: Record<string, string> = {
@@ -78,7 +74,7 @@ const showEditorInfo = computed(() => route.name === 'workspace')
{{ saveStatusText }}
</span>
<span class="status-item" :title="indexStatusText">
<span class="status-dot" :style="{ background: settingsStore.indexStatus.status === 'error' ? 'var(--color-error)' : settingsStore.indexStatus.status === 'indexing' ? 'var(--color-warning)' : 'var(--color-success)' }" />
<span class="status-dot" :style="{ background: settingsStore.indexStatus.status === 'error' ? 'var(--color-error)' : settingsStore.indexStatus.status === 'unknown' ? 'var(--color-text-tertiary)' : settingsStore.indexStatus.status === 'indexing' || settingsStore.indexStatus.vector_refresh_required || settingsStore.indexStatus.active_searches ? 'var(--color-warning)' : 'var(--color-success)' }" />
{{ indexStatusText }}
</span>
<span class="status-item" :style="{ color: aiCoreColor }">
+10
View File
@@ -507,6 +507,11 @@ export interface ThemeConfig {
// ============ Index ============
export interface IndexStatus {
running_jobs?: number
active_searches?: number
completed_searches?: number
failed_searches?: number
cancelled_searches?: number
vector_refresh_required?: boolean
status: 'unknown' | 'idle' | 'indexing' | 'error'
pending_jobs: number
@@ -801,6 +806,11 @@ export interface ApiTask {
}
export interface ApiIndexStatus {
running_jobs?: number
active_searches?: number
completed_searches?: number
failed_searches?: number
cancelled_searches?: number
vector_refresh_required?: boolean
total_notes: number
total_blocks: number
+7 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, watch, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAgentStore } from '@/stores/agent'
import { localeTag, t } from '@/i18n'
@@ -8,6 +8,10 @@ import { runStatusLabel } from './labels'
const agentStore = useAgentStore()
const router = useRouter()
const error = ref('')
const page = ref(1)
const pages = computed(() => Math.max(1, Math.ceil(agentStore.sortedRuns.length / 50)))
const visibleRuns = computed(() => agentStore.sortedRuns.slice((page.value - 1) * 50, page.value * 50))
watch(pages, count => { page.value = Math.min(page.value, count) })
onMounted(async () => {
try { await agentStore.loadRuns() } catch (reason) { error.value = reason instanceof Error ? reason.message : t('运行记录加载失败', 'Failed to load runs') }
@@ -20,8 +24,9 @@ function selectRun(runId: string) { void router.push({ name: 'agent', params: {
<div class="sidebar-panel">
<button class="button-primary new-button" @click="router.push({ name: 'agent' })"> {{ t('新建运行', 'New run') }}</button>
<p v-if="error" class="subtle error-text">{{ error }}</p>
<div v-if="pages > 1" class="inline-actions"><button class="button-secondary" :disabled="page === 1" @click="page--">{{ t('上一页', 'Previous') }}</button><span>{{ page }} / {{ pages }}</span><button class="button-secondary" :disabled="page === pages" @click="page++">{{ t('下一页', 'Next') }}</button></div>
<div class="sidebar-list">
<button v-for="run in agentStore.sortedRuns" :key="run.run_id" class="sidebar-list-item run-item"
<button v-for="run in visibleRuns" :key="run.run_id" class="sidebar-list-item run-item"
:class="{ active: agentStore.activeRunId === run.run_id }" @click="selectRun(run.run_id)">
<span class="badge" :class="{ success: run.status === 'completed', error: run.status === 'failed', warning: run.status === 'waiting_permission' }">{{ runStatusLabel(run.status) }}</span>
<strong>{{ run.run_id.slice(0, 12) }}</strong><small>{{ run.started_at ? new Date(run.started_at).toLocaleString(localeTag()) : t('等待开始', 'Waiting to start') }}</small>
@@ -41,6 +41,17 @@ async function switchToTree(wrapper: ReturnType<typeof mountTree>) {
}
describe('TraceTimeline 树形视图', () => {
it('bounds rendered events while searching the complete history', async () => {
const events = Array.from({ length: 1000 }, (_, i) => event('TextDelta', { text: `message-${i}` }))
const wrapper = mountTree(events)
expect(wrapper.findAll('.event-card')).toHaveLength(200)
await wrapper.findAll('button').find(button => button.text() === '下一页')!.trigger('click')
expect(wrapper.findAll('.event-card')).toHaveLength(200)
await wrapper.get('input').setValue('message-999')
expect(wrapper.findAll('.event-card')).toHaveLength(1)
expect(wrapper.text()).toContain('message-999')
wrapper.unmount()
})
it('filters errors while retaining tree ancestors and final tool data', async () => {
const events = sampleEvents()
const result = events.find(item => item.event === 'ToolResult')!
+29 -7
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { localeTag, t } from '@/i18n'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import type { TraceNode, AgentEvent } from '@/contracts'
import { buildTraceNodes, getToolCallsFromEvents, getTotalDuration } from '@/services/traceService'
import { eventLabel, localizeDetails } from './labels'
@@ -37,9 +37,12 @@ const filteredEvents = computed(() => {
})
const filteredTree = computed(() => {
if (!filtering.value) return traceNodes.value
const matches = (node: TraceNode) => filteredEvents.value.some(event => event.sequence === node.sequence
|| (node.type === 'tool_call' && node.data.tool_call_id != null && node.data.tool_call_id === event.data.tool_call_id)
|| (node.type === 'model_call' && node.data.model_call_id != null && node.data.model_call_id === event.data.model_call_id))
const sequences = new Set(filteredEvents.value.map(event => event.sequence))
const tools = new Set<unknown>(filteredEvents.value.map(event => event.data.tool_call_id).filter(id => id != null))
const models = new Set<unknown>(filteredEvents.value.map(event => event.data.model_call_id).filter(id => id != null))
const matches = (node: TraceNode) => sequences.has(node.sequence)
|| (node.type === 'tool_call' && tools.has(node.data.tool_call_id))
|| (node.type === 'model_call' && models.has(node.data.model_call_id))
const prune = (nodes: TraceNode[]): TraceNode[] => nodes.flatMap(node => {
const children = prune(node.children)
return matches(node) || children.length ? [{ ...node, children }] : []
@@ -50,6 +53,17 @@ function resetFilters() { query.value = ''; eventType.value = ''; toolName.value
const traceNodes = computed(() => buildTraceNodes(props.events))
const toolCalls = computed(() => getToolCallsFromEvents(props.events))
const toolSummary = computed(() => {
const groups = new Map<string, { key: string; name: string; status: string; count: number; duration_ms: number }>()
for (const call of toolCalls.value) {
const key = `${call.name}:${call.status}`
const group = groups.get(key) ?? { key, name: call.name, status: call.status, count: 0, duration_ms: 0 }
group.count++
group.duration_ms += call.duration_ms ?? 0
groups.set(key, group)
}
return [...groups.values()]
})
const totalDuration = computed(() => getTotalDuration(props.events))
const summaryStats = computed(() => {
@@ -153,6 +167,12 @@ function flatNodes(nodes: TraceNode[], depth = 0): Array<{ node: TraceNode; dept
}
const flatTrace = computed(() => flatNodes(filteredTree.value))
const page = ref(1)
const pageCount = computed(() => Math.max(1, Math.ceil((viewMode.value === 'tree' ? flatTrace.value.length : filteredEvents.value.length) / 200)))
const visibleEvents = computed(() => filteredEvents.value.slice((page.value - 1) * 200, page.value * 200))
const visibleTrace = computed(() => flatTrace.value.slice((page.value - 1) * 200, page.value * 200))
watch([query, eventType, toolName, errorsOnly, viewMode], () => { page.value = 1 })
watch(pageCount, count => { page.value = Math.min(page.value, count) })
</script>
<template>
@@ -199,11 +219,12 @@ const flatTrace = computed(() => flatNodes(filteredTree.value))
<button v-if="filtering" class="button-secondary" @click="resetFilters">清除筛选</button>
<span aria-live="polite">{{ filteredEvents.length }} / {{ events.length }} 事件</span>
</div>
<nav v-if="pageCount > 1" class="inline-actions"><button class="button-secondary" :disabled="page === 1" @click="page--">{{ t('上一页', 'Previous') }}</button><span>{{ page }} / {{ pageCount }}</span><button class="button-secondary" :disabled="page === pageCount" @click="page++">{{ t('下一页', 'Next') }}</button></nav>
<p v-if="filtering && !filteredEvents.length" class="subtle" role="status">没有匹配的事件</p>
<div v-if="viewMode === 'timeline'" class="timeline-view">
<div class="timeline">
<article
v-for="event in filteredEvents"
v-for="event in visibleEvents"
:key="event.sequence"
class="event-card"
:class="{ expanded: isDetailOpen(`event-${event.sequence}`) }"
@@ -255,7 +276,7 @@ const flatTrace = computed(() => flatNodes(filteredTree.value))
</div>
<div v-else class="tree-view">
<div v-for="item in flatTrace" :key="item.node.id" class="tree-node" :style="{ paddingLeft: `${item.depth * 24 + 8}px` }">
<div v-for="item in visibleTrace" :key="item.node.id" class="tree-node" :style="{ paddingLeft: `${item.depth * 24 + 8}px` }">
<div
class="node-row"
:class="[getNodeStatusClass(item.node), { 'detail-open': isDetailOpen(item.node.id) }]"
@@ -303,9 +324,10 @@ const flatTrace = computed(() => flatNodes(filteredTree.value))
<div v-if="!filtering && toolCalls.length > 0 && viewMode === 'timeline'" class="tool-calls-summary panel">
<h3 class="panel-title">工具调用统计</h3>
<div class="tool-call-list">
<div v-for="call in toolCalls" :key="call.tool_call_id" class="tool-call-item" :class="call.status">
<div v-for="call in toolSummary" :key="call.key" class="tool-call-item" :class="call.status">
<span class="tool-status-dot"></span>
<code class="tool-name">{{ call.name }}</code>
<span>{{ call.count }} {{ t('次', 'calls') }}</span>
<span v-if="call.duration_ms != null" class="tool-duration">
{{ formatDuration(call.duration_ms) }}
</span>
@@ -55,6 +55,22 @@ afterEach(() => {
})
describe('VisualMarkdownEditor formatting toolbars', () => {
it('opens a rendered Markdown link on Ctrl click without changing its source', async () => {
const wrapper = mount(VisualMarkdownEditor, {
props: { initialContent: '[**文档**](https://example.com/docs)' }, attachTo: document.body,
})
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
const before = editor.action(getMarkdown())
const open = vi.spyOn(window, 'open').mockReturnValue(null)
try {
const link = wrapper.get('.ProseMirror a')
await link.trigger('click', { ctrlKey: true, button: 0 })
expect(open).toHaveBeenCalledWith('https://example.com/docs', '_blank', 'noopener,noreferrer')
expect(editor.action(getMarkdown())).toBe(before)
} finally { open.mockRestore() }
})
it('applies syntax and renderer preferences when opening the visual editor', async () => {
const preferences = useMarkdownPreferencesStore()
preferences.preferences.heading = 'setext'
@@ -16,6 +16,7 @@ import { shikiEditorTheme, shikiLanguages, renderCodeLanguage } from './shikiCod
import './language-icons.css'
import { installLanguagePickerPopover } from './languagePickerPopover'
import { installCodeBlockLabels } from './codeBlockLabels'
import { installLinkNavigation } from './linkNavigation'
import { createMermaidPreview } from './mermaidPreview'
import { splitNoteMetadata, updateMetadataTags } from './noteMetadata'
import { getMarkdown, $remark, $prose } from '@milkdown/kit/utils'
@@ -80,6 +81,7 @@ const fontSizeInput = ref(16)
let crepe: Crepe | null = null
let disposeLanguagePicker: (() => void) | undefined
let disposeCodeLabels: (() => void) | undefined
let disposeLinkNavigation: (() => void) | undefined
let disposeCommands: (() => void) | undefined
let disposed = false
@@ -392,6 +394,7 @@ onMounted(async () => {
await crepe.create()
if (editorRoot.value) disposeLanguagePicker = installLanguagePickerPopover(editorRoot.value)
if (editorRoot.value) disposeCodeLabels = installCodeBlockLabels(editorRoot.value)
if (editorRoot.value) disposeLinkNavigation = installLinkNavigation(editorRoot.value)
applyProofingPreferences()
loading.value = false
if (!disposed) installCommands()
@@ -412,7 +415,7 @@ watch(() => editorStore.headingRequest, request => {
})
})
onBeforeUnmount(() => { disposed = true; disposeCommands?.(); diagramPreviews.clear(); disposeCodeLabels?.(); disposeLanguagePicker?.(); void crepe?.destroy() })
onBeforeUnmount(() => { disposed = true; disposeCommands?.(); diagramPreviews.clear(); disposeLinkNavigation?.(); disposeCodeLabels?.(); disposeLanguagePicker?.(); void crepe?.destroy() })
defineExpose({ getEditor: () => crepe?.editor })
</script>
@@ -0,0 +1,33 @@
// @vitest-environment happy-dom
import { afterEach, expect, it, vi } from 'vitest'
import { installLinkNavigation } from './linkNavigation'
afterEach(() => { document.body.innerHTML = ''; vi.restoreAllMocks() })
it('opens nested link content with Ctrl/Command click but leaves ordinary editing alone', () => {
const root = document.createElement('div')
root.innerHTML = '<div class="ProseMirror"><a href="https://example.com/docs"><strong>Docs</strong></a></div>'
document.body.append(root)
const dispose = installLinkNavigation(root)
const open = vi.spyOn(window, 'open').mockReturnValue(null)
const target = root.querySelector('strong')!
const click = (options: MouseEventInit) => {
const event = new MouseEvent('click', { bubbles: true, cancelable: true, ...options })
target.dispatchEvent(event)
return event
}
expect(click({}).defaultPrevented).toBe(false)
click({ ctrlKey: true, button: 2 })
expect(open).not.toHaveBeenCalled()
expect(click({ ctrlKey: true }).defaultPrevented).toBe(true)
expect(open).toHaveBeenLastCalledWith('https://example.com/docs', '_blank', 'noopener,noreferrer')
click({ metaKey: true })
expect(open).toHaveBeenCalledTimes(2)
root.querySelector('a')!.href = 'javascript:alert(1)'
expect(click({ ctrlKey: true }).defaultPrevented).toBe(true)
expect(open).toHaveBeenCalledTimes(2)
dispose()
root.querySelector('a')!.href = 'https://example.com'
expect(click({ ctrlKey: true }).defaultPrevented).toBe(false)
expect(open).toHaveBeenCalledTimes(2)
})
@@ -0,0 +1,20 @@
/** Editable anchors need explicit navigation; plain clicks keep editing the link. */
export function installLinkNavigation(root: HTMLElement): () => void {
const navigate = (event: MouseEvent) => {
if (event.button !== 0 || !(event.ctrlKey || event.metaKey) || event.altKey) return
const target = event.target instanceof Element ? event.target : (event.target as Node | null)?.parentElement
const link = target?.closest<HTMLAnchorElement>('.ProseMirror a[href]')
if (!link || !root.contains(link)) return
const href = link.getAttribute('href')?.trim()
if (!href) return
// Consume modified clicks before Milkdown's link editor or native navigation.
event.preventDefault()
event.stopPropagation()
let url: URL
try { url = new URL(href, document.baseURI) } catch { return }
if (!['http:', 'https:', 'mailto:', 'tel:'].includes(url.protocol)) return
window.open(url.href, '_blank', 'noopener,noreferrer')
}
root.addEventListener('click', navigate, true)
return () => root.removeEventListener('click', navigate, true)
}
@@ -1,13 +1,37 @@
// @vitest-environment happy-dom
import { afterEach, expect, it } from 'vitest'
import { afterEach, expect, it, vi } from 'vitest'
import { Compartment } from '@codemirror/state'
import { bundledLanguagesInfo } from 'shiki/langs'
import { EditorView } from '@codemirror/view'
import { shikiLanguage, shikiLanguages } from './shikiCodeMirror'
import { getCodeTokenizer } from '@/utils/markdown'
import * as markdown from '@/utils/markdown'
const editors: EditorView[] = []
afterEach(() => { editors.splice(0).forEach(view => view.destroy()) })
afterEach(() => { editors.splice(0).forEach(view => view.destroy()); vi.restoreAllMocks() })
it('reuses highlighting across recreated views and bounds retained entries', async () => {
const tokenize = vi.fn(await getCodeTokenizer('github-light', 'javascript'))
vi.spyOn(markdown, 'getCodeTokenizer').mockResolvedValue(tokenize)
const support = await shikiLanguage('javascript', 'github-light')
const create = (doc: string) => {
const view = new EditorView({ doc, extensions: [support] })
editors.push(view)
return view
}
const source = 'const answer = 42'
create(source)
const recreated = create(source)
expect(tokenize).toHaveBeenCalledTimes(1)
expect(recreated.dom.textContent).toContain(source)
recreated.dispatch({ changes: { from: 0, to: source.length, insert: 'let changed = 1' } })
expect(tokenize).toHaveBeenCalledTimes(2)
expect(recreated.dom.textContent).toContain('let changed = 1')
for (let i = 0; i < 33; i++) create(`const value = ${i}`)
const before = tokenize.mock.calls.length
create(source)
expect(tokenize).toHaveBeenCalledTimes(before + 1)
})
it.each(['github-light', 'github-dark'] as const)('uses Shiki %s tokens and updates editable content', async theme => {
const support = await shikiLanguage('python', theme)
@@ -7,6 +7,10 @@ type CodeTheme = 'github-light' | 'github-dark'
export async function shikiLanguage(language: string, theme: CodeTheme): Promise<LanguageSupport> {
const tokenize = await getCodeTokenizer(theme, language)
// Milkdown recreates off-screen CodeMirror views. Reuse immutable ranges for
// identical code within this language/theme, with a bounded retention budget.
const cache = new Map<string, DecorationSet>()
let cachedCharacters = 0
const highlights = ViewPlugin.fromClass(class {
decorations: DecorationSet
@@ -17,7 +21,13 @@ export async function shikiLanguage(language: string, theme: CodeTheme): Promise
}
highlight(view: EditorView): DecorationSet {
const tokens = tokenize(view.state.doc.toString(), language)
const source = view.state.doc.toString()
const cached = cache.get(source)
if (cached) {
cache.delete(source); cache.set(source, cached)
return cached
}
const tokens = tokenize(source, language)
const ranges = tokens.flatMap((line, index) => {
let offset = view.state.doc.line(index + 1).from
return line.flatMap(token => {
@@ -31,7 +41,15 @@ export async function shikiLanguage(language: string, theme: CodeTheme): Promise
}).range(from, offset)]
})
})
return Decoration.set(ranges)
const decorations = Decoration.set(ranges)
if (source.length <= 16000) {
cache.set(source, decorations); cachedCharacters += source.length
while (cache.size > 32 || cachedCharacters > 64000) {
const oldest = cache.keys().next().value!
cachedCharacters -= oldest.length; cache.delete(oldest)
}
}
return decorations
}
}, { decorations: value => value.decorations })
@@ -0,0 +1,65 @@
// @vitest-environment happy-dom
import { mount, flushPromises } from '@vue/test-utils'
import { afterEach, expect, it, vi } from 'vitest'
import LogsView from './LogsView.vue'
const get = vi.hoisted(() => vi.fn())
vi.mock('@/services/apiClient', () => ({ default: { get } }))
afterEach(() => { vi.useRealTimers(); vi.clearAllMocks() })
const page = (id: number, next: number | null) => ({ items: [{ id, timestamp: '2026-09-06T01:00:00Z', level: 'ERROR', source: 'vectors', event: 'embedding.failed', details: { error_code: 'LOCAL_CUDA_OOM' } }], next_cursor: next, sources: ['vectors'], pending: 0, dropped: 0, write_failures: 0, retention: 20000 })
it('loads older logs without paging away and applies filters', async () => {
get.mockResolvedValueOnce(page(4, 4)).mockResolvedValueOnce(page(2, null)).mockResolvedValue(page(8, null))
const wrapper = mount(LogsView)
await flushPromises()
expect(wrapper.get('details').classes()).toContain('ui-disclosure')
await wrapper.findAll('button').find(button => button.text() === '向上滚动加载更早日志')!.trigger('click')
await flushPromises()
expect(get.mock.lastCall![1].params.before).toBe(4)
expect(wrapper.findAll('details').map(row => row.attributes('data-log-id'))).toEqual(['2', '4'])
await wrapper.get('input[maxlength="200"]').setValue('LOCAL_CUDA_OOM')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(get.mock.lastCall![1].params).toMatchObject({ before: undefined, q: 'LOCAL_CUDA_OOM' })
wrapper.unmount()
})
it('follows at the bottom, pauses while reading, and loads history on scroll', async () => {
vi.useFakeTimers()
get.mockResolvedValue(page(10, 10))
const wrapper = mount(LogsView)
await flushPromises()
const viewport = wrapper.get('.log-list').element as HTMLElement
Object.defineProperty(viewport, 'scrollHeight', { configurable: true, value: 1000 })
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
viewport.scrollTop = 800
await wrapper.get('.log-list').trigger('scroll')
get.mockResolvedValueOnce({ ...page(11, 10), items: [...page(11, 10).items, ...page(10, 10).items] })
await vi.advanceTimersByTimeAsync(5000)
await flushPromises()
expect(wrapper.findAll('details').map(row => row.attributes('data-log-id'))).toEqual(['10', '11'])
expect(viewport.scrollTop).toBe(1000)
viewport.scrollTop = 400
await wrapper.get('.log-list').trigger('scroll')
const calls = get.mock.calls.length
await vi.advanceTimersByTimeAsync(5000)
expect(get).toHaveBeenCalledTimes(calls)
get.mockResolvedValueOnce(page(9, null))
viewport.scrollTop = 0
await wrapper.get('.log-list').trigger('scroll')
await flushPromises()
expect(get.mock.lastCall![1].params.before).toBe(10)
expect(wrapper.findAll('details').map(row => row.attributes('data-log-id'))).toEqual(['9', '10', '11'])
wrapper.unmount()
})
it('surfaces storage loss and transport errors without polling after unmount', async () => {
vi.useFakeTimers()
get.mockResolvedValueOnce({ ...page(1, null), dropped: 3, write_failures: 1 }).mockRejectedValue(new Error('offline'))
const wrapper = mount(LogsView)
await flushPromises()
expect(wrapper.get('[role="alert"]').text()).toContain('3')
await wrapper.get('input[type="checkbox"]').setValue(true)
await vi.advanceTimersByTimeAsync(5000)
expect(wrapper.text()).toContain('offline')
wrapper.unmount()
const calls = get.mock.calls.length
await vi.advanceTimersByTimeAsync(10000)
expect(get).toHaveBeenCalledTimes(calls)
})
+117
View File
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
import apiClient from '@/services/apiClient'
import { t } from '@/i18n'
interface LogEntry { id: number; timestamp: string; level: string; source: string; event: string; details: Record<string, unknown> }
interface LogPage { items: LogEntry[]; next_cursor: number | null; sources: string[]; pending: number; dropped: number; write_failures: number; retention: number }
const page = ref<LogPage>({ items: [], next_cursor: null, sources: [], pending: 0, dropped: 0, write_failures: 0, retention: 20000 })
const level = ref(''), source = ref(''), query = ref(''), error = ref('')
const loading = ref(false), live = ref(true), following = ref(true)
const scroller = ref<HTMLElement>()
const atLatest = ref(true)
const MAX_VISIBLE = 500
let applied = { level: '', source: '', q: '' }
let revision = 0
let timer: ReturnType<typeof setInterval> | undefined
let adjusting = false
async function load(reset = false, older = false) {
if (older && (loading.value || !page.value.next_cursor)) return
if (reset) applied = { level: level.value, source: source.value, q: query.value.trim() }
const version = ++revision
const viewport = scroller.value
const oldHeight = viewport?.scrollHeight ?? 0
const oldTop = viewport?.scrollTop ?? 0
// Preserve a visible row when adding history and trimming the opposite edge.
const anchor = older && viewport ? [...viewport.querySelectorAll<HTMLElement>('[data-log-id]')].find(row => row.getBoundingClientRect().bottom > viewport.getBoundingClientRect().top) : undefined
const anchorTop = anchor?.getBoundingClientRect().top
const anchorId = anchor?.dataset.logId
loading.value = true
try {
const result = await apiClient.get<LogPage>('/api/logs', { params: { limit: 50, before: older ? page.value.next_cursor ?? undefined : undefined, ...applied } })
if (version !== revision) return
// If the reader scrolled away during a refresh, leave their view untouched.
if (!reset && !older && !following.value) return
const previous = page.value.items
const overlaps = result.items.some(item => previous.some(old => old.id === item.id))
const combined = older || (!reset && overlaps) ? [...previous, ...result.items] : result.items
const all = [...new Map(combined.map(item => [item.id, item])).values()].sort((a, b) => a.id - b.id)
const trimmed = all.length > MAX_VISIBLE
const items = older ? all.slice(0, MAX_VISIBLE) : all.slice(-MAX_VISIBLE)
let cursor = older || reset || !overlaps ? result.next_cursor : page.value.next_cursor
if (!older && trimmed) cursor = items[0]?.id ?? null
if (older && trimmed) atLatest.value = false
if (!older) atLatest.value = true
adjusting = true
page.value = { ...result, items, next_cursor: cursor }
error.value = ''
await nextTick()
if (version !== revision) return
if (viewport) {
if (older) {
const retained = anchorId ? viewport.querySelector<HTMLElement>(`[data-log-id="${anchorId}"]`) : undefined
viewport.scrollTop = retained && anchorTop != null ? oldTop + retained.getBoundingClientRect().top - anchorTop : oldTop + viewport.scrollHeight - oldHeight
} else if (reset || following.value) {
viewport.scrollTop = viewport.scrollHeight
following.value = true
}
}
} catch (reason) {
if (version === revision) error.value = reason instanceof Error ? reason.message : t('日志加载失败', 'Failed to load logs')
} finally { if (version === revision) { loading.value = false; adjusting = false } }
}
function onScroll() {
const viewport = scroller.value
if (!viewport || adjusting) return
following.value = atLatest.value && viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop < 24
if (viewport.scrollTop < 80 && viewport.scrollHeight > viewport.clientHeight && !error.value) void load(false, true)
}
onMounted(() => {
void load(true)
timer = setInterval(() => { if (live.value && following.value && !loading.value && !document.hidden) void load() }, 5000)
})
onUnmounted(() => { revision++; clearInterval(timer) })
</script>
<template>
<section class="feature-page logs-page">
<header class="feature-header"><div><h1>{{ t('运行日志', 'Operation logs') }}</h1><p>{{ t('集中查看向量模型、智能体、任务与后台操作。', 'Inspect models, agents, tasks and background operations.') }}</p></div><button class="button-secondary" :disabled="loading" @click="load(true)">{{ t('刷新', 'Refresh') }}</button></header>
<form class="panel log-filters" @submit.prevent="load(true)">
<label class="field">{{ t('级别', 'Level') }}<select v-model="level" class="select"><option value="">{{ t('全部', 'All') }}</option><option>INFO</option><option>WARNING</option><option>ERROR</option><option>CRITICAL</option></select></label>
<label class="field">{{ t('模块', 'Module') }}<select v-model="source" class="select"><option value="">{{ t('全部', 'All') }}</option><option v-for="item in page.sources" :key="item">{{ item }}</option></select></label>
<label class="field log-search">{{ t('事件、错误码或关联 ID', 'Event, error code or correlation ID') }}<input v-model="query" class="input" maxlength="200" /></label>
<button class="button-primary" :disabled="loading">{{ t('筛选', 'Filter') }}</button>
<label><input v-model="live" type="checkbox" /> {{ t('自动跟随新日志', 'Follow new logs') }}</label>
</form>
<p class="subtle">{{ t('本地保留最近', 'Locally retains the latest') }} {{ page.retention.toLocaleString() }} {{ t('条日志;不记录正文、提示词、工具参数及密钥。', 'events; excludes content, prompts, tool arguments and credentials.') }}</p>
<p v-if="page.dropped || page.write_failures" class="error-banner" role="alert">{{ t('日志存储不完整队列溢出', 'Incomplete logging: queue overflow') }} {{ page.dropped }} · {{ t('写入失败', 'Write failures') }} {{ page.write_failures }}</p>
<p v-if="error" class="error-banner" role="alert">{{ error }}</p>
<div class="inline-actions log-controls"><span class="subtle" role="status">{{ live && following ? t('正在跟随最新日志', 'Following latest logs') : t('已暂停跟随,可自由查看历史', 'Following paused; browse history freely') }}</span><button class="button-secondary" :disabled="loading" @click="live = true; load(true)">{{ t('回到最新', 'Back to latest') }}</button><span class="subtle">{{ t('待写入', 'Pending') }} {{ page.pending }}</span></div>
<div ref="scroller" class="panel log-list" :aria-busy="loading" tabindex="0" :aria-label="t('日志列表向上滚动加载历史', 'Log list; scroll up for history')" @scroll.passive="onScroll">
<div class="history-status"><button v-if="page.next_cursor" class="button-secondary" :disabled="loading" @click="load(false, true)">{{ loading ? t('加载中…', 'Loading…') : t('向上滚动加载更早日志', 'Scroll up for older logs') }}</button><span v-else class="subtle">{{ t('已到保留日志的开头', 'Beginning of retained logs') }}</span></div>
<p v-if="!page.items.length">{{ loading ? t('加载中', 'Loading') : t('暂无符合条件的日志', 'No matching logs') }}</p>
<details v-for="entry in page.items" :key="entry.id" :data-log-id="entry.id" class="ui-disclosure log-entry">
<summary><span class="badge" :class="{ error: entry.level === 'ERROR' || entry.level === 'CRITICAL', warning: entry.level === 'WARNING' }">{{ entry.level }}</span><time>{{ new Date(entry.timestamp).toLocaleString() }}</time><span>{{ entry.source }}</span><strong>{{ entry.event }}</strong></summary>
<dl><template v-for="(value, key) in entry.details" :key="key"><dt>{{ key }}</dt><dd>{{ value }}</dd></template></dl>
</details>
</div>
</section>
</template>
<style scoped>
.logs-page > * { width: 100%; max-width: 1180px; margin-inline: auto; box-sizing: border-box; }
.logs-page > .subtle { margin-block: var(--space-md); }
.log-controls { margin-block: var(--space-md); }
.log-list { height: min(65vh, 800px); min-height: 240px; overflow-y: auto; overscroll-behavior: contain; overflow-anchor: none; scroll-behavior: auto; }
.history-status { text-align: center; margin-bottom: var(--space-md); }
.log-filters { display: flex; align-items: end; flex-wrap: wrap; gap: var(--space-md); }
.log-filters .field { min-width: 140px; margin: 0; }
.log-search { flex: 1; }
.log-entry { border-bottom: 1px solid var(--color-border-subtle); padding: var(--space-sm); }
.log-entry + .log-entry { margin-top: var(--space-xs); }
.log-entry summary { display: flex; flex-wrap: wrap; gap: var(--space-sm); cursor: pointer; align-items: center; overflow-wrap: anywhere; }
.log-entry summary::after { margin-left: auto; }
.log-entry dl { display: grid; grid-template-columns: minmax(100px, 160px) 1fr; gap: var(--space-sm); font-size: var(--font-size-sm); }
.log-entry dd { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0; }
.log-entry dt, .log-entry time { color: var(--color-text-secondary); }
</style>
@@ -7,6 +7,31 @@ import { useProviderStore } from '@/stores/provider'
import { useSettingsStore } from '@/stores/settings'
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
it('separates unfinished indexing from retrieval activity and retains short search totals', async () => {
const store = useSettingsStore()
vi.spyOn(store, 'loadDiagnostics').mockResolvedValue()
const providers = useProviderStore()
vi.spyOn(providers, 'loadProviders').mockResolvedValue()
vi.spyOn(providers, 'loadPresets').mockResolvedValue()
vi.spyOn(providers, 'refreshEnabledModels').mockResolvedValue()
const wrapper = mount(SettingsView, { global: { stubs: { UsageCard: true, LocalModelSettings: true, ModelRoutingSettings: true } } })
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '索引与模型')!.trigger('click')
expect(wrapper.text()).toContain('未完成索引 未获取')
store.indexStatus = { status: 'idle', pending_jobs: 1, running_jobs: 0, total_notes: 16, total_blocks: 3787, vector_refresh_required: true,
active_searches: 0, completed_searches: 2, failed_searches: 1, cancelled_searches: 0 }
await flushPromises()
expect(wrapper.text()).toContain('全文可用 · 向量待重建')
expect(wrapper.text()).toContain('未完成索引 1')
expect(wrapper.text()).toContain('已完成 2')
expect(wrapper.text()).toContain('失败 1')
store.indexStatus.status = 'indexing'
store.indexStatus.running_jobs = 1
await flushPromises()
expect(wrapper.text()).toContain('后台计算索引')
expect(wrapper.findAll('button').find(button => button.text() === '重建全部')!.attributes('disabled')).toBeDefined()
wrapper.unmount()
})
it('shows provider status and enables testing only after a successful enable', async () => {
const store = useProviderStore()
store.providers = [{ provider_id: 'p1', name: 'Example', provider_type: 'openai_compatible', enabled: false, default_model: '', capabilities: {}, has_credential: false }]
@@ -128,7 +128,32 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
<UsageCard />
</div>
<div v-else-if="activeSection === 'index'" class="panel settings-section"><h2>{{ t('索引与模型', 'Index and Models') }}</h2><div class="index-summary"><div><span class="badge" :class="{ success: settingsStore.indexStatus.status === 'idle', error: settingsStore.indexStatus.status === 'error' }">{{ settingsStore.indexStatus.status }}</span><p>{{ t('待处理任务', 'Pending jobs') }} {{ settingsStore.indexStatus.pending_jobs }}</p></div><div><strong>{{ settingsStore.indexStatus.total_notes ?? t('未获取', 'Unavailable') }}</strong><small>{{ t('笔记', 'Notes') }}</small></div><div><strong>{{ settingsStore.indexStatus.total_blocks ?? t('未获取', 'Unavailable') }}</strong><small>Block</small></div></div><div v-if="settingsStore.indexStatus.error" class="error-banner">{{ settingsStore.indexStatus.error }}</div><div class="inline-actions"><button class="button-primary" @click="settingsStore.rebuildIndex('full')">{{ t('重建全部', 'Rebuild all') }}</button><span class="subtle">{{ t('当前后端支持全量重建', 'The current backend supports a full rebuild.') }}</span></div><ModelRoutingSettings /></div>
<div v-else-if="activeSection === 'index'" class="panel settings-section">
<h2>{{ t('索引与模型', 'Index and Models') }}</h2>
<div class="index-summary">
<div>
<span class="badge" :class="{ success: settingsStore.indexStatus.status === 'idle' && !settingsStore.indexStatus.vector_refresh_required && !settingsStore.indexStatus.active_searches, error: settingsStore.indexStatus.status === 'error' }">{{ settingsStore.indexStatusLabel }}</span>
<p>{{ t('未完成索引', 'Unfinished indexing jobs') }} {{ settingsStore.indexStatus.status === 'unknown' ? t('未获取', 'Unavailable') : settingsStore.indexStatus.pending_jobs }}</p>
<small>{{ t('运行中', 'Running') }} {{ settingsStore.indexStatus.running_jobs ?? t('未获取', 'Unavailable') }}</small>
</div>
<div><strong>{{ settingsStore.indexStatus.total_notes ?? t('未获取', 'Unavailable') }}</strong><small>{{ t('笔记', 'Notes') }}</small></div>
<div><strong>{{ settingsStore.indexStatus.total_blocks ?? t('未获取', 'Unavailable') }}</strong><small>Block</small></div>
</div>
<p class="subtle">{{ t('未完成数包含运行中的任务;全库重建计为一个任务,不是笔记或 Block 数量。', 'Unfinished jobs include running jobs. A full rebuild counts as one job, not the number of notes or blocks.') }}</p>
<div class="index-search-activity">
<h3>{{ t('向量 / 混合检索', 'Vector / hybrid searches') }}</h3>
<div class="inline-actions">
<span>{{ t('进行中', 'Active') }} {{ settingsStore.indexStatus.active_searches ?? t('未获取', 'Unavailable') }}</span>
<span>{{ t('已完成', 'Completed') }} {{ settingsStore.indexStatus.completed_searches ?? t('未获取', 'Unavailable') }}</span>
<span>{{ t('失败', 'Failed') }} {{ settingsStore.indexStatus.failed_searches ?? t('未获取', 'Unavailable') }}</span>
<span>{{ t('已取消', 'Cancelled') }} {{ settingsStore.indexStatus.cancelled_searches ?? t('未获取', 'Unavailable') }}</span>
</div>
<p class="subtle">{{ t('统计本次 AI Core 启动以来的检索,包含搜索、对话和智能体调用;不计纯全文检索。', 'Counts searches, chat and agent retrievals since AI Core started; excludes full-text-only searches.') }}</p>
</div>
<div v-if="settingsStore.indexStatus.error" class="error-banner">{{ settingsStore.indexStatus.error }}</div>
<div class="inline-actions"><button class="button-primary" :disabled="settingsStore.indexStatus.status === 'indexing'" @click="settingsStore.rebuildIndex('full')">{{ t('重建全部', 'Rebuild all') }}</button><span class="subtle">{{ t('当前后端支持全量重建。', 'The current backend supports a full rebuild.') }}</span></div>
<ModelRoutingSettings />
</div>
<div v-else-if="activeSection === 'permissions'" class="panel settings-section"><h2>{{ t('权限策略', 'Permission Policy') }}</h2><p class="muted section-description">{{ t('以下为后端当前生效的权限策略;全局策略编辑尚未开放,运行时按实际权限请求确认。', 'These policies are active in the backend. Global policy editing is not yet available; runtime requests are confirmed as needed.') }}</p><p v-if="!Object.keys(settingsStore.permissionPolicy).length" class="subtle">{{ t('尚未获取权限策略,请检查后端连接并重新检测。', 'Permission policy is unavailable. Check the backend connection and try again.') }}</p><div class="permission-list"><div v-for="(policy, permission) in settingsStore.permissionPolicy" :key="permission" class="setting-row"><span><strong>{{ permission }}</strong></span><span>{{ policy === 'allow' ? t('允许', 'Allow') : policy === 'confirm' ? t('每次确认', 'Confirm each time') : t('拒绝', 'Deny') }}</span></div></div></div>
+8 -2
View File
@@ -3,12 +3,17 @@ import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
import AppDialog from '@/components/common/AppDialog.vue'
import { onMounted, reactive, ref } from 'vue'
import { computed, watch, onMounted, reactive, ref } from 'vue'
import type { TaskItem, TaskStatus } from '@/contracts'
import { useTaskStore } from '@/stores/task'
import { localeTag, t } from '@/i18n'
const taskStore = useTaskStore()
const page = ref(1)
const pageCount = computed(() => Math.max(1, Math.ceil(taskStore.filteredTasks.length / 100)))
const visibleTasks = computed(() => taskStore.filteredTasks.slice((page.value - 1) * 100, page.value * 100))
watch(() => [taskStore.filterStatus, taskStore.filterPriority, taskStore.filterSource], () => { page.value = 1 })
watch(pageCount, count => { page.value = Math.min(page.value, count) })
const showForm = ref(false)
const editingId = ref<string | null>(null)
const actionError = ref('')
@@ -44,13 +49,14 @@ async function remove(task: TaskItem) {
<header class="feature-header"><div><h1>{{ t('任务', 'Tasks') }}</h1><p>{{ t('管理用户、笔记和 Agent 产生的行动项。', 'Manage action items created by users, notes, and agents.') }}</p></div><button class="button-primary" @click="resetForm(); showForm = true"> {{ t('新建任务', 'New task') }}</button></header>
<div v-if="taskStore.error || actionError" class="error-banner">{{ taskStore.error || actionError }}</div>
<div v-if="taskStore.filteredTasks.length" class="task-list">
<article v-for="task in taskStore.filteredTasks" :key="task.task_id" class="item-card task-card">
<article v-for="task in visibleTasks" :key="task.task_id" class="item-card task-card">
<button class="status-check" :class="{ done: task.status === 'done' }" :title="t('切换完成状态', 'Toggle completion')" @click="setStatus(task, task.status === 'done' ? 'todo' : 'done')">{{ task.status === 'done' ? '' : '' }}</button>
<div class="task-content"><div class="task-title"><strong :class="{ completed: task.status === 'done' }">{{ task.title }}</strong></div><p v-if="task.description" class="muted">{{ task.description }}</p><div class="subtle"><span>{{ task.status }}</span><span v-if="task.due_date">{{ t('截止', 'Due') }} {{ new Date(task.due_date).toLocaleString(localeTag()) }}</span><span v-if="task.note_id">{{ t('关联 Note', 'Linked Note') }}: {{ task.note_id }}</span></div></div>
<div class="inline-actions"><button class="icon-button" @click="editTask(task)">{{ t('编辑', 'Edit') }}</button><button class="button-danger" @click="remove(task)">{{ t('删除', 'Delete') }}</button></div>
</article>
</div>
<div v-else class="empty-state"><div><strong>{{ taskStore.isLoading ? t('正在加载任务…', 'Loading tasks…') : t('没有符合条件的任务', 'No matching tasks') }}</strong><p>{{ t('创建一项任务,或调整左侧筛选条件。', 'Create a task or adjust the filters.') }}</p></div></div>
<nav v-if="pageCount > 1" class="inline-actions"><button class="button-secondary" :disabled="page === 1" @click="page--">{{ t('上一页', 'Previous') }}</button><span>{{ page }} / {{ pageCount }} · {{ taskStore.filteredTasks.length }}</span><button class="button-secondary" :disabled="page === pageCount" @click="page++">{{ t('下一页', 'Next') }}</button></nav>
<AppDialog v-if="showForm" :label="t('任务表单', 'Task form')" @close="showForm = false"><div class="modal"><h2>{{ editingId ? t('编辑任务', 'Edit task') : t('新建任务', 'New task') }}</h2><form @submit.prevent="saveTask"><div class="field"><label>{{ t('标题', 'Title') }}</label><input v-model="form.title" class="input" required /></div><div class="field"><label>{{ t('描述', 'Description') }}</label><textarea v-model="form.description" class="textarea" /></div><div class="field"><label>{{ t('截止时间', 'Due date') }}</label><input v-model="form.due_date" class="input" type="datetime-local" /></div><div class="field"><label>{{ t('关联 Note ID', 'Linked Note ID') }}</label><input v-model="form.note_id" class="input" /></div><div class="inline-actions"><button class="button-primary">{{ t('保存', 'Save') }}</button><button type="button" class="button-secondary" @click="showForm = false">{{ t('取消', 'Cancel') }}</button></div></form></div></AppDialog>
</section>
</template>
@@ -96,7 +96,7 @@ it.each(mockCommunityThemes)('previews uninstalled $theme_id using its actual CS
it('offers and applies the paper theme update without discarding the active theme', async () => {
const store = useThemeStore()
const old = await inspectThemePackage(paperPackage.replace('version: 1.8.0', 'version: 1.6.1'))
const old = await inspectThemePackage(paperPackage.replace(/^version: .+$/m, 'version: 1.6.1'))
await store.installThemeFromInspection(old.manifest, old.css)
store.applyTheme('paper-moments')
wrapper = mount(ThemesView, { global: { stubs: { MarkdownContent: true } } })
@@ -105,6 +105,6 @@ it('offers and applies the paper theme update without discarding the active them
const card = wrapper.findAll('article.theme-card').find(item => item.text().includes('Paper Moments'))!
await card.findAll('button').find(button => button.text() === '更新')!.trigger('click')
await flushPromises()
expect(store.allThemes.find(theme => theme.theme_id === 'paper-moments')?.version).toBe('1.8.0')
expect(store.allThemes.find(theme => theme.theme_id === 'paper-moments')?.version).toBe('1.8.1')
expect(document.getElementById('theme-style-paper-moments')!.textContent).toContain('.surface-nested')
})
+2 -1
View File
@@ -65,7 +65,8 @@ async function openFolderPicker() {
<p class="app-subtitle">{{ t('本地优先的 AI 笔记软件', 'A local-first AI note-taking app') }}</p>
</div>
<div class="vault-card">
<div class="vault-card">
<button class="btn" @click="router.push('/logs')">{{ t('查看运行日志', 'View operation logs') }}</button>
<div v-if="openError" class="error-banner" role="alert">{{ openError }} <button class="btn" @click="initializeVault" :disabled="isLoading">{{ t('重试', 'Retry') }}</button></div>
<p v-if="isLoading" role="status">{{ t('正在打开知识库', 'Opening knowledge base') }}</p>
<h2 class="card-title">{{ t('选择知识库', 'Select Knowledge Base') }}</h2>
+2
View File
@@ -3,6 +3,7 @@ import { useWorkspaceStore } from '@/stores/workspace'
import { t } from '@/i18n'
const routes = [
{ path: '/logs', name: 'logs', component: () => import('@/features/logs/LogsView.vue'), meta: { title: '运行日志' } },
{ path: '/media', name: 'media', component: () => import('@/features/media/MediaView.vue'), meta: { title: '音视频转写', requiresVault: true } },
{
path: '/',
@@ -91,6 +92,7 @@ router.beforeEach((to) => {
export function updateDocumentTitle(to = router.currentRoute.value) {
const baseTitle = 'NotesAgent'
const titles: Record<string, string> = {
logs: t('运行日志', 'Operation logs'),
media: t('音视频转写', 'Media Transcription'),
'vault-entry': t('选择知识库', 'Select Knowledge Base'),
workspace: t('工作区', 'Workspace'),
+5
View File
@@ -3,6 +3,11 @@ import type { ApiIndexJob, ApiIndexStatus, IndexStatus } from '@/contracts'
function toIndexStatus(status: ApiIndexStatus): IndexStatus {
return {
running_jobs: status.running_jobs,
active_searches: status.active_searches,
completed_searches: status.completed_searches,
failed_searches: status.failed_searches,
cancelled_searches: status.cancelled_searches,
vector_refresh_required: status.vector_refresh_required ?? false,
status: status.status === 'idle' ? 'idle' : status.status === 'failed' ? 'error' : 'indexing',
pending_jobs: status.pending_jobs,
+15 -2
View File
@@ -58,9 +58,22 @@ export const useAgentStore = defineStore('agent', () => {
tools.value = await agentService.listTools()
}
let listVersion = 0
async function loadRuns() {
const resp = await agentService.listAgentRuns()
runs.value = resp.items
const version = ++listVersion
const items: AgentRun[] = []
let offset = 0
do {
const resp = await agentService.listAgentRuns({ limit: 100, offset })
if (version !== listVersion) return
items.push(...resp.items)
offset += resp.items.length
if (!resp.items.length || offset >= resp.total) break
} while (true)
const active = runs.value.find(run => run.run_id === activeRunId.value)
const merged = new Map(items.map(item => [item.run_id, item]))
if (active && !merged.has(active.run_id)) merged.set(active.run_id, active)
runs.value = [...merged.values()]
}
async function loadRun(runId: string) {
@@ -0,0 +1,29 @@
// @vitest-environment happy-dom
import { beforeEach, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useTaskStore } from './task'
import { useAgentStore } from './agent'
const mocks = vi.hoisted(() => ({ tasks: vi.fn(), runs: vi.fn() }))
vi.mock('@/services/taskService', () => ({ listTasks: mocks.tasks }))
vi.mock('@/services/agentService', () => ({ listAgentRuns: mocks.runs }))
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
it('loads tasks past the first page before applying global status filters', async () => {
const tasks = Array.from({ length: 251 }, (_, i) => ({ task_id: `t${i}`, status: i >= 200 ? 'done' : 'todo' }))
mocks.tasks.mockImplementation(async ({ offset, limit }) => ({ items: tasks.slice(offset, offset + limit), total: tasks.length }))
const store = useTaskStore()
await store.loadTasks()
expect(store.tasks).toHaveLength(251)
store.setFilterStatus('done')
expect(store.filteredTasks).toHaveLength(51)
expect(mocks.tasks).toHaveBeenCalledTimes(3)
})
it('includes older Agent history and leaves failed refresh data intact', async () => {
const runs = Array.from({ length: 201 }, (_, i) => ({ run_id: `r${i}`, status: 'completed' }))
mocks.runs.mockImplementation(async ({ offset, limit }) => ({ items: runs.slice(offset, offset + limit), total: runs.length }))
const store = useAgentStore()
await store.loadRuns()
expect(store.runs).toHaveLength(201)
mocks.runs.mockRejectedValue(new Error('offline'))
await expect(store.loadRuns()).rejects.toThrow('offline')
expect(store.runs).toHaveLength(201)
})
+11 -1
View File
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import type { AiCoreStatus, IndexStatus } from '@/contracts'
import { resolveApiUrl } from '@/services/apiClient'
import packageInfo from '../../package.json'
@@ -31,6 +31,15 @@ export const useSettingsStore = defineStore('settings', () => {
// Index
const emptyIndex = (): IndexStatus => ({ status: 'unknown', pending_jobs: 0, total_notes: null, total_blocks: null })
const indexStatus = ref<IndexStatus>(emptyIndex())
const indexStatusLabel = computed(() => {
const value = indexStatus.value
if (value.status === 'unknown') return t('索引状态未获取', 'Index status unavailable')
if (value.status === 'indexing') return t('后台计算索引', 'Indexing in background')
if (value.status === 'error') return t('索引错误', 'Index error')
if (value.vector_refresh_required) return t('全文可用 · 向量待重建', 'Full text ready · vectors need rebuilding')
if (value.active_searches) return t('向量检索中', 'Vector search running')
return t('索引就绪', 'Index ready')
})
// Permissions
const permissionPolicy = ref<Record<string, 'allow' | 'confirm' | 'deny'>>({})
@@ -85,6 +94,7 @@ export const useSettingsStore = defineStore('settings', () => {
aiCoreStatus,
aiCoreAddress,
indexStatus,
indexStatusLabel,
permissionPolicy,
diagnosticsError,
loadDiagnostics,
+14 -3
View File
@@ -25,16 +25,27 @@ export const useTaskStore = defineStore('task', () => {
const inProgressTasks = computed(() => tasks.value.filter((t) => t.status === 'in_progress'))
const doneTasks = computed(() => tasks.value.filter((t) => t.status === 'done'))
let loadVersion = 0
async function loadTasks() {
const version = ++loadVersion
isLoading.value = true
try {
const resp = await listTasks()
tasks.value = resp.items
const items: TaskItem[] = []
let offset = 0
do {
const resp = await listTasks({ limit: 100, offset })
if (version !== loadVersion) return
items.push(...resp.items)
offset += resp.items.length
if (!resp.items.length || offset >= resp.total) break
} while (true)
tasks.value = [...new Map(items.map(item => [item.task_id, item])).values()]
error.value = null
} catch (reason) {
if (version !== loadVersion) return
error.value = reason instanceof Error ? reason.message : t('任务加载失败', 'Failed to load tasks')
} finally {
isLoading.value = false
if (version === loadVersion) isLoading.value = false
}
}
+12
View File
@@ -40,3 +40,15 @@ backend/.venv/Scripts/python.exe frontend/tests/performance/run-stress.py --url
`--scroll` 使用纸间时光主题及高度受限的编辑区,派发 120 次真实 CDP 滚轮事件(先向下再向上),记录 animation frame 间隔与长任务;随后从文末全部折叠,记录滚动位置和光标位置。可追加 `--profile --sizes 120000 --runs 1` 保存 CPU profile,用 Chrome DevTools Performance 面板导入。采样会增加开销,勿将 profile 结果与无采样结果直接比较。
帧间隔包含无头浏览器、CDP 调度和布局开销,不等同于用户设备的 FPS。滚轮模式不验证输入法、保存或图表渲染。当前测试容器改为有限高度的 flex 布局,早期普通事务报告使用的容器布局不同,跨版本比较应分别保留同一布局下的基线。
主题对比使用 URL 查询参数:`stress.html?theme=light``?theme=dark`,默认是 `paper-moments`。诊断参数 `?variant=no-outline` 可关闭编辑区轮廓线,用于隔离旧版纸间时光的长文开销;1.8.1 已不再使用这条 outline。`--screenshot` 会在派发滚轮前保存当前视口 PNG,截图时间可能计入记录区间。
## Agent 与任务
`agent-task.html?kind=tasks&theme=light` 测试任务组件,`kind=trace` 测试 Trace;支持 light、dark、paper-moments。继续使用 `run-stress.py --scroll`,任务规模可设 `--sizes 100 1000`Trace 可设 `--sizes 200 2000 10000`。每个规模在新页面中生成独立数据,所有 fetch 被拦截,未知请求直接失败,不落到真实后端。
任务先记录实际分页加载数量,再注入全量夹具测渲染上限,结果包含 `fullListIsInjected`。Trace 测量时间线、树形搜索及切换;滚轮区间与过滤区间分别计时。`scrollContainers``maxScrollTop` 用来确认目标实际滚动。完整结果及限制见 [Agent 与任务压测报告](../../../docs/development/Agent与任务压测报告.md)。
修复后任务会读取所有 API 页,渲染每页 100 条;Trace 每页 200 条,筛选仍覆盖完整数据。`renderedTasks``totalFilteredCount` 区分 DOM 数量与实际记录总数,不能把分页后的 DOM 数量误报为数据丢失。
`logs.html?theme=paper-moments` 使用隔离的合成日志,支持 `light``dark` 主题,用于筛选栏、日志详情和主题视觉检查。可搭配驱动的 `--scroll --screenshot --sizes 1 --runs 1` 保存首屏;该夹具不连接真实日志库,不用于测后端日志吞吐。
@@ -0,0 +1,98 @@
<!doctype html><html><head><meta charset="utf-8"><title>Agent 与任务压测</title></head>
<body><div id="viewport"><div id="app"></div></div>
<script type="module">
import { createApp, h, nextTick, ref } from 'vue'
import { createPinia } from 'pinia'
import TasksView from '/src/features/tasks/TasksView.vue'
import TraceTimeline from '/src/features/agent/TraceTimeline.vue'
import { useTaskStore } from '/src/stores/task.ts'
import { getCommunityThemePreviewCss } from '/src/services/themePackageService.ts'
import '/src/styles/tokens.css'
import '/src/styles/features.css'
const frame = () => new Promise(resolve => requestAnimationFrame(resolve))
const settle = async () => { await nextTick(); await frame(); await frame() }
const summary = values => { const sorted=[...values].sort((a,b)=>a-b); return {median:sorted[Math.floor(sorted.length/2)]??0,p95:sorted[Math.ceil(sorted.length*.95)-1]??0,max:sorted.at(-1)??0} }
window.prepareScrollBenchmark = async (size = 1000) => {
const params=new URLSearchParams(location.search), kind=params.get('kind')||'tasks', theme=params.get('theme')||'light'
document.documentElement.dataset.theme=theme
const style=document.createElement('style');style.textContent=theme==='paper-moments'?getCommunityThemePreviewCss(theme):'';document.head.append(style)
const pinia=createPinia(), store=useTaskStore(pinia), host=document.getElementById('app'), viewport=document.getElementById('viewport')
// App tokens normally clip #app; the real Agent page provides its own scroller.
// This standalone Trace mount uses #viewport in that role.
if(kind==='trace'){host.style.height='auto';host.style.overflow='visible'}
const date='2026-09-06T00:00:00Z'
const tasks=Array.from({length:size},(_,i)=>({task_id:`task_${i}`,title:`压测任务 ${i}`,description:'用于验证任务列表渲染与筛选,独立生成,不读取真实笔记。',status:i%3===0?'done':'todo',created_at:date,updated_at:date}))
const events=ref(Array.from({length:size},(_,i)=>{
const step=Math.floor(i/4), event=['ModelCallStarted','ModelCallCompleted','ToolCall','ToolResult'][i%4]
return {run_id:'stress',sequence:i,event,timestamp:new Date(Date.parse(date)+i*10).toISOString(),data:{step,model_call_id:`m_${step}`,parent_model_call_id:`m_${step}`,tool_call_id:`t_${step}`,name:'system.echo',arguments:{text:`压力测试 ${step}`},output:{text:'工具返回内容'},success:true,duration_ms:10}}
}))
const originalFetch=window.fetch, requests=[]
// Intercept every request in this isolated page: never fall through to the user's backend.
window.fetch=async (input)=>{
const url=new URL(typeof input==='string'?input:input.url,location.href);requests.push(url.pathname+url.search)
if(url.pathname==='/api/tasks'){
const limit=Number(url.searchParams.get('limit')||50),offset=Number(url.searchParams.get('offset')||0)
return new Response(JSON.stringify({items:tasks.slice(offset,offset+limit),page:{total:size,limit,offset}}),{headers:{'Content-Type':'application/json'}})
}
throw Error(`Unexpected request in isolated benchmark: ${url.pathname}`)
}
const start=performance.now()
const app=createApp({render:()=>kind==='tasks'?h(TasksView):h(TraceTimeline,{events:events.value,runStatus:'completed'})}).use(pinia)
app.mount(host)
await settle()
while(store.isLoading) await settle()
const result={kind,theme,size,initialRenderMs:performance.now()-start,requests,initialTaskCount:kind==='tasks'?store.tasks.length:undefined}
if(kind==='tasks'){
const fullStart=performance.now();store.tasks=tasks;await settle()
result.fullListRenderMs=performance.now()-fullStart
result.fullListIsInjected=true; result.renderedTasks=host.querySelectorAll('article.task-card').length // Diagnostic upper bound, distinct from current paginated API behavior.
}
result.domNodes=host.querySelectorAll('*').length
const scroller=host.querySelector('.feature-page')||viewport
result.scrollContainers=[...document.querySelectorAll('html,body,#app,#viewport,.trace-visualization,.timeline-view,.timeline')].map(element=>({node:element.id||element.className||element.tagName,height:element.clientHeight,scrollHeight:element.scrollHeight,overflow:getComputedStyle(element).overflow,position:getComputedStyle(element).position}))
let maxScrollTop=0
const trackScroll=()=>{maxScrollTop=Math.max(maxScrollTop,scroller.scrollTop)}
scroller.addEventListener('scroll',trackScroll,{passive:true})
const gaps=[],longTasks=[];let last=performance.now(),raf=0
const tick=now=>{gaps.push(now-last);last=now;raf=requestAnimationFrame(tick)}
const observer=new PerformanceObserver(list=>longTasks.push(...list.getEntries().map(t=>t.duration)))
observer.observe({type:'longtask',buffered:false});raf=requestAnimationFrame(tick)
window.finishScrollBenchmark=async()=>{
cancelAnimationFrame(raf);observer.disconnect()
result.frameGapsMs=summary(gaps);result.frames=gaps.length;result.longTasks=longTasks
result.scrollTop=scroller.scrollTop;result.scrollHeight=scroller.scrollHeight;result.maxScrollTop=maxScrollTop
scroller.removeEventListener('scroll',trackScroll)
const started=performance.now()
if(kind==='tasks'){
store.setFilterStatus('done');await settle()
result.filteredCount=host.querySelectorAll('article.task-card').length
result.totalFilteredCount=store.filteredTasks.length
result.expectedFilteredCount=Math.min(100,tasks.filter(task=>task.status==='done').length)
if(result.filteredCount!==result.expectedFilteredCount)throw Error('Task filter lost items')
} else {
const input=host.querySelector('input')
if(input){input.value='压力测试 1';input.dispatchEvent(new Event('input',{bubbles:true}));await settle()}
result.filteredDomNodes=host.querySelectorAll('*').length
result.filteredCount=host.querySelectorAll('.event-card').length
result.expectedFilteredCount=Math.min(200,events.value.filter(event=>JSON.stringify(event.data).includes('压力测试 1')).length)
if(result.filteredCount!==result.expectedFilteredCount)throw Error('Trace filter lost events')
}
result.filterMs=performance.now()-started
if(kind==='trace'){
const input=host.querySelector('input');input.value='';input.dispatchEvent(new Event('input',{bubbles:true}));await settle()
let start=performance.now()
;[...host.querySelectorAll('.view-toggle button')].find(button=>button.textContent==='树形').click();await settle()
result.treeSwitchMs=performance.now()-start
start=performance.now();input.value='压力测试';input.dispatchEvent(new Event('input',{bubbles:true}));await settle()
result.treeFilterMs=performance.now()-start
result.treeFilteredDomNodes=host.querySelectorAll('*').length
}
app.unmount();window.fetch=originalFetch;style.remove()
return result
}
const bounds=viewport.getBoundingClientRect();return {x:bounds.left+bounds.width/2,y:bounds.top+bounds.height/2}
}
window.runBenchmark=async size=>{await window.prepareScrollBenchmark(size);return window.finishScrollBenchmark()}
</script>
<style>html,body{height:100%;margin:0;background:var(--color-background-primary);color:var(--color-text-primary);font-family:system-ui}#viewport{height:100vh;overflow:auto}#app{max-width:1200px;margin:auto;padding:24px;box-sizing:border-box}</style>
</body></html>
+31
View File
@@ -0,0 +1,31 @@
<!doctype html><html><head><meta charset="utf-8"><title>日志页面视觉验收</title></head>
<body><div id="app"></div><script type="module">
import { createApp, nextTick } from 'vue'
import LogsView from '/src/features/logs/LogsView.vue'
import { getCommunityThemePreviewCss } from '/src/services/themePackageService.ts'
import '/src/styles/tokens.css'
import '/src/styles/features.css'
const theme = new URLSearchParams(location.search).get('theme') || 'paper-moments'
document.documentElement.dataset.theme = theme
const style = document.createElement('style'); style.textContent = getCommunityThemePreviewCss(theme); document.head.append(style)
const entries = [
{ source: 'vectors', event: 'embedding.failed', level: 'ERROR', details: { model: 'Bge-small-zh', device: 'cuda', error_code: 'LOCAL_CUDA_OOM', fallback: 'cpu', job_id: 'index_demo', frames: 'runtime.py:180:infer' } },
{ source: 'agent', event: 'ToolResult', level: 'INFO', details: { run_id: 'run_demo', tool: 'tasks.create', status: 'running' } },
{ source: 'tasks', event: 'task.created', level: 'INFO', details: { task_id: 'task_demo', run_id: 'run_demo', status: 'todo' } },
{ source: 'models', event: 'model.embedding', level: 'WARNING', details: { model: 'Bge-small-zh', fallback: 'LOCAL_CUDA_OOM', device: 'cpu' } },
{ source: 'http', event: 'request.finished', level: 'INFO', details: { method: 'POST', route: '/api/tasks', status: 200, duration_ms: 32.5 } },
].map((entry, index) => ({ ...entry, id: 10-index, timestamp: '2026-09-06T08:00:00Z' }))
window.fetch = async input => {
const url = new URL(typeof input === 'string' ? input : input.url, location.href)
if (url.pathname !== '/api/logs') throw Error('Unexpected request in isolated log preview')
return new Response(JSON.stringify({ items: entries, next_cursor: null, sources: entries.map(x => x.source), pending: 0, dropped: 0, write_failures: 0, retention: 20000 }), { headers: { 'Content-Type': 'application/json' } })
}
createApp(LogsView).mount('#app')
window.prepareScrollBenchmark = async () => {
await new Promise(resolve => setTimeout(resolve, 300)); await nextTick()
document.querySelector('details').open = true
return { x: 700, y: 400 }
}
window.finishScrollBenchmark = async () => ({ theme, rows: document.querySelectorAll('details').length })
window.runBenchmark = window.prepareScrollBenchmark
</script></body></html>
+5 -1
View File
@@ -1,7 +1,7 @@
"""Real Chromium benchmark. Run with backend/.venv/Scripts/python.exe; requires websockets.
Vite must be serving the frontend. Uses an isolated disposable browser profile.
"""
import argparse, asyncio, json, pathlib, subprocess, tempfile, urllib.request
import argparse, asyncio, base64, json, pathlib, subprocess, tempfile, urllib.request
import websockets
async def main(args):
@@ -44,6 +44,9 @@ async def main(args):
response = await call('Runtime.evaluate', {'expression':expression,'awaitPromise':True,'returnByValue':True})
if args.scroll and 'exceptionDetails' not in response:
point = response['result']['value']
if args.screenshot:
capture = await call('Page.captureScreenshot', {'format': 'png'})
pathlib.Path(args.output + f'.{size}.{repeat+1}.png').write_bytes(base64.b64decode(capture['data']))
if args.profile:
await call('Profiler.enable'); await call('Profiler.start')
await call('Input.dispatchMouseEvent', {'type':'mouseMoved', **point})
@@ -74,6 +77,7 @@ if __name__=='__main__':
parser.add_argument('--runs',type=int,default=3)
parser.add_argument('--output',required=True)
parser.add_argument('--profile',action='store_true',help='Save CPU profiles for scroll runs')
parser.add_argument('--screenshot',action='store_true',help='Save a viewport screenshot before each scroll run')
parser.add_argument('--scroll',action='store_true',help='Dispatch real wheel events and check fold-to-top')
args = parser.parse_args()
if args.runs < 1 or any(size < 1 for size in args.sizes): parser.error('runs and sizes must be positive')
+7 -3
View File
@@ -54,8 +54,12 @@ window.runBenchmark = async (size = 25000) => {
window.prepareScrollBenchmark = async (size = 25000) => {
const source = makeStressDocument(size), target = document.getElementById('app')
document.documentElement.dataset.theme = 'paper-moments'
const style = document.createElement('style'); style.textContent = getCommunityThemePreviewCss('paper-moments'); document.head.append(style)
const options = new URLSearchParams(location.search)
const theme = options.get('theme') || 'paper-moments'
document.documentElement.dataset.theme = theme
const style = document.createElement('style'); style.textContent = theme === 'paper-moments' ? getCommunityThemePreviewCss(theme) : ''; document.head.append(style)
const variant = options.get('variant') || 'default'
if (variant === 'no-outline') style.textContent += '.visual-editor .milkdown-host .ProseMirror { outline: none !important; }'
const pinia = createPinia(), component = ref()
const app = createApp({render:()=>h(Editor,{ref:component,initialContent:source})}).use(pinia)
app.mount(target)
@@ -69,7 +73,7 @@ window.prepareScrollBenchmark = async (size = 25000) => {
observer.observe({type:'longtask',buffered:false});raf=requestAnimationFrame(tick)
window.finishScrollBenchmark = async () => {
cancelAnimationFrame(raf);observer.disconnect()
const result={requestedHan:size,theme:'paper-moments',frameGapsMs:summarize(gaps),frames:gaps.length,framesOver25ms:gaps.filter(g=>g>25).length,longTasks:tasks,scrollTop:scroller.scrollTop,scrollHeight:scroller.scrollHeight}
const result={requestedHan:size,theme,variant,frameGapsMs:summarize(gaps),frames:gaps.length,framesOver25ms:gaps.filter(g=>g>25).length,longTasks:tasks,scrollTop:scroller.scrollTop,scrollHeight:scroller.scrollHeight}
const editor=component.value.getEditor(),view=editor.action(ctx=>ctx.get(editorViewCtx))
view.dispatch(view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(view.state.doc.content.size-1))))
scroller.scrollTop=scroller.scrollHeight;await settle()