Perf/frontend chunk loading优化长文渲染、后台运行与向量检索,补齐运行日志和并发一致性 #34
@@ -18,6 +18,7 @@ backend/.env
|
||||
# 运行期生成的 SQLite 索引(vault 下的 Markdown 测试数据需提交)
|
||||
backend/data/*.db*
|
||||
backend/data/credentials/
|
||||
backend/data/logs/
|
||||
# 阶段验收笔记(验收用,不提交)
|
||||
backend/data/vault/验收/
|
||||
# 本机 MCP 配置、授权状态及服务器工作目录不得提交。
|
||||
|
||||
@@ -26,6 +26,7 @@ NotesAgent/
|
||||
- 多模态:API 优先,未配置或响应无效时回退本地;`local_only` 禁止远程调用。任务、修订、事件、来源和回退原因写入 SQLite。
|
||||
- 模型运行:默认 CPU,可选 CUDA 12.8 组件;固定模型 revision,按需启动独立子进程,交互检索优先排队,CUDA 初始化或显存失败时用同一冻结配置在 CPU 重试一次。
|
||||
- 可观测性:输入、输出、缓存命中、推理 Token 与音频用量卡片;本地运行诊断保留最近 200 条,不保存正文、文件路径、密钥或异常全文。
|
||||
- 运行日志:统一查看向量/模型错误、Agent、任务与 HTTP 操作;独立后台存储最近 20,000 条,支持错误码/关联 ID 筛选和游标分页。入口无需打开 Vault,详见 [后台运行日志与压力问题修复](docs/development/后台运行日志与压力问题修复.md)。
|
||||
- 界面偏好:设置页可即时切换全局中文/英文界面,并控制由系统词典提供的编辑器拼写检查;偏好目前保存于 Web 端设备配置,后续由 Tauri 配置存储接管。
|
||||
|
||||
## 第二阶段最新合并(2026-09-06)
|
||||
@@ -35,6 +36,7 @@ PR #31 已合并。工作区打开与 HTTP 保存不再等待向量推理;正
|
||||
新增开发说明:
|
||||
|
||||
- [工作区后台索引与保存](docs/development/工作区后台索引与保存开发说明.md):状态、并发、恢复和验证。
|
||||
- [模型隔离向量索引与增量登记](docs/development/模型隔离向量索引与增量登记.md):持久化 sqlite-vec 空间、旧向量复用、外部新增文件增量计算与检索性能验证。
|
||||
- [Mermaid 预览与缩放](docs/development/Mermaid预览与缩放开发说明.md):大图适配、鼠标缩放和文字裁切修复。
|
||||
- [扩展安装持久化与社区包](docs/development/扩展安装持久化与社区包开发说明.md):安装边界和示例包验证。
|
||||
- [模型上下文管理](docs/development/模型上下文管理.md):全局人设、预算估算和摘要限制。
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Serialize and batch durable Trace writes off the asyncio event loop."""
|
||||
import asyncio
|
||||
from contextvars import copy_context
|
||||
|
||||
|
||||
class AsyncTraceWriter:
|
||||
def __init__(self, repository):
|
||||
self.repository = repository
|
||||
self.queue = asyncio.Queue(maxsize=1024)
|
||||
self.worker = None
|
||||
|
||||
async def submit(self, operation, *args):
|
||||
future = asyncio.get_running_loop().create_future()
|
||||
await self.queue.put((operation, args, future))
|
||||
if self.worker is None or self.worker.done():
|
||||
self.worker = asyncio.create_task(self._drain())
|
||||
# Cancellation must not let an older snapshot commit after cancellation.
|
||||
cancelled = False
|
||||
while not future.done():
|
||||
try:
|
||||
await asyncio.shield(future)
|
||||
except asyncio.CancelledError:
|
||||
cancelled = True
|
||||
future.result()
|
||||
return cancelled
|
||||
|
||||
async def _drain(self):
|
||||
while not self.queue.empty():
|
||||
batch = []
|
||||
while len(batch) < 64 and not self.queue.empty():
|
||||
batch.append(self.queue.get_nowait())
|
||||
try:
|
||||
work = asyncio.get_running_loop().run_in_executor(
|
||||
None, copy_context().run, self.repository.write_batch, [(op, args) for op, args, _ in batch])
|
||||
# asyncio.run/shutdown may cancel every Task simultaneously. The
|
||||
# executor Future survives; finish it and release all waiters.
|
||||
while not work.done():
|
||||
try:
|
||||
await asyncio.shield(work)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
work.result()
|
||||
except Exception as exc:
|
||||
for _, _, future in batch:
|
||||
future.set_exception(exc)
|
||||
else:
|
||||
for _, _, future in batch:
|
||||
future.set_result(None)
|
||||
finally:
|
||||
for _ in batch:
|
||||
self.queue.task_done()
|
||||
+128
-77
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from app.agent.async_trace import AsyncTraceWriter
|
||||
from app.operation_logs import log_event, agent_run_id
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
@@ -65,6 +67,9 @@ class RunRecord:
|
||||
subscribers: set[asyncio.Queue[AgentEvent]] = field(default_factory=set)
|
||||
task: asyncio.Task[None] | None = None
|
||||
next_sequence: int = 0
|
||||
publish_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
cancel_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
persisted_run: AgentRun | None = None
|
||||
|
||||
|
||||
class AgentRuntime:
|
||||
@@ -84,6 +89,7 @@ class AgentRuntime:
|
||||
self.skills = skills
|
||||
self.trace_repository = trace_repository or AgentTraceRepository()
|
||||
self._records: dict[str, RunRecord] = {}
|
||||
self._writer = AsyncTraceWriter(self.trace_repository)
|
||||
|
||||
async def create_run(self, request: AgentRunCreateRequest) -> AgentRun:
|
||||
self._prune_records()
|
||||
@@ -122,19 +128,25 @@ class AgentRuntime:
|
||||
skill_config=skill_config,
|
||||
allowed_tools=allowed_tools,
|
||||
)
|
||||
self.trace_repository.create_run(
|
||||
run,
|
||||
request,
|
||||
self._config_snapshot(record),
|
||||
)
|
||||
# Reserve capacity before yielding to concurrent creators.
|
||||
self._records[run.run_id] = record
|
||||
try:
|
||||
cancelled = await self._writer.submit('create', run.model_copy(deep=True), request.model_copy(deep=True), self._config_snapshot(record))
|
||||
except BaseException:
|
||||
self._records.pop(run.run_id, None)
|
||||
raise
|
||||
record.persisted_run = run.model_copy(deep=True)
|
||||
log_event('agent', 'run.created', run_id=run.run_id, provider_id=run.provider_id, model=run.model)
|
||||
if cancelled:
|
||||
await self._finish_cancelled(record)
|
||||
raise asyncio.CancelledError
|
||||
record.task = asyncio.create_task(self._execute(record), name=run.run_id)
|
||||
return run.model_copy(deep=True)
|
||||
|
||||
def get_run(self, run_id: str) -> AgentRun:
|
||||
record = self._records.get(run_id)
|
||||
if record is not None:
|
||||
return record.run.model_copy(deep=True)
|
||||
return (record.persisted_run or record.run).model_copy(deep=True)
|
||||
run = self.trace_repository.recover_interrupted(run_id)
|
||||
if run is None:
|
||||
raise AgentRunNotFoundError(run_id)
|
||||
@@ -145,7 +157,7 @@ class AgentRuntime:
|
||||
recovered = [
|
||||
self.trace_repository.recover_interrupted(item.run_id) or item
|
||||
if item.run_id not in self._records
|
||||
else self._records[item.run_id].run.model_copy(deep=True)
|
||||
else (self._records[item.run_id].persisted_run or self._records[item.run_id].run).model_copy(deep=True)
|
||||
for item in items
|
||||
]
|
||||
return recovered, total
|
||||
@@ -154,25 +166,24 @@ class AgentRuntime:
|
||||
record = self._records.get(run_id)
|
||||
if record is None:
|
||||
return self.get_run(run_id)
|
||||
if record.run.status in TERMINAL_STATUSES:
|
||||
return record.run.model_copy(deep=True)
|
||||
record.run.cancelled = True
|
||||
record.run.status = AgentRunStatus.cancelled
|
||||
record.run.updated_at = datetime.now(timezone.utc)
|
||||
self.permissions.cancel_run(run_id)
|
||||
self._publish(record, AgentEventType.run_cancelled, {})
|
||||
if record.task and not record.task.done():
|
||||
record.task.cancel()
|
||||
return record.run.model_copy(deep=True)
|
||||
async with record.cancel_lock:
|
||||
if record.task and not record.task.done():
|
||||
if record.run.status not in TERMINAL_STATUSES:
|
||||
record.task.cancel()
|
||||
self.permissions.cancel_run(run_id)
|
||||
await asyncio.gather(record.task, return_exceptions=True)
|
||||
if record.run.status not in TERMINAL_STATUSES:
|
||||
await self._finish_cancelled(record)
|
||||
return (record.persisted_run or record.run).model_copy(deep=True)
|
||||
|
||||
def resolve_permission(self, run_id: str, request_id: str, decision: str) -> bool:
|
||||
async def resolve_permission(self, run_id: str, request_id: str, decision: str) -> bool:
|
||||
record = self._records.get(run_id)
|
||||
if record is None:
|
||||
return False
|
||||
ticket = self.permissions.get_ticket(run_id, request_id)
|
||||
resolved = self.permissions.resolve(run_id, request_id, decision)
|
||||
if resolved:
|
||||
self._publish(
|
||||
await self._publish(
|
||||
record,
|
||||
AgentEventType.permission_resolved,
|
||||
{
|
||||
@@ -189,23 +200,24 @@ class AgentRuntime:
|
||||
record = self._records.get(run_id)
|
||||
run = self.get_run(run_id)
|
||||
if record is None:
|
||||
for event in self.trace_repository.list_events(
|
||||
for event in await asyncio.to_thread(self.trace_repository.list_events,
|
||||
run_id, after_sequence=after_sequence
|
||||
):
|
||||
yield event
|
||||
return
|
||||
|
||||
# 先注册订阅再读持久化历史;同一事件循环内没有 await,不会丢失交界事件。
|
||||
# 先注册再异步读取历史;历史与实时队列的交界用 sequence 去重。
|
||||
queue: asyncio.Queue[AgentEvent] = asyncio.Queue()
|
||||
record.subscribers.add(queue)
|
||||
history = self.trace_repository.list_events(
|
||||
run_id, after_sequence=after_sequence
|
||||
)
|
||||
last_sequence = after_sequence
|
||||
try:
|
||||
history = await asyncio.to_thread(self.trace_repository.list_events,
|
||||
run_id, after_sequence=after_sequence)
|
||||
for event in history:
|
||||
last_sequence = event.sequence
|
||||
yield event
|
||||
if event.event in {AgentEventType.run_completed, AgentEventType.run_failed, AgentEventType.run_cancelled}:
|
||||
return
|
||||
if run.status in TERMINAL_STATUSES:
|
||||
return
|
||||
while True:
|
||||
@@ -232,7 +244,7 @@ class AgentRuntime:
|
||||
await asyncio.shield(record.task)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
return record.run.model_copy(deep=True)
|
||||
return (record.persisted_run or record.run).model_copy(deep=True)
|
||||
|
||||
def get_trace(
|
||||
self, run_id: str, *, after_sequence: int, limit: int
|
||||
@@ -246,23 +258,35 @@ class AgentRuntime:
|
||||
return trace
|
||||
|
||||
async def _execute(self, record: RunRecord) -> None:
|
||||
token = agent_run_id.set(record.run.run_id)
|
||||
try:
|
||||
async with asyncio.timeout(record.request.run_timeout_seconds):
|
||||
await self._run_loop(record)
|
||||
except asyncio.CancelledError:
|
||||
if record.run.status != AgentRunStatus.cancelled:
|
||||
self._finish_cancelled(record)
|
||||
await self._finish_cancelled(record)
|
||||
except TimeoutError:
|
||||
self._fail(record, "AGENT_TIMEOUT", "Agent run exceeded its timeout.")
|
||||
await self._fail(record, "AGENT_TIMEOUT", "Agent run exceeded its timeout.")
|
||||
except ProviderError as exc:
|
||||
self._fail(record, exc.code, exc.message)
|
||||
await self._fail(record, exc.code, exc.message)
|
||||
except Exception as exc:
|
||||
self._fail(record, "AGENT_FAILED", str(exc))
|
||||
log_event('agent', 'execution.failed', level='ERROR', error=exc, run_id=record.run.run_id)
|
||||
await self._fail(record, "AGENT_FAILED", str(exc))
|
||||
finally:
|
||||
self.permissions.cancel_run(record.run.run_id)
|
||||
agent_run_id.reset(token)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
results = await asyncio.gather(*(self.cancel(run_id) for run_id in list(self._records)), return_exceptions=True)
|
||||
for result in results:
|
||||
if isinstance(result, BaseException):
|
||||
log_event('agent', 'shutdown.failed', level='ERROR', error=result)
|
||||
await self._writer.queue.join()
|
||||
|
||||
async def _run_loop(self, record: RunRecord) -> None:
|
||||
record.run.status = AgentRunStatus.running
|
||||
record.run.updated_at = datetime.now(timezone.utc)
|
||||
self._publish(
|
||||
await self._publish(
|
||||
record,
|
||||
AgentEventType.run_started,
|
||||
{"provider_id": record.request.provider_id, "model": record.request.model},
|
||||
@@ -277,7 +301,7 @@ class AgentRuntime:
|
||||
record.run.updated_at = datetime.now(timezone.utc)
|
||||
model_call_id = f"model_call_{uuid4().hex}"
|
||||
started_at = perf_counter()
|
||||
self._publish(
|
||||
await self._publish(
|
||||
record,
|
||||
AgentEventType.model_call_started,
|
||||
{
|
||||
@@ -299,7 +323,7 @@ class AgentRuntime:
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
self._publish(
|
||||
await self._publish(
|
||||
record,
|
||||
AgentEventType.model_call_failed,
|
||||
{
|
||||
@@ -309,7 +333,7 @@ class AgentRuntime:
|
||||
},
|
||||
)
|
||||
raise
|
||||
self._publish(
|
||||
await self._publish(
|
||||
record,
|
||||
AgentEventType.model_call_completed,
|
||||
{
|
||||
@@ -322,7 +346,7 @@ class AgentRuntime:
|
||||
},
|
||||
)
|
||||
record.run.token_usage += turn.input_tokens + turn.output_tokens
|
||||
self._publish(
|
||||
await self._publish(
|
||||
record,
|
||||
AgentEventType.usage,
|
||||
{"token_usage": record.run.token_usage},
|
||||
@@ -331,12 +355,12 @@ class AgentRuntime:
|
||||
record.request.token_budget is not None
|
||||
and record.run.token_usage > record.request.token_budget
|
||||
):
|
||||
self._fail(record, "TOKEN_BUDGET_EXCEEDED", "Agent token budget exceeded.")
|
||||
await self._fail(record, "TOKEN_BUDGET_EXCEEDED", "Agent token budget exceeded.")
|
||||
return
|
||||
|
||||
if turn.tool_calls:
|
||||
if len(turn.tool_calls) > MAX_TOOL_CALLS_PER_TURN:
|
||||
self._fail(
|
||||
await self._fail(
|
||||
record,
|
||||
"TOO_MANY_TOOL_CALLS",
|
||||
f"Provider requested more than {MAX_TOOL_CALLS_PER_TURN} tools in one turn.",
|
||||
@@ -360,10 +384,17 @@ class AgentRuntime:
|
||||
async with semaphore:
|
||||
return await self._execute_tool(record, call, model_call_id)
|
||||
|
||||
results = await asyncio.gather(*(execute(call) for call in calls))
|
||||
executions = [asyncio.create_task(execute(call)) for call in calls]
|
||||
try:
|
||||
results = await asyncio.gather(*executions)
|
||||
finally:
|
||||
for execution in executions:
|
||||
if not execution.done():
|
||||
execution.cancel()
|
||||
await asyncio.gather(*executions, return_exceptions=True)
|
||||
for call, result in zip(calls, results):
|
||||
record.run.tool_results.append(result)
|
||||
self._collect_citations(record, result)
|
||||
await self._collect_citations(record, result)
|
||||
messages.append(
|
||||
Message(
|
||||
role=MessageRole.tool,
|
||||
@@ -376,20 +407,20 @@ class AgentRuntime:
|
||||
|
||||
if turn.text is not None:
|
||||
record.run.output = turn.text
|
||||
self._publish(record, AgentEventType.text_delta, {"text": turn.text})
|
||||
await self._publish(record, AgentEventType.text_delta, {"text": turn.text})
|
||||
record.run.status = AgentRunStatus.completed
|
||||
record.run.updated_at = datetime.now(timezone.utc)
|
||||
self._publish(
|
||||
await self._publish(
|
||||
record,
|
||||
AgentEventType.run_completed,
|
||||
{"output": turn.text, "token_usage": record.run.token_usage},
|
||||
)
|
||||
return
|
||||
|
||||
self._fail(record, "EMPTY_MODEL_RESPONSE", "Provider returned no text or tool call.")
|
||||
await self._fail(record, "EMPTY_MODEL_RESPONSE", "Provider returned no text or tool call.")
|
||||
return
|
||||
|
||||
self._fail(record, "MAX_STEPS_EXCEEDED", "Agent reached its maximum step count.")
|
||||
await self._fail(record, "MAX_STEPS_EXCEEDED", "Agent reached its maximum step count.")
|
||||
|
||||
async def _execute_tool(
|
||||
self, record: RunRecord, call: ToolCall, parent_model_call_id: str
|
||||
@@ -397,7 +428,7 @@ class AgentRuntime:
|
||||
started_at = perf_counter()
|
||||
call_data = call.model_dump(mode="json")
|
||||
call_data["parent_model_call_id"] = parent_model_call_id
|
||||
self._publish(record, AgentEventType.tool_call, call_data)
|
||||
await self._publish(record, AgentEventType.tool_call, call_data)
|
||||
try:
|
||||
registered = self.tools.get(call.name)
|
||||
except ToolNotFoundError:
|
||||
@@ -411,7 +442,7 @@ class AgentRuntime:
|
||||
error_code="TOOL_NOT_ALLOWED",
|
||||
error_message="Tool is not included in allowed_tools.",
|
||||
)
|
||||
self._publish_tool_result(
|
||||
await self._publish_tool_result(
|
||||
record, result, parent_model_call_id, started_at
|
||||
)
|
||||
return result
|
||||
@@ -425,7 +456,7 @@ class AgentRuntime:
|
||||
error_code="NETWORK_NOT_ALLOWED",
|
||||
error_message="Agent run does not allow network tools.",
|
||||
)
|
||||
self._publish_tool_result(
|
||||
await self._publish_tool_result(
|
||||
record, result, parent_model_call_id, started_at
|
||||
)
|
||||
return result
|
||||
@@ -436,7 +467,7 @@ class AgentRuntime:
|
||||
# 运行状态必须在等待期间可见,前端才能展示并处理权限确认卡片。
|
||||
ticket = self.permissions.create_ticket(record.run.run_id, permission)
|
||||
record.run.status = AgentRunStatus.waiting_permission
|
||||
self._publish(
|
||||
await self._publish(
|
||||
record,
|
||||
AgentEventType.permission_required,
|
||||
{
|
||||
@@ -458,13 +489,18 @@ class AgentRuntime:
|
||||
error_code="PERMISSION_TIMEOUT",
|
||||
error_message="Tool permission confirmation timed out.",
|
||||
)
|
||||
self._publish_tool_result(
|
||||
await self._publish_tool_result(
|
||||
record, result, parent_model_call_id, started_at
|
||||
)
|
||||
return result
|
||||
record.run.status = AgentRunStatus.running
|
||||
record.run.updated_at = datetime.now(timezone.utc)
|
||||
self.trace_repository.save_run(record.run)
|
||||
async with record.publish_lock:
|
||||
snapshot = record.run.model_copy(deep=True)
|
||||
cancelled = await self._writer.submit('save', snapshot)
|
||||
record.persisted_run = snapshot
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError
|
||||
result = (
|
||||
await self._invoke_tool(record, call)
|
||||
if decision in {"allow_once", "allow_session"}
|
||||
@@ -473,10 +509,10 @@ class AgentRuntime:
|
||||
else:
|
||||
result = await self._invoke_tool(record, call)
|
||||
|
||||
self._publish_tool_result(record, result, parent_model_call_id, started_at)
|
||||
await self._publish_tool_result(record, result, parent_model_call_id, started_at)
|
||||
return result
|
||||
|
||||
def _publish_tool_result(
|
||||
async def _publish_tool_result(
|
||||
self,
|
||||
record: RunRecord,
|
||||
result: ToolResult,
|
||||
@@ -486,7 +522,7 @@ class AgentRuntime:
|
||||
data = result.model_dump(mode="json")
|
||||
data["parent_model_call_id"] = parent_model_call_id
|
||||
data["duration_ms"] = int((perf_counter() - started_at) * 1000)
|
||||
self._publish(record, AgentEventType.tool_result, data)
|
||||
await self._publish(record, AgentEventType.tool_result, data)
|
||||
|
||||
async def _invoke_tool(self, record: RunRecord, call: ToolCall) -> ToolResult:
|
||||
try:
|
||||
@@ -519,45 +555,60 @@ class AgentRuntime:
|
||||
error_message="Tool permission was denied.",
|
||||
)
|
||||
|
||||
def _finish_cancelled(self, record: RunRecord) -> None:
|
||||
async def _finish_cancelled(self, record: RunRecord) -> None:
|
||||
record.run.cancelled = True
|
||||
record.run.status = AgentRunStatus.cancelled
|
||||
record.run.updated_at = datetime.now(timezone.utc)
|
||||
self._publish(record, AgentEventType.run_cancelled, {})
|
||||
await self._publish(record, AgentEventType.run_cancelled, {})
|
||||
|
||||
def _fail(self, record: RunRecord, code: str, message: str) -> None:
|
||||
if record.run.status in TERMINAL_STATUSES:
|
||||
async def _fail(self, record: RunRecord, code: str, message: str) -> None:
|
||||
log_event('agent', 'run.error', level='ERROR', run_id=record.run.run_id, error_code=code)
|
||||
if record.persisted_run and record.persisted_run.status in TERMINAL_STATUSES:
|
||||
return
|
||||
record.run.status = AgentRunStatus.failed
|
||||
record.run.error_code = code
|
||||
record.run.error_message = message
|
||||
record.run.updated_at = datetime.now(timezone.utc)
|
||||
self._publish(
|
||||
await self._publish(
|
||||
record,
|
||||
AgentEventType.run_failed,
|
||||
{"code": code, "message": message},
|
||||
)
|
||||
|
||||
def _publish(
|
||||
async def _publish(
|
||||
self, record: RunRecord, event_type: AgentEventType, data: dict[str, object]
|
||||
) -> None:
|
||||
sanitized = sanitize_trace_value(data)
|
||||
assert isinstance(sanitized, dict)
|
||||
event = AgentEvent(
|
||||
event=event_type,
|
||||
run_id=record.run.run_id,
|
||||
sequence=record.next_sequence,
|
||||
data=sanitized,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
record.next_sequence += 1
|
||||
record.events.append(event)
|
||||
self.trace_repository.append_event(record.run, event)
|
||||
# 内存只保留实时订阅窗口;完整审计轨迹由 SQLite 保存。
|
||||
if len(record.events) > MAX_EVENTS_PER_RUN:
|
||||
del record.events[: len(record.events) - MAX_EVENTS_PER_RUN]
|
||||
for queue in record.subscribers:
|
||||
queue.put_nowait(event)
|
||||
async with record.publish_lock:
|
||||
sanitized = sanitize_trace_value(data)
|
||||
assert isinstance(sanitized, dict)
|
||||
event = AgentEvent(
|
||||
event=event_type,
|
||||
run_id=record.run.run_id,
|
||||
sequence=record.next_sequence,
|
||||
data=sanitized,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
snapshot = record.run.model_copy(deep=True)
|
||||
try:
|
||||
cancelled = await self._writer.submit('event', snapshot, event)
|
||||
except Exception as exc:
|
||||
log_event('agent', 'trace.write_failed', level='ERROR', error=exc, run_id=record.run.run_id)
|
||||
raise
|
||||
record.next_sequence += 1
|
||||
record.persisted_run = snapshot
|
||||
record.events.append(event)
|
||||
log_event('agent', event_type.value,
|
||||
level='ERROR' if event_type.value.endswith('Failed') or data.get('success') is False else 'INFO',
|
||||
run_id=record.run.run_id, provider_id=record.run.provider_id, model=record.run.model,
|
||||
sequence=event.sequence, step=record.run.current_step, status=snapshot.status.value,
|
||||
tool=data.get('name'), error_code=data.get('code') or data.get('error_code'))
|
||||
# 内存只保留实时订阅窗口;完整审计轨迹由 SQLite 保存。
|
||||
if len(record.events) > MAX_EVENTS_PER_RUN:
|
||||
del record.events[: len(record.events) - MAX_EVENTS_PER_RUN]
|
||||
for queue in record.subscribers:
|
||||
queue.put_nowait(event)
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
@staticmethod
|
||||
def _request_metadata(record: RunRecord) -> dict[str, object]:
|
||||
@@ -583,7 +634,7 @@ class AgentRuntime:
|
||||
"metadata": record.request.metadata,
|
||||
}
|
||||
|
||||
def _collect_citations(self, record: RunRecord, result: ToolResult) -> None:
|
||||
async def _collect_citations(self, record: RunRecord, result: ToolResult) -> None:
|
||||
if not result.success or not isinstance(result.output, dict):
|
||||
return
|
||||
items = result.output.get("items")
|
||||
@@ -601,7 +652,7 @@ class AgentRuntime:
|
||||
continue
|
||||
known.add(citation.citation_id)
|
||||
record.run.citations.append(citation)
|
||||
self._publish(record, AgentEventType.citation, citation.model_dump(mode="json"))
|
||||
await self._publish(record, AgentEventType.citation, citation.model_dump(mode="json"))
|
||||
|
||||
def _get_record(self, run_id: str) -> RunRecord:
|
||||
try:
|
||||
@@ -618,7 +669,7 @@ class AgentRuntime:
|
||||
(
|
||||
record
|
||||
for record in self._records.values()
|
||||
if record.run.status in TERMINAL_STATUSES
|
||||
if record.run.status in TERMINAL_STATUSES and (record.task is None or record.task.done())
|
||||
),
|
||||
key=lambda record: record.run.updated_at,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ SQLite 中的事件是 SSE、前端 Trace 和 Benchmark 的共同事实来源。
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import nullcontext
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
@@ -94,15 +95,30 @@ def sanitize_trace_value(
|
||||
|
||||
|
||||
class AgentTraceRepository:
|
||||
def write_batch(self, jobs):
|
||||
conn = connect()
|
||||
try:
|
||||
with transaction(conn):
|
||||
for operation, args in jobs:
|
||||
if operation == 'create':
|
||||
self.create_run(*args, _conn=conn)
|
||||
elif operation == 'save':
|
||||
self.save_run(*args, _conn=conn)
|
||||
else:
|
||||
self.append_event(*args, _conn=conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def create_run(
|
||||
self,
|
||||
run: AgentRun,
|
||||
request: AgentRunCreateRequest,
|
||||
config_snapshot: dict[str, Any],
|
||||
*, _conn=None,
|
||||
) -> None:
|
||||
conn = connect()
|
||||
conn = _conn or connect()
|
||||
try:
|
||||
with transaction(conn):
|
||||
with transaction(conn) if _conn is None else nullcontext():
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO agent_runs(
|
||||
@@ -126,22 +142,24 @@ class AgentTraceRepository:
|
||||
),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
if _conn is None:
|
||||
conn.close()
|
||||
|
||||
def save_run(self, run: AgentRun) -> None:
|
||||
conn = connect()
|
||||
def save_run(self, run: AgentRun, *, _conn=None) -> None:
|
||||
conn = _conn or connect()
|
||||
try:
|
||||
with transaction(conn):
|
||||
with transaction(conn) if _conn is None else nullcontext():
|
||||
self._update_run(conn, run)
|
||||
finally:
|
||||
conn.close()
|
||||
if _conn is None:
|
||||
conn.close()
|
||||
|
||||
def append_event(self, run: AgentRun, event: AgentEvent) -> None:
|
||||
def append_event(self, run: AgentRun, event: AgentEvent, *, _conn=None) -> None:
|
||||
"""在同一事务中保存最新 Run 和事件;复写同一序号时保持幂等。"""
|
||||
|
||||
conn = connect()
|
||||
conn = _conn or connect()
|
||||
try:
|
||||
with transaction(conn):
|
||||
with transaction(conn) if _conn is None else nullcontext():
|
||||
self._update_run(conn, run)
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -158,7 +176,8 @@ class AgentTraceRepository:
|
||||
),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
if _conn is None:
|
||||
conn.close()
|
||||
|
||||
def get_run(self, run_id: str) -> AgentRun | None:
|
||||
conn = connect()
|
||||
|
||||
@@ -1132,6 +1132,11 @@ class TranscriptNoteRequest(Contract):
|
||||
|
||||
|
||||
class IndexStatus(Contract):
|
||||
running_jobs: int = 0
|
||||
active_searches: int = 0
|
||||
completed_searches: int = 0
|
||||
failed_searches: int = 0
|
||||
cancelled_searches: int = 0
|
||||
vector_refresh_required: bool = False
|
||||
total_notes: int = 0
|
||||
total_blocks: int = 0
|
||||
|
||||
@@ -25,6 +25,10 @@ class ApiError(Exception):
|
||||
|
||||
|
||||
async def api_error_handler(_: Request, exc: ApiError) -> JSONResponse:
|
||||
from app.operation_logs import log_event
|
||||
log_event('api', 'operation.failed', level='ERROR' if exc.status_code >= 500 else 'WARNING',
|
||||
error=exc, status=exc.status_code,
|
||||
**{key: value for key, value in exc.details.items() if key in {'run_id', 'task_id', 'note_id', 'job_id', 'provider_id'}})
|
||||
body = ErrorResponse(
|
||||
error=ErrorDetail(code=exc.code, message=exc.message, details=exc.details)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Bound embedding result frames so large notes do not exceed pipe line limits."""
|
||||
import json
|
||||
|
||||
|
||||
def response_lines(response, operation):
|
||||
if operation == 'embedding' and 'result' in response and 'error_code' not in response:
|
||||
vectors = response['result']
|
||||
for offset in range(0, len(vectors), 128):
|
||||
yield json.dumps({'embedding_offset': offset, 'embedding_chunk': vectors[offset:offset + 128]}, allow_nan=False) + '\n'
|
||||
response = {**response, 'result': [], 'embedding_count': len(vectors)}
|
||||
yield json.dumps(response, ensure_ascii=False, allow_nan=False) + '\n'
|
||||
@@ -189,15 +189,30 @@ class Runtime:
|
||||
await process.stdin.drain()
|
||||
process.stdin.close()
|
||||
final = None
|
||||
vectors = []
|
||||
while line := await process.stdout.readline():
|
||||
message = json.loads(line)
|
||||
if "progress" in message:
|
||||
if "embedding_chunk" in message:
|
||||
chunk = message['embedding_chunk']
|
||||
if (operation != 'embedding' or not isinstance(chunk, list)
|
||||
or message.get('embedding_offset') != len(vectors)
|
||||
or len(vectors) + len(chunk) > len(payload.get('texts', []))):
|
||||
raise ProviderError('LOCAL_MODEL_INVALID_RESPONSE', '本地向量传输顺序或数量无效。')
|
||||
vectors.extend(chunk)
|
||||
elif "progress" in message:
|
||||
callback = runtime_progress.get()
|
||||
if callback:
|
||||
callback(message)
|
||||
else:
|
||||
final = message
|
||||
await process.wait()
|
||||
if isinstance(final, dict) and 'embedding_count' in final:
|
||||
if (final['embedding_count'] != len(vectors)
|
||||
or len(vectors) != len(payload.get('texts', []))):
|
||||
raise ProviderError('LOCAL_MODEL_INVALID_RESPONSE', '本地向量传输不完整。')
|
||||
final['result'] = vectors
|
||||
elif vectors:
|
||||
raise ProviderError('LOCAL_MODEL_INVALID_RESPONSE', '本地向量传输缺少结束标记。')
|
||||
return final
|
||||
try:
|
||||
result = await asyncio.wait_for(receive(), config.timeout_seconds)
|
||||
|
||||
@@ -216,4 +216,6 @@ if __name__ == "__main__":
|
||||
response = {"error_code": "LOCAL_INFERENCE_FAILED", "message": "本地推理失败,请检查媒体格式、模型和设备配置。"}
|
||||
if "error_code" in response:
|
||||
response["diagnostics"] = {"requested_device": request["config"]["device"], "actual_device": request.get("_actual_device", "unknown")}
|
||||
sys.stdout.buffer.write((json.dumps(response, ensure_ascii=False, allow_nan=False) + "\n").encode("utf-8"))
|
||||
from protocol import response_lines
|
||||
for line in response_lines(response, request['operation']):
|
||||
sys.stdout.buffer.write(line.encode('utf-8'))
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
from fastapi import APIRouter, Query
|
||||
from app.operation_logs import get_store
|
||||
|
||||
router = APIRouter(prefix='/api/logs', tags=['Diagnostics'])
|
||||
|
||||
|
||||
@router.get('')
|
||||
def list_logs(limit: int = Query(50, ge=1, le=200), before: int | None = Query(None, ge=1),
|
||||
level: str = Query('', pattern='^(|INFO|WARNING|ERROR|CRITICAL)$'),
|
||||
source: str = Query('', max_length=100), q: str = Query('', max_length=200)):
|
||||
return get_store().query(limit=limit, before=before, level=level, source=source, q=q)
|
||||
@@ -1,4 +1,7 @@
|
||||
from contextlib import asynccontextmanager
|
||||
import asyncio
|
||||
from time import perf_counter
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
@@ -14,17 +17,22 @@ from app.local_model_routes import router as local_model_router
|
||||
from app.usage_routes import router as usage_router
|
||||
from app.provider_preview_routes import router as provider_preview_router
|
||||
from app.schemas import HealthResponse, ServiceStatusResponse
|
||||
from app.log_routes import router as log_router
|
||||
from app.operation_logs import install_logging, log_event, request_id, shutdown_logging
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
install_logging()
|
||||
log_event('system', 'service.started')
|
||||
from app.services import transcription_service
|
||||
transcription_service.recover_interrupted()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await container.agent.shutdown()
|
||||
from app.services import index_service
|
||||
await index_service.shutdown()
|
||||
await transcription_service.shutdown()
|
||||
@@ -35,6 +43,8 @@ async def lifespan(_: FastAPI):
|
||||
await manager.cancel_download(key)
|
||||
container.plugins.shutdown()
|
||||
container.mcp_servers.shutdown()
|
||||
log_event('system', 'service.stopped')
|
||||
await asyncio.to_thread(shutdown_logging)
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
@@ -60,6 +70,32 @@ app.include_router(media_router)
|
||||
app.include_router(local_model_router)
|
||||
app.include_router(usage_router)
|
||||
app.include_router(provider_preview_router)
|
||||
app.include_router(log_router)
|
||||
|
||||
|
||||
@app.middleware('http')
|
||||
async def operation_log(request, call_next):
|
||||
token = request_id.set(uuid4().hex)
|
||||
started = perf_counter()
|
||||
status = 500
|
||||
failure = None
|
||||
try:
|
||||
response = await call_next(request)
|
||||
status = response.status_code
|
||||
response.headers['X-Request-ID'] = request_id.get()
|
||||
return response
|
||||
except Exception as exc:
|
||||
failure = exc
|
||||
raise
|
||||
finally:
|
||||
# Do not record query strings, request/response bodies or arbitrary URLs.
|
||||
route = getattr(request.scope.get('route'), 'path', 'unmatched')
|
||||
if not route.startswith('/api/logs') and (request.method not in {'GET', 'HEAD', 'OPTIONS'} or status >= 400 or perf_counter() - started > 1):
|
||||
log_event('http', 'request.finished', level='ERROR' if status >= 500 else 'WARNING' if status >= 400 else 'INFO',
|
||||
error=failure, method=request.method, route=route, status=status,
|
||||
duration_ms=round((perf_counter() - started) * 1000, 2),
|
||||
**{k: v for k, v in request.path_params.items() if k in {'run_id', 'task_id', 'note_id', 'job_id', 'provider_id'}})
|
||||
request_id.reset(token)
|
||||
|
||||
|
||||
@app.get("/health", response_model=HealthResponse, tags=["System"])
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Bounded, asynchronous operational diagnostics, separate from business/Trace data.
|
||||
|
||||
Only explicitly allowed metadata is stored. Never store prompts, tool arguments,
|
||||
provider response bodies or raw exception messages in this diagnostic channel.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import queue
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import traceback
|
||||
from contextvars import ContextVar
|
||||
from contextlib import closing
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
request_id: ContextVar[str] = ContextVar('log_request_id', default='')
|
||||
agent_run_id: ContextVar[str] = ContextVar('log_agent_run_id', default='')
|
||||
_allowed = {'run_id', 'task_id', 'note_id', 'job_id', 'provider_id', 'model',
|
||||
'device', 'error_code', 'error_type', 'status', 'duration_ms', 'count',
|
||||
'step', 'sequence', 'tool', 'method', 'route', 'request_id', 'fallback',
|
||||
'frames', 'source', 'changed_fields'}
|
||||
_safe = re.compile(r'[^\w .:/@{}\[\],()=+\-]', re.UNICODE)
|
||||
|
||||
|
||||
def metadata(values: dict) -> dict:
|
||||
result = {}
|
||||
for key, value in values.items():
|
||||
if key not in _allowed or value is None:
|
||||
continue
|
||||
if isinstance(value, (int, float, bool)):
|
||||
if not isinstance(value, float) or math.isfinite(value):
|
||||
result[key] = value
|
||||
else:
|
||||
text = str(value)
|
||||
text = re.sub(r'(?i)(?:bearer\s+\S+|sk-[\w-]+)', '[REDACTED]', text)
|
||||
result[key] = _safe.sub('', text)[:500]
|
||||
return result
|
||||
|
||||
|
||||
class LogStore:
|
||||
def __init__(self, path: Path, *, retain: int = 20_000):
|
||||
self.path = path
|
||||
self.retain = retain
|
||||
self.queue: queue.Queue = queue.Queue(maxsize=4096)
|
||||
self.dropped = 0
|
||||
self.failed = 0
|
||||
self.closed = False
|
||||
self.state_lock = threading.Lock()
|
||||
self.thread = threading.Thread(target=self._write, name='operation-logs', daemon=True)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with closing(self._connect()) as conn, conn:
|
||||
conn.execute('CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY, timestamp TEXT NOT NULL, level TEXT NOT NULL, source TEXT NOT NULL, event TEXT NOT NULL, details TEXT NOT NULL)')
|
||||
conn.execute('CREATE INDEX IF NOT EXISTS logs_level_id ON logs(level, id)')
|
||||
conn.execute('CREATE INDEX IF NOT EXISTS logs_source_id ON logs(source, id)')
|
||||
self.thread.start()
|
||||
|
||||
def _connect(self):
|
||||
conn = sqlite3.connect(self.path, timeout=5)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def emit(self, level: str, source: str, event: str, details: dict):
|
||||
row = (datetime.now(timezone.utc).isoformat(), level, source[:100], event[:160], json.dumps(metadata(details), ensure_ascii=False))
|
||||
with self.state_lock:
|
||||
if self.closed:
|
||||
return
|
||||
try:
|
||||
self.queue.put_nowait(row)
|
||||
except queue.Full:
|
||||
self.dropped += 1
|
||||
|
||||
def _write(self):
|
||||
while True:
|
||||
first = self.queue.get()
|
||||
batch = [first]
|
||||
while len(batch) < 128:
|
||||
try:
|
||||
batch.append(self.queue.get_nowait())
|
||||
except queue.Empty:
|
||||
break
|
||||
stop = None in batch
|
||||
rows = [row for row in batch if row is not None]
|
||||
try:
|
||||
if rows:
|
||||
with closing(self._connect()) as conn, conn:
|
||||
conn.executemany('INSERT INTO logs(timestamp,level,source,event,details) VALUES(?,?,?,?,?)', rows)
|
||||
conn.execute('DELETE FROM logs WHERE id <= (SELECT id FROM logs ORDER BY id DESC LIMIT 1 OFFSET ?)', (self.retain,))
|
||||
except Exception:
|
||||
self.failed += len(rows)
|
||||
finally:
|
||||
for _ in batch:
|
||||
self.queue.task_done()
|
||||
if stop:
|
||||
return
|
||||
|
||||
def query(self, *, limit=50, before=None, level='', source='', q=''):
|
||||
clauses, args = [], []
|
||||
for column, value in [('level', level), ('source', source)]:
|
||||
if value:
|
||||
clauses.append(f'{column} = ?')
|
||||
args.append(value)
|
||||
if before is not None:
|
||||
clauses.append('id < ?')
|
||||
args.append(before)
|
||||
if q:
|
||||
clauses.append('(instr(event, ?) > 0 OR instr(details, ?) > 0)')
|
||||
args += [q, q]
|
||||
where = ' WHERE ' + ' AND '.join(clauses) if clauses else ''
|
||||
with closing(self._connect()) as conn, conn:
|
||||
rows = conn.execute('SELECT * FROM logs' + where + ' ORDER BY id DESC LIMIT ?', (*args, limit + 1)).fetchall()
|
||||
sources = [row[0] for row in conn.execute('SELECT DISTINCT source FROM logs ORDER BY source')]
|
||||
items = [{**dict(row), 'details': json.loads(row['details'])} for row in rows[:limit]]
|
||||
return {'items': items, 'next_cursor': items[-1]['id'] if len(rows) > limit else None,
|
||||
'sources': sources, 'pending': self.queue.qsize(), 'dropped': self.dropped,
|
||||
'write_failures': self.failed, 'retention': self.retain}
|
||||
|
||||
def close(self):
|
||||
with self.state_lock:
|
||||
if self.closed:
|
||||
return
|
||||
self.closed = True
|
||||
self.queue.put(None)
|
||||
self.thread.join(timeout=15)
|
||||
|
||||
|
||||
_store: LogStore | None = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_store() -> LogStore:
|
||||
global _store
|
||||
path = get_settings().data_dir / 'logs' / 'operations.sqlite3'
|
||||
with _lock:
|
||||
if _store is None or _store.path != path or _store.closed:
|
||||
if _store is not None and not _store.closed:
|
||||
_store.close()
|
||||
_store = LogStore(path)
|
||||
return _store
|
||||
|
||||
|
||||
def log_event(module: str, event: str, *, level='INFO', error: BaseException | None = None, **details):
|
||||
if request_id.get():
|
||||
details.setdefault('request_id', request_id.get())
|
||||
if agent_run_id.get():
|
||||
details.setdefault('run_id', agent_run_id.get())
|
||||
if error:
|
||||
details['error_type'] = type(error).__name__
|
||||
details.setdefault('error_code', getattr(error, 'code', None))
|
||||
details['frames'] = '; '.join(f'{Path(f.filename).name}:{f.lineno}:{f.name}' for f in traceback.extract_tb(error.__traceback__)[-8:])
|
||||
try:
|
||||
get_store().emit(level, module, event, details)
|
||||
except Exception:
|
||||
# Logging must not turn a successful save/run into a business failure.
|
||||
logging.getLogger('operation_log_storage').error('Operational log storage unavailable')
|
||||
|
||||
|
||||
class ApplicationLogHandler(logging.Handler):
|
||||
def emit(self, record):
|
||||
if record.name == 'operation_log_storage' or getattr(record, '_notes_operation_logged', False):
|
||||
return
|
||||
record._notes_operation_logged = True
|
||||
# Legacy log messages can include note text/credentials, even in f-strings.
|
||||
# Preserve source location and error class; structured call sites carry IDs.
|
||||
log_event(record.name, 'application.warning' if record.levelno < 40 else 'application.error',
|
||||
level=record.levelname, error=record.exc_info[1] if record.exc_info else None,
|
||||
frames=f'{Path(record.pathname).name}:{record.lineno}:{record.funcName}')
|
||||
|
||||
|
||||
def install_logging():
|
||||
# Uvicorn's default logger stops propagation before the root logger.
|
||||
for name in ('', 'uvicorn'):
|
||||
logger = logging.getLogger(name)
|
||||
if not any(isinstance(h, ApplicationLogHandler) for h in logger.handlers):
|
||||
logger.addHandler(ApplicationLogHandler(level=logging.WARNING))
|
||||
|
||||
|
||||
def shutdown_logging():
|
||||
if _store is not None and not _store.closed:
|
||||
_store.close()
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Process-local retrieval activity, shared by search, RAG and Agent callers."""
|
||||
import asyncio
|
||||
from functools import wraps
|
||||
|
||||
active = 0
|
||||
completed = 0
|
||||
failed = 0
|
||||
cancelled = 0
|
||||
|
||||
|
||||
def track_search(operation):
|
||||
@wraps(operation)
|
||||
async def wrapped(self, request):
|
||||
global active, completed, failed, cancelled
|
||||
if request.mode == 'fts':
|
||||
return await operation(self, request)
|
||||
active += 1
|
||||
try:
|
||||
result = await operation(self, request)
|
||||
completed += 1
|
||||
return result
|
||||
except asyncio.CancelledError:
|
||||
cancelled += 1
|
||||
raise
|
||||
except Exception:
|
||||
failed += 1
|
||||
raise
|
||||
finally:
|
||||
active -= 1
|
||||
return wrapped
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app import repository
|
||||
from app.retrieval.activity import track_search
|
||||
from app.contracts import (
|
||||
Citation,
|
||||
PageMeta,
|
||||
@@ -52,6 +53,7 @@ class RetrievalEngine:
|
||||
# remain authoritative, including monkeypatches on the singleton.
|
||||
self._routed_defaults = (embedding, vector_store) if route_embeddings else None
|
||||
|
||||
@track_search
|
||||
async def search(self, request: SearchRequest) -> SearchResponse:
|
||||
if request.mode == SearchMode.fts:
|
||||
return self._search_fts(request)
|
||||
|
||||
@@ -2,15 +2,14 @@
|
||||
|
||||
The runtime's model_id is the authoritative space ID (including provider URL,
|
||||
endpoint, model and dimensions); equal dimensions alone never imply compatibility.
|
||||
This phase uses a lazy, rebuildable SQLite side table instead of a schema migration.
|
||||
Search scans only current blocks in one database snapshot and requires complete
|
||||
coverage. Cosine ranking costs O(blocks * dimensions) with an O(top_k) heap; this
|
||||
small-vault implementation should become a per-space ANN index at larger scale.
|
||||
Durable vectors are reused to build per-space/dimension sqlite-vec indexes lazily.
|
||||
Native exact KNN avoids Python JSON decoding and dot products on every search.
|
||||
Coverage checks and ranking share one transaction.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import heapq
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
@@ -20,9 +19,11 @@ from typing import Protocol
|
||||
|
||||
from app.database.db import connect, transaction
|
||||
from app.errors import ApiError
|
||||
from app.operation_logs import log_event
|
||||
from app.retrieval.vectorstore import VectorHit
|
||||
from app.retrieval.provenance import record_embedding
|
||||
from app.retrieval.hybrid import rrf_fuse
|
||||
from app.retrieval import space_index
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -101,6 +102,8 @@ async def embed_remote(texts: list[str], *, accept_local=False, strict=False, lo
|
||||
source=result.source,
|
||||
)
|
||||
except Exception as exc:
|
||||
log_event('vectors', 'embedding.failed', level='ERROR' if strict else 'WARNING', error=exc,
|
||||
count=len(texts), fallback='none' if strict else 'local_index')
|
||||
# Avoid logging provider exceptions containing credentials or note text.
|
||||
record_embedding(fallback_reason="REMOTE_EMBEDDING_UNAVAILABLE")
|
||||
logger.warning("Remote embedding unavailable (%s); using local index", type(exc).__name__)
|
||||
@@ -118,9 +121,15 @@ def _ensure_table(conn: sqlite3.Connection) -> None:
|
||||
block_id TEXT NOT NULL REFERENCES blocks(block_id) ON DELETE CASCADE,
|
||||
dimensions INTEGER NOT NULL CHECK (dimensions > 0),
|
||||
vector TEXT NOT NULL,
|
||||
PRIMARY KEY (space_id, block_id)
|
||||
PRIMARY KEY (space_id, dimensions, block_id)
|
||||
)
|
||||
""")
|
||||
primary = [row[1] for row in sorted(conn.execute('PRAGMA table_info(routed_block_vectors)'), key=lambda row: row[5]) if row[5]]
|
||||
if primary == ['space_id', 'block_id']:
|
||||
conn.execute('CREATE TABLE routed_block_vectors_upgrade (space_id TEXT NOT NULL, block_id TEXT NOT NULL REFERENCES blocks(block_id) ON DELETE CASCADE, dimensions INTEGER NOT NULL CHECK(dimensions>0), vector TEXT NOT NULL, PRIMARY KEY(space_id,dimensions,block_id))')
|
||||
conn.execute('INSERT INTO routed_block_vectors_upgrade SELECT * FROM routed_block_vectors')
|
||||
conn.execute('DROP TABLE routed_block_vectors')
|
||||
conn.execute('ALTER TABLE routed_block_vectors_upgrade RENAME TO routed_block_vectors')
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS routed_block_vectors_block_id
|
||||
ON routed_block_vectors(block_id)
|
||||
@@ -146,13 +155,14 @@ def store_remote(
|
||||
conn.executemany(
|
||||
"""INSERT INTO routed_block_vectors (space_id, block_id, dimensions, vector)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (space_id, block_id) DO UPDATE SET
|
||||
ON CONFLICT (space_id, dimensions, block_id) DO UPDATE SET
|
||||
dimensions = excluded.dimensions, vector = excluded.vector""",
|
||||
[
|
||||
(batch.space_id, block_id, batch.dimensions, json.dumps(vector, allow_nan=False))
|
||||
for block_id, vector in zip(block_ids, batch.vectors)
|
||||
],
|
||||
)
|
||||
space_index.upsert(conn, block_ids, batch)
|
||||
except BaseException:
|
||||
conn.execute("ROLLBACK TO routed_vectors_write")
|
||||
raise
|
||||
@@ -180,6 +190,50 @@ async def search_remote(query: str, *, top_k: int, accept_local=False, strict=Fa
|
||||
if batch is None:
|
||||
return None
|
||||
|
||||
if not await _prepare_for_search([batch], strict):
|
||||
return None
|
||||
return await asyncio.to_thread(_search_space, batch, top_k, strict)
|
||||
|
||||
|
||||
async def _prepare_indexes(batches):
|
||||
from app.services.coordination import vault_mutation_lock
|
||||
def prepare(check_only=False):
|
||||
conn = connect()
|
||||
try:
|
||||
if check_only:
|
||||
return space_index.is_ready(conn, batches)
|
||||
space_index.prepare(conn, batches)
|
||||
finally:
|
||||
conn.close()
|
||||
if await asyncio.to_thread(prepare, True):
|
||||
return
|
||||
# Share the cooperative gate with saves: never block the event loop on a
|
||||
# SQLite write lock while a migration owns it in another thread.
|
||||
async with vault_mutation_lock():
|
||||
work = asyncio.create_task(asyncio.to_thread(prepare))
|
||||
cancelled = False
|
||||
while not work.done():
|
||||
try:
|
||||
await asyncio.shield(work)
|
||||
except asyncio.CancelledError:
|
||||
cancelled = True
|
||||
work.result()
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
|
||||
async def _prepare_for_search(batches, strict):
|
||||
try:
|
||||
await _prepare_indexes(batches)
|
||||
return True
|
||||
except Exception as exc:
|
||||
record_embedding(fallback_reason='REMOTE_INDEX_UNAVAILABLE')
|
||||
if strict:
|
||||
raise ApiError(409, 'SEMANTIC_INDEX_UNAVAILABLE', '向量索引准备失败,请检查索引状态。') from exc
|
||||
return False
|
||||
|
||||
|
||||
def _search_space(batch, top_k, strict):
|
||||
record_embedding(attempted_space={"model_id": batch.space_id, "dimensions": batch.dimensions})
|
||||
try:
|
||||
conn = connect()
|
||||
@@ -195,29 +249,7 @@ async def search_remote(query: str, *, top_k: int, accept_local=False, strict=Fa
|
||||
if strict:
|
||||
raise ValueError("semantic index missing")
|
||||
return None
|
||||
rows = conn.execute(
|
||||
"""SELECT b.block_id, r.vector
|
||||
FROM blocks AS b
|
||||
LEFT JOIN routed_block_vectors AS r
|
||||
ON r.block_id = b.block_id AND r.space_id = ? AND r.dimensions = ?
|
||||
ORDER BY b.block_id""",
|
||||
(batch.space_id, batch.dimensions),
|
||||
)
|
||||
|
||||
def hits():
|
||||
for row in rows:
|
||||
if row["vector"] is None:
|
||||
raise ValueError("remote space has incomplete block coverage")
|
||||
vector = _unit_vector(json.loads(row["vector"]), batch.dimensions)
|
||||
score = math.fsum(a * b for a, b in zip(batch.vectors[0], vector))
|
||||
yield VectorHit(id=row["block_id"], score=max(0.0, min(1.0, score)))
|
||||
|
||||
try:
|
||||
result = heapq.nlargest(top_k, hits(), key=lambda hit: hit.score)
|
||||
finally:
|
||||
# Exceptions may retain the generator/traceback; finalize its
|
||||
# cursor now so a subsequent rebuild can acquire a write lock.
|
||||
rows.close()
|
||||
result = space_index.search(conn, batch, top_k)
|
||||
record_embedding(source=batch.source, model_id=batch.space_id,
|
||||
dimensions=batch.dimensions, fallback_reason=None)
|
||||
return result
|
||||
@@ -241,6 +273,12 @@ async def _search_partitioned(query: str, policies: set[bool], *, top_k: int, st
|
||||
if batch is None:
|
||||
return None
|
||||
batches[policy] = batch
|
||||
if not await _prepare_for_search(list(batches.values()), strict):
|
||||
return None
|
||||
return await asyncio.to_thread(_search_partitions, batches, policies, top_k, strict)
|
||||
|
||||
|
||||
def _search_partitions(batches, policies, top_k, strict):
|
||||
conn = connect()
|
||||
try:
|
||||
with transaction(conn):
|
||||
@@ -250,23 +288,7 @@ async def _search_partitioned(query: str, policies: set[bool], *, top_k: int, st
|
||||
raise ValueError("embedding policies changed while querying")
|
||||
ranked = []
|
||||
for policy, batch in batches.items():
|
||||
rows = conn.execute(
|
||||
"SELECT b.block_id,r.vector FROM blocks b LEFT JOIN routed_block_vectors r "
|
||||
"ON r.block_id=b.block_id AND r.space_id=? AND r.dimensions=? "
|
||||
"WHERE b.embedding_local_only=? ORDER BY b.block_id",
|
||||
(batch.space_id, batch.dimensions, int(policy)),
|
||||
)
|
||||
def hits():
|
||||
for row in rows:
|
||||
if row['vector'] is None:
|
||||
raise ValueError("incomplete policy coverage")
|
||||
vector = _unit_vector(json.loads(row['vector']), batch.dimensions)
|
||||
score = math.fsum(a * b for a, b in zip(batch.vectors[0], vector))
|
||||
yield VectorHit(id=row['block_id'], score=max(0.0, min(1.0, score)))
|
||||
try:
|
||||
ranked.append(heapq.nlargest(top_k, hits(), key=lambda hit: hit.score))
|
||||
finally:
|
||||
rows.close()
|
||||
ranked.append(space_index.search(conn, batch, top_k, policy))
|
||||
spaces = [{"source": b.source, "model_id": b.space_id, "dimensions": b.dimensions,
|
||||
"local_only": policy} for policy, b in batches.items()]
|
||||
record_embedding(source="mixed" if len({b.source for b in batches.values()}) > 1 else batch.source,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Persistent vec0 indexes derived from durable routed vectors, one per space/dimension."""
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
|
||||
import sqlite_vec
|
||||
|
||||
from app.retrieval.vectorstore import VectorHit
|
||||
|
||||
|
||||
_migration_lock = threading.Lock()
|
||||
|
||||
|
||||
def is_ready(conn, batches):
|
||||
return all(conn.execute('SELECT 1 FROM sqlite_master WHERE name=?',
|
||||
(table_name(batch.space_id, batch.dimensions),)).fetchone() for batch in batches)
|
||||
|
||||
|
||||
def prepare(conn, batches):
|
||||
"""Finish lazy writes before opening a search snapshot. Warm searches do not write."""
|
||||
from app.retrieval.routed_vectors import _ensure_table
|
||||
batches = list(batches)
|
||||
if is_ready(conn, batches):
|
||||
return
|
||||
# Waiting holds no read transaction, so a concurrent migration can commit.
|
||||
with _migration_lock:
|
||||
if is_ready(conn, batches):
|
||||
return
|
||||
conn.execute('BEGIN IMMEDIATE')
|
||||
try:
|
||||
_ensure_table(conn)
|
||||
for batch in batches:
|
||||
ensure(conn, batch.space_id, batch.dimensions)
|
||||
conn.execute('COMMIT')
|
||||
except BaseException:
|
||||
conn.execute('ROLLBACK')
|
||||
raise
|
||||
|
||||
|
||||
def table_name(space, dimensions):
|
||||
return 'routed_vec_' + hashlib.sha256(json.dumps([space, dimensions]).encode()).hexdigest()
|
||||
|
||||
|
||||
def ensure(conn, space, dimensions):
|
||||
from app.retrieval.routed_vectors import _unit_vector
|
||||
table = table_name(space, dimensions)
|
||||
if conn.execute('SELECT 1 FROM sqlite_master WHERE name=?', (table,)).fetchone():
|
||||
return table
|
||||
if type(dimensions) is not int or not 0 < dimensions <= 8192:
|
||||
raise ValueError('unsupported vector dimensions')
|
||||
conn.execute(f'CREATE VIRTUAL TABLE {table} USING vec0(block_id TEXT PRIMARY KEY, embedding float[{dimensions}], local_only INTEGER)')
|
||||
for row in conn.execute('SELECT r.block_id,r.vector,b.embedding_local_only FROM routed_block_vectors r JOIN blocks b USING(block_id) WHERE r.space_id=? AND r.dimensions=?', (space, dimensions)):
|
||||
conn.execute(f'INSERT INTO {table}(block_id,embedding,local_only) VALUES (?,?,?)',
|
||||
(row[0], sqlite_vec.serialize_float32(_unit_vector(json.loads(row[1]), dimensions)), row[2]))
|
||||
literal = conn.execute('SELECT quote(?)', (space,)).fetchone()[0]
|
||||
for event in ('DELETE', 'UPDATE'):
|
||||
conn.execute(f'''CREATE TRIGGER {table}_{event.lower()} AFTER {event} ON routed_block_vectors
|
||||
WHEN old.space_id={literal} AND old.dimensions={dimensions}
|
||||
BEGIN DELETE FROM {table} WHERE block_id=old.block_id; END''')
|
||||
return table
|
||||
|
||||
|
||||
def upsert(conn, block_ids, batch):
|
||||
from app.retrieval.routed_vectors import _unit_vector
|
||||
table = ensure(conn, batch.space_id, batch.dimensions)
|
||||
for block_id, vector in zip(block_ids, batch.vectors):
|
||||
conn.execute(f'DELETE FROM {table} WHERE block_id=?', (block_id,))
|
||||
conn.execute(f'INSERT INTO {table}(block_id,embedding,local_only) SELECT block_id,?,embedding_local_only FROM blocks WHERE block_id=?',
|
||||
(sqlite_vec.serialize_float32(_unit_vector(vector, batch.dimensions)), block_id))
|
||||
|
||||
|
||||
def search(conn, batch, top_k, policy=None):
|
||||
table = table_name(batch.space_id, batch.dimensions)
|
||||
# Coverage checks stay relational; no JSON decoding or Python dot products on the hot path.
|
||||
where = '' if policy is None else ' AND b.embedding_local_only=?'
|
||||
params = () if policy is None else (int(policy),)
|
||||
missing = conn.execute(f'''SELECT 1 FROM blocks b LEFT JOIN routed_block_vectors r
|
||||
ON r.block_id=b.block_id AND r.space_id=? AND r.dimensions=?
|
||||
WHERE r.block_id IS NULL{where} LIMIT 1''', (batch.space_id, batch.dimensions, *params)).fetchone()
|
||||
expected = conn.execute('SELECT COUNT(*) FROM blocks' + ('' if policy is None else ' WHERE embedding_local_only=?'), params).fetchone()[0]
|
||||
actual = conn.execute(f'SELECT COUNT(*) FROM {table}' + ('' if policy is None else ' WHERE local_only=?'), params).fetchone()[0]
|
||||
if missing or actual != expected:
|
||||
raise ValueError('incomplete vector space coverage')
|
||||
if top_k <= 0:
|
||||
return []
|
||||
rows = conn.execute(f'SELECT block_id,distance FROM {table} WHERE embedding MATCH ? AND k=?'
|
||||
+ ('' if policy is None else ' AND local_only=?'),
|
||||
(sqlite_vec.serialize_float32(batch.vectors[0]), top_k, *params)).fetchall()
|
||||
return [VectorHit(id=row[0], score=max(0.0, min(1.0, 1 - row[1] ** 2 / 2))) for row in rows]
|
||||
+13
-8
@@ -11,6 +11,7 @@ from fastapi.responses import StreamingResponse
|
||||
from app.agent import AgentCapacityError, AgentRunNotFoundError
|
||||
from app.container import container
|
||||
from app.config import get_settings
|
||||
from app.operation_logs import log_event
|
||||
from app.extensions.archive import MAX_ZIP_BYTES, install_zip
|
||||
from app.services.persona_settings import PersonaSettings, load_persona, save_persona
|
||||
from app.contracts import (
|
||||
@@ -455,11 +456,15 @@ async def chat(request: ChatRequest) -> StreamingResponse:
|
||||
usage = {"input_tokens": input_tokens, "output_tokens": output_tokens,
|
||||
"total_tokens": input_tokens + output_tokens}
|
||||
elif event.event == ModelEventType.error:
|
||||
log_event('chat', 'model.error', level='ERROR', provider_id=request.provider_id,
|
||||
model=request.model, error_code=event.data.get('code'))
|
||||
if assistant_content:
|
||||
assistant_content += "\n\n"
|
||||
assistant_content += str(event.data.get("message", "Model generation failed."))
|
||||
yield as_sse(event.event.value, event.model_dump_json())
|
||||
except Exception as exc:
|
||||
log_event('chat', 'chat.failed', level='ERROR', error=exc,
|
||||
provider_id=request.provider_id, model=request.model)
|
||||
failure_message = exc.message if isinstance(exc, ApiError) else "知识库检索或模型生成失败,请检查服务状态。"
|
||||
if assistant_content:
|
||||
assistant_content += "\n\n"
|
||||
@@ -498,7 +503,7 @@ async def chat(request: ChatRequest) -> StreamingResponse:
|
||||
async def list_agent_runs(
|
||||
limit: int = Query(default=50, ge=1, le=100), offset: int = Query(default=0, ge=0)
|
||||
) -> AgentRunListResponse:
|
||||
items, total = container.agent.list_runs(limit=limit, offset=offset)
|
||||
items, total = await asyncio.to_thread(container.agent.list_runs, limit=limit, offset=offset)
|
||||
return AgentRunListResponse(
|
||||
items=items,
|
||||
page=PageMeta(total=total, limit=limit, offset=offset),
|
||||
@@ -603,7 +608,7 @@ async def get_agent_trace(
|
||||
limit: int = Query(default=200, ge=1, le=500),
|
||||
) -> AgentTraceResponse:
|
||||
try:
|
||||
return container.agent.get_trace(
|
||||
return await asyncio.to_thread(container.agent.get_trace,
|
||||
run_id, after_sequence=after_sequence, limit=limit
|
||||
)
|
||||
except AgentRunNotFoundError as exc:
|
||||
@@ -624,7 +629,7 @@ async def decide_agent_permission(
|
||||
run_id: str, request_id: str, request: PermissionDecisionRequest
|
||||
) -> OperationResponse:
|
||||
agent_run_or_404(run_id)
|
||||
if not container.agent.resolve_permission(run_id, request_id, request.decision):
|
||||
if not await container.agent.resolve_permission(run_id, request_id, request.decision):
|
||||
raise ApiError(
|
||||
404,
|
||||
"PERMISSION_REQUEST_NOT_FOUND",
|
||||
@@ -1223,7 +1228,7 @@ async def test_provider(request: ProviderTestRequest) -> ProviderTestResponse:
|
||||
async def list_tasks(
|
||||
limit: int = Query(default=50, ge=1, le=100), offset: int = Query(default=0, ge=0)
|
||||
) -> TaskListResponse:
|
||||
items, total = task_service.list_tasks(limit=limit, offset=offset)
|
||||
items, total = await asyncio.to_thread(task_service.list_tasks, limit=limit, offset=offset)
|
||||
return TaskListResponse(
|
||||
items=items, page=PageMeta(total=total, limit=limit, offset=offset)
|
||||
)
|
||||
@@ -1231,12 +1236,12 @@ async def list_tasks(
|
||||
|
||||
@router.post("/tasks", response_model=Task, tags=["Tasks"])
|
||||
async def create_task(request: TaskCreateRequest) -> Task:
|
||||
return task_service.create_task(**request.model_dump())
|
||||
return await task_service.write_in_background(task_service.create_task, **request.model_dump())
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}", response_model=Task, tags=["Tasks"])
|
||||
async def get_task(task_id: str) -> Task:
|
||||
task = task_service.get_task(task_id)
|
||||
task = await asyncio.to_thread(task_service.get_task, task_id)
|
||||
if task is None:
|
||||
raise ApiError(
|
||||
404, "RESOURCE_NOT_FOUND", "task not found", {"task_id": task_id}
|
||||
@@ -1246,7 +1251,7 @@ async def get_task(task_id: str) -> Task:
|
||||
|
||||
@router.patch("/tasks/{task_id}", response_model=Task, tags=["Tasks"])
|
||||
async def update_task(task_id: str, request: TaskUpdateRequest) -> Task:
|
||||
return task_service.update_task(task_id, request.model_dump(exclude_unset=True))
|
||||
return await task_service.write_in_background(task_service.update_task, task_id, request.model_dump(exclude_unset=True))
|
||||
|
||||
|
||||
@router.delete(
|
||||
@@ -1255,7 +1260,7 @@ async def update_task(task_id: str, request: TaskUpdateRequest) -> Task:
|
||||
tags=["Tasks"],
|
||||
)
|
||||
async def delete_task(task_id: str) -> OperationResponse:
|
||||
if not task_service.delete_task(task_id):
|
||||
if not await task_service.write_in_background(task_service.delete_task, task_id):
|
||||
raise ApiError(
|
||||
404, "RESOURCE_NOT_FOUND", "task not found", {"task_id": task_id}
|
||||
)
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import asyncio
|
||||
from functools import wraps
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
_vault_mutation_lock = asyncio.Lock()
|
||||
_vault_locks = WeakKeyDictionary()
|
||||
|
||||
|
||||
def vault_mutation_lock():
|
||||
# Service/test lifecycle restarts must not reuse a lock bound to a closed loop.
|
||||
loop = asyncio.get_running_loop()
|
||||
return _vault_locks.setdefault(loop, asyncio.Lock())
|
||||
|
||||
|
||||
def serialized_vault_mutation(operation):
|
||||
@@ -9,7 +16,7 @@ def serialized_vault_mutation(operation):
|
||||
|
||||
@wraps(operation)
|
||||
async def wrapped(*args, **kwargs):
|
||||
async with _vault_mutation_lock:
|
||||
async with vault_mutation_lock():
|
||||
return await operation(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from app.operation_logs import log_event
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -16,7 +17,7 @@ from app.errors import ApiError
|
||||
from app.knowledge.parser import parse_note
|
||||
from app.services.note_service import index_note, prepare_note_index
|
||||
from app.database.db import connect, transaction
|
||||
from app.services.coordination import _vault_mutation_lock
|
||||
from app.services.coordination import vault_mutation_lock
|
||||
from app.retrieval.vectorstore import SqliteVecStore
|
||||
from app.local_models.runtime import LocalEmbedding
|
||||
from app.services import note_service
|
||||
@@ -25,6 +26,7 @@ vector_store = SqliteVecStore()
|
||||
|
||||
_jobs: dict[str, IndexJob] = {}
|
||||
_active_job_id: str | None = None
|
||||
_active_scope: str | None = None
|
||||
_last_completed_at: datetime | None = None
|
||||
_last_error: str | None = None
|
||||
MAX_JOBS = 100
|
||||
@@ -33,6 +35,7 @@ _logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _remember_job(job: IndexJob) -> None:
|
||||
log_event('vectors', 'index.' + job.status, job_id=job.job_id, status=job.status)
|
||||
_jobs[job.job_id] = job
|
||||
while len(_jobs) > MAX_JOBS:
|
||||
oldest = next(iter(_jobs))
|
||||
@@ -64,7 +67,7 @@ def _scan_vault() -> list[tuple[str, str, str, datetime, datetime]]:
|
||||
|
||||
|
||||
async def rebuild(request: IndexRebuildRequest) -> IndexJob:
|
||||
global _active_job_id, _last_completed_at, _last_error
|
||||
global _active_job_id, _active_scope, _last_completed_at, _last_error
|
||||
if _active_job_id is not None:
|
||||
raise ApiError(409, "INDEX_BUSY", "索引正在后台计算,请稍后重试。")
|
||||
job_id = "job_" + uuid4().hex[:12]
|
||||
@@ -82,6 +85,7 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
|
||||
saved_paths = {record.file_path: record for record in saved_records.values() if record is not None}
|
||||
|
||||
_active_job_id = job_id
|
||||
_active_scope = 'all'
|
||||
_last_error = None
|
||||
_remember_job(IndexJob(
|
||||
job_id=job_id, status="running", scope=request.scope,
|
||||
@@ -112,7 +116,7 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
|
||||
prepared_notes.append((parsed, prepared))
|
||||
# All network/model awaits precede the transaction. The concrete SQLite
|
||||
# methods below complete synchronously despite their async interfaces.
|
||||
async with _vault_mutation_lock:
|
||||
async with vault_mutation_lock():
|
||||
if _scan_vault() != docs or saved_records != {key: repository.get_note_record(key) for key in _pending_notes()}:
|
||||
raise ApiError(409, "INDEX_SNAPSHOT_CHANGED", "笔记在计算期间发生变化,稍后重新计算。")
|
||||
conn = connect()
|
||||
@@ -148,6 +152,7 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
|
||||
finally:
|
||||
conn.close()
|
||||
except BaseException as exc:
|
||||
log_event('vectors', 'index.failed', level='WARNING' if isinstance(exc, asyncio.CancelledError) else 'ERROR', error=exc, job_id=job_id)
|
||||
_remember_job(IndexJob(
|
||||
job_id=job_id, status="failed", scope=request.scope,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
@@ -156,6 +161,7 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
|
||||
raise
|
||||
finally:
|
||||
_active_job_id = None
|
||||
_active_scope = None
|
||||
|
||||
job = IndexJob(job_id=job_id, status="completed", scope=request.scope, created_at=datetime.now(timezone.utc))
|
||||
_remember_job(job)
|
||||
@@ -166,16 +172,26 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
|
||||
|
||||
|
||||
def get_status() -> IndexStatus:
|
||||
from app.retrieval import activity
|
||||
counts = repository.stats()
|
||||
vector_refresh_required = repository.get_index_meta().get('workspace_vectors_pending') == '1' or bool(_pending_notes())
|
||||
workspace_pending = repository.get_index_meta().get('workspace_vectors_pending') == '1'
|
||||
notes_pending = len(_pending_notes())
|
||||
vector_refresh_required = workspace_pending or bool(notes_pending)
|
||||
running = int(_active_job_id is not None)
|
||||
# An entire-vault rebuild is one job, not one job per block/note.
|
||||
pending = 1 if running and _active_scope == 'all' else (1 + running if workspace_pending else max(notes_pending, running))
|
||||
activity_fields = dict(running_jobs=running, active_searches=activity.active,
|
||||
completed_searches=activity.completed, failed_searches=activity.failed,
|
||||
cancelled_searches=activity.cancelled)
|
||||
if _active_job_id is not None:
|
||||
return IndexStatus(status="running", pending_jobs=0, active_job_id=_active_job_id, vector_refresh_required=vector_refresh_required,
|
||||
return IndexStatus(**activity_fields, status="running", pending_jobs=pending, active_job_id=_active_job_id, vector_refresh_required=vector_refresh_required,
|
||||
total_notes=counts["notes"], total_blocks=counts["blocks"])
|
||||
return IndexStatus(
|
||||
**activity_fields,
|
||||
vector_refresh_required=vector_refresh_required,
|
||||
total_notes=counts["notes"], total_blocks=counts["blocks"],
|
||||
status="failed" if _last_error else "idle",
|
||||
pending_jobs=0,
|
||||
pending_jobs=pending,
|
||||
last_completed_at=_last_completed_at,
|
||||
error_message=_last_error,
|
||||
)
|
||||
@@ -227,7 +243,7 @@ def _pending_notes() -> list[str]:
|
||||
|
||||
|
||||
async def _refresh_saved_note(note_id: str) -> None:
|
||||
global _active_job_id, _last_error, _last_completed_at
|
||||
global _active_job_id, _active_scope, _last_error, _last_completed_at
|
||||
record = repository.get_note_record(note_id)
|
||||
key = f'note_vectors_pending:{note_id}'
|
||||
if record is None:
|
||||
@@ -240,13 +256,14 @@ async def _refresh_saved_note(note_id: str) -> None:
|
||||
parsed.title = record.title
|
||||
job_id = 'job_' + uuid4().hex[:12]
|
||||
_active_job_id = job_id
|
||||
_active_scope = 'note'
|
||||
_last_error = None
|
||||
_remember_job(IndexJob(job_id=job_id, status='running', scope='all', created_at=datetime.now(timezone.utc)))
|
||||
try:
|
||||
prepared = await prepare_note_index(parsed, strict=True)
|
||||
if isinstance(note_service.embedding, LocalEmbedding) and parsed.blocks and prepared[1] is None:
|
||||
raise ApiError(503, "EMBEDDING_UNAVAILABLE", "笔记已保存,后台向量计算未完成。")
|
||||
async with _vault_mutation_lock:
|
||||
async with vault_mutation_lock():
|
||||
current = repository.get_note_record(note_id)
|
||||
if current != record or note_service._read_markdown(record.file_path) != markdown:
|
||||
# Another save or rename won the race; leave the durable queue entry intact.
|
||||
@@ -254,6 +271,14 @@ async def _refresh_saved_note(note_id: str) -> None:
|
||||
conn = connect()
|
||||
try:
|
||||
with transaction(conn):
|
||||
existing_ids = {row[0] for row in conn.execute('SELECT block_id FROM blocks WHERE note_id=?', (note_id,))}
|
||||
if existing_ids != {block.block_id for block in parsed.blocks}:
|
||||
# An external editor changed a newly registered note while inference ran.
|
||||
# Reconcile that note only; the snapshot check above protects newer saves.
|
||||
parsed.title = parse_note(markdown=markdown, file_path=record.file_path,
|
||||
folder=record.folder, tags=record.tags, created_at=record.created_at,
|
||||
updated_at=record.updated_at, note_id=note_id).title
|
||||
await index_note(parsed, prepared=prepared, conn=conn)
|
||||
# Write only vectors: metadata and FTS already represent the saved revision.
|
||||
vectors, remote = prepared
|
||||
from app.retrieval.vectorstore import VectorRecord
|
||||
@@ -261,14 +286,24 @@ async def _refresh_saved_note(note_id: str) -> None:
|
||||
await vector_store.upsert([VectorRecord(id=b.block_id, vector=v)
|
||||
for b, v in zip(parsed.blocks, vectors)], conn=conn)
|
||||
routed_vectors.store_remote(conn, [b.block_id for b in parsed.blocks], remote)
|
||||
if isinstance(note_service.embedding, LocalEmbedding) and parsed.blocks:
|
||||
from app.retrieval.space_index import table_name
|
||||
if remote is None:
|
||||
raise ApiError(503, 'EMBEDDING_UNAVAILABLE', '笔记已保存,向量计算未完成。')
|
||||
table = table_name(remote.space_id, remote.dimensions)
|
||||
missing = conn.execute(f'SELECT 1 FROM blocks b LEFT JOIN {table} v ON v.block_id=b.block_id WHERE b.note_id=? AND v.block_id IS NULL LIMIT 1', (note_id,)).fetchone()
|
||||
if missing:
|
||||
raise ApiError(500, 'SEMANTIC_INDEX_WRITE_FAILED', '向量写入未完成,保留待处理标记。')
|
||||
repository.set_index_meta({key: '0'}, conn=conn)
|
||||
finally:
|
||||
conn.close()
|
||||
_last_completed_at = datetime.now(timezone.utc)
|
||||
_remember_job(IndexJob(job_id=job_id, status='completed', scope='all', created_at=_last_completed_at))
|
||||
except BaseException as exc:
|
||||
log_event('vectors', 'index.failed', level='WARNING' if isinstance(exc, asyncio.CancelledError) else 'ERROR', error=exc, job_id=job_id)
|
||||
_last_error = str(exc) or '后台向量计算已中断,笔记已保存。'
|
||||
_remember_job(IndexJob(job_id=job_id, status='failed', scope='all', created_at=datetime.now(timezone.utc)))
|
||||
raise
|
||||
finally:
|
||||
_active_job_id = None
|
||||
_active_scope = None
|
||||
|
||||
@@ -19,6 +19,13 @@ def connection():
|
||||
|
||||
|
||||
def record(**values):
|
||||
from app.operation_logs import log_event
|
||||
log_event('models', 'model.' + str(values.get('operation', 'inference')),
|
||||
level='ERROR' if values.get('status') == 'failed' else 'WARNING' if values.get('status') == 'fallback' else 'INFO',
|
||||
model=values.get('model'), source=values.get('source'), status=values.get('status'),
|
||||
device=values.get('actual_device') or values.get('attempted_device'),
|
||||
error_code=values.get('error_code'), fallback=values.get('fallback_reason'),
|
||||
duration_ms=round(values.get('elapsed_seconds', 0) * 1000, 2))
|
||||
safe = {key: value[:240] for key, value in values.items() if key in TEXT and isinstance(value, str)}
|
||||
safe.update({key: value for key, value in values.items()
|
||||
if key in NUMBERS and type(value) in (float, int) and math.isfinite(value) and value >= 0})
|
||||
|
||||
@@ -2,11 +2,37 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
import asyncio
|
||||
from contextvars import copy_context
|
||||
from functools import partial
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
from app import repository
|
||||
from app.contracts import Task, TaskStatus
|
||||
from app.database.db import connect, transaction
|
||||
from app.errors import ApiError
|
||||
from app.operation_logs import log_event
|
||||
|
||||
_write_locks = WeakKeyDictionary()
|
||||
|
||||
|
||||
async def write_in_background(operation, *args, **kwargs):
|
||||
# SQLite has one writer. Queue cooperatively instead of letting many worker
|
||||
# threads fight over the file lock and starve unrelated model work.
|
||||
loop = asyncio.get_running_loop()
|
||||
lock = _write_locks.setdefault(loop, asyncio.Lock())
|
||||
async with lock:
|
||||
work = loop.run_in_executor(None, copy_context().run, partial(operation, *args, **kwargs))
|
||||
cancelled = False
|
||||
while not work.done():
|
||||
try:
|
||||
await asyncio.shield(work)
|
||||
except asyncio.CancelledError:
|
||||
cancelled = True
|
||||
result = work.result()
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError
|
||||
return result
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
@@ -49,6 +75,7 @@ def create_task(
|
||||
),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
|
||||
log_event('tasks', 'task.created', task_id=task_id, note_id=note_id, status='todo')
|
||||
return _task_from_row(row)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -111,6 +138,7 @@ def update_task(task_id: str, values: dict[str, object]) -> Task:
|
||||
params,
|
||||
)
|
||||
row = conn.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
|
||||
log_event('tasks', 'task.updated', task_id=task_id, status=row['status'], changed_fields=','.join(values))
|
||||
return _task_from_row(row)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -121,6 +149,7 @@ def delete_task(task_id: str) -> bool:
|
||||
try:
|
||||
with transaction(conn):
|
||||
cursor = conn.execute("DELETE FROM tasks WHERE task_id = ?", (task_id,))
|
||||
log_event('tasks', 'task.deleted' if cursor.rowcount else 'task.not_found', task_id=task_id)
|
||||
return cursor.rowcount > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -98,6 +98,11 @@ class UsageAttempt:
|
||||
reasoning_tokens=first("output_tokens_details.reasoning_tokens", "completion_tokens_details.reasoning_tokens"))
|
||||
|
||||
def persist(self):
|
||||
from app.operation_logs import log_event
|
||||
log_event('providers', 'model.request_finished', level='INFO' if self.completed else 'WARNING',
|
||||
provider_id=self.provider_id, model=self.model, run_id=self.run_id,
|
||||
request_id=self.request_id, source=self.source,
|
||||
status='completed' if self.completed else 'incomplete')
|
||||
try:
|
||||
with closing(connection()) as conn:
|
||||
conn.execute("INSERT OR REPLACE INTO model_usage VALUES (?,?,?,?,?,?,?,?,?,?,?)", (
|
||||
|
||||
@@ -161,7 +161,7 @@ async def _register_workspace_files() -> None:
|
||||
created_at=parsed.created_at, updated_at=parsed.updated_at, blocks=parsed.blocks)
|
||||
conn.execute('UPDATE blocks SET embedding_local_only=? WHERE note_id=?', (int(parsed.embedding_local_only), parsed.note_id))
|
||||
if prepared:
|
||||
repository.set_index_meta({'workspace_vectors_pending': '1'}, conn=conn)
|
||||
repository.set_index_meta({f'note_vectors_pending:{parsed.note_id}': '1' for parsed in prepared}, conn=conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -0,0 +1,958 @@
|
||||
# 长文渲染压力测试
|
||||
|
||||
## 第 1 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 1
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 2 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 2
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 3 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 3
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 4 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 4
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 5 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 5
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 6 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 6
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 7 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 7
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 8 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 8
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
> [!TIP] 验收提示
|
||||
> 内容需要保留,折叠后仍可展开。
|
||||
|
||||
| 项目 | 状态 |
|
||||
| --- | --- |
|
||||
| 渲染 | 待验证 |
|
||||
|
||||
```javascript
|
||||
const note = { title: "长文测试", ready: true };
|
||||
console.log(note);
|
||||
```
|
||||
|
||||
## 第 9 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 9
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 10 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 10
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 11 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 11
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 12 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 12
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 13 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 13
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 14 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 14
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 15 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 15
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 16 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 16
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
> [!TIP] 验收提示
|
||||
> 内容需要保留,折叠后仍可展开。
|
||||
|
||||
| 项目 | 状态 |
|
||||
| --- | --- |
|
||||
| 渲染 | 待验证 |
|
||||
|
||||
```javascript
|
||||
const note = { title: "长文测试", ready: true };
|
||||
console.log(note);
|
||||
```
|
||||
|
||||
## 第 17 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 17
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 18 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 18
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 19 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 19
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 20 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 20
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 21 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 21
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 22 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 22
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 23 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 23
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 24 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 24
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
> [!TIP] 验收提示
|
||||
> 内容需要保留,折叠后仍可展开。
|
||||
|
||||
| 项目 | 状态 |
|
||||
| --- | --- |
|
||||
| 渲染 | 待验证 |
|
||||
|
||||
```javascript
|
||||
const note = { title: "长文测试", ready: true };
|
||||
console.log(note);
|
||||
```
|
||||
|
||||
## 第 25 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 25
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 26 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 26
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 27 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 27
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 28 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 28
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 29 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 29
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 30 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 30
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 31 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 31
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 32 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 32
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
> [!TIP] 验收提示
|
||||
> 内容需要保留,折叠后仍可展开。
|
||||
|
||||
| 项目 | 状态 |
|
||||
| --- | --- |
|
||||
| 渲染 | 待验证 |
|
||||
|
||||
```javascript
|
||||
const note = { title: "长文测试", ready: true };
|
||||
console.log(note);
|
||||
```
|
||||
|
||||
## 第 33 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 33
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 34 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 34
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 35 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 35
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 36 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 36
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 37 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 37
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 38 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 38
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 39 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 39
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 40 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 40
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
> [!TIP] 验收提示
|
||||
> 内容需要保留,折叠后仍可展开。
|
||||
|
||||
| 项目 | 状态 |
|
||||
| --- | --- |
|
||||
| 渲染 | 待验证 |
|
||||
|
||||
```javascript
|
||||
const note = { title: "长文测试", ready: true };
|
||||
console.log(note);
|
||||
```
|
||||
|
||||
## 第 41 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 41
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 42 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 42
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 43 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 43
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 44 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 44
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 45 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 45
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 46 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 46
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 47 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 47
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 48 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 48
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
> [!TIP] 验收提示
|
||||
> 内容需要保留,折叠后仍可展开。
|
||||
|
||||
| 项目 | 状态 |
|
||||
| --- | --- |
|
||||
| 渲染 | 待验证 |
|
||||
|
||||
```javascript
|
||||
const note = { title: "长文测试", ready: true };
|
||||
console.log(note);
|
||||
```
|
||||
|
||||
## 第 49 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 49
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 50 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 50
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 51 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 51
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 52 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 52
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 53 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 53
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 54 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 54
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 55 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 55
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 56 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 56
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
> [!TIP] 验收提示
|
||||
> 内容需要保留,折叠后仍可展开。
|
||||
|
||||
| 项目 | 状态 |
|
||||
| --- | --- |
|
||||
| 渲染 | 待验证 |
|
||||
|
||||
```javascript
|
||||
const note = { title: "长文测试", ready: true };
|
||||
console.log(note);
|
||||
```
|
||||
|
||||
## 第 57 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 57
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 58 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 58
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 59 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 59
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 60 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 60
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 61 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 61
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 62 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 62
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 63 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 63
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 64 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 64
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
> [!TIP] 验收提示
|
||||
> 内容需要保留,折叠后仍可展开。
|
||||
|
||||
| 项目 | 状态 |
|
||||
| --- | --- |
|
||||
| 渲染 | 待验证 |
|
||||
|
||||
```javascript
|
||||
const note = { title: "长文测试", ready: true };
|
||||
console.log(note);
|
||||
```
|
||||
|
||||
## 第 65 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 65
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 66 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 66
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 67 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 67
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 68 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 68
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 69 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 69
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 70 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 70
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 71 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 71
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 72 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 72
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
> [!TIP] 验收提示
|
||||
> 内容需要保留,折叠后仍可展开。
|
||||
|
||||
| 项目 | 状态 |
|
||||
| --- | --- |
|
||||
| 渲染 | 待验证 |
|
||||
|
||||
```javascript
|
||||
const note = { title: "长文测试", ready: true };
|
||||
console.log(note);
|
||||
```
|
||||
|
||||
## 第 73 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 73
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 74 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 74
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 75 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 75
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 76 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 76
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 77 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 77
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 78 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 78
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 79 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 79
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 80 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 80
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
> [!TIP] 验收提示
|
||||
> 内容需要保留,折叠后仍可展开。
|
||||
|
||||
| 项目 | 状态 |
|
||||
| --- | --- |
|
||||
| 渲染 | 待验证 |
|
||||
|
||||
```javascript
|
||||
const note = { title: "长文测试", ready: true };
|
||||
console.log(note);
|
||||
```
|
||||
|
||||
## 第 81 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 81
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 82 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 82
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 83 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 83
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 84 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 84
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 85 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 85
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 86 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 86
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 87 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 87
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 88 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 88
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
> [!TIP] 验收提示
|
||||
> 内容需要保留,折叠后仍可展开。
|
||||
|
||||
| 项目 | 状态 |
|
||||
| --- | --- |
|
||||
| 渲染 | 待验证 |
|
||||
|
||||
```javascript
|
||||
const note = { title: "长文测试", ready: true };
|
||||
console.log(note);
|
||||
```
|
||||
|
||||
## 第 89 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 89
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 90 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 90
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 91 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 91
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 92 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 92
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 93 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 93
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 94 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 94
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 95 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 95
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 96 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 96
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
> [!TIP] 验收提示
|
||||
> 内容需要保留,折叠后仍可展开。
|
||||
|
||||
| 项目 | 状态 |
|
||||
| --- | --- |
|
||||
| 渲染 | 待验证 |
|
||||
|
||||
```javascript
|
||||
const note = { title: "长文测试", ready: true };
|
||||
console.log(note);
|
||||
```
|
||||
|
||||
## 第 97 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 97
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 98 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 98
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 99 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 99
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 100 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 100
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
## 第 101 节:知识整理
|
||||
|
||||
本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。
|
||||
|
||||
### 小结 101
|
||||
|
||||
重点包含 **强调文字**、`inlineCode` 和 [链接](https://example.com)。
|
||||
|
||||
|
||||
## 文末校验
|
||||
|
||||
结束标记:长文内容完整。
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,250 @@
|
||||
"""Offline Agent/runtime and task API load test; all state lives in a temporary directory."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import inspect
|
||||
import math
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
|
||||
|
||||
|
||||
def stats(values):
|
||||
values = sorted(values)
|
||||
return {"count": len(values), "median_ms": round(values[len(values)//2], 2),
|
||||
"p95_ms": round(values[min(len(values)-1, math.ceil(len(values)*.95)-1)], 2),
|
||||
"max_ms": round(values[-1], 2)} if values else {"count": 0}
|
||||
|
||||
|
||||
async def main(output):
|
||||
# Set before importing any app modules: container has import-time initialization.
|
||||
with tempfile.TemporaryDirectory(prefix="notes-agent-task-stress-") as directory:
|
||||
root = pathlib.Path(directory)
|
||||
os.environ.update(APP_DATA_DIR=str(root / 'data'), APP_DB_PATH=str(root / 'app.db'),
|
||||
APP_VAULT_PATH=str(root / 'vault'))
|
||||
from app.container import container
|
||||
from app.contracts import AgentRunCreateRequest, AgentRunStatus
|
||||
from app.agent.runtime import AgentRuntime, AgentCapacityError
|
||||
from app.agent.permissions import PermissionMode
|
||||
from app.main import app
|
||||
import httpx
|
||||
|
||||
report = {"python": platform.python_version(), "platform": platform.platform(),
|
||||
"provider": "mock with 50 ms injected delay per model turn; no network", "results": []}
|
||||
|
||||
def save(name, value):
|
||||
report['results'].append({"scenario": name, **value})
|
||||
output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(json.dumps(report['results'][-1], ensure_ascii=False), flush=True)
|
||||
|
||||
async def measured(operation):
|
||||
delays = []
|
||||
async def heartbeat():
|
||||
while True:
|
||||
start = time.perf_counter()
|
||||
await asyncio.sleep(.01)
|
||||
delays.append(max(0, (time.perf_counter()-start)*1000-10))
|
||||
pulse = asyncio.create_task(heartbeat())
|
||||
await asyncio.sleep(0)
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
result = await operation()
|
||||
await asyncio.sleep(.02)
|
||||
return {**result, "elapsed_ms": round((time.perf_counter()-start)*1000, 2),
|
||||
"event_loop_lag": stats(delays)}
|
||||
finally:
|
||||
pulse.cancel()
|
||||
await asyncio.gather(pulse, return_exceptions=True)
|
||||
|
||||
adapter = container.providers.get('mock').adapter
|
||||
original = adapter.complete
|
||||
async def delayed(request):
|
||||
await asyncio.sleep(.05)
|
||||
return await original(request)
|
||||
adapter.complete = delayed
|
||||
runtime = container.agent
|
||||
|
||||
for concurrency in (1, 10, 50, 200):
|
||||
async def batch():
|
||||
durations, sequences, statuses = [], [], []
|
||||
semaphore = asyncio.Semaphore(concurrency)
|
||||
async def one(index):
|
||||
async with semaphore:
|
||||
start = time.perf_counter()
|
||||
created = await runtime.create_run(AgentRunCreateRequest(
|
||||
input=f'/tool system.echo {{"text":"pressure-{index}"}}',
|
||||
provider_id='mock', model='mock-1', allowed_tools=['system.echo']))
|
||||
events = [event async for event in runtime.events(created.run_id)]
|
||||
done = await runtime.wait(created.run_id)
|
||||
durations.append((time.perf_counter()-start)*1000)
|
||||
statuses.append(done.status.value)
|
||||
sequence = [event.sequence for event in events]
|
||||
sequences.append(sequence == list(range(len(sequence))))
|
||||
assert done.status == AgentRunStatus.completed
|
||||
assert done.tool_results[0].output == {"text": f"pressure-{index}"}
|
||||
replay = [event.sequence async for event in runtime.events(created.run_id, after_sequence=2)]
|
||||
assert replay == sequence[3:]
|
||||
return created.run_id
|
||||
ids = await asyncio.gather(*(one(i) for i in range(max(20, concurrency))))
|
||||
assert all(sequences)
|
||||
recovered = AgentRuntime(container.providers, container.tools, container.permissions,
|
||||
trace_repository=runtime.trace_repository)
|
||||
assert all(recovered.get_run(run_id).status == AgentRunStatus.completed for run_id in ids)
|
||||
assert not any(record.subscribers for record in runtime._records.values())
|
||||
return {"concurrency": concurrency, "runs": len(ids), "latency": stats(durations),
|
||||
"completed": statuses.count('completed'), "ordered_events_and_replay": True,
|
||||
"terminal_recovery": True, "retained_records": len(runtime._records)}
|
||||
save('agent_tool_runs', await measured(batch))
|
||||
|
||||
# Hold model calls so all 200 records remain active while testing admission.
|
||||
gate = asyncio.Event()
|
||||
async def blocked(request):
|
||||
await gate.wait()
|
||||
return await original(request)
|
||||
adapter.complete = blocked
|
||||
async def capacity():
|
||||
ids = [(await runtime.create_run(AgentRunCreateRequest(input='capacity', provider_id='mock', model='mock-1'))).run_id for _ in range(200)]
|
||||
rejected = False
|
||||
try:
|
||||
await runtime.create_run(AgentRunCreateRequest(input='overflow', provider_id='mock', model='mock-1'))
|
||||
except AgentCapacityError:
|
||||
rejected = True
|
||||
await asyncio.sleep(0)
|
||||
latencies = []
|
||||
for run_id in ids:
|
||||
start = time.perf_counter()
|
||||
await runtime.cancel(run_id)
|
||||
latencies.append((time.perf_counter()-start)*1000)
|
||||
states = await asyncio.gather(*(runtime.wait(run_id) for run_id in ids))
|
||||
assert rejected and all(run.status == AgentRunStatus.cancelled for run in states)
|
||||
assert all(not record.task or record.task.done() for record in runtime._records.values())
|
||||
return {"active_limit": 200, "overflow_rejected": rejected, "cancelled": len(states), "cancel_latency": stats(latencies)}
|
||||
save('capacity_and_cancel', await measured(capacity))
|
||||
adapter.complete = delayed
|
||||
|
||||
async def permissions():
|
||||
tool = container.tools.get('system.echo')
|
||||
previous = tool.definition.permission
|
||||
tool.definition.permission = 'stress.confirm'
|
||||
container.permissions.policy.set_rule('stress.confirm', PermissionMode.confirm)
|
||||
async def one(index):
|
||||
created = await runtime.create_run(AgentRunCreateRequest(input='/tool system.echo {"text":"permission"}',
|
||||
provider_id='mock', model='mock-1', allowed_tools=['system.echo'], tool_timeout_seconds=30))
|
||||
stream = runtime.events(created.run_id)
|
||||
try:
|
||||
async for event in stream:
|
||||
if event.event.value == 'PermissionRequired':
|
||||
if index % 2: await runtime.cancel(created.run_id)
|
||||
else: assert await runtime.resolve_permission(created.run_id, str(event.data['request_id']), 'allow')
|
||||
break
|
||||
finally:
|
||||
await stream.aclose()
|
||||
return (await runtime.wait(created.run_id)).status.value
|
||||
try:
|
||||
states = await asyncio.wait_for(asyncio.gather(*(one(i) for i in range(20))), 60)
|
||||
assert states.count('completed') == states.count('cancelled') == 10
|
||||
assert not any(record.subscribers for record in runtime._records.values())
|
||||
return {"runs": 20, "approved_completed": 10, "cancelled_waiting_permission": 10, "subscribers_released": True}
|
||||
finally:
|
||||
tool.definition.permission = previous
|
||||
save('permission_wait', await measured(permissions))
|
||||
|
||||
async def failures():
|
||||
active = 0
|
||||
async def injected(request):
|
||||
text = request.messages[-1].content
|
||||
if text == 'inject-provider-error': raise RuntimeError('injected offline provider failure')
|
||||
if text == 'inject-model-timeout': await asyncio.sleep(60)
|
||||
return await delayed(request)
|
||||
tool = container.tools.get('system.echo')
|
||||
executor = tool.executor
|
||||
async def slow_tool(arguments, context):
|
||||
nonlocal active
|
||||
active += 1
|
||||
try:
|
||||
if arguments.text == 'slow': await asyncio.sleep(60)
|
||||
value = executor(arguments, context)
|
||||
return await value if inspect.isawaitable(value) else value
|
||||
finally: active -= 1
|
||||
adapter.complete = injected
|
||||
tool.executor = slow_tool
|
||||
async def one(index):
|
||||
mode = index % 4
|
||||
text = ['normal', 'inject-provider-error', 'inject-model-timeout', '/tool system.echo {"text":"slow"}'][mode]
|
||||
created = await runtime.create_run(AgentRunCreateRequest(input=text, provider_id='mock', model='mock-1',
|
||||
allowed_tools=['system.echo'], run_timeout_seconds=1 if mode == 2 else 30, tool_timeout_seconds=1))
|
||||
result = await runtime.wait(created.run_id)
|
||||
if mode == 0: assert result.status == AgentRunStatus.completed
|
||||
elif mode == 1: assert result.status == AgentRunStatus.failed and result.error_code == 'AGENT_FAILED'
|
||||
elif mode == 2: assert result.status == AgentRunStatus.failed and result.error_code == 'AGENT_TIMEOUT'
|
||||
else: assert result.tool_results[0].error_code == 'TOOL_TIMEOUT'
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.gather(*(one(i) for i in range(20))), 45)
|
||||
assert active == 0
|
||||
return {"runs": 20, "success": 5, "provider_errors": 5, "model_timeouts": 5,
|
||||
"tool_timeouts": 5, "remaining_tool_executors": active}
|
||||
finally:
|
||||
adapter.complete = delayed
|
||||
tool.executor = executor
|
||||
save('failure_and_timeout_isolation', await measured(failures))
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://stress.local') as client:
|
||||
for count in (100, 1000):
|
||||
async def tasks():
|
||||
timings = {name: [] for name in ('create', 'update', 'list', 'delete')}
|
||||
async def call(method, path, **kwargs):
|
||||
start = time.perf_counter()
|
||||
response = await client.request(method, path, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response.json(), (time.perf_counter()-start)*1000
|
||||
semaphore = asyncio.Semaphore(20)
|
||||
async def create(index):
|
||||
async with semaphore:
|
||||
body, duration = await call('POST', '/api/tasks', json={'title': f'压测任务 {index}', 'description': '独立测试数据'})
|
||||
timings['create'].append(duration)
|
||||
return body['task_id']
|
||||
ids = await asyncio.gather(*(create(i) for i in range(count)))
|
||||
first, _ = await call('GET', '/api/tasks')
|
||||
seen = []
|
||||
for offset in range(0, count, 100):
|
||||
body, duration = await call('GET', f'/api/tasks?limit=100&offset={offset}')
|
||||
timings['list'].append(duration)
|
||||
seen.extend(item['task_id'] for item in body['items'])
|
||||
assert len(set(seen)) == count and set(seen) == set(ids)
|
||||
async def change(run_id):
|
||||
async with semaphore:
|
||||
body, duration = await call('PATCH', f'/api/tasks/{run_id}', json={'status': 'done'})
|
||||
assert body['status'] == 'done'
|
||||
timings['update'].append(duration)
|
||||
_, duration = await call('DELETE', f'/api/tasks/{run_id}')
|
||||
timings['delete'].append(duration)
|
||||
await asyncio.gather(*(change(run_id) for run_id in ids))
|
||||
final, _ = await call('GET', '/api/tasks')
|
||||
assert final['page']['total'] == 0
|
||||
return {"tasks": count, "client_concurrency": 20, "latencies": {key: stats(value) for key, value in timings.items()},
|
||||
"default_page_count": len(first['items']), "default_total": first['page']['total'],
|
||||
"pagination_complete": True, "final_total": 0}
|
||||
save('task_api_crud', await measured(tasks))
|
||||
|
||||
report['database_bytes'] = (root / 'app.db').stat().st_size
|
||||
report['complete'] = True
|
||||
output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
container.plugins.shutdown()
|
||||
container.mcp_servers.shutdown()
|
||||
from app.operation_logs import shutdown_logging
|
||||
await asyncio.to_thread(shutdown_logging)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--output', type=pathlib.Path, required=True)
|
||||
args = parser.parse_args()
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
asyncio.run(main(args.output))
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Real loopback HTTP task load with a separate, temporary Uvicorn process."""
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from time import perf_counter
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def stats(values):
|
||||
values = sorted(values)
|
||||
return {'count': len(values), 'p95_ms': round(values[math.ceil(len(values)*.95)-1], 2),
|
||||
'max_ms': round(values[-1], 2)} if values else {'count': 0}
|
||||
|
||||
|
||||
async def main(args):
|
||||
with tempfile.TemporaryDirectory(prefix='notes-task-http-') as directory:
|
||||
root = Path(directory)
|
||||
env = {**os.environ, 'APP_DATA_DIR': str(root/'data'), 'APP_DB_PATH': str(root/'app.db'), 'APP_VAULT_PATH': str(root/'vault')}
|
||||
with socket.socket() as sock:
|
||||
sock.bind(('127.0.0.1', 0)); port = sock.getsockname()[1]
|
||||
process = subprocess.Popen([sys.executable, '-m', 'uvicorn', 'app.main:app', '--host', '127.0.0.1', '--port', str(port), '--log-level', 'error'],
|
||||
cwd=Path(__file__).resolve().parents[1], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0)
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=f'http://127.0.0.1:{port}', timeout=30) as client:
|
||||
for _ in range(150):
|
||||
if process.poll() is not None: raise RuntimeError('Isolated Uvicorn exited')
|
||||
try:
|
||||
(await client.get('/health')).raise_for_status(); break
|
||||
except httpx.HTTPError: await asyncio.sleep(.1)
|
||||
else: raise TimeoutError('Isolated Uvicorn startup')
|
||||
sem = asyncio.Semaphore(args.concurrency)
|
||||
timings = {key: [] for key in ('create', 'update', 'list', 'delete', 'health')}
|
||||
errors = []
|
||||
async def request(method, path, kind, **kwargs):
|
||||
start = perf_counter()
|
||||
response = await client.request(method, path, **kwargs)
|
||||
timings[kind].append((perf_counter()-start)*1000)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
async def health():
|
||||
while True:
|
||||
try: await request('GET', '/health', 'health')
|
||||
except httpx.HTTPError as error: errors.append(type(error).__name__)
|
||||
await asyncio.sleep(.05)
|
||||
heartbeat = asyncio.create_task(health())
|
||||
start = perf_counter()
|
||||
try:
|
||||
async def create(index):
|
||||
async with sem:
|
||||
return (await request('POST', '/api/tasks', 'create', json={'title': f'HTTP 压测 {index}'}))['task_id']
|
||||
ids = await asyncio.gather(*(create(i) for i in range(args.count)))
|
||||
seen = []
|
||||
for offset in range(0, args.count, 100):
|
||||
page = await request('GET', f'/api/tasks?limit=100&offset={offset}', 'list')
|
||||
seen.extend(item['task_id'] for item in page['items'])
|
||||
assert set(seen) == set(ids) and len(seen) == args.count
|
||||
async def change(task_id):
|
||||
async with sem:
|
||||
updated = await request('PATCH', f'/api/tasks/{task_id}', 'update', json={'status': 'done'})
|
||||
assert updated['status'] == 'done'
|
||||
await request('DELETE', f'/api/tasks/{task_id}', 'delete')
|
||||
await asyncio.gather(*(change(task_id) for task_id in ids))
|
||||
remaining = await request('GET', '/api/tasks', 'list')
|
||||
assert remaining['page']['total'] == 0
|
||||
finally:
|
||||
heartbeat.cancel(); await asyncio.gather(heartbeat, return_exceptions=True)
|
||||
report = {'transport': 'real loopback HTTP, separate Uvicorn process', 'tasks': args.count,
|
||||
'concurrency': args.concurrency, 'elapsed_ms': round((perf_counter()-start)*1000, 2),
|
||||
'latencies': {key: stats(value) for key,value in timings.items()}, 'health_errors': errors,
|
||||
'pagination_complete': True, 'final_total': 0}
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(json.dumps(report, ensure_ascii=False))
|
||||
finally:
|
||||
process.terminate()
|
||||
try: process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired: process.kill(); process.wait()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
parser.add_argument('--count', type=int, default=1000)
|
||||
parser.add_argument('--concurrency', type=int, default=20)
|
||||
args = parser.parse_args()
|
||||
if args.count < 1 or args.concurrency < 1: parser.error('count and concurrency must be positive')
|
||||
asyncio.run(main(args))
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Synthetic, isolated exact-search comparison; does not access the user Vault."""
|
||||
import heapq
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
import sqlite3
|
||||
import statistics
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import sqlite_vec
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from app.retrieval import space_index
|
||||
from app.retrieval.routed_vectors import RemoteEmbeddings, _unit_vector
|
||||
|
||||
|
||||
def main():
|
||||
rng = random.Random(42)
|
||||
count, dimensions = 4000, 384
|
||||
with tempfile.TemporaryDirectory(prefix='notes-vec-bench-') as temporary:
|
||||
conn = sqlite3.connect(Path(temporary) / 'vectors.db')
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.enable_load_extension(True)
|
||||
sqlite_vec.load(conn)
|
||||
conn.enable_load_extension(False)
|
||||
conn.execute('CREATE TABLE blocks(block_id TEXT PRIMARY KEY, embedding_local_only INTEGER)')
|
||||
conn.execute('CREATE TABLE routed_block_vectors(space_id TEXT,block_id TEXT,dimensions INTEGER,vector TEXT,PRIMARY KEY(space_id,dimensions,block_id))')
|
||||
vectors = [_unit_vector([rng.uniform(-1, 1) for _ in range(dimensions)], dimensions) for _ in range(count)]
|
||||
conn.executemany('INSERT INTO blocks VALUES (?,0)', [(str(i),) for i in range(count)])
|
||||
conn.executemany('INSERT INTO routed_block_vectors VALUES (?,?,?,?)', [('benchmark', str(i), dimensions, json.dumps(v)) for i, v in enumerate(vectors)])
|
||||
start = time.perf_counter()
|
||||
space_index.ensure(conn, 'benchmark', dimensions)
|
||||
migration_ms = (time.perf_counter() - start) * 1000
|
||||
conn.commit()
|
||||
query = vectors[0]
|
||||
batch = RemoteEmbeddings('benchmark', dimensions, [query])
|
||||
def legacy():
|
||||
def hits():
|
||||
for row in conn.execute('SELECT block_id,vector FROM routed_block_vectors'):
|
||||
vector = _unit_vector(json.loads(row[1]), dimensions)
|
||||
yield row[0], max(0., min(1., math.fsum(a*b for a,b in zip(query,vector))))
|
||||
return heapq.nlargest(20, hits(), key=lambda hit:hit[1])
|
||||
def native():
|
||||
return [(hit.id,hit.score) for hit in space_index.search(conn,batch,20)]
|
||||
measurements = {}
|
||||
results = {}
|
||||
for name, operation in [('python_json_scan', legacy), ('sqlite_vec',native)]:
|
||||
elapsed = []
|
||||
for _ in range(5):
|
||||
start = time.perf_counter()
|
||||
results[name] = operation()
|
||||
elapsed.append((time.perf_counter()-start)*1000)
|
||||
measurements[name] = {'median_ms':statistics.median(elapsed), 'samples_ms':elapsed}
|
||||
assert [hit[0] for hit in results['python_json_scan']] == [hit[0] for hit in results['sqlite_vec']]
|
||||
print(json.dumps({'blocks':count,'dimensions':dimensions,'top_k':20,'migration_ms':migration_ms,
|
||||
'same_top_k':True,'measurements':measurements},indent=2))
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -112,7 +112,7 @@ def test_permission_confirmation_resumes_agent() -> None:
|
||||
break
|
||||
|
||||
assert request_id is not None
|
||||
assert container.agent.resolve_permission(
|
||||
assert await container.agent.resolve_permission(
|
||||
created.run_id, request_id, "allow_once"
|
||||
)
|
||||
completed = await container.agent.wait(created.run_id)
|
||||
@@ -370,8 +370,9 @@ def test_cancelling_permission_wait_cancels_run() -> None:
|
||||
)
|
||||
|
||||
async with asyncio.timeout(2):
|
||||
while container.agent.get_run(created.run_id).status != AgentRunStatus.waiting_permission:
|
||||
await asyncio.sleep(0)
|
||||
async for event in container.agent.events(created.run_id):
|
||||
if event.event == AgentEventType.permission_required:
|
||||
break
|
||||
|
||||
cancelled = await container.agent.cancel(created.run_id)
|
||||
await container.agent.wait(created.run_id)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.retrieval import activity
|
||||
from app.services import index_service
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_activity(monkeypatch):
|
||||
for field in ('active', 'completed', 'failed', 'cancelled'):
|
||||
monkeypatch.setattr(activity, field, 0)
|
||||
monkeypatch.setattr(index_service, '_active_job_id', None)
|
||||
monkeypatch.setattr(index_service, '_active_scope', None)
|
||||
monkeypatch.setattr(index_service, '_last_error', None)
|
||||
monkeypatch.setattr(index_service.repository, 'stats', lambda: {'notes': 16, 'blocks': 3787})
|
||||
monkeypatch.setattr(index_service.repository, 'get_index_meta', lambda: {})
|
||||
|
||||
|
||||
def test_pending_rebuild_and_incremental_jobs(monkeypatch):
|
||||
meta = {'workspace_vectors_pending': '1', 'note_vectors_pending:a': '1', 'note_vectors_pending:b': '1'}
|
||||
monkeypatch.setattr(index_service.repository, 'get_index_meta', lambda: meta)
|
||||
status = index_service.get_status()
|
||||
assert status.vector_refresh_required and status.pending_jobs == 1
|
||||
assert status.running_jobs == 0
|
||||
monkeypatch.setattr(index_service, '_active_job_id', 'job_test')
|
||||
monkeypatch.setattr(index_service, '_active_scope', 'all')
|
||||
status = index_service.get_status()
|
||||
assert (status.status, status.pending_jobs, status.running_jobs) == ('running', 1, 1)
|
||||
monkeypatch.setattr(index_service, '_active_scope', 'note')
|
||||
assert index_service.get_status().pending_jobs == 2
|
||||
meta.pop('workspace_vectors_pending')
|
||||
assert index_service.get_status().pending_jobs == 2
|
||||
monkeypatch.setattr(index_service, '_active_job_id', None)
|
||||
monkeypatch.setattr(index_service, '_last_error', 'failed')
|
||||
status = index_service.get_status()
|
||||
assert (status.status, status.pending_jobs, status.running_jobs) == ('failed', 2, 0)
|
||||
meta.clear()
|
||||
assert index_service.get_status().pending_jobs == 0
|
||||
|
||||
|
||||
def test_search_activity_covers_completion_failure_and_cancellation():
|
||||
async def scenario():
|
||||
gate = asyncio.Event()
|
||||
|
||||
@activity.track_search
|
||||
async def search(_self, request):
|
||||
await gate.wait()
|
||||
if request.query == 'fail':
|
||||
raise ValueError('failure')
|
||||
return 'ok'
|
||||
|
||||
tasks = [asyncio.create_task(search(None, SimpleNamespace(mode=mode, query=query)))
|
||||
for mode, query in [('vector', 'ok'), ('hybrid', 'fail'), ('vector', 'cancel'), ('fts', 'ok')]]
|
||||
await asyncio.sleep(0)
|
||||
assert index_service.get_status().active_searches == 3
|
||||
tasks[2].cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await tasks[2]
|
||||
gate.set()
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
assert results[0] == results[3] == 'ok'
|
||||
status = index_service.get_status()
|
||||
assert (status.active_searches, status.completed_searches, status.failed_searches, status.cancelled_searches) == (0, 1, 1, 1)
|
||||
assert status.pending_jobs == 0
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -12,6 +12,42 @@ from app.local_models.runtime import Runtime
|
||||
from app.providers.base import ProviderError
|
||||
|
||||
|
||||
@pytest.mark.parametrize('threaded', [False, True])
|
||||
def test_large_embedding_result_crosses_pipe_limit_without_truncation(monkeypatch, tmp_path, threaded):
|
||||
import app.local_models.runtime as module
|
||||
import app.local_models.process as process_module
|
||||
from app.local_models.protocol import response_lines
|
||||
vector = [0.012345678901234567] * 384
|
||||
result = {'result': [vector] * 2111}
|
||||
assert len(json.dumps(result).encode()) > 16 * 1024 * 1024
|
||||
assert max(map(len, response_lines(result, 'embedding'))) < 16 * 1024 * 1024
|
||||
worker = tmp_path / 'large_worker.py'
|
||||
protocol_dir = Path(module.__file__).parent
|
||||
worker.write_text(
|
||||
'import sys,json\n'
|
||||
f'sys.path.insert(0, {str(protocol_dir)!r})\n'
|
||||
'from protocol import response_lines\n'
|
||||
'request=json.load(sys.stdin)\n'
|
||||
'vector=[0.012345678901234567]*384\n'
|
||||
'for line in response_lines({"result":[vector]*len(request["payload"]["texts"])}, "embedding"):\n'
|
||||
' sys.stdout.write(line)\n', encoding='utf-8')
|
||||
monkeypatch.setattr(module, 'read_state', lambda key: {'status': 'installed'})
|
||||
monkeypatch.setattr(module, 'interpreter', lambda *_: Path(sys.executable))
|
||||
original_async = asyncio.create_subprocess_exec
|
||||
original_threaded = process_module.ThreadedProcess
|
||||
|
||||
async def spawn(*args, **kwargs):
|
||||
if threaded:
|
||||
raise NotImplementedError
|
||||
return await original_async(sys.executable, str(worker), **kwargs)
|
||||
|
||||
monkeypatch.setattr(module.asyncio, 'create_subprocess_exec', spawn)
|
||||
monkeypatch.setattr(process_module, 'ThreadedProcess',
|
||||
lambda args, **kwargs: original_threaded((sys.executable, str(worker)), **kwargs))
|
||||
actual = asyncio.run(Runtime().infer('bekko', 'embedding', {'texts': ['test'] * 2111}))
|
||||
assert actual == result['result']
|
||||
|
||||
|
||||
def test_download_resumes_partial_and_checks_digest(monkeypatch):
|
||||
payload = b'verified-model-weights'
|
||||
entry = {'path':'model.safetensors','size':len(payload),'hash':hashlib.sha256(payload).hexdigest(),
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from app.operation_logs import LogStore, ApplicationLogHandler, get_store, log_event, shutdown_logging
|
||||
|
||||
|
||||
def test_logs_persist_filter_cursor_and_retention(tmp_path):
|
||||
store = LogStore(tmp_path / 'logs.db', retain=3)
|
||||
try:
|
||||
for i in range(6):
|
||||
store.emit('ERROR' if i % 2 else 'INFO', 'vectors', 'embedding.failed', {'run_id': f'run_{i}'})
|
||||
store.queue.join()
|
||||
first = store.query(limit=2)
|
||||
assert len(first['items']) == 2 and first['next_cursor']
|
||||
assert len(store.query(before=first['next_cursor'])['items']) == 1
|
||||
assert len(store.query(level='ERROR')['items']) == 2
|
||||
assert len(store.query(q='run_5')['items']) == 1
|
||||
assert not store.query(source='tasks')['items']
|
||||
finally:
|
||||
store.close()
|
||||
reopened = LogStore(tmp_path / 'logs.db', retain=3)
|
||||
try:
|
||||
assert len(reopened.query()['items']) == 3
|
||||
finally:
|
||||
reopened.close()
|
||||
|
||||
|
||||
def test_logs_exclude_content_and_legacy_exception_messages():
|
||||
try:
|
||||
log_event('vectors', 'embedding.failed', level='ERROR',
|
||||
error=ValueError('private note and secret'), model='embedding-v1',
|
||||
prompt='private note', arguments={'api_key': 'secret'}, api_key='secret')
|
||||
handler = ApplicationLogHandler()
|
||||
record = logging.LogRecord('app.sample', logging.ERROR, __file__, 1,
|
||||
'private note and secret %s', ('credentials',), None)
|
||||
handler.emit(record)
|
||||
handler.emit(record) # a logger propagated to another installed handler
|
||||
store = get_store()
|
||||
store.queue.join()
|
||||
data = json.dumps(store.query())
|
||||
assert len(store.query()['items']) == 2
|
||||
assert 'private note' not in data and 'credentials' not in data and 'api_key' not in data
|
||||
assert 'ValueError' in data and 'embedding-v1' in data
|
||||
finally:
|
||||
shutdown_logging()
|
||||
|
||||
|
||||
def test_http_log_correlates_task_operations_without_body():
|
||||
import httpx
|
||||
from app.main import app
|
||||
async def scenario():
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client:
|
||||
response = await client.post('/api/tasks', json={'title': 'private title'})
|
||||
assert response.status_code == 200
|
||||
rid = response.headers['x-request-id']
|
||||
store = get_store()
|
||||
await asyncio.to_thread(store.queue.join)
|
||||
result = (await client.get('/api/logs', params={'q': rid})).json()
|
||||
assert len(result['items']) >= 2
|
||||
assert 'private title' not in json.dumps(result)
|
||||
assert any(item['event'] == 'task.created' for item in result['items'])
|
||||
assert (await client.get('/api/logs', params={'limit': 201})).status_code == 422
|
||||
try:
|
||||
asyncio.run(scenario())
|
||||
finally:
|
||||
shutdown_logging()
|
||||
|
||||
|
||||
def test_trace_writer_batches_off_loop_and_survives_cancel():
|
||||
from app.agent.async_trace import AsyncTraceWriter
|
||||
started, release = threading.Event(), threading.Event()
|
||||
class Repository:
|
||||
def write_batch(self, jobs):
|
||||
assert threading.current_thread() is not threading.main_thread()
|
||||
started.set()
|
||||
assert release.wait(2)
|
||||
self.jobs = jobs
|
||||
async def scenario():
|
||||
repo = Repository()
|
||||
writer = AsyncTraceWriter(repo)
|
||||
pending = asyncio.create_task(writer.submit('save', 'snapshot'))
|
||||
while not started.is_set():
|
||||
await asyncio.sleep(.001)
|
||||
pending.cancel()
|
||||
writer.worker.cancel() # simultaneous application shutdown
|
||||
await asyncio.sleep(.005)
|
||||
assert not pending.done()
|
||||
release.set()
|
||||
assert await asyncio.wait_for(pending, 2) is True
|
||||
assert repo.jobs == [('save', ('snapshot',))]
|
||||
assert writer.queue.empty()
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_trace_write_failure_is_reported_and_next_submission_recovers():
|
||||
from app.agent.async_trace import AsyncTraceWriter
|
||||
class Repository:
|
||||
fail = True
|
||||
def write_batch(self, jobs):
|
||||
if self.fail:
|
||||
self.fail = False
|
||||
raise OSError('disk unavailable')
|
||||
async def scenario():
|
||||
import pytest
|
||||
writer = AsyncTraceWriter(Repository())
|
||||
with pytest.raises(OSError):
|
||||
await writer.submit('save', 'first')
|
||||
assert not await writer.submit('save', 'second')
|
||||
await writer.queue.join()
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_log_write_failure_does_not_stall_queue(tmp_path, monkeypatch):
|
||||
store = LogStore(tmp_path / 'failed.db')
|
||||
try:
|
||||
def broken():
|
||||
raise OSError('disk unavailable')
|
||||
monkeypatch.setattr(store, '_connect', broken)
|
||||
store.emit('ERROR', 'vectors', 'embedding.failed', {'duration_ms': float('nan')})
|
||||
store.queue.join()
|
||||
assert store.failed == 1
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
def test_cancelled_task_write_keeps_its_slot_until_commit():
|
||||
from app.services.task_service import write_in_background
|
||||
started, release = threading.Event(), threading.Event()
|
||||
order = []
|
||||
def first():
|
||||
started.set()
|
||||
assert release.wait(2)
|
||||
order.append('first')
|
||||
async def scenario():
|
||||
import pytest
|
||||
pending = asyncio.create_task(write_in_background(first))
|
||||
while not started.is_set():
|
||||
await asyncio.sleep(.001)
|
||||
pending.cancel()
|
||||
second = asyncio.create_task(write_in_background(lambda: order.append('second')))
|
||||
await asyncio.sleep(.01)
|
||||
assert order == []
|
||||
release.set()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await pending
|
||||
await second
|
||||
assert order == ['first', 'second']
|
||||
asyncio.run(scenario())
|
||||
@@ -66,6 +66,161 @@ async def seed():
|
||||
return apple, banana
|
||||
|
||||
|
||||
def test_native_spaces_isolate_dimensions_and_reuse_without_json_scan(runtime, monkeypatch):
|
||||
from app.retrieval import space_index
|
||||
async def scenario():
|
||||
apple, banana = await seed()
|
||||
ids = [b.block_id for note in (apple, banana) for b in note.blocks]
|
||||
conn = connect()
|
||||
try:
|
||||
with transaction(conn):
|
||||
routed_vectors.store_remote(conn, ids, routed_vectors.RemoteEmbeddings('space-a', 4, [[1., 0., 0., 0.]] * len(ids)))
|
||||
assert conn.execute('SELECT COUNT(DISTINCT dimensions) FROM routed_block_vectors').fetchone()[0] == 2
|
||||
finally:
|
||||
conn.close()
|
||||
# A new connection uses the persistent native index, without reading vector JSON.
|
||||
def forbidden(*args, **kwargs):
|
||||
raise AssertionError('query decoded stored JSON')
|
||||
monkeypatch.setattr(space_index.json, 'loads', forbidden)
|
||||
hits = await routed_vectors.search_remote('apple orchard', top_k=2, strict=True)
|
||||
assert len(hits) == 2
|
||||
assert hits[0].id == apple.blocks[0].block_id
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_legacy_vectors_migrate_without_document_embedding(runtime):
|
||||
from app.retrieval import space_index
|
||||
async def scenario():
|
||||
apple, banana = await seed()
|
||||
conn = connect()
|
||||
table = space_index.table_name('space-a', 3)
|
||||
try:
|
||||
with transaction(conn):
|
||||
conn.execute(f'DROP TRIGGER {table}_delete')
|
||||
conn.execute(f'DROP TRIGGER {table}_update')
|
||||
conn.execute(f'DROP TABLE {table}')
|
||||
conn.execute('ALTER TABLE routed_block_vectors RENAME TO saved_vectors')
|
||||
conn.execute('CREATE TABLE routed_block_vectors(space_id TEXT,block_id TEXT REFERENCES blocks(block_id) ON DELETE CASCADE,dimensions INTEGER,vector TEXT,PRIMARY KEY(space_id,block_id))')
|
||||
conn.execute('INSERT INTO routed_block_vectors SELECT * FROM saved_vectors')
|
||||
conn.execute('DROP TABLE saved_vectors')
|
||||
finally:
|
||||
conn.close()
|
||||
runtime.calls.clear()
|
||||
hits = await routed_vectors.search_remote('apple orchard', top_k=2, strict=True)
|
||||
assert len(hits) == 2
|
||||
assert runtime.calls == [['apple orchard']]
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('partitioned', [False, True])
|
||||
def test_concurrent_first_search_serializes_migration_and_warm_search_is_read_only(runtime, monkeypatch, partitioned):
|
||||
import threading
|
||||
from app.retrieval import space_index
|
||||
async def scenario():
|
||||
await seed()
|
||||
table = space_index.table_name('space-a', 3)
|
||||
conn = connect()
|
||||
try:
|
||||
with transaction(conn):
|
||||
conn.execute(f'DROP TRIGGER {table}_delete')
|
||||
conn.execute(f'DROP TRIGGER {table}_update')
|
||||
conn.execute(f'DROP TABLE {table}')
|
||||
finally:
|
||||
conn.close()
|
||||
entered, release, second = threading.Event(), threading.Event(), threading.Event()
|
||||
original_ensure, original_prepare = space_index.ensure, space_index.prepare
|
||||
calls = []
|
||||
def ensure(*args):
|
||||
calls.append(1)
|
||||
entered.set()
|
||||
assert release.wait(5)
|
||||
return original_ensure(*args)
|
||||
def prepare(*args):
|
||||
if entered.is_set():
|
||||
second.set()
|
||||
return original_prepare(*args)
|
||||
monkeypatch.setattr(space_index, 'ensure', ensure)
|
||||
monkeypatch.setattr(space_index, 'prepare', prepare)
|
||||
batch = routed_vectors.RemoteEmbeddings('space-a', 3, [[1., 0., 0.]])
|
||||
async def search():
|
||||
if entered.is_set():
|
||||
second.set()
|
||||
await routed_vectors._prepare_indexes([batch])
|
||||
if partitioned:
|
||||
return await asyncio.to_thread(routed_vectors._search_partitions, {False: batch}, {False}, 2, True)
|
||||
return await asyncio.to_thread(routed_vectors._search_space, batch, 2, True)
|
||||
tasks = []
|
||||
try:
|
||||
tasks.append(asyncio.create_task(search()))
|
||||
assert await asyncio.to_thread(entered.wait, 5)
|
||||
tasks.append(asyncio.create_task(search()))
|
||||
assert await asyncio.to_thread(second.wait, 5)
|
||||
release.set()
|
||||
first, other = await asyncio.gather(*tasks)
|
||||
assert first == other and len(first) == 2
|
||||
assert len(calls) == 1
|
||||
# Prepared indexes are reusable even with SQLite query_only enforced.
|
||||
original_connect = routed_vectors.connect
|
||||
def read_only():
|
||||
connection = original_connect()
|
||||
connection.execute('PRAGMA query_only=ON')
|
||||
return connection
|
||||
monkeypatch.setattr(routed_vectors, 'connect', read_only)
|
||||
assert await search() == first
|
||||
finally:
|
||||
release.set()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('cancel_search', [False, True])
|
||||
def test_save_waits_for_migration_even_when_search_is_cancelled(runtime, monkeypatch, cancel_search):
|
||||
import threading
|
||||
from app.retrieval import space_index
|
||||
async def scenario():
|
||||
apple, _ = await seed()
|
||||
table = space_index.table_name('space-a', 3)
|
||||
conn = connect()
|
||||
try:
|
||||
with transaction(conn):
|
||||
conn.execute(f'DROP TRIGGER {table}_delete')
|
||||
conn.execute(f'DROP TRIGGER {table}_update')
|
||||
conn.execute(f'DROP TABLE {table}')
|
||||
finally:
|
||||
conn.close()
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
original = space_index.ensure
|
||||
def slow(*args):
|
||||
entered.set()
|
||||
assert release.wait(5)
|
||||
return original(*args)
|
||||
monkeypatch.setattr(space_index, 'ensure', slow)
|
||||
# Keep the subsequent vector job queued; test saving and its durable marker.
|
||||
monkeypatch.setattr(index_service, 'schedule_workspace_rebuild', lambda: None)
|
||||
query = asyncio.create_task(routed_vectors.search_remote('apple orchard', top_k=2, strict=True))
|
||||
save = None
|
||||
try:
|
||||
assert await asyncio.to_thread(entered.wait, 5)
|
||||
if cancel_search:
|
||||
query.cancel()
|
||||
save = asyncio.create_task(note_service.update_note(apple.note_id, markdown='Saved during migration', defer_vectors=True))
|
||||
await asyncio.sleep(0.02)
|
||||
assert not save.done()
|
||||
release.set()
|
||||
saved = await asyncio.wait_for(save, 5)
|
||||
assert saved.markdown == 'Saved during migration'
|
||||
assert (await note_service.get_note(apple.note_id)).markdown == saved.markdown
|
||||
assert repository.get_index_meta()[f'note_vectors_pending:{apple.note_id}'] == '1'
|
||||
# Query may observe the saved revision's pending index, but saving must succeed.
|
||||
result = (await asyncio.gather(query, return_exceptions=True))[0]
|
||||
if cancel_search:
|
||||
assert isinstance(result, asyncio.CancelledError)
|
||||
finally:
|
||||
release.set()
|
||||
await asyncio.gather(*([query, save] if save else [query]), return_exceptions=True)
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("outcome", ["api", "api_failure", "missing_space"])
|
||||
def test_benchmark_reports_actual_embedding_and_fallback(runtime, outcome):
|
||||
from app.benchmarks import service
|
||||
|
||||
@@ -5,6 +5,33 @@ from app.config import get_settings
|
||||
from app.services import index_service, workspace_service
|
||||
|
||||
|
||||
def test_external_new_note_does_not_rebuild_existing_notes(monkeypatch):
|
||||
from app.services import note_service
|
||||
async def scenario():
|
||||
await note_service.create_note(title='Existing', markdown='Keep existing vectors', folder=None, tags=[])
|
||||
calls = []
|
||||
original = index_service.prepare_note_index
|
||||
async def record(parsed, **kwargs):
|
||||
calls.append(parsed.file_path)
|
||||
return await original(parsed, **kwargs)
|
||||
async def forbidden(*args, **kwargs):
|
||||
raise AssertionError('full rebuild should not run')
|
||||
monkeypatch.setattr(index_service, 'prepare_note_index', record)
|
||||
monkeypatch.setattr(index_service, 'rebuild', forbidden)
|
||||
path = get_settings().vault_path / 'external.md'
|
||||
path.write_text('# External\n\nNew content', encoding='utf-8')
|
||||
try:
|
||||
await workspace_service.refresh_workspace_tree()
|
||||
await index_service._background_task
|
||||
assert calls == ['external.md']
|
||||
assert not index_service.get_status().vector_refresh_required
|
||||
await workspace_service.open_workspace(None)
|
||||
assert calls == ['external.md']
|
||||
finally:
|
||||
await index_service.shutdown()
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_open_returns_before_vectors_and_deduplicates_background(monkeypatch):
|
||||
async def scenario():
|
||||
started, release = asyncio.Event(), asyncio.Event()
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
- [前端构建分块优化开发说明](development/前端构建分块优化开发说明.md)
|
||||
|
||||
- [工作区后台索引与保存开发说明](development/工作区后台索引与保存开发说明.md)
|
||||
- [模型隔离向量索引与增量登记](development/模型隔离向量索引与增量登记.md)
|
||||
- [Mermaid 预览与缩放开发说明](development/Mermaid预览与缩放开发说明.md)
|
||||
- [扩展安装持久化与社区包开发说明](development/扩展安装持久化与社区包开发说明.md)
|
||||
- [模型上下文管理](development/模型上下文管理.md)
|
||||
@@ -95,3 +96,7 @@
|
||||
- 文档中的“计划实现”和“已经实现”必须明确区分;实现状态以代码、测试和运行时契约为准。
|
||||
|
||||
- [Markdown 语法预设与外部文件刷新](development/Markdown语法预设与外部文件刷新.md)
|
||||
|
||||
- [长文渲染优化与压测报告](development/长文渲染优化与压测报告.md)
|
||||
- [Agent 与任务压测报告](development/Agent与任务压测报告.md)
|
||||
- [后台运行日志与压力问题修复](development/后台运行日志与压力问题修复.md)
|
||||
|
||||
@@ -202,8 +202,20 @@ RunCancelled
|
||||
## 2026-09-06:后台索引补充
|
||||
|
||||
- `POST /api/workspace/open` 返回可使用的 WorkspaceSnapshot,不等待向量推理。
|
||||
- 外部新增文件登记后按笔记持久化后台向量任务,不再因此设置全库重建标记;已存在的全库待处理标记仍继续执行。模型空间与维度的持久化 sqlite-vec 索引从已有向量转换,接口响应结构不变。实现与性能验证见 [模型隔离向量索引与增量登记](../development/模型隔离向量索引与增量登记.md)。
|
||||
- `PATCH /api/notes/{note_id}` 成功代表正文、元数据和 FTS 已保存;后台向量失败不撤销这次保存。
|
||||
- `GET /api/index/status` 新增 `vector_refresh_required: boolean`,表示工作区或笔记存在向量待处理标记。该字段不是进度百分比;任务失败时也可为 true。
|
||||
- `pending_jobs` 返回真实未完成索引任务数(包含运行中),不再固定为 0。全库待重建或运行中的全量重建计一个任务;逐笔记刷新按待处理标记计数。`running_jobs` 返回当前运行数。失败后保留的待重建标记仍计入未完成数。
|
||||
- `active_searches`、`completed_searches`、`failed_searches`、`cancelled_searches` 分别表示向量/混合检索的进行中、完成、失败、取消次数,覆盖搜索、对话和 Agent 经统一检索引擎发起的调用,排除纯 FTS;混合检索回退 FTS 后成功仍计完成。计数仅保存在当前服务进程内,重启归零,与索引队列互相独立。
|
||||
- 设置与搜索页每秒轮询状态,其他页面空闲时每 5 秒轮询;不是事件推送。短检索可能无法观察到进行中状态,但完成/失败计数会保留。设置页与底部状态栏复用状态标签,未知字段显示“未获取”。
|
||||
- `POST /api/index/rebuild` 仍仅支持全量重建,并等待结果;不要将上述异步语义推广到所有索引 API。
|
||||
|
||||
状态、恢复限制与验证见 [工作区后台索引与保存开发说明](../development/工作区后台索引与保存开发说明.md)。
|
||||
|
||||
## 2026-09-06:统一运行日志
|
||||
|
||||
`GET /api/logs` 返回独立持久化的后台操作日志,无需打开 Vault。参数:`limit` 默认 50、最大 200;`before` 为上一页 next_cursor;`level` 为 INFO/WARNING/ERROR/CRITICAL 或空;`source` 按模块精确匹配;`q` 在事件名和脱敏元数据中做字面搜索。
|
||||
|
||||
返回 `items: [{id,timestamp,level,source,event,details}]`、`next_cursor`(无后续页时 null)、`sources`、`pending`、`dropped`、`write_failures`、`retention`。日志按 ID 倒序,保留最近 20,000 条。响应头 `X-Request-ID` 与后台日志关联。不得依赖日志记录笔记正文、工具参数、凭据或原始异常消息。
|
||||
|
||||
队列、错误处理、字段白名单和验证方法见 [后台运行日志与压力问题修复](../development/后台运行日志与压力问题修复.md)。
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# Agent 与任务压测报告
|
||||
|
||||
> 日期:2026-09-06。范围:当前第二阶段 Agent Runtime、任务 API、任务列表及 Trace 组件。测试在 Windows 11、Python 3.12.6、独立无头 Chrome 下执行,前端为 Vite 开发模式,纸间时光版本 1.8.1。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
功能正确性检查通过:Agent 工具调用、事件序号与回放、终态持久化恢复、容量拒绝、取消、权限等待、故障隔离,以及任务增删改查和分页均得到预期结果。性能仍有三项明确问题:
|
||||
|
||||
| 优先级 | 问题 | 本轮证据 | 建议方向 |
|
||||
| --- | --- | --- | --- |
|
||||
| P1 | 任务列表未加载后续页;Agent 历史列表也没有后续分页入口 | 100、1000 条任务的实际页面均只请求 `/api/tasks` 并显示 50 条;Agent store 同样仅调用默认分页一次,接口默认 50 | 增加服务端筛选与分页/加载更多,显示总量,防止统计仅基于已加载项 |
|
||||
| P1 | 高并发 Agent 阻塞事件循环 | 200 并发的完成延迟 P95 约 10.55 秒;进程内心跳最大延迟约 3.56 秒 | 分析同步持久化,设计有界执行调度及顺序写入,保留事件落盘、终态与回放一致性 |
|
||||
| P1 | 大 Trace 的树形筛选长时间阻塞 | 2000 事件树形搜索约 483–617 ms;10000 事件约 25.91 秒 | 为匹配的 sequence/tool/model ID 建 Set,避免逐节点重复遍历事件;再评估分段显示及增量更新 |
|
||||
|
||||
本次新增的是可复现压测与报告,没有把上述生产问题标记为已修复。页面滚动与树形搜索是不同瓶颈:10000 事件的滚动 P95 约 40 ms,并不能说明搜索交互也流畅。
|
||||
|
||||
## 2. 隔离与方法
|
||||
|
||||
- 后端脚本在导入任何 app 模块前,将 `APP_DATA_DIR`、`APP_DB_PATH`、`APP_VAULT_PATH` 指向临时目录,结束后清理;不读写用户 Vault、现有任务及 Provider 凭据。
|
||||
- Agent 使用内置 Mock Provider,每轮模型调用注入 50 ms 异步等待,正常样本包含一次 `system.echo` 工具调用和两轮模型调用;不调用真实厂商,也不衡量回答质量或真实模型吞吐。
|
||||
- 进程内运行直接调用真实 Runtime、SQLite 和任务 ASGI 路由。任务 HTTP 复核另起独立 Uvicorn 进程,用真实回环连接执行,避免将 ASGI 驱动的连续无让出执行误解为网络服务延迟。
|
||||
- 前端挂载真实 TasksView、TraceTimeline 和 Pinia store。压测页拦截全部 fetch,不向真实后端发出请求。任务接口夹具保留默认 50 条分页,先测实际加载条数,再注入完整列表测试渲染上限,两者分开记录。
|
||||
- Trace 由确定性模型开始/完成、工具调用/结果事件组成,带调用与父节点 ID;测量首次渲染、时间线过滤、树形切换、匹配全部节点的树形搜索。
|
||||
- 滚动用 CDP 派发 120 次滚轮事件,记录真实滚动容器及 `maxScrollTop`,防止对未滚动页面统计帧间隔。Trace 测试容器显式解除应用根节点的裁剪,由独立滚动区承担真实 Agent 页滚动容器的职责。
|
||||
|
||||
本轮每个前端配置测一次;开发编译、后台负载、缓存和浏览器调度都会影响结果,数据用于发现瓶颈,不作为生产环境 SLA。首次渲染计时不包含模块下载,包含 Vue 更新及两个 animation frame。
|
||||
|
||||
## 3. Agent 后端
|
||||
|
||||
### 3.1 正常工具调用与回放
|
||||
|
||||
| 并发数 | 运行数 | 完成延迟中位数(ms) | 完成延迟 P95(ms) | 场景心跳最大延迟(ms) |
|
||||
| ---: | ---: | ---: | ---: | ---: |
|
||||
| 1 | 20 | 146.20 | 170.13 | 26.84 |
|
||||
| 10 | 20 | 405.75 | 505.74 | 136.71 |
|
||||
| 50 | 50 | 2234.45 | 2304.42 | 798.84 |
|
||||
| 200 | 200 | 10262.47 | 10554.52 | 3559.97 |
|
||||
|
||||
290 个正常运行全部完成,工具返回内容逐项一致;实时订阅事件序号连续,`after_sequence=2` 回放与原事件后缀一致。新的 Runtime 实例可以从 SQLite 恢复这些终态运行。订阅者集合最终清空,进程内保留记录不超过 200。
|
||||
|
||||
“并发”是同时提交的协程数,不代表独立 CPU 工作线程。当前 queued 是启动前状态,Runtime 并没有把超额活跃运行无限排入执行队列。场景心跳还包含提交、持久化回放与恢复校验,不能将其全部归为模型执行耗时;50、200 并发场景的心跳采样数仅 8 个,表中因此列最大值而非宣称稳定分位数。
|
||||
|
||||
代码定位:`AgentRuntime._publish()` 同步调用 `AgentTraceRepository.append_event()`,后者逐事件连接 SQLite 并提交事务;`connect()` 还加载扩展和检查迁移。这是需要进一步拆分测量的阻塞路径,本轮未完成各子步骤 CPU/磁盘成本归因。
|
||||
|
||||
### 3.2 容量、取消与故障注入
|
||||
|
||||
- 保持 200 个模型调用活跃,第 201 次创建得到 `AgentCapacityError`;随后取消全部 200 个运行,全部进入 cancelled,后台任务均结束。
|
||||
- 20 个运行等待权限:10 个允许后完成,10 个等待中取消,订阅者均释放。
|
||||
- 20 个混合运行:5 个正常完成、5 个 Provider 异常、5 个模型超时、5 个工具超时,均获得预期状态或错误码;工具执行器剩余数量为 0。
|
||||
- 正常回放和故障场景合计创建 530 个运行,临时数据库最终约 2.95 MB。工具超时被记录为 ToolResult 错误,后续 Mock 模型仍可能完成运行,不将它误算为整个 Run 必然失败。
|
||||
|
||||
这里的“恢复”是新 Runtime 读取终态持久化数据,未模拟 OS 强杀时的写入中断。SSE 通过 Runtime 的事件生成器验证序号及断点回放,没有进行真实网络慢读者/断网压力测试。
|
||||
|
||||
## 4. 任务 API
|
||||
|
||||
进程内分别对 100、1000 条任务执行创建、逐页读取、更新为 done、删除,所有 ID 完整,最终总量为 0。默认接口每页 50 条,显式每页 100 条遍历可以取回全部数据。
|
||||
|
||||
ASGITransport 的 1000 条突发操作中,心跳出现约 14 秒延迟:测试客户端与同步路由处于同一事件循环,连续就绪协程缺少网络等待,不能将其视作真实 HTTP 健康检查延迟。因此增加独立 Uvicorn + 回环 HTTP 复核:
|
||||
|
||||
| 操作 | 数量 | P95(ms) | 最大值(ms) |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| 创建 | 1000 | 116.45 | 134.91 |
|
||||
| 更新 | 1000 | 159.29 | 193.37 |
|
||||
| 删除 | 1000 | 156.43 | 190.10 |
|
||||
| 分页/收尾查询 | 11 | 20.96 | 20.96 |
|
||||
| 并行健康检查 | 117 | 157.00 | 195.07 |
|
||||
|
||||
HTTP 客户端并发 20,总耗时约 18.31 秒;健康检查零失败,分页无重复/遗漏,最终任务数为 0。此处是本机单次结果,未覆盖跨网络、长期持久负载或多进程同时写同一数据库。
|
||||
|
||||
## 5. 前端
|
||||
|
||||
### 5.1 任务列表
|
||||
|
||||
100、1000 条数据的实际加载阶段都只有一次 `/api/tasks` 请求,store 中均为 50 条。以下是额外注入完整 1000 条数据后的结果,不是生产页面当前可以加载 1000 条的证明。
|
||||
|
||||
| 主题 | 完整列表渲染(ms) | 完成状态筛选(ms) |
|
||||
| --- | ---: | ---: |
|
||||
| 默认浅色 | 117.4 | 44.1 |
|
||||
| 默认深色 | 137.8 | 47.8 |
|
||||
| 纸间时光 1.8.1 | 150.0 | 59.5 |
|
||||
|
||||
1000 条列表约 11007 个 DOM 节点,实际滚动最远达到 81000 px;筛选 done 得到 334 条,与夹具期望一致。100 条样本筛选得到 34 条,也一致。默认浅色的 1000 条滚动 P95 约 30.2 ms,纸间时光约 50.1 ms;样本数少,暂不据此认定新的单一 CSS 根因。
|
||||
|
||||
### 5.2 Agent Trace
|
||||
|
||||
| 事件数/主题 | 首次渲染(ms) | 时间线搜索(ms) | 树形切换(ms) | 树形全匹配搜索(ms) |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| 2000 / 默认浅色 | 283.8 | 97.4 | 64.0 | 617.2 |
|
||||
| 2000 / 默认深色 | 289.0 | 74.5 | 62.9 | 483.0 |
|
||||
| 2000 / 纸间时光 | 337.3 | 86.3 | 62.1 | 540.1 |
|
||||
| 10000 / 纸间时光 | 1803.9 | 582.8 | 317.5 | 25914.8 |
|
||||
|
||||
200 事件的树形搜索约 20–29 ms。2000 事件时间线约 20542 个 DOM 节点,10000 事件约 102542 个。查询“压力测试 1”分别命中 444、4444 条事件,与直接检查输入数据的结果一致;树形搜索“压力测试”匹配所有事件,会自动展开匹配子树。
|
||||
|
||||
代码定位:`TraceTimeline.filteredTree` 为每个节点调用 `filteredEvents.some(...)`。节点数和匹配事件数一起增加时,会产生近似二次增长的匹配工作,随后还需构造并渲染展开子树。建议先用匹配 ID 集合消除嵌套扫描,再测 DOM 更新成本;不要仅用防抖隐藏单次 25 秒阻塞。
|
||||
|
||||
10000 是扩大数据规模的诊断测试,高于 Runtime 默认内存事件保留量 2000;它直接向组件传入事件,不代表当前单次 API 页或实时 store 默认会收到这一数量。也未覆盖逐事件 SSE 增量刷新的端到端成本。
|
||||
|
||||
## 6. 复现与原始数据
|
||||
|
||||
```powershell
|
||||
# 后端:独立临时数据,离线 Mock
|
||||
backend/.venv/Scripts/python.exe backend/scripts/agent-task-stress.py --output .local-plans/agent-task-backend.json
|
||||
# 真实 HTTP:独立 Uvicorn、随机回环端口
|
||||
backend/.venv/Scripts/python.exe backend/scripts/task-http-stress.py --count 1000 --concurrency 20 --output .local-plans/task-http.json
|
||||
# 前端:先启动独立 Vite,另一个终端执行后续命令
|
||||
npm --prefix frontend run dev -- --port 5175 --strictPort
|
||||
backend/.venv/Scripts/python.exe frontend/tests/performance/run-stress.py --url 'http://127.0.0.1:5175/tests/performance/agent-task.html?kind=tasks&theme=paper-moments' --scroll --sizes 100 1000 --runs 1 --output .local-plans/task-ui.json
|
||||
backend/.venv/Scripts/python.exe frontend/tests/performance/run-stress.py --url 'http://127.0.0.1:5175/tests/performance/agent-task.html?kind=trace&theme=paper-moments' --scroll --sizes 200 2000 10000 --runs 1 --output .local-plans/trace-ui.json
|
||||
```
|
||||
|
||||
URL 的 theme 可换为 light、dark。测试应串行运行,避免不同负载相互争用;10k Trace 树形筛选可能长时间占用测试浏览器。浏览器使用临时用户目录,不接管用户当前浏览器。
|
||||
|
||||
- [Agent 与进程内任务 API 数据](performance/2026-09-06-agent-task-backend.json)
|
||||
- [真实 HTTP 任务数据](performance/2026-09-06-task-http.json)
|
||||
- [前端 13 组样本数据](performance/2026-09-06-agent-task-ui.json)
|
||||
- [前端压测工具](../../frontend/tests/performance/README.md)
|
||||
|
||||
附加回归:`test_agent_core.py`、`test_api.py` 共 26 项通过。脚本中的状态、序号、输出、分页与筛选断言均通过。未提交或推送本次材料。
|
||||
|
||||
## 7. 修复后复测(2026-09-06)
|
||||
|
||||
以上保留首次压测基线。本节对应后台 Trace 批量写入、任务后台写入、完整列表加载、Trace 集合匹配/分页和统一日志接入后的实现。样本开启新操作日志,仍只调用隔离 Mock。
|
||||
|
||||
| 项目 | 修复前 | 修复后 |
|
||||
| --- | ---: | ---: |
|
||||
| 200 并发 Agent 完成 P95 | 10,554.52 ms | 1,233.12 ms |
|
||||
| 200 并发测量区间事件循环最大延迟 | 3,559.97 ms | 509.00 ms |
|
||||
| 纸间时光 10k Trace 首次渲染 | 1,803.9 ms | 94.4 ms |
|
||||
| 纸间时光 10k Trace 树形全匹配筛选 | 25,914.8 ms | 189.2 ms |
|
||||
| 纸间时光 10k Trace 首屏 DOM 节点 | 约 102,542 | 1,852 |
|
||||
| 1,000 条任务首次实际加载 | 50 条 | 1,000 条(10 次 API 请求) |
|
||||
|
||||
Agent 测量区间还包含同步读回、校验和重启恢复,最大心跳延迟不是纯执行阶段指标。290 次普通运行、200 个容量/取消场景、20 个权限场景、20 个失败/超时场景全部通过,订阅者和工具执行器释放。不能声称消除了所有主线程工作。
|
||||
|
||||
Trace 搜索检查完整数据,只有渲染分页。10k 样本滚动 frame gap P95 为 10.1 ms,无 longtask;时间线筛选 284.9 ms,树形切换 100.9 ms。这是单次本机样本,不代表所有硬件与实时 SSE 输入。
|
||||
|
||||
1,000 条任务界面每页 100 条;筛选后数据总数 334,当前页 100。筛选 18.7 ms,滚动 frame gap P95 为 30 ms,无 longtask。断言同时检查完整数据量和分页渲染数量。
|
||||
|
||||
真实 HTTP 测试 1,000 条任务、20 并发,CRUD 全部成功,分页完整,最终任务数 0:
|
||||
|
||||
| 指标 | 修复前 | 修复后 |
|
||||
| --- | ---: | ---: |
|
||||
| health P95 | 157.00 ms | 38.64 ms |
|
||||
| 创建 P95 | 116.45 ms | 157.68 ms |
|
||||
| 更新 P95 | 159.29 ms | 208.68 ms |
|
||||
| 删除 P95 | 156.43 ms | 205.45 ms |
|
||||
| 总耗时 | 18.31 s | 24.51 s |
|
||||
|
||||
后台排队和操作记录改善了无关请求的响应,但这组样本写入吞吐下降,不能描述为 CRUD 全面提速。后续可以评估任务事务批量化与连接初始化开销;本次没有降低 SQLite 持久化级别。
|
||||
|
||||
- [Agent / 进程内任务复测](performance/2026-09-06-agent-task-backend-fixed.json)
|
||||
- [真实 HTTP 任务复测](performance/2026-09-06-task-http-fixed.json)
|
||||
- [Trace 浏览器复测](performance/2026-09-06-agent-trace-ui-fixed.json)
|
||||
- [任务浏览器复测](performance/2026-09-06-task-ui-fixed.json)
|
||||
- [日志架构与验收说明](后台运行日志与压力问题修复.md)
|
||||
@@ -0,0 +1,230 @@
|
||||
{
|
||||
"python": "3.12.6",
|
||||
"platform": "Windows-11-10.0.26100-SP0",
|
||||
"provider": "mock with 50 ms injected delay per model turn; no network",
|
||||
"results": [
|
||||
{
|
||||
"scenario": "agent_tool_runs",
|
||||
"concurrency": 1,
|
||||
"runs": 20,
|
||||
"latency": {
|
||||
"count": 20,
|
||||
"median_ms": 150.79,
|
||||
"p95_ms": 169.57,
|
||||
"max_ms": 178.52
|
||||
},
|
||||
"completed": 20,
|
||||
"ordered_events_and_replay": true,
|
||||
"terminal_recovery": true,
|
||||
"retained_records": 20,
|
||||
"elapsed_ms": 3047.59,
|
||||
"event_loop_lag": {
|
||||
"count": 995,
|
||||
"median_ms": 0,
|
||||
"p95_ms": 5.65,
|
||||
"max_ms": 21.83
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "agent_tool_runs",
|
||||
"concurrency": 10,
|
||||
"runs": 20,
|
||||
"latency": {
|
||||
"count": 20,
|
||||
"median_ms": 187.48,
|
||||
"p95_ms": 214.02,
|
||||
"max_ms": 214.58
|
||||
},
|
||||
"completed": 20,
|
||||
"ordered_events_and_replay": true,
|
||||
"terminal_recovery": true,
|
||||
"retained_records": 40,
|
||||
"elapsed_ms": 514.15,
|
||||
"event_loop_lag": {
|
||||
"count": 192,
|
||||
"median_ms": 0,
|
||||
"p95_ms": 4.82,
|
||||
"max_ms": 20.02
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "agent_tool_runs",
|
||||
"concurrency": 50,
|
||||
"runs": 50,
|
||||
"latency": {
|
||||
"count": 50,
|
||||
"median_ms": 364.9,
|
||||
"p95_ms": 366.99,
|
||||
"max_ms": 367.3
|
||||
},
|
||||
"completed": 50,
|
||||
"ordered_events_and_replay": true,
|
||||
"terminal_recovery": true,
|
||||
"retained_records": 90,
|
||||
"elapsed_ms": 526.56,
|
||||
"event_loop_lag": {
|
||||
"count": 172,
|
||||
"median_ms": 0,
|
||||
"p95_ms": 4.57,
|
||||
"max_ms": 69.89
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "agent_tool_runs",
|
||||
"concurrency": 200,
|
||||
"runs": 200,
|
||||
"latency": {
|
||||
"count": 200,
|
||||
"median_ms": 1118.63,
|
||||
"p95_ms": 1233.12,
|
||||
"max_ms": 1237.31
|
||||
},
|
||||
"completed": 200,
|
||||
"ordered_events_and_replay": true,
|
||||
"terminal_recovery": true,
|
||||
"retained_records": 200,
|
||||
"elapsed_ms": 1839.2,
|
||||
"event_loop_lag": {
|
||||
"count": 470,
|
||||
"median_ms": 0,
|
||||
"p95_ms": 2.6,
|
||||
"max_ms": 509.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "capacity_and_cancel",
|
||||
"active_limit": 200,
|
||||
"overflow_rejected": true,
|
||||
"cancelled": 200,
|
||||
"cancel_latency": {
|
||||
"count": 200,
|
||||
"median_ms": 7.71,
|
||||
"p95_ms": 10.8,
|
||||
"max_ms": 17.37
|
||||
},
|
||||
"elapsed_ms": 3613.48,
|
||||
"event_loop_lag": {
|
||||
"count": 1613,
|
||||
"median_ms": 0,
|
||||
"p95_ms": 0,
|
||||
"max_ms": 7.86
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "permission_wait",
|
||||
"runs": 20,
|
||||
"approved_completed": 10,
|
||||
"cancelled_waiting_permission": 10,
|
||||
"subscribers_released": true,
|
||||
"elapsed_ms": 388.83,
|
||||
"event_loop_lag": {
|
||||
"count": 72,
|
||||
"median_ms": 0,
|
||||
"p95_ms": 9.9,
|
||||
"max_ms": 15.27
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "failure_and_timeout_isolation",
|
||||
"runs": 20,
|
||||
"success": 5,
|
||||
"provider_errors": 5,
|
||||
"model_timeouts": 5,
|
||||
"tool_timeouts": 5,
|
||||
"remaining_tool_executors": 0,
|
||||
"elapsed_ms": 1295.29,
|
||||
"event_loop_lag": {
|
||||
"count": 152,
|
||||
"median_ms": 0,
|
||||
"p95_ms": 7.2,
|
||||
"max_ms": 17.24
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "task_api_crud",
|
||||
"tasks": 100,
|
||||
"client_concurrency": 20,
|
||||
"latencies": {
|
||||
"create": {
|
||||
"count": 100,
|
||||
"median_ms": 214.09,
|
||||
"p95_ms": 264.51,
|
||||
"max_ms": 309.99
|
||||
},
|
||||
"update": {
|
||||
"count": 100,
|
||||
"median_ms": 221.48,
|
||||
"p95_ms": 403.74,
|
||||
"max_ms": 495.1
|
||||
},
|
||||
"list": {
|
||||
"count": 1,
|
||||
"median_ms": 7.92,
|
||||
"p95_ms": 7.92,
|
||||
"max_ms": 7.92
|
||||
},
|
||||
"delete": {
|
||||
"count": 100,
|
||||
"median_ms": 232.9,
|
||||
"p95_ms": 468.48,
|
||||
"max_ms": 475.63
|
||||
}
|
||||
},
|
||||
"default_page_count": 50,
|
||||
"default_total": 100,
|
||||
"pagination_complete": true,
|
||||
"final_total": 0,
|
||||
"elapsed_ms": 3673.22,
|
||||
"event_loop_lag": {
|
||||
"count": 2337,
|
||||
"median_ms": 0,
|
||||
"p95_ms": 0,
|
||||
"max_ms": 118.92
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "task_api_crud",
|
||||
"tasks": 1000,
|
||||
"client_concurrency": 20,
|
||||
"latencies": {
|
||||
"create": {
|
||||
"count": 1000,
|
||||
"median_ms": 200.87,
|
||||
"p95_ms": 237.75,
|
||||
"max_ms": 376.8
|
||||
},
|
||||
"update": {
|
||||
"count": 1000,
|
||||
"median_ms": 220.04,
|
||||
"p95_ms": 260.19,
|
||||
"max_ms": 300.07
|
||||
},
|
||||
"list": {
|
||||
"count": 10,
|
||||
"median_ms": 7.89,
|
||||
"p95_ms": 9.61,
|
||||
"max_ms": 9.61
|
||||
},
|
||||
"delete": {
|
||||
"count": 1000,
|
||||
"median_ms": 218.1,
|
||||
"p95_ms": 266.23,
|
||||
"max_ms": 316.14
|
||||
}
|
||||
},
|
||||
"default_page_count": 50,
|
||||
"default_total": 1000,
|
||||
"pagination_complete": true,
|
||||
"final_total": 0,
|
||||
"elapsed_ms": 32562.93,
|
||||
"event_loop_lag": {
|
||||
"count": 24893,
|
||||
"median_ms": 0,
|
||||
"p95_ms": 0,
|
||||
"max_ms": 91.85
|
||||
}
|
||||
}
|
||||
],
|
||||
"database_bytes": 2981888,
|
||||
"complete": true
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
{
|
||||
"python": "3.12.6",
|
||||
"platform": "Windows-11-10.0.26100-SP0",
|
||||
"provider": "mock with 50 ms injected delay per model turn; no network",
|
||||
"results": [
|
||||
{
|
||||
"scenario": "agent_tool_runs",
|
||||
"concurrency": 1,
|
||||
"runs": 20,
|
||||
"latency": {
|
||||
"count": 20,
|
||||
"median_ms": 146.2,
|
||||
"p95_ms": 170.13,
|
||||
"max_ms": 171.23
|
||||
},
|
||||
"completed": 20,
|
||||
"ordered_events_and_replay": true,
|
||||
"terminal_recovery": true,
|
||||
"retained_records": 20,
|
||||
"elapsed_ms": 3041.03,
|
||||
"event_loop_lag": {
|
||||
"count": 190,
|
||||
"median_ms": 5.64,
|
||||
"p95_ms": 16.81,
|
||||
"max_ms": 26.84
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "agent_tool_runs",
|
||||
"concurrency": 10,
|
||||
"runs": 20,
|
||||
"latency": {
|
||||
"count": 20,
|
||||
"median_ms": 405.75,
|
||||
"p95_ms": 505.74,
|
||||
"max_ms": 539.28
|
||||
},
|
||||
"completed": 20,
|
||||
"ordered_events_and_replay": true,
|
||||
"terminal_recovery": true,
|
||||
"retained_records": 40,
|
||||
"elapsed_ms": 1045.19,
|
||||
"event_loop_lag": {
|
||||
"count": 15,
|
||||
"median_ms": 57.63,
|
||||
"p95_ms": 136.71,
|
||||
"max_ms": 136.71
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "agent_tool_runs",
|
||||
"concurrency": 50,
|
||||
"runs": 50,
|
||||
"latency": {
|
||||
"count": 50,
|
||||
"median_ms": 2234.45,
|
||||
"p95_ms": 2304.42,
|
||||
"max_ms": 2307.54
|
||||
},
|
||||
"completed": 50,
|
||||
"ordered_events_and_replay": true,
|
||||
"terminal_recovery": true,
|
||||
"retained_records": 90,
|
||||
"elapsed_ms": 2552.49,
|
||||
"event_loop_lag": {
|
||||
"count": 8,
|
||||
"median_ms": 345.24,
|
||||
"p95_ms": 798.84,
|
||||
"max_ms": 798.84
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "agent_tool_runs",
|
||||
"concurrency": 200,
|
||||
"runs": 200,
|
||||
"latency": {
|
||||
"count": 200,
|
||||
"median_ms": 10262.47,
|
||||
"p95_ms": 10554.52,
|
||||
"max_ms": 10584.03
|
||||
},
|
||||
"completed": 200,
|
||||
"ordered_events_and_replay": true,
|
||||
"terminal_recovery": true,
|
||||
"retained_records": 200,
|
||||
"elapsed_ms": 11442.28,
|
||||
"event_loop_lag": {
|
||||
"count": 8,
|
||||
"median_ms": 1647.87,
|
||||
"p95_ms": 3559.97,
|
||||
"max_ms": 3559.97
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "capacity_and_cancel",
|
||||
"active_limit": 200,
|
||||
"overflow_rejected": true,
|
||||
"cancelled": 200,
|
||||
"cancel_latency": {
|
||||
"count": 200,
|
||||
"median_ms": 4.32,
|
||||
"p95_ms": 5.36,
|
||||
"max_ms": 8.27
|
||||
},
|
||||
"elapsed_ms": 3645.04,
|
||||
"event_loop_lag": {
|
||||
"count": 3,
|
||||
"median_ms": 14.04,
|
||||
"p95_ms": 3610.52,
|
||||
"max_ms": 3610.52
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "permission_wait",
|
||||
"runs": 20,
|
||||
"approved_completed": 10,
|
||||
"cancelled_waiting_permission": 10,
|
||||
"subscribers_released": true,
|
||||
"elapsed_ms": 971.64,
|
||||
"event_loop_lag": {
|
||||
"count": 11,
|
||||
"median_ms": 49.09,
|
||||
"p95_ms": 314.46,
|
||||
"max_ms": 314.46
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "failure_and_timeout_isolation",
|
||||
"runs": 20,
|
||||
"success": 5,
|
||||
"provider_errors": 5,
|
||||
"model_timeouts": 5,
|
||||
"tool_timeouts": 5,
|
||||
"remaining_tool_executors": 0,
|
||||
"elapsed_ms": 1531.14,
|
||||
"event_loop_lag": {
|
||||
"count": 83,
|
||||
"median_ms": 5.17,
|
||||
"p95_ms": 27.82,
|
||||
"max_ms": 255.96
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "task_api_crud",
|
||||
"tasks": 100,
|
||||
"client_concurrency": 20,
|
||||
"latencies": {
|
||||
"create": {
|
||||
"count": 100,
|
||||
"median_ms": 5.37,
|
||||
"p95_ms": 34.16,
|
||||
"max_ms": 61.99
|
||||
},
|
||||
"update": {
|
||||
"count": 100,
|
||||
"median_ms": 7.22,
|
||||
"p95_ms": 35.0,
|
||||
"max_ms": 96.33
|
||||
},
|
||||
"list": {
|
||||
"count": 1,
|
||||
"median_ms": 3.73,
|
||||
"p95_ms": 3.73,
|
||||
"max_ms": 3.73
|
||||
},
|
||||
"delete": {
|
||||
"count": 100,
|
||||
"median_ms": 5.44,
|
||||
"p95_ms": 14.57,
|
||||
"max_ms": 36.43
|
||||
}
|
||||
},
|
||||
"default_page_count": 50,
|
||||
"default_total": 100,
|
||||
"pagination_complete": true,
|
||||
"final_total": 0,
|
||||
"elapsed_ms": 2532.22,
|
||||
"event_loop_lag": {
|
||||
"count": 4,
|
||||
"median_ms": 776.23,
|
||||
"p95_ms": 1712.81,
|
||||
"max_ms": 1712.81
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario": "task_api_crud",
|
||||
"tasks": 1000,
|
||||
"client_concurrency": 20,
|
||||
"latencies": {
|
||||
"create": {
|
||||
"count": 1000,
|
||||
"median_ms": 5.18,
|
||||
"p95_ms": 32.23,
|
||||
"max_ms": 43.29
|
||||
},
|
||||
"update": {
|
||||
"count": 1000,
|
||||
"median_ms": 6.51,
|
||||
"p95_ms": 14.72,
|
||||
"max_ms": 90.27
|
||||
},
|
||||
"list": {
|
||||
"count": 10,
|
||||
"median_ms": 5.48,
|
||||
"p95_ms": 6.18,
|
||||
"max_ms": 6.18
|
||||
},
|
||||
"delete": {
|
||||
"count": 1000,
|
||||
"median_ms": 4.76,
|
||||
"p95_ms": 8.42,
|
||||
"max_ms": 97.64
|
||||
}
|
||||
},
|
||||
"default_page_count": 50,
|
||||
"default_total": 1000,
|
||||
"pagination_complete": true,
|
||||
"final_total": 0,
|
||||
"elapsed_ms": 21242.37,
|
||||
"event_loop_lag": {
|
||||
"count": 4,
|
||||
"median_ms": 6932.7,
|
||||
"p95_ms": 14260.46,
|
||||
"max_ms": 14260.46
|
||||
}
|
||||
}
|
||||
],
|
||||
"database_bytes": 2949120,
|
||||
"complete": true
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
[
|
||||
{
|
||||
"kind": "tasks",
|
||||
"theme": "light",
|
||||
"size": 100,
|
||||
"initialRenderMs": 33.099999994039536,
|
||||
"requests": [
|
||||
"/api/tasks"
|
||||
],
|
||||
"initialTaskCount": 50,
|
||||
"fullListRenderMs": 79.09999999403954,
|
||||
"fullListIsInjected": true,
|
||||
"domNodes": 1107,
|
||||
"frameGapsMs": {
|
||||
"median": 10,
|
||||
"p95": 10.100000000000364,
|
||||
"max": 69.49999999403951
|
||||
},
|
||||
"frames": 407,
|
||||
"longTasks": [],
|
||||
"scrollTop": 0,
|
||||
"scrollHeight": 11702,
|
||||
"maxScrollTop": 10845,
|
||||
"filteredCount": 34,
|
||||
"expectedFilteredCount": 34,
|
||||
"filterMs": 16.299999982118607,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"kind": "tasks",
|
||||
"theme": "light",
|
||||
"size": 1000,
|
||||
"initialRenderMs": 4.4000000059604645,
|
||||
"requests": [
|
||||
"/api/tasks"
|
||||
],
|
||||
"initialTaskCount": 50,
|
||||
"fullListRenderMs": 117.39999997615814,
|
||||
"fullListIsInjected": true,
|
||||
"domNodes": 11007,
|
||||
"frameGapsMs": {
|
||||
"median": 20.099999999999454,
|
||||
"p95": 30.199999999999818,
|
||||
"max": 50
|
||||
},
|
||||
"frames": 353,
|
||||
"longTasks": [],
|
||||
"scrollTop": 54000,
|
||||
"scrollHeight": 115652,
|
||||
"maxScrollTop": 81000,
|
||||
"filteredCount": 334,
|
||||
"expectedFilteredCount": 334,
|
||||
"filterMs": 44.10000002384186,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"kind": "tasks",
|
||||
"theme": "dark",
|
||||
"size": 100,
|
||||
"initialRenderMs": 30.900000005960464,
|
||||
"requests": [
|
||||
"/api/tasks"
|
||||
],
|
||||
"initialTaskCount": 50,
|
||||
"fullListRenderMs": 78.2999999821186,
|
||||
"fullListIsInjected": true,
|
||||
"domNodes": 1107,
|
||||
"frameGapsMs": {
|
||||
"median": 10,
|
||||
"p95": 10.100000000000364,
|
||||
"max": 49.69999998807907
|
||||
},
|
||||
"frames": 411,
|
||||
"longTasks": [],
|
||||
"scrollTop": 0,
|
||||
"scrollHeight": 11702,
|
||||
"maxScrollTop": 10845,
|
||||
"filteredCount": 34,
|
||||
"expectedFilteredCount": 34,
|
||||
"filterMs": 16.099999994039536,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"kind": "tasks",
|
||||
"theme": "dark",
|
||||
"size": 1000,
|
||||
"initialRenderMs": 5,
|
||||
"requests": [
|
||||
"/api/tasks"
|
||||
],
|
||||
"initialTaskCount": 50,
|
||||
"fullListRenderMs": 137.7999999821186,
|
||||
"fullListIsInjected": true,
|
||||
"domNodes": 11007,
|
||||
"frameGapsMs": {
|
||||
"median": 20,
|
||||
"p95": 30.199999999999818,
|
||||
"max": 40.19999999999982
|
||||
},
|
||||
"frames": 360,
|
||||
"longTasks": [],
|
||||
"scrollTop": 54000,
|
||||
"scrollHeight": 115652,
|
||||
"maxScrollTop": 81000,
|
||||
"filteredCount": 334,
|
||||
"expectedFilteredCount": 334,
|
||||
"filterMs": 47.79999998211861,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"kind": "tasks",
|
||||
"theme": "paper-moments",
|
||||
"size": 100,
|
||||
"initialRenderMs": 38.099999994039536,
|
||||
"requests": [
|
||||
"/api/tasks"
|
||||
],
|
||||
"initialTaskCount": 50,
|
||||
"fullListRenderMs": 116.09999999403954,
|
||||
"fullListIsInjected": true,
|
||||
"domNodes": 1107,
|
||||
"frameGapsMs": {
|
||||
"median": 10,
|
||||
"p95": 20,
|
||||
"max": 59.900000000000034
|
||||
},
|
||||
"frames": 387,
|
||||
"longTasks": [],
|
||||
"scrollTop": 0,
|
||||
"scrollHeight": 11764,
|
||||
"maxScrollTop": 10907,
|
||||
"filteredCount": 34,
|
||||
"expectedFilteredCount": 34,
|
||||
"filterMs": 11.600000023841858,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"kind": "tasks",
|
||||
"theme": "paper-moments",
|
||||
"size": 1000,
|
||||
"initialRenderMs": 5.800000011920929,
|
||||
"requests": [
|
||||
"/api/tasks"
|
||||
],
|
||||
"initialTaskCount": 50,
|
||||
"fullListRenderMs": 150,
|
||||
"fullListIsInjected": true,
|
||||
"domNodes": 11007,
|
||||
"frameGapsMs": {
|
||||
"median": 10.100000000000364,
|
||||
"p95": 50.100000000000364,
|
||||
"max": 80
|
||||
},
|
||||
"frames": 368,
|
||||
"longTasks": [],
|
||||
"scrollTop": 54000,
|
||||
"scrollHeight": 115714,
|
||||
"maxScrollTop": 81000,
|
||||
"filteredCount": 334,
|
||||
"expectedFilteredCount": 334,
|
||||
"filterMs": 59.5,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"kind": "trace",
|
||||
"theme": "light",
|
||||
"size": 200,
|
||||
"initialRenderMs": 75.10000002384186,
|
||||
"requests": [],
|
||||
"domNodes": 2092,
|
||||
"scrollContainers": [
|
||||
{
|
||||
"node": "HTML",
|
||||
"height": 905,
|
||||
"scrollHeight": 905,
|
||||
"overflow": "hidden",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "BODY",
|
||||
"height": 905,
|
||||
"scrollHeight": 905,
|
||||
"overflow": "hidden",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "viewport",
|
||||
"height": 905,
|
||||
"scrollHeight": 18853,
|
||||
"overflow": "auto",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "app",
|
||||
"height": 18853,
|
||||
"scrollHeight": 18853,
|
||||
"overflow": "visible",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "trace-visualization",
|
||||
"height": 18805,
|
||||
"scrollHeight": 18805,
|
||||
"overflow": "visible",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "timeline-view",
|
||||
"height": 16692,
|
||||
"scrollHeight": 16692,
|
||||
"overflow": "visible",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "timeline",
|
||||
"height": 16692,
|
||||
"scrollHeight": 16692,
|
||||
"overflow": "visible",
|
||||
"position": "relative"
|
||||
}
|
||||
],
|
||||
"frameGapsMs": {
|
||||
"median": 10,
|
||||
"p95": 10.100000000000364,
|
||||
"max": 40.00000000000003
|
||||
},
|
||||
"frames": 411,
|
||||
"longTasks": [],
|
||||
"scrollTop": 0,
|
||||
"scrollHeight": 18853,
|
||||
"maxScrollTop": 17948,
|
||||
"filteredDomNodes": 436,
|
||||
"filteredCount": 44,
|
||||
"expectedFilteredCount": 44,
|
||||
"filterMs": 16,
|
||||
"treeSwitchMs": 32.099999994039536,
|
||||
"treeFilterMs": 27.900000005960464,
|
||||
"treeFilteredDomNodes": 689,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"kind": "trace",
|
||||
"theme": "light",
|
||||
"size": 2000,
|
||||
"initialRenderMs": 283.7999999821186,
|
||||
"requests": [],
|
||||
"domNodes": 20542,
|
||||
"scrollContainers": [
|
||||
{
|
||||
"node": "HTML",
|
||||
"height": 905,
|
||||
"scrollHeight": 905,
|
||||
"overflow": "hidden",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "BODY",
|
||||
"height": 905,
|
||||
"scrollHeight": 905,
|
||||
"overflow": "hidden",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "viewport",
|
||||
"height": 905,
|
||||
"scrollHeight": 184678,
|
||||
"overflow": "auto",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "app",
|
||||
"height": 184678,
|
||||
"scrollHeight": 184678,
|
||||
"overflow": "visible",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "trace-visualization",
|
||||
"height": 184630,
|
||||
"scrollHeight": 184630,
|
||||
"overflow": "visible",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "timeline-view",
|
||||
"height": 166992,
|
||||
"scrollHeight": 166992,
|
||||
"overflow": "visible",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "timeline",
|
||||
"height": 166992,
|
||||
"scrollHeight": 166992,
|
||||
"overflow": "visible",
|
||||
"position": "relative"
|
||||
}
|
||||
],
|
||||
"frameGapsMs": {
|
||||
"median": 10,
|
||||
"p95": 10.100000000000364,
|
||||
"max": 20.100000000000023
|
||||
},
|
||||
"frames": 491,
|
||||
"longTasks": [],
|
||||
"scrollTop": 54000,
|
||||
"scrollHeight": 184678,
|
||||
"maxScrollTop": 81000,
|
||||
"filteredDomNodes": 4036,
|
||||
"filteredCount": 444,
|
||||
"expectedFilteredCount": 444,
|
||||
"filterMs": 97.40000000596046,
|
||||
"treeSwitchMs": 64,
|
||||
"treeFilterMs": 617.1999999880791,
|
||||
"treeFilteredDomNodes": 6539,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"kind": "trace",
|
||||
"theme": "dark",
|
||||
"size": 200,
|
||||
"initialRenderMs": 81.5,
|
||||
"requests": [],
|
||||
"domNodes": 2092,
|
||||
"scrollContainers": [
|
||||
{
|
||||
"node": "HTML",
|
||||
"height": 905,
|
||||
"scrollHeight": 905,
|
||||
"overflow": "hidden",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "BODY",
|
||||
"height": 905,
|
||||
"scrollHeight": 905,
|
||||
"overflow": "hidden",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "viewport",
|
||||
"height": 905,
|
||||
"scrollHeight": 18853,
|
||||
"overflow": "auto",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "app",
|
||||
"height": 18853,
|
||||
"scrollHeight": 18853,
|
||||
"overflow": "visible",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "trace-visualization",
|
||||
"height": 18805,
|
||||
"scrollHeight": 18805,
|
||||
"overflow": "visible",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "timeline-view",
|
||||
"height": 16692,
|
||||
"scrollHeight": 16692,
|
||||
"overflow": "visible",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "timeline",
|
||||
"height": 16692,
|
||||
"scrollHeight": 16692,
|
||||
"overflow": "visible",
|
||||
"position": "relative"
|
||||
}
|
||||
],
|
||||
"frameGapsMs": {
|
||||
"median": 10,
|
||||
"p95": 10.100000000000364,
|
||||
"max": 30
|
||||
},
|
||||
"frames": 415,
|
||||
"longTasks": [],
|
||||
"scrollTop": 0,
|
||||
"scrollHeight": 18853,
|
||||
"maxScrollTop": 17948,
|
||||
"filteredDomNodes": 436,
|
||||
"filteredCount": 44,
|
||||
"expectedFilteredCount": 44,
|
||||
"filterMs": 16.69999998807907,
|
||||
"treeSwitchMs": 31.099999994039536,
|
||||
"treeFilterMs": 28.900000005960464,
|
||||
"treeFilteredDomNodes": 689,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"kind": "trace",
|
||||
"theme": "dark",
|
||||
"size": 2000,
|
||||
"initialRenderMs": 289,
|
||||
"requests": [],
|
||||
"domNodes": 20542,
|
||||
"scrollContainers": [
|
||||
{
|
||||
"node": "HTML",
|
||||
"height": 905,
|
||||
"scrollHeight": 905,
|
||||
"overflow": "hidden",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "BODY",
|
||||
"height": 905,
|
||||
"scrollHeight": 905,
|
||||
"overflow": "hidden",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "viewport",
|
||||
"height": 905,
|
||||
"scrollHeight": 184678,
|
||||
"overflow": "auto",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "app",
|
||||
"height": 184678,
|
||||
"scrollHeight": 184678,
|
||||
"overflow": "visible",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "trace-visualization",
|
||||
"height": 184630,
|
||||
"scrollHeight": 184630,
|
||||
"overflow": "visible",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "timeline-view",
|
||||
"height": 166992,
|
||||
"scrollHeight": 166992,
|
||||
"overflow": "visible",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "timeline",
|
||||
"height": 166992,
|
||||
"scrollHeight": 166992,
|
||||
"overflow": "visible",
|
||||
"position": "relative"
|
||||
}
|
||||
],
|
||||
"frameGapsMs": {
|
||||
"median": 10,
|
||||
"p95": 10.100000000000364,
|
||||
"max": 20.200000000000045
|
||||
},
|
||||
"frames": 487,
|
||||
"longTasks": [],
|
||||
"scrollTop": 54000,
|
||||
"scrollHeight": 184678,
|
||||
"maxScrollTop": 81000,
|
||||
"filteredDomNodes": 4036,
|
||||
"filteredCount": 444,
|
||||
"expectedFilteredCount": 444,
|
||||
"filterMs": 74.5,
|
||||
"treeSwitchMs": 62.900000005960464,
|
||||
"treeFilterMs": 483,
|
||||
"treeFilteredDomNodes": 6539,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"kind": "trace",
|
||||
"theme": "paper-moments",
|
||||
"size": 200,
|
||||
"initialRenderMs": 119.59999999403954,
|
||||
"requests": [],
|
||||
"domNodes": 2092,
|
||||
"scrollContainers": [
|
||||
{
|
||||
"node": "HTML",
|
||||
"height": 905,
|
||||
"scrollHeight": 905,
|
||||
"overflow": "hidden",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "BODY",
|
||||
"height": 905,
|
||||
"scrollHeight": 905,
|
||||
"overflow": "hidden",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "viewport",
|
||||
"height": 905,
|
||||
"scrollHeight": 18853,
|
||||
"overflow": "auto",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "app",
|
||||
"height": 18853,
|
||||
"scrollHeight": 18853,
|
||||
"overflow": "visible",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "trace-visualization",
|
||||
"height": 18805,
|
||||
"scrollHeight": 18805,
|
||||
"overflow": "visible",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "timeline-view",
|
||||
"height": 16692,
|
||||
"scrollHeight": 16692,
|
||||
"overflow": "visible",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "timeline",
|
||||
"height": 16692,
|
||||
"scrollHeight": 16692,
|
||||
"overflow": "visible",
|
||||
"position": "relative"
|
||||
}
|
||||
],
|
||||
"frameGapsMs": {
|
||||
"median": 10,
|
||||
"p95": 20,
|
||||
"max": 89.99999999999997
|
||||
},
|
||||
"frames": 394,
|
||||
"longTasks": [],
|
||||
"scrollTop": 0,
|
||||
"scrollHeight": 18853,
|
||||
"maxScrollTop": 17948,
|
||||
"filteredDomNodes": 436,
|
||||
"filteredCount": 44,
|
||||
"expectedFilteredCount": 44,
|
||||
"filterMs": 19.700000017881393,
|
||||
"treeSwitchMs": 40.10000002384186,
|
||||
"treeFilterMs": 19.899999976158142,
|
||||
"treeFilteredDomNodes": 689,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"kind": "trace",
|
||||
"theme": "paper-moments",
|
||||
"size": 2000,
|
||||
"initialRenderMs": 337.2999999821186,
|
||||
"requests": [],
|
||||
"domNodes": 20542,
|
||||
"scrollContainers": [
|
||||
{
|
||||
"node": "HTML",
|
||||
"height": 905,
|
||||
"scrollHeight": 905,
|
||||
"overflow": "hidden",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "BODY",
|
||||
"height": 905,
|
||||
"scrollHeight": 905,
|
||||
"overflow": "hidden",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "viewport",
|
||||
"height": 905,
|
||||
"scrollHeight": 184678,
|
||||
"overflow": "auto",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "app",
|
||||
"height": 184678,
|
||||
"scrollHeight": 184678,
|
||||
"overflow": "visible",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "trace-visualization",
|
||||
"height": 184630,
|
||||
"scrollHeight": 184630,
|
||||
"overflow": "visible",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "timeline-view",
|
||||
"height": 166992,
|
||||
"scrollHeight": 166992,
|
||||
"overflow": "visible",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "timeline",
|
||||
"height": 166992,
|
||||
"scrollHeight": 166992,
|
||||
"overflow": "visible",
|
||||
"position": "relative"
|
||||
}
|
||||
],
|
||||
"frameGapsMs": {
|
||||
"median": 10,
|
||||
"p95": 20.100000000000023,
|
||||
"max": 30.100000000000023
|
||||
},
|
||||
"frames": 416,
|
||||
"longTasks": [],
|
||||
"scrollTop": 54000,
|
||||
"scrollHeight": 184678,
|
||||
"maxScrollTop": 81000,
|
||||
"filteredDomNodes": 4036,
|
||||
"filteredCount": 444,
|
||||
"expectedFilteredCount": 444,
|
||||
"filterMs": 86.2999999821186,
|
||||
"treeSwitchMs": 62.099999994039536,
|
||||
"treeFilterMs": 540.0999999940395,
|
||||
"treeFilteredDomNodes": 6539,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"kind": "trace",
|
||||
"theme": "paper-moments",
|
||||
"size": 10000,
|
||||
"initialRenderMs": 1803.9000000059605,
|
||||
"requests": [],
|
||||
"domNodes": 102542,
|
||||
"scrollContainers": [
|
||||
{
|
||||
"node": "HTML",
|
||||
"height": 905,
|
||||
"scrollHeight": 905,
|
||||
"overflow": "hidden",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "BODY",
|
||||
"height": 905,
|
||||
"scrollHeight": 905,
|
||||
"overflow": "hidden",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "viewport",
|
||||
"height": 905,
|
||||
"scrollHeight": 921678,
|
||||
"overflow": "auto",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "app",
|
||||
"height": 921678,
|
||||
"scrollHeight": 921678,
|
||||
"overflow": "visible",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "trace-visualization",
|
||||
"height": 921630,
|
||||
"scrollHeight": 921630,
|
||||
"overflow": "visible",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "timeline-view",
|
||||
"height": 834992,
|
||||
"scrollHeight": 834992,
|
||||
"overflow": "visible",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "timeline",
|
||||
"height": 834992,
|
||||
"scrollHeight": 834992,
|
||||
"overflow": "visible",
|
||||
"position": "relative"
|
||||
}
|
||||
],
|
||||
"frameGapsMs": {
|
||||
"median": 20.100000000000364,
|
||||
"p95": 40,
|
||||
"max": 100
|
||||
},
|
||||
"frames": 357,
|
||||
"longTasks": [
|
||||
82,
|
||||
53
|
||||
],
|
||||
"scrollTop": 54000,
|
||||
"scrollHeight": 921678,
|
||||
"maxScrollTop": 81000,
|
||||
"filteredDomNodes": 40036,
|
||||
"filteredCount": 4444,
|
||||
"expectedFilteredCount": 4444,
|
||||
"filterMs": 582.7999999821186,
|
||||
"treeSwitchMs": 317.5,
|
||||
"treeFilterMs": 25914.80000001192,
|
||||
"treeFilteredDomNodes": 32539,
|
||||
"repeat": 1
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,156 @@
|
||||
[
|
||||
{
|
||||
"kind": "trace",
|
||||
"theme": "paper-moments",
|
||||
"size": 2000,
|
||||
"initialRenderMs": 117.19999998807907,
|
||||
"requests": [],
|
||||
"domNodes": 1852,
|
||||
"scrollContainers": [
|
||||
{
|
||||
"node": "HTML",
|
||||
"height": 905,
|
||||
"scrollHeight": 905,
|
||||
"overflow": "hidden",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "BODY",
|
||||
"height": 905,
|
||||
"scrollHeight": 905,
|
||||
"overflow": "hidden",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "viewport",
|
||||
"height": 905,
|
||||
"scrollHeight": 17214,
|
||||
"overflow": "auto",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "app",
|
||||
"height": 17214,
|
||||
"scrollHeight": 17214,
|
||||
"overflow": "visible",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "trace-visualization",
|
||||
"height": 17166,
|
||||
"scrollHeight": 17166,
|
||||
"overflow": "visible",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "timeline-view",
|
||||
"height": 16692,
|
||||
"scrollHeight": 16692,
|
||||
"overflow": "visible",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "timeline",
|
||||
"height": 16692,
|
||||
"scrollHeight": 16692,
|
||||
"overflow": "visible",
|
||||
"position": "relative"
|
||||
}
|
||||
],
|
||||
"frameGapsMs": {
|
||||
"median": 10,
|
||||
"p95": 10.100000000000364,
|
||||
"max": 80.09999999999997
|
||||
},
|
||||
"frames": 408,
|
||||
"longTasks": [],
|
||||
"scrollTop": 0,
|
||||
"scrollHeight": 17214,
|
||||
"maxScrollTop": 16309,
|
||||
"filteredDomNodes": 1844,
|
||||
"filteredCount": 200,
|
||||
"expectedFilteredCount": 200,
|
||||
"filterMs": 71.90000000596046,
|
||||
"treeSwitchMs": 51.400000005960464,
|
||||
"treeFilterMs": 48.70000001788139,
|
||||
"treeFilteredDomNodes": 1343,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"kind": "trace",
|
||||
"theme": "paper-moments",
|
||||
"size": 10000,
|
||||
"initialRenderMs": 94.40000000596046,
|
||||
"requests": [],
|
||||
"domNodes": 1852,
|
||||
"scrollContainers": [
|
||||
{
|
||||
"node": "HTML",
|
||||
"height": 905,
|
||||
"scrollHeight": 905,
|
||||
"overflow": "hidden",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "BODY",
|
||||
"height": 905,
|
||||
"scrollHeight": 905,
|
||||
"overflow": "hidden",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "viewport",
|
||||
"height": 905,
|
||||
"scrollHeight": 17214,
|
||||
"overflow": "auto",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "app",
|
||||
"height": 17214,
|
||||
"scrollHeight": 17214,
|
||||
"overflow": "visible",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "trace-visualization",
|
||||
"height": 17166,
|
||||
"scrollHeight": 17166,
|
||||
"overflow": "visible",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "timeline-view",
|
||||
"height": 16692,
|
||||
"scrollHeight": 16692,
|
||||
"overflow": "visible",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "timeline",
|
||||
"height": 16692,
|
||||
"scrollHeight": 16692,
|
||||
"overflow": "visible",
|
||||
"position": "relative"
|
||||
}
|
||||
],
|
||||
"frameGapsMs": {
|
||||
"median": 10,
|
||||
"p95": 10.100000000000364,
|
||||
"max": 50.09999999999991
|
||||
},
|
||||
"frames": 414,
|
||||
"longTasks": [],
|
||||
"scrollTop": 0,
|
||||
"scrollHeight": 17214,
|
||||
"maxScrollTop": 16309,
|
||||
"filteredDomNodes": 1844,
|
||||
"filteredCount": 200,
|
||||
"expectedFilteredCount": 200,
|
||||
"filterMs": 284.90000000596046,
|
||||
"treeSwitchMs": 100.90000000596046,
|
||||
"treeFilterMs": 189.19999998807907,
|
||||
"treeFilteredDomNodes": 1343,
|
||||
"repeat": 1
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,380 @@
|
||||
[
|
||||
{
|
||||
"requestedHan": 25000,
|
||||
"hanCharacters": 25632,
|
||||
"sourceCharacters": 35728,
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
|
||||
"viewport": [
|
||||
1424,
|
||||
905
|
||||
],
|
||||
"openMs": 379.09999999403954,
|
||||
"domNodes": 2110,
|
||||
"selectionMs": {
|
||||
"median": 0.4000000059604645,
|
||||
"p95": 0.7000000178813934,
|
||||
"max": 1.300000011920929
|
||||
},
|
||||
"insertMs": {
|
||||
"median": 6.300000011920929,
|
||||
"p95": 9.599999994039536,
|
||||
"max": 10.400000005960464
|
||||
},
|
||||
"foldMs": {
|
||||
"median": 42.900000005960464,
|
||||
"p95": 45.599999994039536,
|
||||
"max": 45.599999994039536
|
||||
},
|
||||
"integrity": true,
|
||||
"previewMs": [
|
||||
176.30000001192093,
|
||||
22.69999998807907,
|
||||
19.80000001192093
|
||||
],
|
||||
"previewNodes": 1058,
|
||||
"longTasks": {
|
||||
"count": 4,
|
||||
"median": 91,
|
||||
"p95": 359,
|
||||
"max": 359
|
||||
},
|
||||
"heapUsedBytes": 36560189,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"requestedHan": 25000,
|
||||
"hanCharacters": 25632,
|
||||
"sourceCharacters": 35728,
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
|
||||
"viewport": [
|
||||
1424,
|
||||
905
|
||||
],
|
||||
"openMs": 218.59999999403954,
|
||||
"domNodes": 2110,
|
||||
"selectionMs": {
|
||||
"median": 0.29999998211860657,
|
||||
"p95": 0.5,
|
||||
"max": 0.5
|
||||
},
|
||||
"insertMs": {
|
||||
"median": 4.799999982118607,
|
||||
"p95": 6.9000000059604645,
|
||||
"max": 7.699999988079071
|
||||
},
|
||||
"foldMs": {
|
||||
"median": 45.30000001192093,
|
||||
"p95": 79,
|
||||
"max": 79
|
||||
},
|
||||
"integrity": true,
|
||||
"previewMs": [
|
||||
112.90000000596046,
|
||||
27.80000001192093,
|
||||
25.399999976158142
|
||||
],
|
||||
"previewNodes": 1058,
|
||||
"longTasks": {
|
||||
"count": 5,
|
||||
"median": 65,
|
||||
"p95": 198,
|
||||
"max": 198
|
||||
},
|
||||
"heapUsedBytes": 78394355,
|
||||
"repeat": 2
|
||||
},
|
||||
{
|
||||
"requestedHan": 25000,
|
||||
"hanCharacters": 25632,
|
||||
"sourceCharacters": 35728,
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
|
||||
"viewport": [
|
||||
1424,
|
||||
905
|
||||
],
|
||||
"openMs": 207.59999999403954,
|
||||
"domNodes": 2110,
|
||||
"selectionMs": {
|
||||
"median": 0.4000000059604645,
|
||||
"p95": 0.5999999940395355,
|
||||
"max": 0.699999988079071
|
||||
},
|
||||
"insertMs": {
|
||||
"median": 4.600000023841858,
|
||||
"p95": 5.9000000059604645,
|
||||
"max": 6.800000011920929
|
||||
},
|
||||
"foldMs": {
|
||||
"median": 35.900000005960464,
|
||||
"p95": 45.400000005960464,
|
||||
"max": 45.400000005960464
|
||||
},
|
||||
"integrity": true,
|
||||
"previewMs": [
|
||||
98.30000001192093,
|
||||
29.69999998807907,
|
||||
29.80000001192093
|
||||
],
|
||||
"previewNodes": 1058,
|
||||
"longTasks": {
|
||||
"count": 4,
|
||||
"median": 59,
|
||||
"p95": 184,
|
||||
"max": 184
|
||||
},
|
||||
"heapUsedBytes": 72765231,
|
||||
"repeat": 3
|
||||
},
|
||||
{
|
||||
"requestedHan": 60000,
|
||||
"hanCharacters": 61172,
|
||||
"sourceCharacters": 85716,
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
|
||||
"viewport": [
|
||||
1424,
|
||||
905
|
||||
],
|
||||
"openMs": 394.30000001192093,
|
||||
"domNodes": 4720,
|
||||
"selectionMs": {
|
||||
"median": 0.4000000059604645,
|
||||
"p95": 0.6000000238418579,
|
||||
"max": 0.699999988079071
|
||||
},
|
||||
"insertMs": {
|
||||
"median": 6.300000011920929,
|
||||
"p95": 7.799999982118607,
|
||||
"max": 13.599999994039536
|
||||
},
|
||||
"foldMs": {
|
||||
"median": 95.80000001192093,
|
||||
"p95": 145.5,
|
||||
"max": 145.5
|
||||
},
|
||||
"integrity": true,
|
||||
"previewMs": [
|
||||
180.59999999403954,
|
||||
61.20000001788139,
|
||||
50
|
||||
],
|
||||
"previewNodes": 2560,
|
||||
"longTasks": {
|
||||
"count": 10,
|
||||
"median": 103,
|
||||
"p95": 358,
|
||||
"max": 358
|
||||
},
|
||||
"heapUsedBytes": 73234300,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"requestedHan": 60000,
|
||||
"hanCharacters": 61172,
|
||||
"sourceCharacters": 85716,
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
|
||||
"viewport": [
|
||||
1424,
|
||||
905
|
||||
],
|
||||
"openMs": 447.5,
|
||||
"domNodes": 4720,
|
||||
"selectionMs": {
|
||||
"median": 0.4000000059604645,
|
||||
"p95": 0.5999999940395355,
|
||||
"max": 0.5999999940395355
|
||||
},
|
||||
"insertMs": {
|
||||
"median": 6.699999988079071,
|
||||
"p95": 12.699999988079071,
|
||||
"max": 14.5
|
||||
},
|
||||
"foldMs": {
|
||||
"median": 99.5,
|
||||
"p95": 110.40000000596046,
|
||||
"max": 110.40000000596046
|
||||
},
|
||||
"integrity": true,
|
||||
"previewMs": [
|
||||
153,
|
||||
44.900000005960464,
|
||||
49.400000005960464
|
||||
],
|
||||
"previewNodes": 2560,
|
||||
"longTasks": {
|
||||
"count": 9,
|
||||
"median": 113,
|
||||
"p95": 410,
|
||||
"max": 410
|
||||
},
|
||||
"heapUsedBytes": 97026266,
|
||||
"repeat": 2
|
||||
},
|
||||
{
|
||||
"requestedHan": 60000,
|
||||
"hanCharacters": 61172,
|
||||
"sourceCharacters": 85716,
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
|
||||
"viewport": [
|
||||
1424,
|
||||
905
|
||||
],
|
||||
"openMs": 492.69999998807907,
|
||||
"domNodes": 4720,
|
||||
"selectionMs": {
|
||||
"median": 0.5,
|
||||
"p95": 0.6000000238418579,
|
||||
"max": 1
|
||||
},
|
||||
"insertMs": {
|
||||
"median": 7.199999988079071,
|
||||
"p95": 9.599999994039536,
|
||||
"max": 10.100000023841858
|
||||
},
|
||||
"foldMs": {
|
||||
"median": 115.80000001192093,
|
||||
"p95": 149.7000000178814,
|
||||
"max": 149.7000000178814
|
||||
},
|
||||
"integrity": true,
|
||||
"previewMs": [
|
||||
141.39999997615814,
|
||||
67.5,
|
||||
61.900000005960464
|
||||
],
|
||||
"previewNodes": 2560,
|
||||
"longTasks": {
|
||||
"count": 11,
|
||||
"median": 109,
|
||||
"p95": 445,
|
||||
"max": 445
|
||||
},
|
||||
"heapUsedBytes": 88746335,
|
||||
"repeat": 3
|
||||
},
|
||||
{
|
||||
"requestedHan": 120000,
|
||||
"hanCharacters": 122322,
|
||||
"sourceCharacters": 171613,
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
|
||||
"viewport": [
|
||||
1424,
|
||||
905
|
||||
],
|
||||
"openMs": 893.4000000059605,
|
||||
"domNodes": 9139,
|
||||
"selectionMs": {
|
||||
"median": 0.7000000178813934,
|
||||
"p95": 1.2000000178813934,
|
||||
"max": 2.399999976158142
|
||||
},
|
||||
"insertMs": {
|
||||
"median": 18,
|
||||
"p95": 28.900000005960464,
|
||||
"max": 31.5
|
||||
},
|
||||
"foldMs": {
|
||||
"median": 217,
|
||||
"p95": 292.90000000596046,
|
||||
"max": 292.90000000596046
|
||||
},
|
||||
"integrity": true,
|
||||
"previewMs": [
|
||||
203.10000002384186,
|
||||
98.7999999821186,
|
||||
92.30000001192093
|
||||
],
|
||||
"previewNodes": 5117,
|
||||
"longTasks": {
|
||||
"count": 13,
|
||||
"median": 199,
|
||||
"p95": 796,
|
||||
"max": 796
|
||||
},
|
||||
"heapUsedBytes": 113890741,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"requestedHan": 120000,
|
||||
"hanCharacters": 122322,
|
||||
"sourceCharacters": 171613,
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
|
||||
"viewport": [
|
||||
1424,
|
||||
905
|
||||
],
|
||||
"openMs": 869.0999999940395,
|
||||
"domNodes": 9139,
|
||||
"selectionMs": {
|
||||
"median": 0.7000000178813934,
|
||||
"p95": 1.300000011920929,
|
||||
"max": 3.4000000059604645
|
||||
},
|
||||
"insertMs": {
|
||||
"median": 16.69999998807907,
|
||||
"p95": 22.599999994039536,
|
||||
"max": 22.80000001192093
|
||||
},
|
||||
"foldMs": {
|
||||
"median": 220.5,
|
||||
"p95": 263.09999999403954,
|
||||
"max": 263.09999999403954
|
||||
},
|
||||
"integrity": true,
|
||||
"previewMs": [
|
||||
207.39999997615814,
|
||||
98.7000000178814,
|
||||
90.2999999821186
|
||||
],
|
||||
"previewNodes": 5117,
|
||||
"longTasks": {
|
||||
"count": 14,
|
||||
"median": 205,
|
||||
"p95": 780,
|
||||
"max": 780
|
||||
},
|
||||
"heapUsedBytes": 125636820,
|
||||
"repeat": 2
|
||||
},
|
||||
{
|
||||
"requestedHan": 120000,
|
||||
"hanCharacters": 122322,
|
||||
"sourceCharacters": 171613,
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
|
||||
"viewport": [
|
||||
1424,
|
||||
905
|
||||
],
|
||||
"openMs": 845.9000000059605,
|
||||
"domNodes": 9139,
|
||||
"selectionMs": {
|
||||
"median": 0.699999988079071,
|
||||
"p95": 1.0999999940395355,
|
||||
"max": 2.9000000059604645
|
||||
},
|
||||
"insertMs": {
|
||||
"median": 18.69999998807907,
|
||||
"p95": 27.19999998807907,
|
||||
"max": 35
|
||||
},
|
||||
"foldMs": {
|
||||
"median": 205.7000000178814,
|
||||
"p95": 263.90000000596046,
|
||||
"max": 263.90000000596046
|
||||
},
|
||||
"integrity": true,
|
||||
"previewMs": [
|
||||
213.09999999403954,
|
||||
100,
|
||||
91.5
|
||||
],
|
||||
"previewNodes": 5117,
|
||||
"longTasks": {
|
||||
"count": 13,
|
||||
"median": 191,
|
||||
"p95": 768,
|
||||
"max": 768
|
||||
},
|
||||
"heapUsedBytes": 86940861,
|
||||
"repeat": 3
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,380 @@
|
||||
[
|
||||
{
|
||||
"requestedHan": 25000,
|
||||
"hanCharacters": 25632,
|
||||
"sourceCharacters": 35728,
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
|
||||
"viewport": [
|
||||
1424,
|
||||
905
|
||||
],
|
||||
"openMs": 398,
|
||||
"domNodes": 2110,
|
||||
"selectionMs": {
|
||||
"median": 1.0999999940395355,
|
||||
"p95": 1.9000000059604645,
|
||||
"max": 1.9000000059604645
|
||||
},
|
||||
"insertMs": {
|
||||
"median": 5.300000011920929,
|
||||
"p95": 8.100000023841858,
|
||||
"max": 8.800000011920929
|
||||
},
|
||||
"foldMs": {
|
||||
"median": 58.70000001788139,
|
||||
"p95": 70.19999998807907,
|
||||
"max": 70.19999998807907
|
||||
},
|
||||
"integrity": true,
|
||||
"previewMs": [
|
||||
230.7999999821186,
|
||||
45.5,
|
||||
39.70000001788139
|
||||
],
|
||||
"previewNodes": 1058,
|
||||
"longTasks": {
|
||||
"count": 5,
|
||||
"median": 84,
|
||||
"p95": 372,
|
||||
"max": 372
|
||||
},
|
||||
"heapUsedBytes": 42428845,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"requestedHan": 25000,
|
||||
"hanCharacters": 25632,
|
||||
"sourceCharacters": 35728,
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
|
||||
"viewport": [
|
||||
1424,
|
||||
905
|
||||
],
|
||||
"openMs": 272.09999999403954,
|
||||
"domNodes": 2110,
|
||||
"selectionMs": {
|
||||
"median": 1,
|
||||
"p95": 2.0999999940395355,
|
||||
"max": 2.399999976158142
|
||||
},
|
||||
"insertMs": {
|
||||
"median": 5.699999988079071,
|
||||
"p95": 7,
|
||||
"max": 7.300000011920929
|
||||
},
|
||||
"foldMs": {
|
||||
"median": 48.900000005960464,
|
||||
"p95": 55,
|
||||
"max": 55
|
||||
},
|
||||
"integrity": true,
|
||||
"previewMs": [
|
||||
157,
|
||||
37.69999998807907,
|
||||
40
|
||||
],
|
||||
"previewNodes": 1058,
|
||||
"longTasks": {
|
||||
"count": 6,
|
||||
"median": 63,
|
||||
"p95": 245,
|
||||
"max": 245
|
||||
},
|
||||
"heapUsedBytes": 62409024,
|
||||
"repeat": 2
|
||||
},
|
||||
{
|
||||
"requestedHan": 25000,
|
||||
"hanCharacters": 25632,
|
||||
"sourceCharacters": 35728,
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
|
||||
"viewport": [
|
||||
1424,
|
||||
905
|
||||
],
|
||||
"openMs": 224.90000000596046,
|
||||
"domNodes": 2110,
|
||||
"selectionMs": {
|
||||
"median": 0.7000000178813934,
|
||||
"p95": 1.4000000059604645,
|
||||
"max": 2
|
||||
},
|
||||
"insertMs": {
|
||||
"median": 4.399999976158142,
|
||||
"p95": 6.4000000059604645,
|
||||
"max": 8
|
||||
},
|
||||
"foldMs": {
|
||||
"median": 39.099999994039536,
|
||||
"p95": 44.10000002384186,
|
||||
"max": 44.10000002384186
|
||||
},
|
||||
"integrity": true,
|
||||
"previewMs": [
|
||||
139,
|
||||
46.099999994039536,
|
||||
39.900000005960464
|
||||
],
|
||||
"previewNodes": 1058,
|
||||
"longTasks": {
|
||||
"count": 4,
|
||||
"median": 92,
|
||||
"p95": 200,
|
||||
"max": 200
|
||||
},
|
||||
"heapUsedBytes": 35903770,
|
||||
"repeat": 3
|
||||
},
|
||||
{
|
||||
"requestedHan": 60000,
|
||||
"hanCharacters": 61172,
|
||||
"sourceCharacters": 85716,
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
|
||||
"viewport": [
|
||||
1424,
|
||||
905
|
||||
],
|
||||
"openMs": 458.39999997615814,
|
||||
"domNodes": 4720,
|
||||
"selectionMs": {
|
||||
"median": 2.9000000059604645,
|
||||
"p95": 5.699999988079071,
|
||||
"max": 6.0999999940395355
|
||||
},
|
||||
"insertMs": {
|
||||
"median": 7.5999999940395355,
|
||||
"p95": 9.699999988079071,
|
||||
"max": 12.400000005960464
|
||||
},
|
||||
"foldMs": {
|
||||
"median": 120.30000001192093,
|
||||
"p95": 160.09999999403954,
|
||||
"max": 160.09999999403954
|
||||
},
|
||||
"integrity": true,
|
||||
"previewMs": [
|
||||
173.5,
|
||||
65.60000002384186,
|
||||
70.09999999403954
|
||||
],
|
||||
"previewNodes": 2560,
|
||||
"longTasks": {
|
||||
"count": 12,
|
||||
"median": 110,
|
||||
"p95": 415,
|
||||
"max": 415
|
||||
},
|
||||
"heapUsedBytes": 60644006,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"requestedHan": 60000,
|
||||
"hanCharacters": 61172,
|
||||
"sourceCharacters": 85716,
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
|
||||
"viewport": [
|
||||
1424,
|
||||
905
|
||||
],
|
||||
"openMs": 649.6999999880791,
|
||||
"domNodes": 4720,
|
||||
"selectionMs": {
|
||||
"median": 2.699999988079071,
|
||||
"p95": 5.699999988079071,
|
||||
"max": 5.800000011920929
|
||||
},
|
||||
"insertMs": {
|
||||
"median": 7.5,
|
||||
"p95": 9.099999994039536,
|
||||
"max": 10.699999988079071
|
||||
},
|
||||
"foldMs": {
|
||||
"median": 116,
|
||||
"p95": 149.09999999403954,
|
||||
"max": 149.09999999403954
|
||||
},
|
||||
"integrity": true,
|
||||
"previewMs": [
|
||||
209.30000001192093,
|
||||
80.90000000596046,
|
||||
88.7999999821186
|
||||
],
|
||||
"previewNodes": 2560,
|
||||
"longTasks": {
|
||||
"count": 12,
|
||||
"median": 116,
|
||||
"p95": 586,
|
||||
"max": 586
|
||||
},
|
||||
"heapUsedBytes": 64379838,
|
||||
"repeat": 2
|
||||
},
|
||||
{
|
||||
"requestedHan": 60000,
|
||||
"hanCharacters": 61172,
|
||||
"sourceCharacters": 85716,
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
|
||||
"viewport": [
|
||||
1424,
|
||||
905
|
||||
],
|
||||
"openMs": 519.7000000178814,
|
||||
"domNodes": 4720,
|
||||
"selectionMs": {
|
||||
"median": 2.9000000059604645,
|
||||
"p95": 4.9000000059604645,
|
||||
"max": 5.5
|
||||
},
|
||||
"insertMs": {
|
||||
"median": 9.200000017881393,
|
||||
"p95": 11.700000017881393,
|
||||
"max": 11.900000005960464
|
||||
},
|
||||
"foldMs": {
|
||||
"median": 118.40000000596046,
|
||||
"p95": 134.5,
|
||||
"max": 134.5
|
||||
},
|
||||
"integrity": true,
|
||||
"previewMs": [
|
||||
198.5,
|
||||
84.80000001192093,
|
||||
91.59999999403954
|
||||
],
|
||||
"previewNodes": 2560,
|
||||
"longTasks": {
|
||||
"count": 11,
|
||||
"median": 119,
|
||||
"p95": 469,
|
||||
"max": 469
|
||||
},
|
||||
"heapUsedBytes": 43218088,
|
||||
"repeat": 3
|
||||
},
|
||||
{
|
||||
"requestedHan": 120000,
|
||||
"hanCharacters": 122322,
|
||||
"sourceCharacters": 171613,
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
|
||||
"viewport": [
|
||||
1424,
|
||||
905
|
||||
],
|
||||
"openMs": 894.6000000238419,
|
||||
"domNodes": 9139,
|
||||
"selectionMs": {
|
||||
"median": 7.9000000059604645,
|
||||
"p95": 11.599999994039536,
|
||||
"max": 24.700000017881393
|
||||
},
|
||||
"insertMs": {
|
||||
"median": 18.19999998807907,
|
||||
"p95": 22.19999998807907,
|
||||
"max": 30.799999982118607
|
||||
},
|
||||
"foldMs": {
|
||||
"median": 247.59999999403954,
|
||||
"p95": 256.59999999403954,
|
||||
"max": 256.59999999403954
|
||||
},
|
||||
"integrity": true,
|
||||
"previewMs": [
|
||||
294.2999999821186,
|
||||
134.09999999403954,
|
||||
129.90000000596046
|
||||
],
|
||||
"previewNodes": 5117,
|
||||
"longTasks": {
|
||||
"count": 14,
|
||||
"median": 207,
|
||||
"p95": 821,
|
||||
"max": 821
|
||||
},
|
||||
"heapUsedBytes": 60930415,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"requestedHan": 120000,
|
||||
"hanCharacters": 122322,
|
||||
"sourceCharacters": 171613,
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
|
||||
"viewport": [
|
||||
1424,
|
||||
905
|
||||
],
|
||||
"openMs": 875.3999999761581,
|
||||
"domNodes": 9139,
|
||||
"selectionMs": {
|
||||
"median": 9.199999988079071,
|
||||
"p95": 15.199999988079071,
|
||||
"max": 15.900000005960464
|
||||
},
|
||||
"insertMs": {
|
||||
"median": 20.599999994039536,
|
||||
"p95": 23.19999998807907,
|
||||
"max": 26.5
|
||||
},
|
||||
"foldMs": {
|
||||
"median": 244.09999999403954,
|
||||
"p95": 269,
|
||||
"max": 269
|
||||
},
|
||||
"integrity": true,
|
||||
"previewMs": [
|
||||
272.2999999821186,
|
||||
146.30000001192093,
|
||||
157.40000000596046
|
||||
],
|
||||
"previewNodes": 5117,
|
||||
"longTasks": {
|
||||
"count": 14,
|
||||
"median": 192,
|
||||
"p95": 785,
|
||||
"max": 785
|
||||
},
|
||||
"heapUsedBytes": 31982024,
|
||||
"repeat": 2
|
||||
},
|
||||
{
|
||||
"requestedHan": 120000,
|
||||
"hanCharacters": 122322,
|
||||
"sourceCharacters": 171613,
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36",
|
||||
"viewport": [
|
||||
1424,
|
||||
905
|
||||
],
|
||||
"openMs": 842.3000000119209,
|
||||
"domNodes": 9139,
|
||||
"selectionMs": {
|
||||
"median": 8,
|
||||
"p95": 11.900000005960464,
|
||||
"max": 15.199999988079071
|
||||
},
|
||||
"insertMs": {
|
||||
"median": 18.900000005960464,
|
||||
"p95": 20.099999994039536,
|
||||
"max": 21.700000017881393
|
||||
},
|
||||
"foldMs": {
|
||||
"median": 212.5,
|
||||
"p95": 240.5,
|
||||
"max": 240.5
|
||||
},
|
||||
"integrity": true,
|
||||
"previewMs": [
|
||||
253.7000000178814,
|
||||
129.19999998807907,
|
||||
172
|
||||
],
|
||||
"previewNodes": 5117,
|
||||
"longTasks": {
|
||||
"count": 14,
|
||||
"median": 191,
|
||||
"p95": 762,
|
||||
"max": 762
|
||||
},
|
||||
"heapUsedBytes": 49460811,
|
||||
"repeat": 3
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,122 @@
|
||||
[
|
||||
{
|
||||
"requestedHan": 25000,
|
||||
"theme": "paper-moments",
|
||||
"frameGapsMs": {
|
||||
"median": 10,
|
||||
"p95": 20.100000000000364,
|
||||
"max": 110.09999999999991
|
||||
},
|
||||
"frames": 380,
|
||||
"framesOver25ms": 14,
|
||||
"longTasks": [
|
||||
97
|
||||
],
|
||||
"scrollTop": 6111,
|
||||
"scrollHeight": 33908,
|
||||
"foldedScrollTop": 0,
|
||||
"caretAfterFold": 1,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"requestedHan": 25000,
|
||||
"theme": "paper-moments",
|
||||
"frameGapsMs": {
|
||||
"median": 10,
|
||||
"p95": 20.100000000000136,
|
||||
"max": 50
|
||||
},
|
||||
"frames": 374,
|
||||
"framesOver25ms": 15,
|
||||
"longTasks": [
|
||||
57
|
||||
],
|
||||
"scrollTop": 6111,
|
||||
"scrollHeight": 33908,
|
||||
"foldedScrollTop": 0,
|
||||
"caretAfterFold": 1,
|
||||
"repeat": 2
|
||||
},
|
||||
{
|
||||
"requestedHan": 60000,
|
||||
"theme": "paper-moments",
|
||||
"frameGapsMs": {
|
||||
"median": 30.100000000000364,
|
||||
"p95": 50,
|
||||
"max": 100
|
||||
},
|
||||
"frames": 267,
|
||||
"framesOver25ms": 210,
|
||||
"longTasks": [
|
||||
81,
|
||||
84
|
||||
],
|
||||
"scrollTop": 52492,
|
||||
"scrollHeight": 80287,
|
||||
"foldedScrollTop": 0,
|
||||
"caretAfterFold": 1,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"requestedHan": 60000,
|
||||
"theme": "paper-moments",
|
||||
"frameGapsMs": {
|
||||
"median": 30,
|
||||
"p95": 40.100000000000364,
|
||||
"max": 100
|
||||
},
|
||||
"frames": 279,
|
||||
"framesOver25ms": 213,
|
||||
"longTasks": [
|
||||
77,
|
||||
59
|
||||
],
|
||||
"scrollTop": 52493,
|
||||
"scrollHeight": 80287,
|
||||
"foldedScrollTop": 0,
|
||||
"caretAfterFold": 1,
|
||||
"repeat": 2
|
||||
},
|
||||
{
|
||||
"requestedHan": 120000,
|
||||
"theme": "paper-moments",
|
||||
"frameGapsMs": {
|
||||
"median": 70,
|
||||
"p95": 100,
|
||||
"max": 130
|
||||
},
|
||||
"frames": 264,
|
||||
"framesOver25ms": 229,
|
||||
"longTasks": [
|
||||
84,
|
||||
52,
|
||||
89,
|
||||
58
|
||||
],
|
||||
"scrollTop": 53157,
|
||||
"scrollHeight": 159554,
|
||||
"foldedScrollTop": 0,
|
||||
"caretAfterFold": 1,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"requestedHan": 120000,
|
||||
"theme": "paper-moments",
|
||||
"frameGapsMs": {
|
||||
"median": 70.09999999999854,
|
||||
"p95": 100,
|
||||
"max": 130.10000000000036
|
||||
},
|
||||
"frames": 261,
|
||||
"framesOver25ms": 230,
|
||||
"longTasks": [
|
||||
83,
|
||||
53
|
||||
],
|
||||
"scrollTop": 53195,
|
||||
"scrollHeight": 159554,
|
||||
"foldedScrollTop": 0,
|
||||
"caretAfterFold": 1,
|
||||
"repeat": 2
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,119 @@
|
||||
[
|
||||
{
|
||||
"requestedHan": 25000,
|
||||
"theme": "paper-moments",
|
||||
"frameGapsMs": {
|
||||
"median": 10,
|
||||
"p95": 30.09999999999991,
|
||||
"max": 120.10000000000002
|
||||
},
|
||||
"frames": 356,
|
||||
"framesOver25ms": 45,
|
||||
"longTasks": [
|
||||
116
|
||||
],
|
||||
"scrollTop": 6075,
|
||||
"scrollHeight": 33871,
|
||||
"foldedScrollTop": 0,
|
||||
"caretAfterFold": 1,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"requestedHan": 25000,
|
||||
"theme": "paper-moments",
|
||||
"frameGapsMs": {
|
||||
"median": 10,
|
||||
"p95": 30,
|
||||
"max": 80.10000000000002
|
||||
},
|
||||
"frames": 369,
|
||||
"framesOver25ms": 29,
|
||||
"longTasks": [
|
||||
93
|
||||
],
|
||||
"scrollTop": 6111,
|
||||
"scrollHeight": 33908,
|
||||
"foldedScrollTop": 0,
|
||||
"caretAfterFold": 1,
|
||||
"repeat": 2
|
||||
},
|
||||
{
|
||||
"requestedHan": 60000,
|
||||
"theme": "paper-moments",
|
||||
"frameGapsMs": {
|
||||
"median": 40,
|
||||
"p95": 59.900000000001455,
|
||||
"max": 100.09999999999991
|
||||
},
|
||||
"frames": 264,
|
||||
"framesOver25ms": 221,
|
||||
"longTasks": [
|
||||
92
|
||||
],
|
||||
"scrollTop": 52345,
|
||||
"scrollHeight": 80177,
|
||||
"foldedScrollTop": 0,
|
||||
"caretAfterFold": 1,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"requestedHan": 60000,
|
||||
"theme": "paper-moments",
|
||||
"frameGapsMs": {
|
||||
"median": 40,
|
||||
"p95": 50.100000000000364,
|
||||
"max": 130.10000000000036
|
||||
},
|
||||
"frames": 265,
|
||||
"framesOver25ms": 229,
|
||||
"longTasks": [
|
||||
84,
|
||||
109
|
||||
],
|
||||
"scrollTop": 52344,
|
||||
"scrollHeight": 80177,
|
||||
"foldedScrollTop": 0,
|
||||
"caretAfterFold": 1,
|
||||
"repeat": 2
|
||||
},
|
||||
{
|
||||
"requestedHan": 120000,
|
||||
"theme": "paper-moments",
|
||||
"frameGapsMs": {
|
||||
"median": 70,
|
||||
"p95": 90.10000000000036,
|
||||
"max": 170.19999999999982
|
||||
},
|
||||
"frames": 260,
|
||||
"framesOver25ms": 235,
|
||||
"longTasks": [
|
||||
89,
|
||||
77
|
||||
],
|
||||
"scrollTop": 53200,
|
||||
"scrollHeight": 159554,
|
||||
"foldedScrollTop": 0,
|
||||
"caretAfterFold": 1,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"requestedHan": 120000,
|
||||
"theme": "paper-moments",
|
||||
"frameGapsMs": {
|
||||
"median": 70,
|
||||
"p95": 90.10000000000036,
|
||||
"max": 140.10000000000036
|
||||
},
|
||||
"frames": 259,
|
||||
"framesOver25ms": 228,
|
||||
"longTasks": [
|
||||
92,
|
||||
57
|
||||
],
|
||||
"scrollTop": 53196,
|
||||
"scrollHeight": 159554,
|
||||
"foldedScrollTop": 0,
|
||||
"caretAfterFold": 1,
|
||||
"repeat": 2
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"light": {
|
||||
"requestedHan": 120000,
|
||||
"theme": "light",
|
||||
"variant": "default",
|
||||
"frameGapsMs": {
|
||||
"median": 10,
|
||||
"p95": 30,
|
||||
"max": 90.09999999999991
|
||||
},
|
||||
"frames": 403,
|
||||
"framesOver25ms": 29,
|
||||
"longTasks": [
|
||||
89
|
||||
],
|
||||
"scrollTop": 54000,
|
||||
"scrollHeight": 168515,
|
||||
"foldedScrollTop": 0,
|
||||
"caretAfterFold": 1,
|
||||
"repeat": 1
|
||||
},
|
||||
"dark": {
|
||||
"requestedHan": 120000,
|
||||
"theme": "dark",
|
||||
"variant": "default",
|
||||
"frameGapsMs": {
|
||||
"median": 10,
|
||||
"p95": 30,
|
||||
"max": 90
|
||||
},
|
||||
"frames": 408,
|
||||
"framesOver25ms": 27,
|
||||
"longTasks": [
|
||||
88
|
||||
],
|
||||
"scrollTop": 54000,
|
||||
"scrollHeight": 168515,
|
||||
"foldedScrollTop": 0,
|
||||
"caretAfterFold": 1,
|
||||
"repeat": 1
|
||||
},
|
||||
"fixed": [
|
||||
{
|
||||
"requestedHan": 120000,
|
||||
"theme": "paper-moments",
|
||||
"variant": "default",
|
||||
"frameGapsMs": {
|
||||
"median": 10,
|
||||
"p95": 30.100000000000136,
|
||||
"max": 100
|
||||
},
|
||||
"frames": 422,
|
||||
"framesOver25ms": 31,
|
||||
"longTasks": [
|
||||
99
|
||||
],
|
||||
"scrollTop": 54037,
|
||||
"scrollHeight": 160247,
|
||||
"foldedScrollTop": 0,
|
||||
"caretAfterFold": 1,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"requestedHan": 120000,
|
||||
"theme": "paper-moments",
|
||||
"variant": "default",
|
||||
"frameGapsMs": {
|
||||
"median": 10,
|
||||
"p95": 30,
|
||||
"max": 90
|
||||
},
|
||||
"frames": 427,
|
||||
"framesOver25ms": 31,
|
||||
"longTasks": [
|
||||
84,
|
||||
58
|
||||
],
|
||||
"scrollTop": 54000,
|
||||
"scrollHeight": 160211,
|
||||
"foldedScrollTop": 0,
|
||||
"caretAfterFold": 1,
|
||||
"repeat": 2
|
||||
},
|
||||
{
|
||||
"requestedHan": 120000,
|
||||
"theme": "paper-moments",
|
||||
"variant": "default",
|
||||
"frameGapsMs": {
|
||||
"median": 10,
|
||||
"p95": 30.09999999999991,
|
||||
"max": 90.09999999999991
|
||||
},
|
||||
"frames": 422,
|
||||
"framesOver25ms": 29,
|
||||
"longTasks": [
|
||||
85
|
||||
],
|
||||
"scrollTop": 54037,
|
||||
"scrollHeight": 160247,
|
||||
"foldedScrollTop": 0,
|
||||
"caretAfterFold": 1,
|
||||
"repeat": 3
|
||||
}
|
||||
],
|
||||
"outlineDisabled": [
|
||||
{
|
||||
"requestedHan": 120000,
|
||||
"theme": "paper-moments",
|
||||
"variant": "no-outline",
|
||||
"frameGapsMs": {
|
||||
"median": 10,
|
||||
"p95": 30.099999999999454,
|
||||
"max": 100
|
||||
},
|
||||
"frames": 418,
|
||||
"framesOver25ms": 31,
|
||||
"longTasks": [
|
||||
101
|
||||
],
|
||||
"scrollTop": 54037,
|
||||
"scrollHeight": 160247,
|
||||
"foldedScrollTop": 0,
|
||||
"caretAfterFold": 1,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"requestedHan": 120000,
|
||||
"theme": "paper-moments",
|
||||
"variant": "no-outline",
|
||||
"frameGapsMs": {
|
||||
"median": 10,
|
||||
"p95": 30,
|
||||
"max": 99.89999999999964
|
||||
},
|
||||
"frames": 413,
|
||||
"framesOver25ms": 32,
|
||||
"longTasks": [
|
||||
87,
|
||||
50,
|
||||
52,
|
||||
52
|
||||
],
|
||||
"scrollTop": 53965,
|
||||
"scrollHeight": 160174,
|
||||
"foldedScrollTop": 0,
|
||||
"caretAfterFold": 1,
|
||||
"repeat": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
[
|
||||
{
|
||||
"requestedHan": 120000,
|
||||
"theme": "paper-moments",
|
||||
"frameGapsMs": {
|
||||
"median": 70.29999999999927,
|
||||
"p95": 100.20000000000073,
|
||||
"max": 210.20000000000073
|
||||
},
|
||||
"frames": 308,
|
||||
"framesOver25ms": 286,
|
||||
"longTasks": [
|
||||
86,
|
||||
54,
|
||||
56,
|
||||
62,
|
||||
50,
|
||||
58,
|
||||
53,
|
||||
52,
|
||||
58,
|
||||
100,
|
||||
107,
|
||||
96,
|
||||
60,
|
||||
99,
|
||||
89,
|
||||
136,
|
||||
101,
|
||||
98,
|
||||
101,
|
||||
90,
|
||||
66,
|
||||
120,
|
||||
74,
|
||||
99,
|
||||
58
|
||||
],
|
||||
"scrollTop": 53127,
|
||||
"scrollHeight": 159554,
|
||||
"foldedScrollTop": 0,
|
||||
"caretAfterFold": 1,
|
||||
"repeat": 1
|
||||
},
|
||||
{
|
||||
"requestedHan": 120000,
|
||||
"theme": "paper-moments",
|
||||
"frameGapsMs": {
|
||||
"median": 70,
|
||||
"p95": 90,
|
||||
"max": 150
|
||||
},
|
||||
"frames": 261,
|
||||
"framesOver25ms": 235,
|
||||
"longTasks": [
|
||||
84,
|
||||
67,
|
||||
52
|
||||
],
|
||||
"scrollTop": 53233,
|
||||
"scrollHeight": 159554,
|
||||
"foldedScrollTop": 0,
|
||||
"caretAfterFold": 1,
|
||||
"repeat": 2
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"blocks": 4000,
|
||||
"dimensions": 384,
|
||||
"top_k": 20,
|
||||
"migration_ms": 1329.5076999929734,
|
||||
"same_top_k": true,
|
||||
"measurements": {
|
||||
"python_json_scan": {
|
||||
"median_ms": 1289.638800022658,
|
||||
"samples_ms": [
|
||||
1289.638800022658,
|
||||
1238.6701999930665,
|
||||
1115.6752999813762,
|
||||
1436.7559000093024,
|
||||
1833.9683999947738
|
||||
]
|
||||
},
|
||||
"sqlite_vec": {
|
||||
"median_ms": 19.673499977216125,
|
||||
"samples_ms": [
|
||||
29.709700000239536,
|
||||
17.876600002637133,
|
||||
35.13619999284856,
|
||||
19.673499977216125,
|
||||
18.28439999371767
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"transport": "real loopback HTTP, separate Uvicorn process",
|
||||
"tasks": 1000,
|
||||
"concurrency": 20,
|
||||
"elapsed_ms": 24509.81,
|
||||
"latencies": {
|
||||
"create": {
|
||||
"count": 1000,
|
||||
"p95_ms": 157.68,
|
||||
"max_ms": 186.7
|
||||
},
|
||||
"update": {
|
||||
"count": 1000,
|
||||
"p95_ms": 208.68,
|
||||
"max_ms": 259.91
|
||||
},
|
||||
"list": {
|
||||
"count": 11,
|
||||
"p95_ms": 23.39,
|
||||
"max_ms": 23.39
|
||||
},
|
||||
"delete": {
|
||||
"count": 1000,
|
||||
"p95_ms": 205.45,
|
||||
"max_ms": 279.12
|
||||
},
|
||||
"health": {
|
||||
"count": 375,
|
||||
"p95_ms": 38.64,
|
||||
"max_ms": 164.76
|
||||
}
|
||||
},
|
||||
"health_errors": [],
|
||||
"pagination_complete": true,
|
||||
"final_total": 0
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"transport": "real loopback HTTP, separate Uvicorn process",
|
||||
"tasks": 1000,
|
||||
"concurrency": 20,
|
||||
"elapsed_ms": 18312.92,
|
||||
"latencies": {
|
||||
"create": {
|
||||
"count": 1000,
|
||||
"p95_ms": 116.45,
|
||||
"max_ms": 134.91
|
||||
},
|
||||
"update": {
|
||||
"count": 1000,
|
||||
"p95_ms": 159.29,
|
||||
"max_ms": 193.37
|
||||
},
|
||||
"list": {
|
||||
"count": 11,
|
||||
"p95_ms": 20.96,
|
||||
"max_ms": 20.96
|
||||
},
|
||||
"delete": {
|
||||
"count": 1000,
|
||||
"p95_ms": 156.43,
|
||||
"max_ms": 190.1
|
||||
},
|
||||
"health": {
|
||||
"count": 117,
|
||||
"p95_ms": 157.0,
|
||||
"max_ms": 195.07
|
||||
}
|
||||
},
|
||||
"health_errors": [],
|
||||
"pagination_complete": true,
|
||||
"final_total": 0
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
[
|
||||
{
|
||||
"kind": "tasks",
|
||||
"theme": "paper-moments",
|
||||
"size": 1000,
|
||||
"initialRenderMs": 173,
|
||||
"requests": [
|
||||
"/api/tasks?limit=100&offset=0",
|
||||
"/api/tasks?limit=100&offset=100",
|
||||
"/api/tasks?limit=100&offset=200",
|
||||
"/api/tasks?limit=100&offset=300",
|
||||
"/api/tasks?limit=100&offset=400",
|
||||
"/api/tasks?limit=100&offset=500",
|
||||
"/api/tasks?limit=100&offset=600",
|
||||
"/api/tasks?limit=100&offset=700",
|
||||
"/api/tasks?limit=100&offset=800",
|
||||
"/api/tasks?limit=100&offset=900"
|
||||
],
|
||||
"initialTaskCount": 1000,
|
||||
"fullListRenderMs": 85.39999997615814,
|
||||
"fullListIsInjected": true,
|
||||
"renderedTasks": 100,
|
||||
"domNodes": 1111,
|
||||
"scrollContainers": [
|
||||
{
|
||||
"node": "HTML",
|
||||
"height": 905,
|
||||
"scrollHeight": 905,
|
||||
"overflow": "hidden",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "BODY",
|
||||
"height": 905,
|
||||
"scrollHeight": 905,
|
||||
"overflow": "hidden",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "viewport",
|
||||
"height": 905,
|
||||
"scrollHeight": 905,
|
||||
"overflow": "auto",
|
||||
"position": "static"
|
||||
},
|
||||
{
|
||||
"node": "app",
|
||||
"height": 905,
|
||||
"scrollHeight": 905,
|
||||
"overflow": "hidden",
|
||||
"position": "static"
|
||||
}
|
||||
],
|
||||
"frameGapsMs": {
|
||||
"median": 10,
|
||||
"p95": 30,
|
||||
"max": 50.10000000000002
|
||||
},
|
||||
"frames": 384,
|
||||
"longTasks": [],
|
||||
"scrollTop": 0,
|
||||
"scrollHeight": 11798,
|
||||
"maxScrollTop": 10941,
|
||||
"filteredCount": 100,
|
||||
"totalFilteredCount": 334,
|
||||
"expectedFilteredCount": 100,
|
||||
"filterMs": 18.69999998807907,
|
||||
"repeat": 1
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,101 @@
|
||||
# 后台运行日志与压力问题修复
|
||||
|
||||
## 1. 范围与入口
|
||||
|
||||
本次针对 Agent 与任务压测发现的历史记录截断、同步数据库写入阻塞事件循环、长 Trace 树形筛选过慢进行修复,并增加统一运行日志。
|
||||
|
||||
主导航的「日志」打开 `/logs`。知识库选择页也有入口;无需成功打开 Vault 即可查看。需要 AI Core 正常提供 HTTP 服务。后台未启动时,页面显示连接错误,不伪造历史结果。
|
||||
|
||||
页面提供级别、模块、事件名/错误码/关联 ID 筛选及详情展开。日志列表高度受限,按时间从旧到新显示,默认每 5 秒获取新日志并跟随到底部。向上滚动时暂停跟随,抵达顶部自动加载更早日志并保持阅读位置;可一键回到最新。隐藏页面和卸载后停止轮询。界面最多保留当前浏览窗口的 500 条,继续读取旧日志时移出另一端的记录,后台保留窗口不受影响。所有组件使用已有主题变量、卡片、按钮、下拉框及 `ui-disclosure` 展开样式。
|
||||
|
||||
## 2. 日志覆盖
|
||||
|
||||
| 模块 | 记录内容 | 定位信息 |
|
||||
| --- | --- | --- |
|
||||
| vectors | 后台索引状态、向量调用失败、是否使用本地索引回退 | job_id、错误码、异常类型、调用位置、批次数量 |
|
||||
| models | 本地模型/远程模型运行诊断、CUDA/CPU、失败与回退 | 模型、设备、状态、错误码、耗时 |
|
||||
| agent | 创建运行、模型轮次、工具调用与结果、权限决策、完成/失败/取消 | run_id、Provider、模型、步骤、事件序号、工具名 |
|
||||
| tasks | 创建、修改、删除及不存在的删除目标 | task_id、关联 note_id、修改字段名、状态 |
|
||||
| providers / chat | 模型请求完成或未完整结束、流式模型错误、对话失败 | Provider、模型、request_id、run_id、错误码 |
|
||||
| http / api | HTTP 写操作、失败请求、超过一秒的请求及业务错误 | HTTP 方法、路由模板、状态码、耗时、request_id、资源 ID |
|
||||
| system / Python logger | 服务启动与停止,以及应用、服务器和依赖库的 warning/error | 模块、代码位置、异常类型 |
|
||||
|
||||
`X-Request-ID` 响应头可与日志中的 request_id 对照。请求上下文通过 ContextVar 传播到后台任务和线程;Agent 内部工具触发的日志还带 run_id。查询日志自身以及正常短轮询不生成 HTTP 操作日志,避免轮询放大日志量。
|
||||
|
||||
这里只记录操作诊断,不替代完整 Agent Trace、Token 用量台账和模型诊断表。旧代码 logger 的自由文本可能包含笔记或厂商返回,因此桥接时只记录级别、模块、代码位置和异常类型;需要业务错误码的地方使用结构化接口。
|
||||
|
||||
## 3. 存储、容量与隐私
|
||||
|
||||
- 位置:`APP_DATA_DIR/logs/operations.sqlite3`,与业务数据库独立,应用重启后可继续查看。
|
||||
- 保留最近 20,000 条;每批写入时淘汰更早的记录。不是按天永久归档。
|
||||
- 后台线程批量写入,队列最多 4,096 条,一批最多 128 条。队列满时丢弃新诊断记录并计数,绝不阻塞保存或模型推理。
|
||||
- 日志页面显示待写入数量、队列溢出和写入失败数量。计数属于当前进程;重启后重置。存储不可读时显示请求失败。
|
||||
- 正常退出先停止 Agent 和其他后台工作,再排空日志队列;强制终止进程可能丢失尚未写入的队列内容。
|
||||
- 不记录请求/响应正文、系统提示词、工具参数/输出、笔记标题或正文、凭据、原始异常消息、查询字符串。元数据按白名单筛选并限长,Bearer 和 `sk-` 凭据形式额外脱敏。
|
||||
- API 仅返回保留窗口内的诊断数据,不提供任意文件读取和日志删除接口。
|
||||
|
||||
## 4. 开发接口
|
||||
|
||||
```python
|
||||
from app.operation_logs import log_event
|
||||
|
||||
log_event('vectors', 'embedding.failed', level='ERROR', error=exc,
|
||||
model=model_id, job_id=job_id, error_code='EMBEDDING_UNAVAILABLE',
|
||||
fallback='local_index')
|
||||
```
|
||||
|
||||
第一个参数是模块名。`source` 是可选的元数据字段,例如 `local` 或 `api`,不要与模块名混淆。事件名应使用开发时定义的短常量。不要把正文、URL、异常消息或用户输入拼入事件名。
|
||||
|
||||
`GET /api/logs` 参数:
|
||||
|
||||
| 参数 | 规则 |
|
||||
| --- | --- |
|
||||
| limit | 默认 50,范围 1–200 |
|
||||
| before | 上页 next_cursor;正整数,返回更早的 ID |
|
||||
| level | 空或 INFO / WARNING / ERROR / CRITICAL |
|
||||
| source | 模块名精确匹配,最多 100 字符 |
|
||||
| q | 在事件名及脱敏元数据中做字面子串搜索,最多 200 字符 |
|
||||
|
||||
返回 `items`、`next_cursor`、`sources`、`pending`、`dropped`、`write_failures`、`retention`。按递增 ID 的倒序分页,插入新日志不改变已经翻到的旧页边界。没有匹配记录时 items 为空、next_cursor 为 null。
|
||||
|
||||
## 5. 压力问题的修复方式
|
||||
|
||||
Agent Trace 写入由独立异步队列调度到线程,最多合并 64 个写入为一个 SQLite 事务。提交成功后才更新对外可见快照和广播 SSE。创建记录在第一次 await 前占用运行名额,避免并发创建突破 200 个活动运行上限。取消等待正在提交的快照结束,再写终态;服务关闭会等待 Agent 和 Trace 队列。数据库失败不会被当成成功提交。
|
||||
|
||||
任务 HTTP 写操作在协程中排队,然后在线程执行;读操作也在线程执行,减少文件锁争用和主事件循环阻塞。SQLite 仍是单写者,不意味着任务写入支持无限并发。
|
||||
|
||||
前端读取任务和 Agent 列表的后续 API 页,不再只显示第一页的 50 条。任务筛选和数量基于已获取的完整列表,界面每页显示 100 个任务;运行侧栏每页 50 条。列表刷新失败保留上次结果。列表读取使用现有 offset 协议,不是跨多页的事务快照;其他客户端同时修改数据时可以刷新重新获取。
|
||||
|
||||
Trace 搜索先生成事件序号、模型调用 ID、工具调用 ID 的集合,再一次遍历调用树。匹配子节点保留父节点。搜索作用于完整已加载的历史,每页只渲染 200 个事件或树节点。工具统计按工具名和状态汇总,避免在底部再次生成数千个调用卡片。
|
||||
|
||||
## 6. 验证方法
|
||||
|
||||
后端回归覆盖日志持久化、重启、保留窗口、筛选与游标、正文排除、HTTP 关联 ID,以及后台 Trace 写入同时取消时仍释放等待方。业务回归覆盖 Agent/任务、模型路由、本地模型、用量统计和对话。
|
||||
|
||||
```powershell
|
||||
cd backend
|
||||
.venv/Scripts/python.exe -m pytest tests/test_agent_core.py tests/test_api.py tests/test_operation_logs.py tests/test_model_routing.py tests/test_local_models.py tests/test_usage_overrides.py tests/test_chat_history.py tests/test_chat_context.py -q
|
||||
```
|
||||
|
||||
运行测试前应将 APP_DATA_DIR、APP_DB_PATH 和 APP_VAULT_PATH 指向临时目录,避免模块导入时初始化真实扩展。pytest 的 fixture 会继续为每个测试隔离数据。
|
||||
|
||||
前端执行 `npm --prefix frontend test` 和 `npm --prefix frontend run build`。新增用例验证后续任务/Agent 页完整加载、Trace 全历史搜索与分页、日志筛选/游标、自动跟随与上滚暂停、顶部历史加载、存储异常提示及卸载后停止刷新。
|
||||
|
||||
2026-09-06 实测:后端全量 627 项通过;随后增加的 3 项写入失败/取消回归也通过,最后相关接口回归 21 项通过。前端全量 413 项通过,生产构建通过。构建仍提示已有部分 Markdown/Mermaid 依赖块大于 500 kB;后端测试保留已有 Starlette TestClient 弃用提示。纸间时光日志页已用隔离合成数据检查截图,运行中的本机 `/api/logs` 也已确认可返回结果。
|
||||
|
||||
离线并发压测与真实本机 HTTP 压测复现方式、前后数据见 [Agent 与任务压测报告](Agent与任务压测报告.md)。压测只使用隔离数据和 Mock Provider,不消耗用户厂商额度。
|
||||
|
||||
手工验收建议:
|
||||
|
||||
1. 创建、修改和删除一个测试任务,按返回的 task_id 或 X-Request-ID 筛选日志。
|
||||
2. 运行含工具调用的 Agent,批准或取消权限请求;按 run_id 检查创建、权限、工具结果和终态。
|
||||
3. 在隔离配置中请求未安装的本地模型,查看 models/vectors 的错误码与回退信息,不应出现输入文本。
|
||||
4. 重启 AI Core,确认已提交日志仍可查看;退出 Vault 后从入口页打开日志。
|
||||
5. 切换默认浅色、深色、纸间时光主题,检查筛选栏、错误徽标和详情展开;窄窗口下应换行且详情不撑出页面。
|
||||
# 2026-09-06:大批量本地向量传输修复
|
||||
|
||||
全量重建包含 2111 个文本块时,本地模型可能成功完成推理,但整批向量被写成一行 JSON。接收端单行限制为 16 MiB:asyncio 管道拒绝超长行,Windows 线程管道则读到不完整 JSON,最终被包装成 `LOCAL_MODEL_INVALID_RESPONSE`。这类错误不表示 CUDA 显存不足,也不应通过切换 CPU 处理。
|
||||
|
||||
Embedding 响应改为每 128 条向量一个 JSON 帧,结束帧携带总数。接收端检查偏移连续性、请求数量和结束标记;缺块时明确失败,不把部分向量写入索引。模型仍只加载一次,推理批大小不变。语音等其他响应保持原协议。
|
||||
|
||||
验证方法:在隔离数据目录运行 `tests/test_local_models.py`,分别使用 asyncio 与 Windows 线程管道传输 2111 × 384 的结果,确认旧格式超过 16 MiB,而分帧可完整接收。该文件 7 项测试通过。另使用本机 Bekko 权重、CUDA 对 2111 条合成文本做真实推理:返回 2111 条 384 维向量,设备 `cuda:0`,耗时约 12.55 秒;旧格式 JSON 为 17,886,180 字节。该验收不修改用户笔记、索引或运行配置;用户原始长文档的全库重建仍需在服务加载修复后重试。
|
||||
@@ -0,0 +1,30 @@
|
||||
# 模型隔离向量索引与增量登记
|
||||
|
||||
## 目标与实现
|
||||
|
||||
向量仍保存在本地 `app.db`,重启复用。`routed_block_vectors` 主键升级为 `(space_id, dimensions, block_id)`,同维度不同模型、同模型不同维度均独立存储。每个空间建立以模型标识和维度的 SHA-256 命名的 `vec0` 虚拟表,动态表名不包含厂商输入。
|
||||
|
||||
首次访问旧空间时,在事务中将已有 JSON 向量转换为归一化 float32 索引,不重新调用 Embedding 模型。随后检索由 sqlite-vec 执行精确 KNN,避免每次在 Python 中解码所有向量、计算点积。该实现是精确搜索,不是 ANN;依据归一化向量的欧氏距离换算余弦分数。参见 [sqlite-vec KNN 文档](https://alexgarcia.xyz/sqlite-vec/features/knn.html)。
|
||||
|
||||
覆盖检查和 KNN 使用同一事务。向量更新、删除及笔记级联删除通过触发器清理派生索引,新向量与原始向量在同一保存点写入。索引缺失或不完整仍明确失败/按已有策略回退,不混入其他模型结果。本地专用笔记按 `local_only` 元数据过滤,不同空间的结果继续使用 RRF 融合。
|
||||
|
||||
数据库搜索及旧空间转换在线程中运行,不阻塞异步服务事件循环。首次转换仍会占用 SQLite 写事务;超大库应进一步评估迁移耗时。新增空间保留原空间索引,暂不自动清理历史空间。
|
||||
|
||||
首次转换使用进程内迁移锁串行执行,在打开检索读快照之前以 `BEGIN IMMEDIATE` 获取写事务;等待迁移时不持有读事务,避免多个搜索从读锁升级写锁发生冲突。迁移完成后二次检查即可复用。普通检索和隐私分区检索共用该流程,已有索引的检索仅执行读取。
|
||||
|
||||
首次转换同时通过 Vault 的异步写入锁与笔记保存、后台索引协调,避免保存事务读取旧 Block 后与迁移争抢写锁。等待是异步的;取消检索时,仍等待迁移线程结束后才释放锁。已就绪的空间直接走只读检查,不进入写入队列。写入锁按事件循环生命周期创建,避免重启后复用已关闭循环的锁。
|
||||
|
||||
## 外部新增文件
|
||||
|
||||
文件树检测到新增 Markdown 后先登记元数据与全文索引,然后为每条新笔记持久化 `note_vectors_pending:<note_id>`,逐笔记在后台计算。不再因新增文件设置全库重建标记;已存在的全库待处理标记仍保留,以免跳过之前未完成的任务。
|
||||
|
||||
新增文件在推理期间再次变化时校验快照并重试,只同步该笔记。写入失败保留待处理标记。手动“重建全部”仍执行全库重建,正常重新打开已完成的工作区不再入队。
|
||||
|
||||
## 验证
|
||||
|
||||
- 隔离数据库运行后端全套测试:637 项通过;最终覆盖检查优化另运行检索及后台工作区回归,96 项通过。
|
||||
- 新增用例验证同模型不同维度并存、重新连接复用且查询不解码向量 JSON、旧表迁移只计算查询向量,以及外部新增文件不触发全量重建。
|
||||
- 并发迁移回归用同步事件暂停第一个请求的转换,同时发起第二个请求;验证普通/隐私分区路径均只迁移一次、两个请求结果一致,并开启 SQLite `query_only` 验证后续检索不会写入。相关检索与后台工作区测试 91 项通过。
|
||||
- 并发保存回归暂停迁移后发起实际 `update_note(..., defer_vectors=True)`,验证保存等待、事件循环仍可运行、放行迁移后正文成功落盘并保留新内容的向量待处理标记;另覆盖搜索被取消时不得提前放行保存。
|
||||
- `backend/scripts/vector-index-benchmark.py` 使用临时数据库、固定随机种子,比较 4000 条 384 维向量的 top-20,旧新路径结果顺序一致。5 次采样中位数:Python 扫描约 1289.64 ms,sqlite-vec 约 19.67 ms;首次转换约 1329.51 ms。该结果只测向量检索,不包含查询 Embedding、重排与 HTTP 耗时,不代表端到端加速比例。
|
||||
- 原始结果:[2026-09-06-sqlite-vec-search.json](performance/2026-09-06-sqlite-vec-search.json)。基准可用后端虚拟环境 Python 直接执行上述脚本,不读写用户 Vault。
|
||||
@@ -0,0 +1,110 @@
|
||||
# 长文渲染优化与压测报告
|
||||
|
||||
日期:2026-09-06。基线为 `750e172`,结果为本次未提交的渲染优化;基线和优化版使用相同样本、浏览器参数及操作序列。
|
||||
|
||||
## 1. 测试环境与范围
|
||||
|
||||
Windows、Intel Core i7-13620H、Headless Chrome 152,视口 1424 × 905,Vite 开发服务。每种规模重开测试页面三次,独立测试浏览器,不读写真实笔记。耗时基于 performance.now;打开指标不包含模块网络加载。样本含标题、表格、代码块、提示框和行内格式。
|
||||
|
||||
实际汉字数为 25632、61172、122322;Markdown 总字符数以原始 JSON 为准。不是仅以文件字节数代替“字数”。共完成优化前后 18 轮浏览器运行,所有运行的插入内容与文末完整性检查通过。
|
||||
|
||||
## 2. 本次优化
|
||||
|
||||
- 标题装饰按不可变文档和折叠状态弱缓存;光标移动不改变折叠状态时复用结果。没有折叠范围时不遍历全文生成隐藏装饰。
|
||||
- 文档变更后用位置 Set 验证标题映射,避免对每个已折叠标题反复扫描所有标题。
|
||||
- 字号装饰按不可变文档弱缓存;提示框标记位置按文档缓存,光标移动只判断标记是否处于编辑范围。
|
||||
- 静态代码高亮增加 LRU,最多 64 项、累计 100 万个字符串字符;超过预算四分之一的单块不缓存。缓存同时包含深浅主题颜色,最终输出继续净化。缓存只在进程内,不写入存储。
|
||||
|
||||
弱缓存随文档释放,不用全局 Map 持有历史文档;高亮缓存有明确上限。
|
||||
|
||||
## 3. 对比结果
|
||||
|
||||
单位 ms。打开取三轮中位数;选区、插入、折叠取各轮 P95 的中位数;热预览取六次热运行中位数。P95 样本量较少,仅用于本机前后比较。
|
||||
|
||||
| 实际汉字数 | 打开 前→后 | 光标移动 P95 前→后 | 插入 P95 前→后 | 折叠/展开 P95 前→后 | 热预览 前→后 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 25632 | 272.1 → 218.6 | 1.9 → 0.6 | 7.0 → 6.9 | 55.0 → 45.6 | 40.0 → 26.6 |
|
||||
| 61172 | 519.7 → 447.5 | 5.7 → 0.6 | 9.7 → 9.6 | 149.1 → 145.5 | 82.9 → 55.6 |
|
||||
| 122322 | 875.4 → 869.1 | 11.9 → 1.2 | 22.2 → 27.2 | 256.6 → 263.9 | 140.2 → 95.5 |
|
||||
|
||||
主要收益是减少光标移动的重复工作,以及重复代码块/重复预览的高亮开销。2.5 万汉字样本完成全部操作,但不以此宣称所有文档都能维持 60 FPS。
|
||||
|
||||
12 万汉字的整篇折叠仍超过 250 ms,插入 P95 本轮反而增加,不能声称打字性能全面提升;全量 DOM 更新、编辑器自身处理与浏览器调度仍需进一步分项剖析。打开仍有长任务,缓存没有解决虚拟化问题。
|
||||
|
||||
## 4. 原始数据与复现
|
||||
|
||||
- [优化前 JSON](performance/2026-09-06-long-document-baseline.json)
|
||||
- [优化后 JSON](performance/2026-09-06-long-document-after.json)
|
||||
- [压测脚本与指标说明](../../frontend/tests/performance/README.md)
|
||||
|
||||
```powershell
|
||||
npm --prefix frontend run test
|
||||
npm --prefix frontend run build
|
||||
npm --prefix frontend run dev -- --port 5175 --strictPort
|
||||
# 另一个终端:
|
||||
backend/.venv/Scripts/python.exe frontend/tests/performance/run-stress.py --url http://127.0.0.1:5175/tests/performance/stress.html --runs 3 --output .local-plans/stress-results.json
|
||||
```
|
||||
|
||||
新增回归检查确认光标移动复用标题装饰、正文编辑使缓存失效、折叠与展开不残留隐藏内容。前端 402 项测试通过。
|
||||
|
||||
## 5. 边界与后续
|
||||
|
||||
本次测量是浏览器中的程序化编辑事务,不是输入法、实际键盘到屏幕显示、滚动帧率或生产包启动测试。样本代码块存在重复,LRU 收益不能直接外推到全部唯一代码块的文档。堆内存只采样,不作为内存泄漏结论。
|
||||
|
||||
后续应针对 12 万字的输入和整篇折叠采集独立 CPU trace,评估装饰的增量映射及可见区域渲染;在输入法、表格与代码块编辑验收之前,不直接给 contenteditable 应用跳过布局或整篇虚拟化。
|
||||
|
||||
## 6. 手动压测反馈后的滚动与定位修复
|
||||
|
||||
用户实测:2.56 万字正常,6 万字快速滚动轻微停顿,12 万字明显卡顿;从长文末尾全部折叠没有回到顶层。
|
||||
|
||||
本轮将“全部折叠”设为一次性把光标移到首个标题并将编辑区滚动到顶部,单个标题折叠保留原有定位行为。回归用例包含 100 个同级顶层标题,从文末触发后检查光标、scrollTop 与正文不变,避免只有一个 H1 的样本掩盖问题。
|
||||
|
||||
语言标签改为只同步变动的代码块,语言菜单只在打开时响应滚动定位,并合并同一帧的重复事件;监听祖先滚动,卸载时清理监听及待执行帧。
|
||||
|
||||
新增独立 CDP 滚轮测试,在相同的有限高度 flex 容器、纸间时光主题下,每档运行两次。下表为每次帧间隔 P95 的中位数,单位 ms,越小越好。这不是原先编辑事务基准的延续,也不是生产环境 FPS。
|
||||
|
||||
| 请求汉字数 | 修改前 | 修改后 |
|
||||
| --- | ---: | ---: |
|
||||
| 25000 | 30.05 | 20.10 |
|
||||
| 60000 | 55.00 | 45.05 |
|
||||
| 120000 | 90.10 | 100.00 |
|
||||
|
||||
两档较短样本有改善,12 万字没有改善,仍未达到流畅滚动的目标。CPU 采样显示滚入新代码块时的 CodeMirror 初始化、选区测量及 DOM 观察回调占用明显;不能仅凭此认定唯一原因。代码块布局隔离实验未带来收益,已撤回,未引入可能影响编辑、选区或布局的虚拟化。
|
||||
|
||||
- [滚轮基线数据](performance/2026-09-06-scroll-baseline.json)
|
||||
- [滚轮修改后数据](performance/2026-09-06-scroll-after.json)
|
||||
|
||||
下一步需要评估代码块按交互初始化或编辑器级虚拟化,并同时验证输入、选区、搜索定位和折叠;当前不将 12 万字滚动问题标记为完成。
|
||||
|
||||
本轮前端 405 项测试通过,涵盖折叠定位、语言标签增量同步、菜单祖先滚动定位和卸载清理。
|
||||
|
||||
## 7. CodeMirror 高亮复用实验
|
||||
|
||||
在上轮提交 `874e916` 的基础上继续尝试:同一语言及主题的 CodeMirror LanguageSupport 内缓存高亮装饰,代码块离屏销毁后重新创建时,相同正文不再重复分词及构建装饰。缓存最多 32 项、64000 个源字符,超过 16000 字符的单块不缓存,使用 LRU 淘汰;语言支持释放时缓存一并释放。编辑后的内容按新正文计算,不复用旧位置。
|
||||
|
||||
回归测试通过统计分词调用验证跨视图复用、正文编辑重新计算和超限淘汰。原有深浅主题切换与编辑测试继续验证显示内容。
|
||||
|
||||
12 万字继续测量两次,帧间隔 P95 分别约 100.2 ms、90 ms,二者中位数约 95.1 ms;上一轮约 100 ms。样本重复代码较多,结果仍在明显波动范围内,不能宣称解决滚动卡顿。此优化减少确定可重复的高亮工作,但下一步仍须处理创建 CodeMirror 和布局测量的成本。
|
||||
|
||||
- [本轮滚轮原始数据](performance/2026-09-06-scroll-token-cache.json)
|
||||
|
||||
## 8. 主题对照与纸间时光 1.8.1 修复
|
||||
|
||||
用户补充:同一份 12 万字文档在默认浅色、深色下不卡顿。因此重新进行主题对照,之前将 CodeMirror 作为主要优化方向的判断不足;CPU 采样中的布局及选区测量耗时不能单独证明编辑器是主要原因。
|
||||
|
||||
保持相同文档和滚轮脚本,默认浅色、深色各测一次,P95 均约 30 ms。纸间时光 1.8.0 仅禁用整篇 `.ProseMirror` 的虚线 outline 后,两次 P95 约 30.1、30 ms;相比此前约 90–100 ms,构成明确的样式消融证据。主题与 CodeMirror 的交互仍可能影响布局,但不需要先用编辑器虚拟化解决这个差异。
|
||||
|
||||
修复将贯穿长文的虚线轮廓改为四条小尺寸渐变平铺背景,保留纸张、缝线、胶带、段落横线和叠纸阴影。独立伪元素虚线 border 的实验反而更慢,已撤回。正式主题版本升为 1.8.1。
|
||||
|
||||
| 主题或实验 | 次数 | 帧间隔 P95(ms) |
|
||||
| --- | ---: | --- |
|
||||
| 默认浅色 | 1 | 30 |
|
||||
| 默认深色 | 1 | 30 |
|
||||
| 1.8.0 仅禁用整篇 outline | 2 | 30.1、30 |
|
||||
| 1.8.1 平铺缝线 | 3 | 30.1、30、30.1 |
|
||||
|
||||
1.8.1 三次测试的帧间隔中位数均为 10 ms,全部折叠后的 scrollTop 均为 0,光标位置均为 1。已检查视口截图,手账装饰保留。这里的结果说明本轮样本与环境下,纸间时光恢复到接近默认主题的滚动表现,不代表所有设备、所有复杂文档均无长任务。
|
||||
|
||||
已安装主题保存在本地,不会自动被新版资源覆盖。刷新前端后,到“主题 → 社区主题 → 纸间时光”点击“更新”,确认版本 1.8.1 再进行手动复测。
|
||||
|
||||
- [主题对比原始数据](performance/2026-09-06-scroll-theme-comparison.json)
|
||||
@@ -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'
|
||||
@@ -102,6 +118,40 @@ describe('VisualMarkdownEditor formatting toolbars', () => {
|
||||
expect(wrapper.get('.section-actions button').text()).toBe('全部折叠')
|
||||
expect(wrapper.find('.heading-fold-hidden').exists()).toBe(false)
|
||||
})
|
||||
it('reuses heading decorations for cursor-only moves and invalidates them for folds and edits', async () => {
|
||||
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '# A\n\nfirst paragraph\n\nsecond paragraph' }, attachTo: document.body })
|
||||
mounted.push(wrapper)
|
||||
const editor = await waitForEditor(wrapper)
|
||||
editor.action(ctx => {
|
||||
const view = ctx.get(editorViewCtx), plugin = headingFoldKey.get(view.state)!
|
||||
const decorations = () => plugin.props.decorations!.call(plugin, view.state)
|
||||
const initial = decorations()
|
||||
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 6)))
|
||||
expect(decorations()).toBe(initial)
|
||||
view.dispatch(view.state.tr.insertText('新增'))
|
||||
expect(decorations()).not.toBe(initial)
|
||||
})
|
||||
await wrapper.get('.section-actions button').trigger('click')
|
||||
expect(wrapper.find('.heading-fold-hidden').exists()).toBe(true)
|
||||
await wrapper.get('.section-actions button').trigger('click')
|
||||
expect(wrapper.find('.heading-fold-hidden').exists()).toBe(false)
|
||||
})
|
||||
it('returns to the top after collapsing many sibling chapters from the document end', async () => {
|
||||
const source = Array.from({ length: 100 }, (_, i) => `# Chapter ${i}\n\nBody ${i}`).join('\n\n')
|
||||
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: source }, attachTo: document.body })
|
||||
mounted.push(wrapper)
|
||||
const editor = await waitForEditor(wrapper)
|
||||
editor.action(ctx => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
view.dispatch(view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(view.state.doc.content.size - 1))))
|
||||
})
|
||||
const viewport = wrapper.get('.milkdown-host').element as HTMLElement
|
||||
viewport.scrollTop = 5000
|
||||
await wrapper.get('.section-actions button').trigger('click')
|
||||
expect(viewport.scrollTop).toBe(0)
|
||||
editor.action(ctx => expect(ctx.get(editorViewCtx).state.selection.from).toBe(1))
|
||||
expect(editor.action(getMarkdown()).trim()).toBe(source)
|
||||
})
|
||||
it('offers expand all when individually collapsed parents hide expanded children', async () => {
|
||||
const source = '# A\n\nbody\n\n## B\n\nchild\n\n# C\n\nbody'
|
||||
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: source }, attachTo: document.body })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -148,6 +150,10 @@ function foldHeadings(action: 'toggle' | 'all' | 'none') {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const tr = headingFoldTransaction(view.state, action)
|
||||
if (tr) view.dispatch(tr)
|
||||
if (action === 'all') {
|
||||
const viewport = editorRoot.value?.closest<HTMLElement>('.milkdown-host')
|
||||
if (viewport) viewport.scrollTop = 0
|
||||
}
|
||||
})
|
||||
}
|
||||
const diagramPreviews = new Map<string, { source: string; apply: (value: HTMLElement) => void }>()
|
||||
@@ -388,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()
|
||||
@@ -408,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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { $prose } from '@milkdown/kit/utils'
|
||||
import { Plugin } from '@milkdown/kit/prose/state'
|
||||
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'
|
||||
import type { Node as ProseNode } from '@milkdown/kit/prose/model'
|
||||
import { parseCallout } from '@/utils/callouts'
|
||||
import '@/styles/callouts.css'
|
||||
import { remarkStringifyOptionsCtx, type Editor } from '@milkdown/kit/core'
|
||||
@@ -21,21 +22,30 @@ export const configureCalloutSerialization: Parameters<Editor['config']>[0] = ct
|
||||
}))
|
||||
}
|
||||
|
||||
const markerCache = new WeakMap<ProseNode, { from: number; to: number }[]>()
|
||||
function calloutMarkers(doc: ProseNode) {
|
||||
const cached = markerCache.get(doc)
|
||||
if (cached) return cached
|
||||
const markers: { from: number; to: number }[] = []
|
||||
doc.descendants((node, position) => {
|
||||
if (node.type.name !== 'blockquote' || node.firstChild?.type.name !== 'paragraph' || node.firstChild.firstChild?.marks.length) return
|
||||
const callout = parseCallout(node.firstChild.textBetween(0, node.firstChild.content.size, '\n', '\n'))
|
||||
if (callout) markers.push({ from: position + 2, to: position + 2 + callout.markerLength })
|
||||
})
|
||||
markerCache.set(doc, markers)
|
||||
return markers
|
||||
}
|
||||
|
||||
// Keep native blockquotes in the document: typing, undo and Markdown serialization
|
||||
// remain Milkdown transactions; the view never rewrites a user's callout source.
|
||||
export const calloutPlugin = $prose(() => new Plugin({
|
||||
props: {
|
||||
decorations(state) {
|
||||
const decorations: Decoration[] = []
|
||||
state.doc.descendants((node, position) => {
|
||||
if (node.type.name !== 'blockquote' || node.firstChild?.type.name !== 'paragraph' || node.firstChild.firstChild?.marks.length) return
|
||||
const callout = parseCallout(node.firstChild.textBetween(0, node.firstChild.content.size, '\n', '\n'))
|
||||
if (!callout) return
|
||||
const from = position + 2
|
||||
const to = from + callout.markerLength
|
||||
for (const { from, to } of calloutMarkers(state.doc)) {
|
||||
const editing = state.selection.from <= to && state.selection.to >= from
|
||||
decorations.push(Decoration.inline(from, to, { class: editing ? 'callout-marker-editing' : 'callout-marker' }))
|
||||
})
|
||||
}
|
||||
return DecorationSet.create(state.doc, decorations)
|
||||
},
|
||||
nodeViews: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { expect, it } from 'vitest'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { installCodeBlockLabels } from './codeBlockLabels'
|
||||
|
||||
it('keeps footer labels in sync when the language changes and stops after disposal', async () => {
|
||||
@@ -16,3 +16,21 @@ it('keeps footer labels in sync when the language changes and stops after dispos
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(block.dataset.languageLabel).toBe('TypeScript')
|
||||
})
|
||||
|
||||
it('ignores code text mutations and discovers newly inserted code blocks', async () => {
|
||||
const root = document.createElement('div')
|
||||
root.innerHTML = '<div class="milkdown-code-block"><button class="language-button">Python</button><div class="cm-content">old</div></div>'
|
||||
const dispose = installCodeBlockLabels(root)
|
||||
const scan = vi.spyOn(root, 'querySelectorAll')
|
||||
try {
|
||||
root.querySelector('.cm-content')!.textContent = 'new code'
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(scan).not.toHaveBeenCalled()
|
||||
const block = document.createElement('div')
|
||||
block.className = 'milkdown-code-block'
|
||||
block.innerHTML = '<button class="language-button">Rust</button>'
|
||||
root.append(block)
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(block.dataset.languageLabel).toBe('Rust')
|
||||
} finally { dispose(); scan.mockRestore() }
|
||||
})
|
||||
|
||||
@@ -1,11 +1,38 @@
|
||||
/** Mirror the live picker label for theme decorations without changing Markdown. */
|
||||
/** Mirror changed language labels without rescanning every code block on each DOM mutation. */
|
||||
export function installCodeBlockLabels(root: HTMLElement): () => void {
|
||||
const sync = () => root.querySelectorAll<HTMLElement>('.milkdown-code-block').forEach(block => {
|
||||
const sync = (block: HTMLElement) => {
|
||||
const label = block.querySelector('.language-button')?.textContent?.trim() || 'Plain text'
|
||||
if (block.dataset.languageLabel !== label) block.dataset.languageLabel = label
|
||||
}
|
||||
const discover = (node: Node, blocks: Set<HTMLElement>) => {
|
||||
if (!(node instanceof HTMLElement)) return
|
||||
if (node.matches('.milkdown-code-block')) blocks.add(node)
|
||||
node.querySelectorAll<HTMLElement>('.milkdown-code-block').forEach(block => blocks.add(block))
|
||||
}
|
||||
const observer = new MutationObserver(records => {
|
||||
const changed = new Set<HTMLElement>()
|
||||
for (const record of records) {
|
||||
const element = record.target instanceof Element ? record.target : record.target.parentElement
|
||||
// CodeMirror viewport/text changes do not change the footer's language.
|
||||
const label = element?.closest('.language-button')
|
||||
const block = label?.closest<HTMLElement>('.milkdown-code-block')
|
||||
if (block) changed.add(block)
|
||||
if ([...record.removedNodes].some(node => node instanceof Element &&
|
||||
(node.matches('.language-button') || node.querySelector('.language-button')))) {
|
||||
const owner = element?.closest<HTMLElement>('.milkdown-code-block')
|
||||
if (owner) changed.add(owner)
|
||||
}
|
||||
for (const node of record.addedNodes) {
|
||||
discover(node, changed)
|
||||
if (node instanceof Element && (node.matches('.language-button') || node.querySelector('.language-button'))) {
|
||||
const owner = node.closest<HTMLElement>('.milkdown-code-block')
|
||||
if (owner) changed.add(owner)
|
||||
}
|
||||
}
|
||||
}
|
||||
changed.forEach(block => { if (root.contains(block)) sync(block) })
|
||||
})
|
||||
const observer = new MutationObserver(sync)
|
||||
root.querySelectorAll<HTMLElement>('.milkdown-code-block').forEach(sync)
|
||||
observer.observe(root, { subtree: true, childList: true, characterData: true })
|
||||
sync()
|
||||
return () => observer.disconnect()
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { $prose } from '@milkdown/kit/utils'
|
||||
import { Plugin, TextSelection } from '@milkdown/kit/prose/state'
|
||||
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'
|
||||
import type { Node } from '@milkdown/kit/prose/model'
|
||||
import type { Editor } from '@milkdown/kit/core'
|
||||
import { editorViewCtx } from '@milkdown/kit/core'
|
||||
|
||||
const decorationCache = new WeakMap<Node, DecorationSet>()
|
||||
|
||||
const openingTag = /^<span style="font-size:\s*(\d+(?:\.\d+)?)px">$/i
|
||||
const closingTag = /^<\/span>$/i
|
||||
|
||||
export const fontSizeMarkdownPlugin = $prose(() => new Plugin({
|
||||
props: {
|
||||
decorations(state) {
|
||||
const cached = decorationCache.get(state.doc)
|
||||
if (cached) return cached
|
||||
const decorations: Decoration[] = []
|
||||
const stack: Array<{ from: number; size: string }> = []
|
||||
|
||||
@@ -36,7 +41,9 @@ export const fontSizeMarkdownPlugin = $prose(() => new Plugin({
|
||||
}
|
||||
})
|
||||
|
||||
return DecorationSet.create(state.doc, decorations)
|
||||
const result = DecorationSet.create(state.doc, decorations)
|
||||
decorationCache.set(state.doc, result)
|
||||
return result
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -7,6 +7,7 @@ import { t } from '@/i18n'
|
||||
export const headingFoldKey = new PluginKey<Set<number>>('heading-folding')
|
||||
type Section = { from: number; body: number; end: number; level: number }
|
||||
const sectionCache = new WeakMap<Node, Section[]>()
|
||||
const decorationCache = new WeakMap<Node, WeakMap<Set<number>, DecorationSet>>()
|
||||
/** A section ends at the next sibling heading of the same or a higher rank. */
|
||||
export function headingSections(doc: Node): Section[] {
|
||||
const cached = sectionCache.get(doc)
|
||||
@@ -50,7 +51,8 @@ export function headingFoldTransaction(state: EditorState, action: 'toggle' | 'a
|
||||
}
|
||||
const tr = state.tr
|
||||
const enclosing = sections.find(section => folded.has(section.from) && state.selection.to >= section.body && state.selection.from < section.end)
|
||||
if (enclosing) tr.setSelection(TextSelection.near(state.doc.resolve(enclosing.from + 1)))
|
||||
if (action === 'all' && sections.length) tr.setSelection(TextSelection.near(state.doc.resolve(sections[0]!.from + 1))).scrollIntoView()
|
||||
else if (enclosing) tr.setSelection(TextSelection.near(state.doc.resolve(enclosing.from + 1)))
|
||||
return tr.setMeta(headingFoldKey, folded).setMeta('addToHistory', false)
|
||||
}
|
||||
|
||||
@@ -61,11 +63,21 @@ export const headingFoldingPlugin = $prose(() => new Plugin<Set<number>>({
|
||||
apply(tr, previous) {
|
||||
const explicit = tr.getMeta(headingFoldKey) as Set<number> | undefined
|
||||
if (explicit) return explicit
|
||||
if (!previous.size) return previous
|
||||
const sections = headingSections(tr.doc)
|
||||
if (!tr.docChanged) {
|
||||
if (!tr.selectionSet) return previous
|
||||
const opened = sections.filter(section => previous.has(section.from) && tr.selection.to >= section.body && tr.selection.from < section.end)
|
||||
if (!opened.length) return previous
|
||||
const next = new Set(previous)
|
||||
opened.forEach(section => next.delete(section.from))
|
||||
return next
|
||||
}
|
||||
const positions = new Set(sections.map(section => section.from))
|
||||
const mapped = new Set<number>()
|
||||
for (const old of previous) {
|
||||
const result = tr.mapping.mapResult(old, 1)
|
||||
if (!result.deleted && sections.some(section => section.from === result.pos)) mapped.add(result.pos)
|
||||
if (!result.deleted && positions.has(result.pos)) mapped.add(result.pos)
|
||||
}
|
||||
// Outline jumps, find and keyboard navigation must never leave a hidden caret.
|
||||
if (tr.selectionSet || tr.docChanged) {
|
||||
@@ -77,6 +89,8 @@ export const headingFoldingPlugin = $prose(() => new Plugin<Set<number>>({
|
||||
props: {
|
||||
decorations(state) {
|
||||
const folded = headingFoldKey.getState(state) ?? new Set<number>()
|
||||
const cached = decorationCache.get(state.doc)?.get(folded)
|
||||
if (cached) return cached
|
||||
const sections = headingSections(state.doc)
|
||||
const decorations: Decoration[] = []
|
||||
for (const section of sections) {
|
||||
@@ -103,7 +117,7 @@ export const headingFoldingPlugin = $prose(() => new Plugin<Set<number>>({
|
||||
else hidden.push({ body: section.body, end: section.end })
|
||||
}
|
||||
let rangeIndex = 0
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (hidden.length) state.doc.descendants((node, pos) => {
|
||||
if (!node.isBlock) return
|
||||
while (hidden[rangeIndex] && pos >= hidden[rangeIndex]!.end) rangeIndex++
|
||||
const range = hidden[rangeIndex]
|
||||
@@ -112,7 +126,11 @@ export const headingFoldingPlugin = $prose(() => new Plugin<Set<number>>({
|
||||
return false
|
||||
}
|
||||
})
|
||||
return DecorationSet.create(state.doc, decorations)
|
||||
const result = DecorationSet.create(state.doc, decorations)
|
||||
let byState = decorationCache.get(state.doc)
|
||||
if (!byState) { byState = new WeakMap(); decorationCache.set(state.doc, byState) }
|
||||
byState.set(folded, result)
|
||||
return result
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { installLanguagePickerPopover } from './languagePickerPopover'
|
||||
|
||||
it('measures only open menus on ancestor scroll and cleans up scheduled work', async () => {
|
||||
const root = document.createElement('div')
|
||||
root.innerHTML = '<div><button class="language-button" data-expanded="false">JS</button><div class="language-picker"><input class="search-input"></div></div>'
|
||||
document.body.append(root)
|
||||
const menu = root.querySelector<HTMLElement>('.language-picker')!
|
||||
const trigger = root.querySelector<HTMLElement>('button')!
|
||||
let open = false
|
||||
menu.showPopover = vi.fn(() => { open = true })
|
||||
menu.hidePopover = vi.fn(() => { open = false })
|
||||
const matches = menu.matches.bind(menu)
|
||||
vi.spyOn(menu, 'matches').mockImplementation(selector => selector === ':popover-open' ? open : matches(selector))
|
||||
const measure = vi.spyOn(trigger, 'getBoundingClientRect')
|
||||
let callback: FrameRequestCallback | undefined
|
||||
const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(fn => { callback = fn; return 42 })
|
||||
const cancel = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
||||
const dispose = installLanguagePickerPopover(root)
|
||||
try {
|
||||
document.dispatchEvent(new Event('scroll'))
|
||||
expect(raf).not.toHaveBeenCalled()
|
||||
expect(measure).not.toHaveBeenCalled()
|
||||
trigger.dataset.expanded = 'true'
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(menu.showPopover).toHaveBeenCalledOnce()
|
||||
measure.mockClear()
|
||||
document.dispatchEvent(new Event('scroll'))
|
||||
document.dispatchEvent(new Event('scroll'))
|
||||
expect(raf).toHaveBeenCalledOnce()
|
||||
callback!(0)
|
||||
expect(measure).toHaveBeenCalledOnce()
|
||||
document.dispatchEvent(new Event('scroll'))
|
||||
dispose()
|
||||
expect(cancel).toHaveBeenCalledWith(42)
|
||||
expect(menu.hidePopover).toHaveBeenCalledOnce()
|
||||
raf.mockClear()
|
||||
document.dispatchEvent(new Event('scroll'))
|
||||
expect(raf).not.toHaveBeenCalled()
|
||||
} finally { dispose(); root.remove(); vi.restoreAllMocks() }
|
||||
})
|
||||
@@ -1,43 +1,68 @@
|
||||
/** Promote Milkdown's menu to the top layer without moving its Vue-owned DOM. */
|
||||
/** Promote menus to the top layer; only open menus need scroll measurements. */
|
||||
export function installLanguagePickerPopover(root: HTMLElement): () => void {
|
||||
const menus = new Set<HTMLElement>()
|
||||
function sync() {
|
||||
root.querySelectorAll<HTMLElement>('.language-picker').forEach(menu => {
|
||||
const trigger = menu.parentElement?.querySelector<HTMLElement>('.language-button')
|
||||
if (!trigger || typeof menu.showPopover !== 'function') return
|
||||
menus.add(menu)
|
||||
menu.setAttribute('popover', 'manual')
|
||||
const search = menu.querySelector<HTMLInputElement>('.search-input')
|
||||
if (search) {
|
||||
search.autocomplete = 'off'
|
||||
search.spellcheck = false
|
||||
}
|
||||
if (trigger.dataset.expanded !== 'true' || !menu.firstElementChild) {
|
||||
if (menu.matches(':popover-open')) menu.hidePopover()
|
||||
return
|
||||
}
|
||||
if (!menu.matches(':popover-open')) menu.showPopover()
|
||||
const anchor = trigger.getBoundingClientRect()
|
||||
const below = window.innerHeight - anchor.bottom - 16
|
||||
const above = anchor.top - 16
|
||||
const placeAbove = below < 240 && above > below
|
||||
const available = Math.max(80, placeAbove ? above : below)
|
||||
menu.style.setProperty('--picker-list-height', `${Math.min(280, Math.max(32, available - 64))}px`)
|
||||
const bounds = menu.getBoundingClientRect()
|
||||
menu.style.setProperty('--picker-left', `${Math.max(12, Math.min(anchor.left, window.innerWidth - bounds.width - 12))}px`)
|
||||
menu.style.setProperty('--picker-top', `${Math.max(12, placeAbove ? anchor.top - bounds.height - 8 : anchor.bottom + 8)}px`)
|
||||
})
|
||||
for (const menu of menus) if (!root.contains(menu)) menus.delete(menu)
|
||||
const openMenus = new Set<HTMLElement>()
|
||||
let frame = 0
|
||||
function sync(menu: HTMLElement) {
|
||||
if (!root.contains(menu)) { menus.delete(menu); openMenus.delete(menu); return }
|
||||
const trigger = menu.parentElement?.querySelector<HTMLElement>('.language-button')
|
||||
if (!trigger || typeof menu.showPopover !== 'function') return
|
||||
menus.add(menu)
|
||||
menu.setAttribute('popover', 'manual')
|
||||
const search = menu.querySelector<HTMLInputElement>('.search-input')
|
||||
if (search) { search.autocomplete = 'off'; search.spellcheck = false }
|
||||
if (trigger.dataset.expanded !== 'true' || !menu.firstElementChild) {
|
||||
openMenus.delete(menu)
|
||||
if (menu.matches(':popover-open')) menu.hidePopover()
|
||||
return
|
||||
}
|
||||
openMenus.add(menu)
|
||||
if (!menu.matches(':popover-open')) menu.showPopover()
|
||||
const anchor = trigger.getBoundingClientRect()
|
||||
const below = window.innerHeight - anchor.bottom - 16, above = anchor.top - 16
|
||||
const placeAbove = below < 240 && above > below
|
||||
const available = Math.max(80, placeAbove ? above : below)
|
||||
menu.style.setProperty('--picker-list-height', `${Math.min(280, Math.max(32, available - 64))}px`)
|
||||
const bounds = menu.getBoundingClientRect()
|
||||
menu.style.setProperty('--picker-left', `${Math.max(12, Math.min(anchor.left, window.innerWidth - bounds.width - 12))}px`)
|
||||
menu.style.setProperty('--picker-top', `${Math.max(12, placeAbove ? anchor.top - bounds.height - 8 : anchor.bottom + 8)}px`)
|
||||
}
|
||||
const observer = new MutationObserver(sync)
|
||||
function discover(node: Node, changed: Set<HTMLElement>) {
|
||||
if (!(node instanceof HTMLElement)) return
|
||||
if (node.matches('.language-picker')) changed.add(node)
|
||||
node.querySelectorAll<HTMLElement>('.language-picker').forEach(menu => changed.add(menu))
|
||||
}
|
||||
const observer = new MutationObserver(records => {
|
||||
const changed = new Set<HTMLElement>()
|
||||
let removed = false
|
||||
for (const record of records) {
|
||||
const target = record.target instanceof Element ? record.target : record.target.parentElement
|
||||
const menu = target?.closest<HTMLElement>('.language-picker')
|
||||
if (menu) changed.add(menu)
|
||||
if (record.type === 'attributes') {
|
||||
const sibling = target?.parentElement?.querySelector<HTMLElement>('.language-picker')
|
||||
if (sibling) changed.add(sibling)
|
||||
}
|
||||
record.addedNodes.forEach(node => discover(node, changed))
|
||||
removed ||= record.removedNodes.length > 0
|
||||
}
|
||||
if (removed) for (const menu of menus) if (!root.contains(menu)) { menus.delete(menu); openMenus.delete(menu) }
|
||||
changed.forEach(sync)
|
||||
})
|
||||
const positionOpenMenus = () => {
|
||||
if (!openMenus.size || frame) return
|
||||
frame = requestAnimationFrame(() => { frame = 0; openMenus.forEach(sync) })
|
||||
}
|
||||
root.querySelectorAll<HTMLElement>('.language-picker').forEach(sync)
|
||||
observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-expanded'] })
|
||||
root.addEventListener('scroll', sync, true)
|
||||
window.addEventListener('resize', sync)
|
||||
sync()
|
||||
// The outer editor viewport is an ancestor of root, so listen in capture on the document.
|
||||
document.addEventListener('scroll', positionOpenMenus, { capture: true, passive: true })
|
||||
window.addEventListener('resize', positionOpenMenus)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
root.removeEventListener('scroll', sync, true)
|
||||
window.removeEventListener('resize', sync)
|
||||
for (const menu of menus) if (menu.matches(':popover-open')) menu.hidePopover()
|
||||
observer.disconnect(); cancelAnimationFrame(frame)
|
||||
document.removeEventListener('scroll', positionOpenMenus, true)
|
||||
window.removeEventListener('resize', positionOpenMenus)
|
||||
for (const menu of openMenus) if (menu.matches(':popover-open')) menu.hidePopover()
|
||||
menus.clear(); openMenus.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,13 +81,36 @@ async function loadCodeLanguage(requestedLanguage: string) {
|
||||
return { shiki, language }
|
||||
}
|
||||
|
||||
// Bounded LRU of dual-theme HTML. Large one-off blocks never remain in the cache.
|
||||
const highlightedBlocks = new Map<string, string>()
|
||||
let highlightedCharacters = 0
|
||||
const highlightBudget = 1_000_000
|
||||
export async function highlightCode(source: string, requestedLanguage = 'text'): Promise<string> {
|
||||
const key = JSON.stringify([requestedLanguage.toLowerCase(), source])
|
||||
const cached = highlightedBlocks.get(key)
|
||||
if (cached !== undefined) {
|
||||
highlightedBlocks.delete(key); highlightedBlocks.set(key, cached)
|
||||
return cached
|
||||
}
|
||||
const { shiki, language } = await loadCodeLanguage(requestedLanguage)
|
||||
return shiki.codeToHtml(source, {
|
||||
const html = shiki.codeToHtml(source, {
|
||||
lang: language,
|
||||
themes: { light: 'github-light', dark: 'github-dark' },
|
||||
defaultColor: false,
|
||||
})
|
||||
const cost = key.length + html.length
|
||||
if (cost <= highlightBudget / 4) {
|
||||
// A concurrent caller may already have filled the same entry.
|
||||
const previous = highlightedBlocks.get(key)
|
||||
if (previous !== undefined) { highlightedCharacters -= key.length + previous.length; highlightedBlocks.delete(key) }
|
||||
while (highlightedBlocks.size && (highlightedBlocks.size >= 64 || highlightedCharacters + cost > highlightBudget)) {
|
||||
const oldest = highlightedBlocks.keys().next().value!
|
||||
highlightedCharacters -= oldest.length + highlightedBlocks.get(oldest)!.length
|
||||
highlightedBlocks.delete(oldest)
|
||||
}
|
||||
highlightedBlocks.set(key, html); highlightedCharacters += cost
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
/** Share the initialized grammar/theme registry with editable code blocks. */
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# 长文渲染压测
|
||||
|
||||
使用真实无头 Chrome 和 Chrome DevTools Protocol,加载当前 Vite 工作区。样本由 `fixture.js` 确定性生成,包含至少 25000、60000、120000 汉字、H1–H3、表格、代码块、提示框、链接和行内格式,不读写真实 Vault。
|
||||
|
||||
## 运行
|
||||
|
||||
仓库根目录启动独立服务:
|
||||
|
||||
```powershell
|
||||
npm --prefix frontend run dev -- --port 5175 --strictPort
|
||||
```
|
||||
|
||||
在另一个终端执行(Python 环境需安装 websockets):
|
||||
|
||||
```powershell
|
||||
backend/.venv/Scripts/python.exe frontend/tests/performance/run-stress.py --url http://127.0.0.1:5175/tests/performance/stress.html --runs 3 --output .local-plans/stress-results.json
|
||||
```
|
||||
|
||||
可用 `--chrome` 指定 Chromium 路径,用 `--sizes 25000 60000 120000` 指定样本。脚本使用独立临时浏览器配置,结束后关闭测试进程;不接管用户 Chrome。样本在每次导航后重建,不向后端保存。
|
||||
|
||||
## 指标口径
|
||||
|
||||
- openMs:组件挂载到编辑器完成初始化及两个 animation frame;不包含模块下载与 Vite 编译。
|
||||
- selectionMs:30 次光标选区事务的同步耗时;insertMs:20 次插入“压测输入”的事务耗时。
|
||||
- foldMs:6 次全折叠/展开按钮操作的同步耗时。
|
||||
- previewMs:同一正文静态渲染三次,包含 HTML 插入与两个 animation frame。第一次包含首次高亮初始化,后两次为热运行。
|
||||
- longTasks:浏览器 Long Tasks API,包含整个测量过程;heapUsedBytes 为单次采样,不代表峰值或泄漏结论。
|
||||
- integrity:序列化输出保留插入内容与文末标记。常规单元测试另外覆盖 Markdown 往返。
|
||||
|
||||
这些是开发模式下的微基准,不等于真实键盘/输入法的端到端延迟,不覆盖滚动帧率、自动保存网络、向量计算或 Mermaid 图表压力。不同机器、后台负载和缓存状态会影响结果,不能把一次结果作为通用 SLA。重型图表应单独使用已有 `tests/visual/mermaid-matrix.html` 验证。
|
||||
|
||||
打开 stress.html 后也可通过控制台调用 `await runBenchmark(25000)` 查看 JSON 结果。页面只用于测试,不在正式路由中注册。
|
||||
|
||||
## 连续滚轮与折叠定位
|
||||
|
||||
```powershell
|
||||
backend/.venv/Scripts/python.exe frontend/tests/performance/run-stress.py --url http://127.0.0.1:5175/tests/performance/stress.html --scroll --runs 2 --output .local-plans/scroll-results.json
|
||||
```
|
||||
|
||||
`--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,12 @@
|
||||
/** Deterministic CJK prose plus headings, tables, code and callouts; no user documents. */
|
||||
export function makeStressDocument(minHan = 25000) {
|
||||
const prose = '本地知识库保存课程记录与项目思考,编辑时需要稳定响应。长篇文档包含章节结构和引用信息,阅读过程中可以随时折叠展开。这里使用生成的测试内容验证渲染性能,不读取真实笔记。'
|
||||
let source = '# 长文渲染压力测试\n\n', han = 0, section = 0
|
||||
while (han < minHan) {
|
||||
source += `## 第 ${++section} 节:知识整理\n\n${prose.repeat(3)}\n\n### 小结 ${section}\n\n重点包含 **强调文字**、\`inlineCode\` 和 [链接](https://example.com)。\n\n`
|
||||
han += prose.length * 3
|
||||
if (section % 8 === 0) source += '> [!TIP] 验收提示\n> 内容需要保留,折叠后仍可展开。\n\n| 项目 | 状态 |\n| --- | --- |\n| 渲染 | 待验证 |\n\n```javascript\nconst note = { title: "长文测试", ready: true };\nconsole.log(note);\n```\n\n'
|
||||
}
|
||||
source += '\n## 文末校验\n\n结束标记:长文内容完整。\n'
|
||||
return source
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,85 @@
|
||||
"""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, base64, json, pathlib, subprocess, tempfile, urllib.request
|
||||
import websockets
|
||||
|
||||
async def main(args):
|
||||
with tempfile.TemporaryDirectory(prefix='notes-stress-') as profile:
|
||||
process = subprocess.Popen([args.chrome, '--headless=new', '--no-first-run', '--no-proxy-server', '--no-default-browser-check', '--disable-background-networking', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--remote-debugging-port=0', '--window-size=1440,1000', f'--user-data-dir={profile}', 'about:blank'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
port_file = pathlib.Path(profile) / 'DevToolsActivePort'
|
||||
for _ in range(100):
|
||||
if port_file.exists(): break
|
||||
await asyncio.sleep(.1)
|
||||
port = port_file.read_text().splitlines()[0]
|
||||
with urllib.request.urlopen(f'http://127.0.0.1:{port}/json') as response: target = next(item for item in json.load(response) if item['type'] == 'page')
|
||||
async with websockets.connect(target['webSocketDebuggerUrl'], max_size=100_000_000) as socket:
|
||||
sequence = 0
|
||||
async def call(method, params=None):
|
||||
nonlocal sequence
|
||||
sequence += 1; request = sequence
|
||||
await socket.send(json.dumps({'id':request,'method':method,'params':params or {}}))
|
||||
while True:
|
||||
response = json.loads(await asyncio.wait_for(socket.recv(), 180))
|
||||
if response.get('method') in ['Runtime.exceptionThrown','Log.entryAdded','Network.loadingFailed']: print(json.dumps(response),flush=True)
|
||||
if response.get('id') == request:
|
||||
if 'error' in response: raise RuntimeError(response['error'])
|
||||
return response.get('result', {})
|
||||
await call('Runtime.enable')
|
||||
await call('Log.enable')
|
||||
await call('Network.enable')
|
||||
await asyncio.sleep(1)
|
||||
results=[]
|
||||
for size in args.sizes:
|
||||
for repeat in range(args.runs):
|
||||
navigation = await call('Page.navigate', {'url':args.url})
|
||||
if navigation.get('errorText'): raise RuntimeError(navigation['errorText'])
|
||||
for _ in range(600):
|
||||
state = await call('Runtime.evaluate', {'expression':'typeof window.runBenchmark', 'returnByValue':True})
|
||||
if state.get('result',{}).get('value')=='function':break
|
||||
await asyncio.sleep(.1)
|
||||
else: raise RuntimeError('Benchmark page did not load; check the Vite URL and browser errors')
|
||||
expression = f'window.prepareScrollBenchmark({size})' if args.scroll else f'window.runBenchmark({size})'
|
||||
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})
|
||||
for step in range(120):
|
||||
await call('Input.dispatchMouseEvent', {'type':'mouseWheel', **point, 'deltaX':0,'deltaY':900 if step < 90 else -900})
|
||||
await asyncio.sleep(.016)
|
||||
if args.profile:
|
||||
profile_data = await call('Profiler.stop')
|
||||
pathlib.Path(args.output + f'.{size}.{repeat+1}.cpuprofile').write_text(json.dumps(profile_data['profile']),encoding='utf-8')
|
||||
await call('Runtime.evaluate', {'expression':'new Promise(r => setTimeout(r, 150))','awaitPromise':True})
|
||||
response = await call('Runtime.evaluate', {'expression':'window.finishScrollBenchmark()','awaitPromise':True,'returnByValue':True})
|
||||
if 'exceptionDetails' in response: raise RuntimeError(response['exceptionDetails'])
|
||||
result = response['result']['value']; result['repeat']=repeat+1
|
||||
results.append(result)
|
||||
print(json.dumps(result,ensure_ascii=False),flush=True)
|
||||
pathlib.Path(args.output).write_text(json.dumps(results,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
finally:
|
||||
process.terminate()
|
||||
try: process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired: process.kill(); process.wait()
|
||||
await asyncio.sleep(.5)
|
||||
|
||||
if __name__=='__main__':
|
||||
parser=argparse.ArgumentParser()
|
||||
parser.add_argument('--chrome',default='C:/Program Files/Google/Chrome/Application/chrome.exe')
|
||||
parser.add_argument('--url',default='http://127.0.0.1:5173/tests/performance/stress.html')
|
||||
parser.add_argument('--sizes',nargs='+',type=int,default=[25000,60000,120000])
|
||||
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')
|
||||
pathlib.Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
||||
asyncio.run(main(args))
|
||||
@@ -0,0 +1,89 @@
|
||||
<!doctype html><html><head><meta charset="utf-8"><title>长文渲染压测</title></head>
|
||||
<body><div id="app"></div><pre id="report">通过 run-stress.py 运行,或在控制台调用 runBenchmark(25000)。</pre>
|
||||
<script type="module">
|
||||
import { createApp, h, ref, nextTick } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import Editor from '/src/features/editor/VisualMarkdownEditor.vue'
|
||||
import { editorViewCtx } from '@milkdown/kit/core'
|
||||
import { TextSelection } from '@milkdown/kit/prose/state'
|
||||
import { getMarkdown } from '@milkdown/kit/utils'
|
||||
import { renderMarkdown } from '/src/utils/markdown.ts'
|
||||
import { useEditorStore } from '/src/stores/editor.ts'
|
||||
import { getCommunityThemePreviewCss } from '/src/services/themePackageService.ts'
|
||||
import { makeStressDocument } from './fixture.js'
|
||||
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 summarize = values => { const sorted = [...values].sort((a,b)=>a-b); return {median:sorted[Math.floor(sorted.length/2)],p95:sorted[Math.min(sorted.length-1,Math.ceil(sorted.length*.95)-1)],max:sorted.at(-1)} }
|
||||
window.runBenchmark = async (size = 25000) => {
|
||||
const source = makeStressDocument(size), target = document.getElementById('app')
|
||||
const pinia = createPinia(), component = ref(), tasks = []
|
||||
const observer = new PerformanceObserver(list => tasks.push(...list.getEntries().map(t => t.duration)))
|
||||
observer.observe({type:'longtask',buffered:false})
|
||||
const app = createApp({render:()=>h(Editor,{ref:component,initialContent:source})}).use(pinia)
|
||||
const result = {requestedHan:size,hanCharacters:(source.match(/\p{Script=Han}/gu)||[]).length,sourceCharacters:source.length,userAgent:navigator.userAgent,viewport:[innerWidth,innerHeight]}
|
||||
const start = performance.now(); app.mount(target)
|
||||
try {
|
||||
const deadline = performance.now()+120000
|
||||
while (!component.value?.getEditor() || target.querySelector('.milkdown-host.loading')) { if(performance.now()>deadline)throw Error('Editor startup timeout'); await frame() }
|
||||
await settle(); result.openMs = performance.now()-start
|
||||
const editor = component.value.getEditor(), view = editor.action(ctx=>ctx.get(editorViewCtx))
|
||||
result.domNodes = target.querySelectorAll('*').length
|
||||
const measure = async (count, operation) => { const values=[]; for(let i=0;i<count;i++) {const start=performance.now(); operation(i); values.push(performance.now()-start); await settle()} return summarize(values) }
|
||||
const positions=[]; view.state.doc.descendants((node,pos)=>{if(node.isTextblock && !node.type.spec.code)positions.push(pos+1)})
|
||||
result.selectionMs = await measure(30, i => view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc,positions[Math.floor(i*positions.length/30)]))))
|
||||
result.insertMs = await measure(20, () => view.dispatch(view.state.tr.insertText('压测输入')))
|
||||
result.foldMs = await measure(6, () => target.querySelector('.section-actions button').click())
|
||||
const serialized = editor.action(getMarkdown())
|
||||
if(!serialized.includes('压测输入') || !serialized.includes('结束标记:长文内容完整。'))throw Error('Content integrity check failed')
|
||||
result.integrity = true
|
||||
const preview = document.createElement('div'); document.body.append(preview)
|
||||
const previews=[]
|
||||
for(let i=0;i<3;i++){const start=performance.now(); preview.innerHTML=await renderMarkdown(source); await settle(); previews.push(performance.now()-start)}
|
||||
result.previewMs=previews
|
||||
result.previewNodes=preview.querySelectorAll('*').length; preview.remove()
|
||||
await settle(); result.longTasks={count:tasks.length,...summarize(tasks.length?tasks:[0])}
|
||||
result.heapUsedBytes=performance.memory?.usedJSHeapSize
|
||||
return result
|
||||
} finally {
|
||||
observer.disconnect(); useEditorStore(pinia).closeFile(); app.unmount()
|
||||
document.getElementById('report').textContent=JSON.stringify(result,null,2)
|
||||
}
|
||||
}
|
||||
|
||||
window.prepareScrollBenchmark = async (size = 25000) => {
|
||||
const source = makeStressDocument(size), target = document.getElementById('app')
|
||||
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)
|
||||
const deadline = performance.now()+120000
|
||||
while (!component.value?.getEditor() || target.querySelector('.milkdown-host.loading')) { if(performance.now()>deadline)throw Error('Editor startup timeout'); await frame() }
|
||||
await settle()
|
||||
const scroller=target.querySelector('.milkdown-host'), gaps=[], tasks=[]
|
||||
let raf=0, last=performance.now()
|
||||
const tick = now => {gaps.push(now-last);last=now;raf=requestAnimationFrame(tick)}
|
||||
const observer = new PerformanceObserver(list=>tasks.push(...list.getEntries().map(t=>t.duration)))
|
||||
observer.observe({type:'longtask',buffered:false});raf=requestAnimationFrame(tick)
|
||||
window.finishScrollBenchmark = async () => {
|
||||
cancelAnimationFrame(raf);observer.disconnect()
|
||||
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()
|
||||
target.querySelector('.section-actions button').click();await settle()
|
||||
result.foldedScrollTop=scroller.scrollTop
|
||||
result.caretAfterFold=view.state.selection.from
|
||||
useEditorStore(pinia).closeFile();app.unmount();style.remove()
|
||||
return result
|
||||
}
|
||||
const rect=scroller.getBoundingClientRect()
|
||||
return {x:rect.left+rect.width/2,y:rect.top+rect.height/2}
|
||||
}
|
||||
</script><style>html,body{margin:0;height:100%;}#app{display:flex;flex-direction:column;height:90vh;max-width:1200px;margin:auto;}#report{white-space:pre-wrap;}</style></body></html>
|
||||
Reference in New Issue
Block a user