fix: stabilize background operations and large embedding results
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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') },
|
||||
])
|
||||
|
||||
|
||||
@@ -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 }">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')!
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user