Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a6643ca6f | ||
|
|
d1458ee9fb | ||
|
|
1f840bf48f | ||
|
|
a820d14656 | ||
|
|
8553d6f3c3 | ||
|
|
2cb47c08f1 | ||
|
|
f899b50930 | ||
|
|
b5e1d5c06b | ||
|
|
9c38a2f6af | ||
|
|
0b77b3c08a | ||
|
|
17188357b8 | ||
|
|
da4dde2951 | ||
|
|
35dc1ddefb | ||
|
|
cd7b47116b | ||
|
|
a03dcafd5b | ||
|
|
cba8f3f324 | ||
|
|
c1008ea08d | ||
|
|
d85362ab53 | ||
|
|
71047aea17 | ||
|
|
dd99a7a6f5 | ||
|
|
afacee0ffd | ||
|
|
51ed010e54 | ||
|
|
d0dc358938 | ||
|
|
8d78af018b |
@@ -10,7 +10,7 @@
|
|||||||
NotesAgent/
|
NotesAgent/
|
||||||
├── frontend/ Vue 3 + TypeScript + Vite 前端
|
├── frontend/ Vue 3 + TypeScript + Vite 前端
|
||||||
├── backend/ FastAPI + Pydantic 后端
|
├── backend/ FastAPI + Pydantic 后端
|
||||||
├── docs/ 分工与技术栈说明
|
├── docs/ 架构、契约、开发说明、协作规范与问题复盘
|
||||||
└── server sync/ 云同步服务预留目录,当前未实现
|
└── server sync/ 云同步服务预留目录,当前未实现
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ python --version
|
|||||||
uv --version
|
uv --version
|
||||||
```
|
```
|
||||||
|
|
||||||
当前 Web 联调不需要 Rust 和 Tauri。开始桌面端集成后,再按照 `docs/AI笔记软件技术栈说明-团队版-v2.2.md` 安装 Rust Toolchain 与 Tauri CLI。
|
当前 Web 联调不需要 Rust 和 Tauri。开始桌面端集成后,再按照 `docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md` 安装 Rust Toolchain 与 Tauri CLI。
|
||||||
|
|
||||||
## 首次初始化
|
## 首次初始化
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ cd frontend
|
|||||||
pnpm test
|
pnpm test
|
||||||
```
|
```
|
||||||
|
|
||||||
当前回归基线为后端 71 项测试、前端 14 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
|
当前回归基线为后端 81 项测试、前端 27 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
|
||||||
|
|
||||||
构建产物位于 `frontend/dist`,该目录不提交到 Git。
|
构建产物位于 `frontend/dist`,该目录不提交到 Git。
|
||||||
|
|
||||||
@@ -126,19 +126,15 @@ pnpm test
|
|||||||
|
|
||||||
| 文档 | 用途 |
|
| 文档 | 用途 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [技术栈说明](docs/AI笔记软件技术栈说明-团队版-v2.2.md) | 目标架构、当前实施边界与模块依赖 |
|
| [文档总索引](docs/README.md) | 文档分类、阅读顺序和维护规则 |
|
||||||
| [第一阶段分工表](docs/第一阶段分工表.md) | 成员职责、协作关系与当前交付状态 |
|
| [技术栈说明](docs/architecture/AI笔记软件技术栈说明-团队版-v2.3.md) | 目标架构、第二阶段技术边界与模块依赖 |
|
||||||
| [后端接口契约](docs/后端接口契约-开发版.md) | HTTP/SSE 接口、错误和当前实现状态 |
|
| [第二阶段分工表](docs/architecture/第二阶段团队分工表.md) | 第二阶段人员职责、任务顺序、协作关系与验收项 |
|
||||||
| [AI Core 与 Agent Core](docs/AI-Core与Agent-Core开发说明.md) | Provider、Agent、Tool、Permission 与 Extension Core |
|
| [后端接口契约](docs/contracts/后端接口契约-开发版.md) | HTTP/SSE 接口、错误和当前实现状态 |
|
||||||
| [Knowledge 与 Retrieval Core](docs/Knowledge与Retrieval-Core开发说明.md) | Block、索引、混合检索和 Citation |
|
| [第二阶段接口契约](docs/contracts/第二阶段接口契约-开发版.md) | 第二阶段公共 DTO、计划接口、SSE、错误码与联调顺序 |
|
||||||
| [模型提供商与模型发现](docs/模型提供商与模型发现开发说明.md) | Provider 预设、模型发现和凭据边界 |
|
| [AI Core 与 Agent Core](docs/development/AI-Core与Agent-Core开发说明.md) | Provider、Agent、Tool、Permission 与 Extension Core |
|
||||||
| [前端页面需求](docs/前端页面需求说明-开发版.md) | 页面、交互、状态与验收基线 |
|
| [Git 使用细则](docs/guides/Git使用细则-团队开发版.md) | 分支、提交、PR、Review 与合并流程 |
|
||||||
| [前端实现说明](docs/前端壳子与接口层开发说明.md) | 当前前端目录、Service、SSE 和运行边界 |
|
| [CI/CD 细则](docs/guides/CI-CD细则-团队开发版.md) | Gitea 流水线、质量门禁、产物、发布与回滚规则 |
|
||||||
| [前端写作体验](docs/前端写作体验优化开发说明.md) | Milkdown、CodeMirror、格式栏和 Shiki |
|
| [Agent Trace 复盘](docs/retrospectives/Agent-Core第二阶段问题与修复复盘.md) | Agent 持久化、SSE 恢复、事件契约与脱敏问题复盘 |
|
||||||
| [Git 使用细则](docs/Git使用细则-团队开发版.md) | 分支、提交、PR、Review 与合并流程 |
|
|
||||||
| [后端审阅复盘](docs/后端全面审阅问题与修复复盘.md) | 后端问题原因、后果与修复方案 |
|
|
||||||
| [Knowledge/Retrieval 复盘](docs/Knowledge与Retrieval-Core问题与修复复盘.md) | 检索与事务问题复盘 |
|
|
||||||
| [前端审阅复盘](docs/前端合并审阅问题与修复复盘.md) | 前端工程、契约和交互问题复盘 |
|
|
||||||
|
|
||||||
## 日常开发注意事项
|
## 日常开发注意事项
|
||||||
|
|
||||||
@@ -148,6 +144,7 @@ pnpm test
|
|||||||
- API 默认监听 `127.0.0.1:8000`,前端默认监听 `127.0.0.1:5173`。
|
- API 默认监听 `127.0.0.1:8000`,前端默认监听 `127.0.0.1:5173`。
|
||||||
- 后端附件目录默认是 `backend/data/attachments`,可通过 `APP_ATTACHMENTS_PATH` 覆盖;该目录由桌面 Host 管理。
|
- 后端附件目录默认是 `backend/data/attachments`,可通过 `APP_ATTACHMENTS_PATH` 覆盖;该目录由桌面 Host 管理。
|
||||||
- 跨模块接口发生变化时,需要同步更新前后端类型和 `docs` 中的接口说明。
|
- 跨模块接口发生变化时,需要同步更新前后端类型和 `docs` 中的接口说明。
|
||||||
- 当前前后端接口清单见 `docs/后端接口契约-开发版.md`,OpenAPI 以 `/openapi.json` 为准。
|
- 当前已实现接口见 `docs/contracts/后端接口契约-开发版.md`,第二阶段规划接口见 `docs/contracts/第二阶段接口契约-开发版.md`;已实现能力以 `/openapi.json` 为准。
|
||||||
- 前端页面、交互、状态管理和第一阶段验收要求见 `docs/前端页面需求说明-开发版.md`。
|
- 前端页面、交互、状态管理和第一阶段验收要求见 `docs/contracts/前端页面需求说明-开发版.md`。
|
||||||
- 分支、提交、Pull Request、Review 和冲突处理规范见 `docs/Git使用细则-团队开发版.md`。
|
- 分支、提交、Pull Request、Review 和冲突处理规范见 `docs/guides/Git使用细则-团队开发版.md`。
|
||||||
|
- CI 检查、产物、发布和回滚规范见 `docs/guides/CI-CD细则-团队开发版.md`。
|
||||||
|
|||||||
+4
-4
@@ -23,10 +23,10 @@ uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
|
|||||||
uv run pytest
|
uv run pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
当前基线为 71 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。
|
当前基线为 81 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 或 `AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。
|
||||||
|
|
||||||
团队接口清单见 `../docs/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。
|
团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。
|
||||||
|
|
||||||
AI Core 与 Agent Core 的模块边界、Mock Provider 和 Tool Calling 调试方式见 `../docs/AI-Core与Agent-Core开发说明.md`。
|
AI Core 与 Agent Core 的模块边界、Mock Provider 和 Tool Calling 调试方式见 `../docs/development/AI-Core与Agent-Core开发说明.md`。
|
||||||
|
|
||||||
Knowledge Core 与 Retrieval Core 的模块边界、数据模型、接口与检索流程见 `../docs/Knowledge与Retrieval-Core开发说明.md`。
|
Knowledge Core 与 Retrieval Core 的模块边界、数据模型、接口与检索流程见 `../docs/development/Knowledge与Retrieval-Core开发说明.md`。
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"""Agent 工具权限策略与一次性确认票据。"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
@@ -51,6 +53,7 @@ class PermissionPolicy:
|
|||||||
def mode_for(self, permission: str | None) -> PermissionMode:
|
def mode_for(self, permission: str | None) -> PermissionMode:
|
||||||
if permission is None:
|
if permission is None:
|
||||||
return PermissionMode.allow
|
return PermissionMode.allow
|
||||||
|
# 未登记权限一律拒绝,防止扩展通过拼写错误或新权限绕过策略。
|
||||||
return self._rules.get(permission, PermissionMode.deny)
|
return self._rules.get(permission, PermissionMode.deny)
|
||||||
|
|
||||||
|
|
||||||
@@ -63,6 +66,8 @@ class PermissionTicket:
|
|||||||
|
|
||||||
|
|
||||||
class PermissionManager:
|
class PermissionManager:
|
||||||
|
"""管理当前进程内的确认请求与会话级授权。"""
|
||||||
|
|
||||||
def __init__(self, policy: PermissionPolicy) -> None:
|
def __init__(self, policy: PermissionPolicy) -> None:
|
||||||
self.policy = policy
|
self.policy = policy
|
||||||
self._pending: dict[tuple[str, str], PermissionTicket] = {}
|
self._pending: dict[tuple[str, str], PermissionTicket] = {}
|
||||||
@@ -94,10 +99,16 @@ class PermissionManager:
|
|||||||
if ticket is None or ticket.future.done():
|
if ticket is None or ticket.future.done():
|
||||||
return False
|
return False
|
||||||
if decision == "allow_session":
|
if decision == "allow_session":
|
||||||
|
# 会话授权只存在于进程内,应用重启后按默认策略重新确认。
|
||||||
self._session_grants.add(ticket.permission)
|
self._session_grants.add(ticket.permission)
|
||||||
ticket.future.set_result(decision)
|
ticket.future.set_result(decision)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def get_ticket(self, run_id: str, request_id: str) -> PermissionTicket | None:
|
||||||
|
"""只读返回待确认票据,供 Trace 记录权限类型;不暴露 Future 给接口层。"""
|
||||||
|
|
||||||
|
return self._pending.get((run_id, request_id))
|
||||||
|
|
||||||
def cancel_run(self, run_id: str) -> None:
|
def cancel_run(self, run_id: str) -> None:
|
||||||
for key, ticket in list(self._pending.items()):
|
for key, ticket in list(self._pending.items()):
|
||||||
if ticket.run_id == run_id:
|
if ticket.run_id == run_id:
|
||||||
|
|||||||
+188
-32
@@ -1,3 +1,5 @@
|
|||||||
|
"""Agent 运行时:负责模型轮次、工具调用、权限确认与事件发布。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -5,17 +7,20 @@ import json
|
|||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from time import perf_counter
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from app.agent.permissions import PermissionManager, PermissionMode
|
from app.agent.permissions import PermissionManager, PermissionMode
|
||||||
from app.agent.tools import ToolExecutionContext, ToolNotFoundError, ToolRegistry
|
from app.agent.tools import ToolExecutionContext, ToolNotFoundError, ToolRegistry
|
||||||
|
from app.agent.trace_repository import AgentTraceRepository, sanitize_trace_value
|
||||||
from app.contracts import (
|
from app.contracts import (
|
||||||
AgentEvent,
|
AgentEvent,
|
||||||
AgentEventType,
|
AgentEventType,
|
||||||
AgentRun,
|
AgentRun,
|
||||||
AgentRunCreateRequest,
|
AgentRunCreateRequest,
|
||||||
AgentRunStatus,
|
AgentRunStatus,
|
||||||
|
AgentTraceResponse,
|
||||||
Citation,
|
Citation,
|
||||||
Message,
|
Message,
|
||||||
MessageRole,
|
MessageRole,
|
||||||
@@ -50,6 +55,8 @@ MAX_TOOL_CALLS_PER_TURN = 50
|
|||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class RunRecord:
|
class RunRecord:
|
||||||
|
"""单次运行的可变上下文,仅由 AgentRuntime 持有。"""
|
||||||
|
|
||||||
run: AgentRun
|
run: AgentRun
|
||||||
request: AgentRunCreateRequest
|
request: AgentRunCreateRequest
|
||||||
skill_config: AgentConfiguration | None = None
|
skill_config: AgentConfiguration | None = None
|
||||||
@@ -57,20 +64,25 @@ class RunRecord:
|
|||||||
events: list[AgentEvent] = field(default_factory=list)
|
events: list[AgentEvent] = field(default_factory=list)
|
||||||
subscribers: set[asyncio.Queue[AgentEvent]] = field(default_factory=set)
|
subscribers: set[asyncio.Queue[AgentEvent]] = field(default_factory=set)
|
||||||
task: asyncio.Task[None] | None = None
|
task: asyncio.Task[None] | None = None
|
||||||
|
next_sequence: int = 0
|
||||||
|
|
||||||
|
|
||||||
class AgentRuntime:
|
class AgentRuntime:
|
||||||
|
"""进程内 Agent 编排器;对外返回深拷贝,避免调用方修改运行状态。"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
providers: ProviderRegistry,
|
providers: ProviderRegistry,
|
||||||
tools: ToolRegistry,
|
tools: ToolRegistry,
|
||||||
permissions: PermissionManager,
|
permissions: PermissionManager,
|
||||||
skills: SkillRuntime | None = None,
|
skills: SkillRuntime | None = None,
|
||||||
|
trace_repository: AgentTraceRepository | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.providers = providers
|
self.providers = providers
|
||||||
self.tools = tools
|
self.tools = tools
|
||||||
self.permissions = permissions
|
self.permissions = permissions
|
||||||
self.skills = skills
|
self.skills = skills
|
||||||
|
self.trace_repository = trace_repository or AgentTraceRepository()
|
||||||
self._records: dict[str, RunRecord] = {}
|
self._records: dict[str, RunRecord] = {}
|
||||||
|
|
||||||
async def create_run(self, request: AgentRunCreateRequest) -> AgentRun:
|
async def create_run(self, request: AgentRunCreateRequest) -> AgentRun:
|
||||||
@@ -98,6 +110,7 @@ class AgentRuntime:
|
|||||||
)
|
)
|
||||||
allowed_tools = list(request.allowed_tools)
|
allowed_tools = list(request.allowed_tools)
|
||||||
if skill_config is not None:
|
if skill_config is not None:
|
||||||
|
# 同时指定 Skill 与工具白名单时取交集,避免 Skill 扩大调用权限。
|
||||||
allowed_tools = (
|
allowed_tools = (
|
||||||
[name for name in skill_config.allowed_tools if name in allowed_tools]
|
[name for name in skill_config.allowed_tools if name in allowed_tools]
|
||||||
if allowed_tools
|
if allowed_tools
|
||||||
@@ -109,22 +122,38 @@ class AgentRuntime:
|
|||||||
skill_config=skill_config,
|
skill_config=skill_config,
|
||||||
allowed_tools=allowed_tools,
|
allowed_tools=allowed_tools,
|
||||||
)
|
)
|
||||||
|
self.trace_repository.create_run(
|
||||||
|
run,
|
||||||
|
request,
|
||||||
|
self._config_snapshot(record),
|
||||||
|
)
|
||||||
self._records[run.run_id] = record
|
self._records[run.run_id] = record
|
||||||
record.task = asyncio.create_task(self._execute(record), name=run.run_id)
|
record.task = asyncio.create_task(self._execute(record), name=run.run_id)
|
||||||
return run.model_copy(deep=True)
|
return run.model_copy(deep=True)
|
||||||
|
|
||||||
def get_run(self, run_id: str) -> AgentRun:
|
def get_run(self, run_id: str) -> AgentRun:
|
||||||
return self._get_record(run_id).run.model_copy(deep=True)
|
record = self._records.get(run_id)
|
||||||
|
if record is not None:
|
||||||
|
return record.run.model_copy(deep=True)
|
||||||
|
run = self.trace_repository.recover_interrupted(run_id)
|
||||||
|
if run is None:
|
||||||
|
raise AgentRunNotFoundError(run_id)
|
||||||
|
return run.model_copy(deep=True)
|
||||||
|
|
||||||
def list_runs(self, limit: int, offset: int) -> tuple[list[AgentRun], int]:
|
def list_runs(self, limit: int, offset: int) -> tuple[list[AgentRun], int]:
|
||||||
records = sorted(
|
items, total = self.trace_repository.list_runs(limit=limit, offset=offset)
|
||||||
self._records.values(), key=lambda item: item.run.created_at, reverse=True
|
recovered = [
|
||||||
)
|
self.trace_repository.recover_interrupted(item.run_id) or item
|
||||||
items = [item.run.model_copy(deep=True) for item in records[offset : offset + limit]]
|
if item.run_id not in self._records
|
||||||
return items, len(records)
|
else self._records[item.run_id].run.model_copy(deep=True)
|
||||||
|
for item in items
|
||||||
|
]
|
||||||
|
return recovered, total
|
||||||
|
|
||||||
async def cancel(self, run_id: str) -> AgentRun:
|
async def cancel(self, run_id: str) -> AgentRun:
|
||||||
record = self._get_record(run_id)
|
record = self._records.get(run_id)
|
||||||
|
if record is None:
|
||||||
|
return self.get_run(run_id)
|
||||||
if record.run.status in TERMINAL_STATUSES:
|
if record.run.status in TERMINAL_STATUSES:
|
||||||
return record.run.model_copy(deep=True)
|
return record.run.model_copy(deep=True)
|
||||||
record.run.cancelled = True
|
record.run.cancelled = True
|
||||||
@@ -137,21 +166,53 @@ class AgentRuntime:
|
|||||||
return record.run.model_copy(deep=True)
|
return record.run.model_copy(deep=True)
|
||||||
|
|
||||||
def resolve_permission(self, run_id: str, request_id: str, decision: str) -> bool:
|
def resolve_permission(self, run_id: str, request_id: str, decision: str) -> bool:
|
||||||
self._get_record(run_id)
|
record = self._records.get(run_id)
|
||||||
return self.permissions.resolve(run_id, request_id, decision)
|
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(
|
||||||
|
record,
|
||||||
|
AgentEventType.permission_resolved,
|
||||||
|
{
|
||||||
|
"request_id": request_id,
|
||||||
|
"permission": ticket.permission if ticket else None,
|
||||||
|
"decision": decision,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return resolved
|
||||||
|
|
||||||
async def events(self, run_id: str) -> AsyncIterator[AgentEvent]:
|
async def events(
|
||||||
record = self._get_record(run_id)
|
self, run_id: str, *, after_sequence: int = -1
|
||||||
|
) -> AsyncIterator[AgentEvent]:
|
||||||
|
record = self._records.get(run_id)
|
||||||
|
run = self.get_run(run_id)
|
||||||
|
if record is None:
|
||||||
|
for event in self.trace_repository.list_events(
|
||||||
|
run_id, after_sequence=after_sequence
|
||||||
|
):
|
||||||
|
yield event
|
||||||
|
return
|
||||||
|
|
||||||
|
# 先注册订阅再读持久化历史;同一事件循环内没有 await,不会丢失交界事件。
|
||||||
queue: asyncio.Queue[AgentEvent] = asyncio.Queue()
|
queue: asyncio.Queue[AgentEvent] = asyncio.Queue()
|
||||||
record.subscribers.add(queue)
|
record.subscribers.add(queue)
|
||||||
history = [event.model_copy(deep=True) for event in record.events]
|
history = self.trace_repository.list_events(
|
||||||
|
run_id, after_sequence=after_sequence
|
||||||
|
)
|
||||||
|
last_sequence = after_sequence
|
||||||
try:
|
try:
|
||||||
for event in history:
|
for event in history:
|
||||||
|
last_sequence = event.sequence
|
||||||
yield event
|
yield event
|
||||||
if record.run.status in TERMINAL_STATUSES:
|
if run.status in TERMINAL_STATUSES:
|
||||||
return
|
return
|
||||||
while True:
|
while True:
|
||||||
event = await queue.get()
|
event = await queue.get()
|
||||||
|
if event.sequence <= last_sequence:
|
||||||
|
continue
|
||||||
|
last_sequence = event.sequence
|
||||||
yield event.model_copy(deep=True)
|
yield event.model_copy(deep=True)
|
||||||
if event.event in {
|
if event.event in {
|
||||||
AgentEventType.run_completed,
|
AgentEventType.run_completed,
|
||||||
@@ -163,7 +224,9 @@ class AgentRuntime:
|
|||||||
record.subscribers.discard(queue)
|
record.subscribers.discard(queue)
|
||||||
|
|
||||||
async def wait(self, run_id: str) -> AgentRun:
|
async def wait(self, run_id: str) -> AgentRun:
|
||||||
record = self._get_record(run_id)
|
record = self._records.get(run_id)
|
||||||
|
if record is None:
|
||||||
|
return self.get_run(run_id)
|
||||||
if record.task:
|
if record.task:
|
||||||
try:
|
try:
|
||||||
await asyncio.shield(record.task)
|
await asyncio.shield(record.task)
|
||||||
@@ -171,6 +234,17 @@ class AgentRuntime:
|
|||||||
pass
|
pass
|
||||||
return record.run.model_copy(deep=True)
|
return record.run.model_copy(deep=True)
|
||||||
|
|
||||||
|
def get_trace(
|
||||||
|
self, run_id: str, *, after_sequence: int, limit: int
|
||||||
|
) -> AgentTraceResponse:
|
||||||
|
self.get_run(run_id)
|
||||||
|
trace = self.trace_repository.get_trace(
|
||||||
|
run_id, after_sequence=after_sequence, limit=limit
|
||||||
|
)
|
||||||
|
if trace is None:
|
||||||
|
raise AgentRunNotFoundError(run_id)
|
||||||
|
return trace
|
||||||
|
|
||||||
async def _execute(self, record: RunRecord) -> None:
|
async def _execute(self, record: RunRecord) -> None:
|
||||||
try:
|
try:
|
||||||
async with asyncio.timeout(record.request.run_timeout_seconds):
|
async with asyncio.timeout(record.request.run_timeout_seconds):
|
||||||
@@ -201,15 +275,51 @@ class AgentRuntime:
|
|||||||
for step in range(1, record.request.max_steps + 1):
|
for step in range(1, record.request.max_steps + 1):
|
||||||
record.run.current_step = step
|
record.run.current_step = step
|
||||||
record.run.updated_at = datetime.now(timezone.utc)
|
record.run.updated_at = datetime.now(timezone.utc)
|
||||||
turn = await provider.complete(
|
model_call_id = f"model_call_{uuid4().hex}"
|
||||||
ModelRequest(
|
started_at = perf_counter()
|
||||||
provider_id=record.request.provider_id,
|
self._publish(
|
||||||
model=record.request.model,
|
record,
|
||||||
system=(record.skill_config.system_prompt if record.skill_config else None),
|
AgentEventType.model_call_started,
|
||||||
messages=messages,
|
{
|
||||||
tools=allowed_tools,
|
"model_call_id": model_call_id,
|
||||||
metadata=self._request_metadata(record),
|
"step": step,
|
||||||
|
"provider_id": record.request.provider_id,
|
||||||
|
"model": record.request.model,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
turn = await provider.complete(
|
||||||
|
ModelRequest(
|
||||||
|
provider_id=record.request.provider_id,
|
||||||
|
model=record.request.model,
|
||||||
|
system=(record.skill_config.system_prompt if record.skill_config else None),
|
||||||
|
messages=messages,
|
||||||
|
tools=allowed_tools,
|
||||||
|
metadata=self._request_metadata(record),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self._publish(
|
||||||
|
record,
|
||||||
|
AgentEventType.model_call_failed,
|
||||||
|
{
|
||||||
|
"model_call_id": model_call_id,
|
||||||
|
"duration_ms": int((perf_counter() - started_at) * 1000),
|
||||||
|
"error_code": getattr(exc, "code", type(exc).__name__),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
self._publish(
|
||||||
|
record,
|
||||||
|
AgentEventType.model_call_completed,
|
||||||
|
{
|
||||||
|
"model_call_id": model_call_id,
|
||||||
|
"duration_ms": int((perf_counter() - started_at) * 1000),
|
||||||
|
"finish_reason": "tool_calls" if turn.tool_calls else "stop",
|
||||||
|
"input_tokens": turn.input_tokens,
|
||||||
|
"output_tokens": turn.output_tokens,
|
||||||
|
"tool_call_count": len(turn.tool_calls),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
record.run.token_usage += turn.input_tokens + turn.output_tokens
|
record.run.token_usage += turn.input_tokens + turn.output_tokens
|
||||||
self._publish(
|
self._publish(
|
||||||
@@ -243,11 +353,12 @@ class AgentRuntime:
|
|||||||
messages.append(
|
messages.append(
|
||||||
Message(role=MessageRole.assistant, content=turn.text or "", tool_calls=calls)
|
Message(role=MessageRole.assistant, content=turn.text or "", tool_calls=calls)
|
||||||
)
|
)
|
||||||
|
# 工具可以并发执行,但结果按模型原始调用顺序写回上下文,保证轮次可复现。
|
||||||
semaphore = asyncio.Semaphore(record.request.max_concurrent_tools)
|
semaphore = asyncio.Semaphore(record.request.max_concurrent_tools)
|
||||||
|
|
||||||
async def execute(call: ToolCall) -> ToolResult:
|
async def execute(call: ToolCall) -> ToolResult:
|
||||||
async with semaphore:
|
async with semaphore:
|
||||||
return await self._execute_tool(record, call)
|
return await self._execute_tool(record, call, model_call_id)
|
||||||
|
|
||||||
results = await asyncio.gather(*(execute(call) for call in calls))
|
results = await asyncio.gather(*(execute(call) for call in calls))
|
||||||
for call, result in zip(calls, results):
|
for call, result in zip(calls, results):
|
||||||
@@ -280,8 +391,13 @@ class AgentRuntime:
|
|||||||
|
|
||||||
self._fail(record, "MAX_STEPS_EXCEEDED", "Agent reached its maximum step count.")
|
self._fail(record, "MAX_STEPS_EXCEEDED", "Agent reached its maximum step count.")
|
||||||
|
|
||||||
async def _execute_tool(self, record: RunRecord, call: ToolCall) -> ToolResult:
|
async def _execute_tool(
|
||||||
self._publish(record, AgentEventType.tool_call, call.model_dump(mode="json"))
|
self, record: RunRecord, call: ToolCall, parent_model_call_id: str
|
||||||
|
) -> ToolResult:
|
||||||
|
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)
|
||||||
try:
|
try:
|
||||||
registered = self.tools.get(call.name)
|
registered = self.tools.get(call.name)
|
||||||
except ToolNotFoundError:
|
except ToolNotFoundError:
|
||||||
@@ -295,7 +411,9 @@ class AgentRuntime:
|
|||||||
error_code="TOOL_NOT_ALLOWED",
|
error_code="TOOL_NOT_ALLOWED",
|
||||||
error_message="Tool is not included in allowed_tools.",
|
error_message="Tool is not included in allowed_tools.",
|
||||||
)
|
)
|
||||||
self._publish(record, AgentEventType.tool_result, result.model_dump(mode="json"))
|
self._publish_tool_result(
|
||||||
|
record, result, parent_model_call_id, started_at
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
permission = registered.definition.permission if registered else None
|
permission = registered.definition.permission if registered else None
|
||||||
@@ -307,12 +425,15 @@ class AgentRuntime:
|
|||||||
error_code="NETWORK_NOT_ALLOWED",
|
error_code="NETWORK_NOT_ALLOWED",
|
||||||
error_message="Agent run does not allow network tools.",
|
error_message="Agent run does not allow network tools.",
|
||||||
)
|
)
|
||||||
self._publish(record, AgentEventType.tool_result, result.model_dump(mode="json"))
|
self._publish_tool_result(
|
||||||
|
record, result, parent_model_call_id, started_at
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
mode = self.permissions.mode_for(permission)
|
mode = self.permissions.mode_for(permission)
|
||||||
if mode == PermissionMode.deny:
|
if mode == PermissionMode.deny:
|
||||||
result = self._permission_denied(call)
|
result = self._permission_denied(call)
|
||||||
elif mode == PermissionMode.confirm and permission:
|
elif mode == PermissionMode.confirm and permission:
|
||||||
|
# 运行状态必须在等待期间可见,前端才能展示并处理权限确认卡片。
|
||||||
ticket = self.permissions.create_ticket(record.run.run_id, permission)
|
ticket = self.permissions.create_ticket(record.run.run_id, permission)
|
||||||
record.run.status = AgentRunStatus.waiting_permission
|
record.run.status = AgentRunStatus.waiting_permission
|
||||||
self._publish(
|
self._publish(
|
||||||
@@ -337,11 +458,13 @@ class AgentRuntime:
|
|||||||
error_code="PERMISSION_TIMEOUT",
|
error_code="PERMISSION_TIMEOUT",
|
||||||
error_message="Tool permission confirmation timed out.",
|
error_message="Tool permission confirmation timed out.",
|
||||||
)
|
)
|
||||||
self._publish(
|
self._publish_tool_result(
|
||||||
record, AgentEventType.tool_result, result.model_dump(mode="json")
|
record, result, parent_model_call_id, started_at
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
record.run.status = AgentRunStatus.running
|
record.run.status = AgentRunStatus.running
|
||||||
|
record.run.updated_at = datetime.now(timezone.utc)
|
||||||
|
self.trace_repository.save_run(record.run)
|
||||||
result = (
|
result = (
|
||||||
await self._invoke_tool(record, call)
|
await self._invoke_tool(record, call)
|
||||||
if decision in {"allow_once", "allow_session"}
|
if decision in {"allow_once", "allow_session"}
|
||||||
@@ -350,9 +473,21 @@ class AgentRuntime:
|
|||||||
else:
|
else:
|
||||||
result = await self._invoke_tool(record, call)
|
result = await self._invoke_tool(record, call)
|
||||||
|
|
||||||
self._publish(record, AgentEventType.tool_result, result.model_dump(mode="json"))
|
self._publish_tool_result(record, result, parent_model_call_id, started_at)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def _publish_tool_result(
|
||||||
|
self,
|
||||||
|
record: RunRecord,
|
||||||
|
result: ToolResult,
|
||||||
|
parent_model_call_id: str,
|
||||||
|
started_at: float,
|
||||||
|
) -> None:
|
||||||
|
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)
|
||||||
|
|
||||||
async def _invoke_tool(self, record: RunRecord, call: ToolCall) -> ToolResult:
|
async def _invoke_tool(self, record: RunRecord, call: ToolCall) -> ToolResult:
|
||||||
try:
|
try:
|
||||||
return await asyncio.wait_for(
|
return await asyncio.wait_for(
|
||||||
@@ -400,14 +535,19 @@ class AgentRuntime:
|
|||||||
def _publish(
|
def _publish(
|
||||||
self, record: RunRecord, event_type: AgentEventType, data: dict[str, object]
|
self, record: RunRecord, event_type: AgentEventType, data: dict[str, object]
|
||||||
) -> None:
|
) -> None:
|
||||||
|
sanitized = sanitize_trace_value(data)
|
||||||
|
assert isinstance(sanitized, dict)
|
||||||
event = AgentEvent(
|
event = AgentEvent(
|
||||||
event=event_type,
|
event=event_type,
|
||||||
run_id=record.run.run_id,
|
run_id=record.run.run_id,
|
||||||
sequence=len(record.events),
|
sequence=record.next_sequence,
|
||||||
data=data,
|
data=sanitized,
|
||||||
timestamp=datetime.now(timezone.utc),
|
timestamp=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
|
record.next_sequence += 1
|
||||||
record.events.append(event)
|
record.events.append(event)
|
||||||
|
self.trace_repository.append_event(record.run, event)
|
||||||
|
# 内存只保留实时订阅窗口;完整审计轨迹由 SQLite 保存。
|
||||||
if len(record.events) > MAX_EVENTS_PER_RUN:
|
if len(record.events) > MAX_EVENTS_PER_RUN:
|
||||||
del record.events[: len(record.events) - MAX_EVENTS_PER_RUN]
|
del record.events[: len(record.events) - MAX_EVENTS_PER_RUN]
|
||||||
for queue in record.subscribers:
|
for queue in record.subscribers:
|
||||||
@@ -421,6 +561,21 @@ class AgentRuntime:
|
|||||||
metadata["retrieval"] = record.skill_config.retrieval.model_dump(mode="json")
|
metadata["retrieval"] = record.skill_config.retrieval.model_dump(mode="json")
|
||||||
return metadata
|
return metadata
|
||||||
|
|
||||||
|
def _config_snapshot(self, record: RunRecord) -> dict[str, object]:
|
||||||
|
provider = self.providers.get(record.request.provider_id).config
|
||||||
|
return {
|
||||||
|
"provider_id": record.request.provider_id,
|
||||||
|
"provider_type": provider.provider_type.value,
|
||||||
|
"model": record.request.model,
|
||||||
|
"capabilities": [item.value for item in provider.capabilities],
|
||||||
|
"skill_id": record.request.skill_id,
|
||||||
|
"allowed_tools": list(record.allowed_tools),
|
||||||
|
"max_steps": record.request.max_steps,
|
||||||
|
"token_budget": record.request.token_budget,
|
||||||
|
"allow_network": record.request.allow_network,
|
||||||
|
"metadata": record.request.metadata,
|
||||||
|
}
|
||||||
|
|
||||||
def _collect_citations(self, record: RunRecord, result: ToolResult) -> None:
|
def _collect_citations(self, record: RunRecord, result: ToolResult) -> None:
|
||||||
if not result.success or not isinstance(result.output, dict):
|
if not result.success or not isinstance(result.output, dict):
|
||||||
return
|
return
|
||||||
@@ -448,6 +603,7 @@ class AgentRuntime:
|
|||||||
raise AgentRunNotFoundError(run_id) from exc
|
raise AgentRunNotFoundError(run_id) from exc
|
||||||
|
|
||||||
def _prune_records(self) -> None:
|
def _prune_records(self) -> None:
|
||||||
|
# 只清理终态记录,绝不为了容量取消仍在执行或等待授权的任务。
|
||||||
overflow = len(self._records) - MAX_RUN_RECORDS + 1
|
overflow = len(self._records) - MAX_RUN_RECORDS + 1
|
||||||
if overflow <= 0:
|
if overflow <= 0:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"""Agent 工具注册与执行边界。"""
|
||||||
|
|
||||||
import inspect
|
import inspect
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
@@ -29,6 +31,8 @@ class ToolNotFoundError(LookupError):
|
|||||||
|
|
||||||
|
|
||||||
class ToolRegistry:
|
class ToolRegistry:
|
||||||
|
"""统一校验工具入参并隔离执行异常,避免单个工具击穿 Agent 主循环。"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._tools: dict[str, RegisteredTool] = {}
|
self._tools: dict[str, RegisteredTool] = {}
|
||||||
|
|
||||||
@@ -80,6 +84,7 @@ class ToolRegistry:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# JSON Schema 约束模型可见的协议,Pydantic 再完成运行时类型转换。
|
||||||
Draft202012Validator(registered.definition.parameters).validate(call.arguments)
|
Draft202012Validator(registered.definition.parameters).validate(call.arguments)
|
||||||
arguments = registered.arguments_model.model_validate(call.arguments)
|
arguments = registered.arguments_model.model_validate(call.arguments)
|
||||||
except (ValidationError, JsonSchemaValidationError) as exc:
|
except (ValidationError, JsonSchemaValidationError) as exc:
|
||||||
@@ -103,7 +108,7 @@ class ToolRegistry:
|
|||||||
output=output,
|
output=output,
|
||||||
duration_ms=round((perf_counter() - started) * 1000),
|
duration_ms=round((perf_counter() - started) * 1000),
|
||||||
)
|
)
|
||||||
except Exception as exc: # Tool failures are isolated from the Agent loop.
|
except Exception as exc: # 工具失败转换成结构化结果,由模型决定是否降级或重试。
|
||||||
return ToolResult(
|
return ToolResult(
|
||||||
tool_call_id=call.tool_call_id,
|
tool_call_id=call.tool_call_id,
|
||||||
name=call.name,
|
name=call.name,
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
"""Agent Run/Event 持久化与 Trace 查询。
|
||||||
|
|
||||||
|
SQLite 中的事件是 SSE、前端 Trace 和 Benchmark 的共同事实来源。写入前统一脱敏和
|
||||||
|
限长,避免 Secret 或无限大的 Tool Result 进入审计数据。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.contracts import (
|
||||||
|
AgentEvent,
|
||||||
|
AgentEventType,
|
||||||
|
AgentRun,
|
||||||
|
AgentRunCreateRequest,
|
||||||
|
AgentRunStatus,
|
||||||
|
AgentTraceResponse,
|
||||||
|
AgentTraceSummary,
|
||||||
|
)
|
||||||
|
from app.database.db import connect, transaction
|
||||||
|
|
||||||
|
MAX_TRACE_STRING = 4_096
|
||||||
|
MAX_TRACE_COLLECTION = 100
|
||||||
|
MAX_TRACE_DEPTH = 8
|
||||||
|
_SECRET_KEYS = {
|
||||||
|
"api_key",
|
||||||
|
"apikey",
|
||||||
|
"authorization",
|
||||||
|
"access_token",
|
||||||
|
"refresh_token",
|
||||||
|
"client_secret",
|
||||||
|
"password",
|
||||||
|
"secret",
|
||||||
|
"token",
|
||||||
|
}
|
||||||
|
_SECRET_KEY_SUFFIXES = ("_api_key", "_password", "_secret")
|
||||||
|
_TERMINAL_VALUES = {
|
||||||
|
AgentRunStatus.completed.value,
|
||||||
|
AgentRunStatus.failed.value,
|
||||||
|
AgentRunStatus.cancelled.value,
|
||||||
|
}
|
||||||
|
_BEARER_PATTERN = re.compile(r"(?i)\bBearer\s+[^\s,;]+")
|
||||||
|
_API_KEY_PATTERN = re.compile(r"\bsk-[A-Za-z0-9_-]{8,}\b")
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_trace_value(
|
||||||
|
value: Any, *, depth: int = 0, apply_limits: bool = True
|
||||||
|
) -> Any:
|
||||||
|
"""递归净化持久化数据;可按审计用途限制体积,Secret 始终脱敏。"""
|
||||||
|
|
||||||
|
if apply_limits and depth >= MAX_TRACE_DEPTH:
|
||||||
|
return "[MAX_DEPTH]"
|
||||||
|
if isinstance(value, dict):
|
||||||
|
sanitized: dict[str, Any] = {}
|
||||||
|
for index, (key, item) in enumerate(value.items()):
|
||||||
|
if apply_limits and index >= MAX_TRACE_COLLECTION:
|
||||||
|
sanitized["__truncated__"] = True
|
||||||
|
break
|
||||||
|
normalized = str(key).casefold().replace("-", "_")
|
||||||
|
sanitized[str(key)] = (
|
||||||
|
"[REDACTED]"
|
||||||
|
if normalized in _SECRET_KEYS
|
||||||
|
or normalized.endswith(_SECRET_KEY_SUFFIXES)
|
||||||
|
else sanitize_trace_value(
|
||||||
|
item, depth=depth + 1, apply_limits=apply_limits
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return sanitized
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
source_items = value[:MAX_TRACE_COLLECTION] if apply_limits else value
|
||||||
|
items = [
|
||||||
|
sanitize_trace_value(
|
||||||
|
item, depth=depth + 1, apply_limits=apply_limits
|
||||||
|
)
|
||||||
|
for item in source_items
|
||||||
|
]
|
||||||
|
if apply_limits and len(value) > MAX_TRACE_COLLECTION:
|
||||||
|
items.append("[TRUNCATED]")
|
||||||
|
return items
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = _BEARER_PATTERN.sub("Bearer [REDACTED]", value)
|
||||||
|
value = _API_KEY_PATTERN.sub("[REDACTED]", value)
|
||||||
|
if apply_limits and len(value) > MAX_TRACE_STRING:
|
||||||
|
return f"{value[:MAX_TRACE_STRING]}...[TRUNCATED]"
|
||||||
|
return value
|
||||||
|
if value is None or isinstance(value, (str, int, float, bool)):
|
||||||
|
return value
|
||||||
|
return sanitize_trace_value(
|
||||||
|
str(value), depth=depth + 1, apply_limits=apply_limits
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentTraceRepository:
|
||||||
|
def create_run(
|
||||||
|
self,
|
||||||
|
run: AgentRun,
|
||||||
|
request: AgentRunCreateRequest,
|
||||||
|
config_snapshot: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
with transaction(conn):
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_runs(
|
||||||
|
run_id, status, run_json, request_json, config_snapshot_json,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
run.run_id,
|
||||||
|
run.status.value,
|
||||||
|
self._serialize_run(run),
|
||||||
|
json.dumps(
|
||||||
|
sanitize_trace_value(request.model_dump(mode="json")),
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
json.dumps(
|
||||||
|
sanitize_trace_value(config_snapshot), ensure_ascii=False
|
||||||
|
),
|
||||||
|
run.created_at.isoformat(),
|
||||||
|
run.updated_at.isoformat(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def save_run(self, run: AgentRun) -> None:
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
with transaction(conn):
|
||||||
|
self._update_run(conn, run)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def append_event(self, run: AgentRun, event: AgentEvent) -> None:
|
||||||
|
"""在同一事务中保存最新 Run 和事件;复写同一序号时保持幂等。"""
|
||||||
|
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
with transaction(conn):
|
||||||
|
self._update_run(conn, run)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_events(run_id, sequence, event, data_json, timestamp)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(run_id, sequence) DO NOTHING
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
event.run_id,
|
||||||
|
event.sequence,
|
||||||
|
event.event.value,
|
||||||
|
json.dumps(event.data, ensure_ascii=False),
|
||||||
|
event.timestamp.isoformat(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def get_run(self, run_id: str) -> AgentRun | None:
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT run_json FROM agent_runs WHERE run_id = ?", (run_id,)
|
||||||
|
).fetchone()
|
||||||
|
return AgentRun.model_validate_json(row["run_json"]) if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def list_runs(self, limit: int, offset: int) -> tuple[list[AgentRun], int]:
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
total = int(conn.execute("SELECT COUNT(*) FROM agent_runs").fetchone()[0])
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT run_json FROM agent_runs
|
||||||
|
ORDER BY created_at DESC LIMIT ? OFFSET ?
|
||||||
|
""",
|
||||||
|
(limit, offset),
|
||||||
|
).fetchall()
|
||||||
|
return [AgentRun.model_validate_json(row["run_json"]) for row in rows], total
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def list_events(
|
||||||
|
self, run_id: str, *, after_sequence: int = -1, limit: int | None = None
|
||||||
|
) -> list[AgentEvent]:
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
sql = """
|
||||||
|
SELECT event, sequence, data_json, timestamp
|
||||||
|
FROM agent_events
|
||||||
|
WHERE run_id = ? AND sequence > ?
|
||||||
|
ORDER BY sequence
|
||||||
|
"""
|
||||||
|
params: tuple[Any, ...] = (run_id, after_sequence)
|
||||||
|
if limit is not None:
|
||||||
|
sql += " LIMIT ?"
|
||||||
|
params += (limit,)
|
||||||
|
return [self._event_from_row(run_id, row) for row in conn.execute(sql, params)]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def get_trace(
|
||||||
|
self, run_id: str, *, after_sequence: int, limit: int
|
||||||
|
) -> AgentTraceResponse | None:
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT run_json, config_snapshot_json
|
||||||
|
FROM agent_runs WHERE run_id = ?
|
||||||
|
""",
|
||||||
|
(run_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
run = AgentRun.model_validate_json(row["run_json"])
|
||||||
|
event_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT event, sequence, data_json, timestamp
|
||||||
|
FROM agent_events
|
||||||
|
WHERE run_id = ? AND sequence > ?
|
||||||
|
ORDER BY sequence LIMIT ?
|
||||||
|
""",
|
||||||
|
(run_id, after_sequence, limit + 1),
|
||||||
|
).fetchall()
|
||||||
|
has_more = len(event_rows) > limit
|
||||||
|
items = [
|
||||||
|
self._event_from_row(run_id, item) for item in event_rows[:limit]
|
||||||
|
]
|
||||||
|
counts = {
|
||||||
|
item["event"]: int(item["count"])
|
||||||
|
for item in conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT event, COUNT(*) AS count
|
||||||
|
FROM agent_events WHERE run_id = ? GROUP BY event
|
||||||
|
""",
|
||||||
|
(run_id,),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
tool_errors = int(
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FROM agent_events
|
||||||
|
WHERE run_id = ? AND event = 'ToolResult'
|
||||||
|
AND json_extract(data_json, '$.success') = 0
|
||||||
|
""",
|
||||||
|
(run_id,),
|
||||||
|
).fetchone()[0]
|
||||||
|
)
|
||||||
|
errors = (
|
||||||
|
counts.get(AgentEventType.run_failed.value, 0)
|
||||||
|
+ counts.get(AgentEventType.model_call_failed.value, 0)
|
||||||
|
+ tool_errors
|
||||||
|
)
|
||||||
|
duration_ms = max(
|
||||||
|
0, int((run.updated_at - run.created_at).total_seconds() * 1000)
|
||||||
|
)
|
||||||
|
return AgentTraceResponse(
|
||||||
|
run_id=run_id,
|
||||||
|
status=run.status,
|
||||||
|
items=items,
|
||||||
|
next_sequence=items[-1].sequence if items else after_sequence,
|
||||||
|
has_more=has_more,
|
||||||
|
summary=AgentTraceSummary(
|
||||||
|
model_calls=counts.get(AgentEventType.model_call_started.value, 0),
|
||||||
|
tool_calls=counts.get(AgentEventType.tool_call.value, 0),
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
token_usage=run.token_usage,
|
||||||
|
errors=errors,
|
||||||
|
),
|
||||||
|
config_snapshot=json.loads(row["config_snapshot_json"]),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def recover_interrupted(self, run_id: str) -> AgentRun | None:
|
||||||
|
"""把上个进程遗留的非终态 Run 收束为失败,并追加可回放终止事件。"""
|
||||||
|
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
with transaction(conn):
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT run_json, status FROM agent_runs WHERE run_id = ?", (run_id,)
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
run = AgentRun.model_validate_json(row["run_json"])
|
||||||
|
if row["status"] in _TERMINAL_VALUES:
|
||||||
|
return run
|
||||||
|
run.status = AgentRunStatus.failed
|
||||||
|
run.error_code = "AGENT_PROCESS_RESTARTED"
|
||||||
|
run.error_message = "Agent process restarted before the run completed."
|
||||||
|
run.updated_at = datetime.now(timezone.utc)
|
||||||
|
next_sequence = int(
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT COALESCE(MAX(sequence), -1) + 1
|
||||||
|
FROM agent_events WHERE run_id = ?
|
||||||
|
""",
|
||||||
|
(run_id,),
|
||||||
|
).fetchone()[0]
|
||||||
|
)
|
||||||
|
event = AgentEvent(
|
||||||
|
event=AgentEventType.run_failed,
|
||||||
|
run_id=run_id,
|
||||||
|
sequence=next_sequence,
|
||||||
|
data={
|
||||||
|
"code": run.error_code,
|
||||||
|
"message": run.error_message,
|
||||||
|
},
|
||||||
|
timestamp=run.updated_at,
|
||||||
|
)
|
||||||
|
self._update_run(conn, run)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_events(run_id, sequence, event, data_json, timestamp)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
run_id,
|
||||||
|
next_sequence,
|
||||||
|
event.event.value,
|
||||||
|
json.dumps(event.data, ensure_ascii=False),
|
||||||
|
event.timestamp.isoformat(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return run
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _update_run(conn, run: AgentRun) -> None:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE agent_runs
|
||||||
|
SET status = ?, run_json = ?, updated_at = ?
|
||||||
|
WHERE run_id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
run.status.value,
|
||||||
|
AgentTraceRepository._serialize_run(run),
|
||||||
|
run.updated_at.isoformat(),
|
||||||
|
run.run_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if cursor.rowcount != 1:
|
||||||
|
raise LookupError(run.run_id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _event_from_row(run_id: str, row) -> AgentEvent:
|
||||||
|
return AgentEvent(
|
||||||
|
event=AgentEventType(row["event"]),
|
||||||
|
run_id=run_id,
|
||||||
|
sequence=int(row["sequence"]),
|
||||||
|
data=json.loads(row["data_json"]),
|
||||||
|
timestamp=datetime.fromisoformat(row["timestamp"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _serialize_run(run: AgentRun) -> str:
|
||||||
|
# Run 是重启后 GET/list 的完整事实;只做 Secret 脱敏,不套用 Trace 摘要限长。
|
||||||
|
return json.dumps(
|
||||||
|
sanitize_trace_value(
|
||||||
|
run.model_dump(mode="json"), apply_limits=False
|
||||||
|
),
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
@@ -31,6 +31,48 @@ class OperationResponse(Contract):
|
|||||||
message: str | None = None
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# Workspace boundary (single configured Vault in Web development mode)
|
||||||
|
class WorkspaceInfo(Contract):
|
||||||
|
vault_id: str = "default"
|
||||||
|
name: str
|
||||||
|
path: str
|
||||||
|
file_count: int = 0
|
||||||
|
indexed_note_count: int = 0
|
||||||
|
requires_refresh: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceEntry(Contract):
|
||||||
|
entry_id: str
|
||||||
|
name: str
|
||||||
|
path: str
|
||||||
|
type: Literal["file", "folder"]
|
||||||
|
note_id: str | None = None
|
||||||
|
children: list["WorkspaceEntry"] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceSnapshot(Contract):
|
||||||
|
workspace: WorkspaceInfo
|
||||||
|
items: list[WorkspaceEntry] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceOpenRequest(Contract):
|
||||||
|
path: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FolderCreateRequest(Contract):
|
||||||
|
parent: str = ""
|
||||||
|
name: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class FolderRenameRequest(Contract):
|
||||||
|
path: str
|
||||||
|
new_name: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class FolderDeleteRequest(Contract):
|
||||||
|
path: str
|
||||||
|
|
||||||
|
|
||||||
# Notes and retrieval
|
# Notes and retrieval
|
||||||
class NoteBlock(Contract):
|
class NoteBlock(Contract):
|
||||||
block_id: str
|
block_id: str
|
||||||
@@ -79,6 +121,10 @@ class NoteMoveRequest(Contract):
|
|||||||
folder: str
|
folder: str
|
||||||
|
|
||||||
|
|
||||||
|
class NoteRenameRequest(Contract):
|
||||||
|
file_name: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
class SearchMode(str, Enum):
|
class SearchMode(str, Enum):
|
||||||
fts = "fts"
|
fts = "fts"
|
||||||
vector = "vector"
|
vector = "vector"
|
||||||
@@ -283,6 +329,10 @@ class AgentEventType(str, Enum):
|
|||||||
permission_required = "PermissionRequired"
|
permission_required = "PermissionRequired"
|
||||||
usage = "Usage"
|
usage = "Usage"
|
||||||
citation = "Citation"
|
citation = "Citation"
|
||||||
|
model_call_started = "ModelCallStarted"
|
||||||
|
model_call_completed = "ModelCallCompleted"
|
||||||
|
model_call_failed = "ModelCallFailed"
|
||||||
|
permission_resolved = "PermissionResolved"
|
||||||
run_completed = "RunCompleted"
|
run_completed = "RunCompleted"
|
||||||
run_failed = "RunFailed"
|
run_failed = "RunFailed"
|
||||||
run_cancelled = "RunCancelled"
|
run_cancelled = "RunCancelled"
|
||||||
@@ -296,6 +346,24 @@ class AgentEvent(Contract):
|
|||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AgentTraceSummary(Contract):
|
||||||
|
model_calls: int = 0
|
||||||
|
tool_calls: int = 0
|
||||||
|
duration_ms: int = 0
|
||||||
|
token_usage: int = 0
|
||||||
|
errors: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class AgentTraceResponse(Contract):
|
||||||
|
run_id: str
|
||||||
|
status: AgentRunStatus
|
||||||
|
items: list[AgentEvent] = Field(default_factory=list)
|
||||||
|
next_sequence: int
|
||||||
|
has_more: bool = False
|
||||||
|
summary: AgentTraceSummary = Field(default_factory=AgentTraceSummary)
|
||||||
|
config_snapshot: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class PermissionDecisionRequest(Contract):
|
class PermissionDecisionRequest(Contract):
|
||||||
decision: Literal["allow_once", "allow_session", "deny"]
|
decision: Literal["allow_once", "allow_session", "deny"]
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,33 @@ MIGRATIONS: list[str] = [
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_tasks_status_due ON tasks(status, due_at);
|
CREATE INDEX IF NOT EXISTS idx_tasks_status_due ON tasks(status, due_at);
|
||||||
""",
|
""",
|
||||||
|
# v3: 第二阶段 Agent Trace;Run 与事件事实持久化,供 SSE 恢复和 Benchmark 复用。
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_runs (
|
||||||
|
run_id TEXT PRIMARY KEY,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
run_json TEXT NOT NULL,
|
||||||
|
request_json TEXT NOT NULL,
|
||||||
|
config_snapshot_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_runs_created
|
||||||
|
ON agent_runs(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_runs_status
|
||||||
|
ON agent_runs(status, updated_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_events (
|
||||||
|
run_id TEXT NOT NULL REFERENCES agent_runs(run_id) ON DELETE CASCADE,
|
||||||
|
sequence INTEGER NOT NULL,
|
||||||
|
event TEXT NOT NULL,
|
||||||
|
data_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (run_id, sequence)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_events_type
|
||||||
|
ON agent_events(run_id, event, sequence);
|
||||||
|
""",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ class SkillRuntime:
|
|||||||
self._records: dict[str, _SkillRecord] = {}
|
self._records: dict[str, _SkillRecord] = {}
|
||||||
|
|
||||||
def install(self, package_path: str | Path) -> Skill:
|
def install(self, package_path: str | Path) -> Skill:
|
||||||
|
# TODO(extension): 将安装记录持久化,应用重启后从可信包目录恢复状态。
|
||||||
root = _package_dir(package_path)
|
root = _package_dir(package_path)
|
||||||
raw = _read_yaml(root / "skill.yaml")
|
raw = _read_yaml(root / "skill.yaml")
|
||||||
if "id" in raw and "skill_id" not in raw:
|
if "id" in raw and "skill_id" not in raw:
|
||||||
@@ -252,6 +253,7 @@ class PluginRuntime:
|
|||||||
self._records: dict[str, _PluginRecord] = {}
|
self._records: dict[str, _PluginRecord] = {}
|
||||||
|
|
||||||
def install(self, package_path: str | Path) -> Plugin:
|
def install(self, package_path: str | Path) -> Plugin:
|
||||||
|
# 当前只加载声明式清单,不导入或执行插件包中的任意 Python 代码。
|
||||||
root = _package_dir(package_path)
|
root = _package_dir(package_path)
|
||||||
raw = _read_yaml(root / "plugin.yaml")
|
raw = _read_yaml(root / "plugin.yaml")
|
||||||
if "id" in raw and "plugin_id" not in raw:
|
if "id" in raw and "plugin_id" not in raw:
|
||||||
@@ -315,6 +317,7 @@ class PluginRuntime:
|
|||||||
if record.plugin.enabled:
|
if record.plugin.enabled:
|
||||||
return record.plugin.model_copy(deep=True)
|
return record.plugin.model_copy(deep=True)
|
||||||
if record.plugin.manifest.backend.type == "mcp":
|
if record.plugin.manifest.backend.type == "mcp":
|
||||||
|
# TODO(extension): 第二阶段以隔离进程实现 MCP Host,并补充签名与来源校验。
|
||||||
record.plugin.status = PluginStatus.dependency_missing
|
record.plugin.status = PluginStatus.dependency_missing
|
||||||
raise ExtensionError(
|
raise ExtensionError(
|
||||||
"PLUGIN_HOST_UNAVAILABLE",
|
"PLUGIN_HOST_UNAVAILABLE",
|
||||||
@@ -366,6 +369,7 @@ class PluginRuntime:
|
|||||||
)
|
)
|
||||||
record.registered_tools.append(spec.name)
|
record.registered_tools.append(spec.name)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
# 注册过程必须具备回滚语义,防止半启用插件污染全局工具表。
|
||||||
for name in record.registered_tools:
|
for name in record.registered_tools:
|
||||||
self.registry.unregister(name)
|
self.registry.unregister(name)
|
||||||
record.registered_tools.clear()
|
record.registered_tools.clear()
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"""Provider 凭据解析及本地加密存储。"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -43,6 +45,8 @@ class EnvironmentCredentialResolver:
|
|||||||
class EncryptedCredentialStore:
|
class EncryptedCredentialStore:
|
||||||
"""将本地开发凭据作为 Fernet 密文存储,Provider 使用时按 ID 解密。"""
|
"""将本地开发凭据作为 Fernet 密文存储,Provider 使用时按 ID 解密。"""
|
||||||
|
|
||||||
|
# TODO(security): 桌面 Host 接入后将主密钥迁移到系统钥匙串/凭据保险库。
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
@@ -75,6 +79,7 @@ class EncryptedCredentialStore:
|
|||||||
key_path.parent.mkdir(parents=True, exist_ok=True)
|
key_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self._restrict(key_path.parent, 0o700)
|
self._restrict(key_path.parent, 0o700)
|
||||||
if not key_path.exists():
|
if not key_path.exists():
|
||||||
|
# 先写临时文件再原子替换,避免异常退出留下半截主密钥。
|
||||||
temporary = key_path.with_suffix(".tmp")
|
temporary = key_path.with_suffix(".tmp")
|
||||||
temporary.write_bytes(Fernet.generate_key())
|
temporary.write_bytes(Fernet.generate_key())
|
||||||
self._restrict(temporary, 0o600)
|
self._restrict(temporary, 0o600)
|
||||||
@@ -112,6 +117,7 @@ class EncryptedCredentialStore:
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
self._restrict(temporary, 0o600)
|
self._restrict(temporary, 0o600)
|
||||||
|
# 凭据表同样使用原子替换,确保并发读取只会看到完整 JSON。
|
||||||
temporary.replace(store_path)
|
temporary.replace(store_path)
|
||||||
self._restrict(store_path, 0o600)
|
self._restrict(store_path, 0o600)
|
||||||
|
|
||||||
@@ -158,6 +164,7 @@ class ChainedCredentialResolver:
|
|||||||
self._resolvers = resolvers
|
self._resolvers = resolvers
|
||||||
|
|
||||||
def resolve(self, credential_id: str | None) -> str | None:
|
def resolve(self, credential_id: str | None) -> str | None:
|
||||||
|
# 顺序即优先级:调用方可让 Host 注入值覆盖本地开发凭据。
|
||||||
for resolver in self._resolvers:
|
for resolver in self._resolvers:
|
||||||
value = resolver.resolve(credential_id)
|
value = resolver.resolve(credential_id)
|
||||||
if value:
|
if value:
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ class FtsHit:
|
|||||||
bm25: float
|
bm25: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class NoteLocation:
|
||||||
|
note_id: str
|
||||||
|
title: str
|
||||||
|
file_path: str
|
||||||
|
folder: str
|
||||||
|
|
||||||
|
|
||||||
def replace_note_metadata(
|
def replace_note_metadata(
|
||||||
*,
|
*,
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
@@ -219,6 +227,52 @@ def fts_search(match: str, limit: int = 100) -> list[FtsHit]:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def list_note_locations(*, conn: sqlite3.Connection | None = None) -> list[NoteLocation]:
|
||||||
|
"""返回 Workspace 构树和目录事务所需的最小笔记位置集合。"""
|
||||||
|
|
||||||
|
owns = conn is None
|
||||||
|
conn = conn or connect()
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT note_id, title, file_path, folder FROM notes ORDER BY file_path"
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
NoteLocation(
|
||||||
|
note_id=row["note_id"],
|
||||||
|
title=row["title"],
|
||||||
|
file_path=row["file_path"],
|
||||||
|
folder=row["folder"],
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
if owns:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def update_note_location(
|
||||||
|
*,
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
note_id: str,
|
||||||
|
title: str,
|
||||||
|
file_path: str,
|
||||||
|
folder: str,
|
||||||
|
updated_at: datetime,
|
||||||
|
) -> None:
|
||||||
|
"""更新文件位置和展示标题;Block/FTS/向量内容不变,无需重新生成。"""
|
||||||
|
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE notes
|
||||||
|
SET title = ?, file_path = ?, folder = ?, updated_at = ?
|
||||||
|
WHERE note_id = ?
|
||||||
|
""",
|
||||||
|
(title, file_path, folder, _iso(updated_at), note_id),
|
||||||
|
)
|
||||||
|
if cursor.rowcount != 1:
|
||||||
|
raise LookupError(note_id)
|
||||||
|
|
||||||
|
|
||||||
def fts_search_page(
|
def fts_search_page(
|
||||||
*,
|
*,
|
||||||
match: str,
|
match: str,
|
||||||
|
|||||||
+112
-7
@@ -2,17 +2,21 @@ from collections.abc import AsyncIterator
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, Query
|
from fastapi import APIRouter, Header, Query
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from app.contracts import (
|
from app.contracts import (
|
||||||
AgentRun,
|
AgentRun,
|
||||||
AgentRunCreateRequest,
|
AgentRunCreateRequest,
|
||||||
AgentRunListResponse,
|
AgentRunListResponse,
|
||||||
|
AgentTraceResponse,
|
||||||
ChatRequest,
|
ChatRequest,
|
||||||
CredentialStatus,
|
CredentialStatus,
|
||||||
CredentialWriteRequest,
|
CredentialWriteRequest,
|
||||||
ExtensionInstallRequest,
|
ExtensionInstallRequest,
|
||||||
|
FolderCreateRequest,
|
||||||
|
FolderDeleteRequest,
|
||||||
|
FolderRenameRequest,
|
||||||
IndexJob,
|
IndexJob,
|
||||||
IndexRebuildRequest,
|
IndexRebuildRequest,
|
||||||
IndexStatus,
|
IndexStatus,
|
||||||
@@ -22,6 +26,7 @@ from app.contracts import (
|
|||||||
NoteCreateRequest,
|
NoteCreateRequest,
|
||||||
NoteListResponse,
|
NoteListResponse,
|
||||||
NoteMoveRequest,
|
NoteMoveRequest,
|
||||||
|
NoteRenameRequest,
|
||||||
NoteUpdateRequest,
|
NoteUpdateRequest,
|
||||||
OperationResponse,
|
OperationResponse,
|
||||||
PageMeta,
|
PageMeta,
|
||||||
@@ -48,6 +53,10 @@ from app.contracts import (
|
|||||||
ToolListResponse,
|
ToolListResponse,
|
||||||
TranscriptionJob,
|
TranscriptionJob,
|
||||||
TranscriptionRequest,
|
TranscriptionRequest,
|
||||||
|
WorkspaceEntry,
|
||||||
|
WorkspaceInfo,
|
||||||
|
WorkspaceOpenRequest,
|
||||||
|
WorkspaceSnapshot,
|
||||||
)
|
)
|
||||||
from app.agent import AgentCapacityError, AgentRunNotFoundError
|
from app.agent import AgentCapacityError, AgentRunNotFoundError
|
||||||
from app.container import container
|
from app.container import container
|
||||||
@@ -58,7 +67,13 @@ from app.providers.factory import UnsupportedProviderError
|
|||||||
from app.providers.base import ProviderError
|
from app.providers.base import ProviderError
|
||||||
from app.providers.credentials import CredentialStoreError
|
from app.providers.credentials import CredentialStoreError
|
||||||
from app.retrieval.engine import engine
|
from app.retrieval.engine import engine
|
||||||
from app.services import index_service, note_service, task_service, transcription_service
|
from app.services import (
|
||||||
|
index_service,
|
||||||
|
note_service,
|
||||||
|
task_service,
|
||||||
|
transcription_service,
|
||||||
|
workspace_service,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
@@ -67,8 +82,9 @@ def utc_now() -> datetime:
|
|||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def as_sse(event: str, payload: str) -> str:
|
def as_sse(event: str, payload: str, *, event_id: int | None = None) -> str:
|
||||||
return f"event: {event}\ndata: {payload}\n\n"
|
id_line = f"id: {event_id}\n" if event_id is not None else ""
|
||||||
|
return f"{id_line}event: {event}\ndata: {payload}\n\n"
|
||||||
|
|
||||||
|
|
||||||
def provider_or_404(provider_id: str):
|
def provider_or_404(provider_id: str):
|
||||||
@@ -114,6 +130,41 @@ def extension_call(operation):
|
|||||||
raise ApiError(exc.status_code, exc.code, exc.message, exc.details) from exc
|
raise ApiError(exc.status_code, exc.code, exc.message, exc.details) from exc
|
||||||
|
|
||||||
|
|
||||||
|
# Workspace (single configured Vault in Web development mode)
|
||||||
|
@router.get("/workspace", response_model=WorkspaceInfo, tags=["Workspace"])
|
||||||
|
async def get_workspace() -> WorkspaceInfo:
|
||||||
|
return workspace_service.get_workspace_info()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/workspace/open", response_model=WorkspaceSnapshot, tags=["Workspace"])
|
||||||
|
async def open_workspace(request: WorkspaceOpenRequest) -> WorkspaceSnapshot:
|
||||||
|
return await workspace_service.open_workspace(request.path)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/workspace/tree", response_model=list[WorkspaceEntry], tags=["Workspace"])
|
||||||
|
async def get_workspace_tree() -> list[WorkspaceEntry]:
|
||||||
|
return workspace_service.get_workspace_tree()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/workspace/folders", response_model=WorkspaceEntry, tags=["Workspace"])
|
||||||
|
async def create_workspace_folder(request: FolderCreateRequest) -> WorkspaceEntry:
|
||||||
|
return await workspace_service.create_folder(request.parent, request.name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/workspace/folders/rename", response_model=WorkspaceEntry, tags=["Workspace"]
|
||||||
|
)
|
||||||
|
async def rename_workspace_folder(request: FolderRenameRequest) -> WorkspaceEntry:
|
||||||
|
return await workspace_service.rename_folder(request.path, request.new_name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/workspace/folders/delete", response_model=OperationResponse, tags=["Workspace"]
|
||||||
|
)
|
||||||
|
async def delete_workspace_folder(request: FolderDeleteRequest) -> OperationResponse:
|
||||||
|
return await workspace_service.delete_folder(request.path)
|
||||||
|
|
||||||
|
|
||||||
# Notes
|
# Notes
|
||||||
@router.get("/notes", response_model=NoteListResponse, tags=["Notes"])
|
@router.get("/notes", response_model=NoteListResponse, tags=["Notes"])
|
||||||
async def list_notes(
|
async def list_notes(
|
||||||
@@ -160,6 +211,11 @@ async def move_note(note_id: str, request: NoteMoveRequest) -> Note:
|
|||||||
return await note_service.move_note(note_id, folder=request.folder)
|
return await note_service.move_note(note_id, folder=request.folder)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/notes/{note_id}/rename", response_model=Note, tags=["Notes"])
|
||||||
|
async def rename_note(note_id: str, request: NoteRenameRequest) -> Note:
|
||||||
|
return await note_service.rename_note(note_id, file_name=request.file_name)
|
||||||
|
|
||||||
|
|
||||||
# Retrieval and chat
|
# Retrieval and chat
|
||||||
@router.post("/search", response_model=SearchResponse, tags=["Search"])
|
@router.post("/search", response_model=SearchResponse, tags=["Search"])
|
||||||
async def search_notes(request: SearchRequest) -> SearchResponse:
|
async def search_notes(request: SearchRequest) -> SearchResponse:
|
||||||
@@ -260,16 +316,65 @@ async def cancel_agent_run(run_id: str) -> OperationResponse:
|
|||||||
},
|
},
|
||||||
tags=["Agent"],
|
tags=["Agent"],
|
||||||
)
|
)
|
||||||
async def agent_events(run_id: str) -> StreamingResponse:
|
async def agent_events(
|
||||||
|
run_id: str,
|
||||||
|
after_sequence: int | None = Query(default=None, ge=-1),
|
||||||
|
last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
|
||||||
|
) -> StreamingResponse:
|
||||||
agent_run_or_404(run_id)
|
agent_run_or_404(run_id)
|
||||||
|
cursor = after_sequence
|
||||||
|
if cursor is None and last_event_id is not None:
|
||||||
|
try:
|
||||||
|
cursor = int(last_event_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ApiError(
|
||||||
|
400,
|
||||||
|
"TRACE_CURSOR_INVALID",
|
||||||
|
"Last-Event-ID must be an integer sequence.",
|
||||||
|
{"last_event_id": last_event_id},
|
||||||
|
) from exc
|
||||||
|
if cursor < -1:
|
||||||
|
raise ApiError(
|
||||||
|
400,
|
||||||
|
"TRACE_CURSOR_INVALID",
|
||||||
|
"Last-Event-ID must be greater than or equal to -1.",
|
||||||
|
)
|
||||||
|
cursor = cursor if cursor is not None else -1
|
||||||
|
|
||||||
async def stream() -> AsyncIterator[str]:
|
async def stream() -> AsyncIterator[str]:
|
||||||
async for event in container.agent.events(run_id):
|
async for event in container.agent.events(run_id, after_sequence=cursor):
|
||||||
yield as_sse(event.event.value, event.model_dump_json())
|
yield as_sse(
|
||||||
|
event.event.value,
|
||||||
|
event.model_dump_json(),
|
||||||
|
event_id=event.sequence,
|
||||||
|
)
|
||||||
|
|
||||||
return StreamingResponse(stream(), media_type="text/event-stream")
|
return StreamingResponse(stream(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/agent/runs/{run_id}/trace",
|
||||||
|
response_model=AgentTraceResponse,
|
||||||
|
tags=["Agent"],
|
||||||
|
)
|
||||||
|
async def get_agent_trace(
|
||||||
|
run_id: str,
|
||||||
|
after_sequence: int = Query(default=-1, ge=-1),
|
||||||
|
limit: int = Query(default=200, ge=1, le=500),
|
||||||
|
) -> AgentTraceResponse:
|
||||||
|
try:
|
||||||
|
return container.agent.get_trace(
|
||||||
|
run_id, after_sequence=after_sequence, limit=limit
|
||||||
|
)
|
||||||
|
except AgentRunNotFoundError as exc:
|
||||||
|
raise ApiError(
|
||||||
|
404,
|
||||||
|
"AGENT_RUN_NOT_FOUND",
|
||||||
|
f"Agent run does not exist: {run_id}",
|
||||||
|
{"run_id": run_id},
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/agent/runs/{run_id}/permissions/{request_id}",
|
"/agent/runs/{run_id}/permissions/{request_id}",
|
||||||
response_model=OperationResponse,
|
response_model=OperationResponse,
|
||||||
|
|||||||
@@ -6,13 +6,11 @@ Markdown 文件是笔记正文的持久化载体(Vault),SQLite/FTS5/向量
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from app import repository
|
from app import repository
|
||||||
from app.config import get_settings
|
|
||||||
from app.contracts import Note, NoteBlock, NoteSummary
|
from app.contracts import Note, NoteBlock, NoteSummary
|
||||||
from app.database.db import connect, transaction
|
from app.database.db import connect, transaction
|
||||||
from app.errors import ApiError
|
from app.errors import ApiError
|
||||||
@@ -20,74 +18,40 @@ from app.knowledge.parser import ParsedNote, parse_note
|
|||||||
from app.retrieval.embedding import HashEmbeddingProvider
|
from app.retrieval.embedding import HashEmbeddingProvider
|
||||||
from app.retrieval.vectorstore import SqliteVecStore, VectorRecord
|
from app.retrieval.vectorstore import SqliteVecStore, VectorRecord
|
||||||
from app.services.coordination import serialized_vault_mutation
|
from app.services.coordination import serialized_vault_mutation
|
||||||
|
from app.services.vault_paths import (
|
||||||
|
normalize_entry_name,
|
||||||
|
normalize_folder,
|
||||||
|
resolve_in_vault,
|
||||||
|
safe_note_filename,
|
||||||
|
)
|
||||||
|
|
||||||
# 轻量实现实例(无状态,可直接复用);接入真实模型后替换为对应 Provider
|
# 轻量实现实例(无状态,可直接复用);接入真实模型后替换为对应 Provider
|
||||||
embedding = HashEmbeddingProvider()
|
embedding = HashEmbeddingProvider()
|
||||||
vector_store = SqliteVecStore()
|
vector_store = SqliteVecStore()
|
||||||
|
|
||||||
|
|
||||||
def _vault() -> Path:
|
|
||||||
return get_settings().vault_path
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_name(title: str) -> str:
|
|
||||||
name = re.sub(r'[\\/:*?"<>|]', "_", title).strip()
|
|
||||||
return name or "untitled"
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_folder(folder: str | None) -> str:
|
|
||||||
"""清洗 folder 为安全的相对目录,拒绝 `..`/`.`/绝对路径/盘符/空字节,防路径逃逸。"""
|
|
||||||
if not folder:
|
|
||||||
return ""
|
|
||||||
if "\x00" in folder:
|
|
||||||
raise ApiError(400, "INVALID_PATH", "folder must not contain NUL bytes", {"folder": folder})
|
|
||||||
segments: list[str] = []
|
|
||||||
for part in re.split(r"[\\/]+", folder):
|
|
||||||
if part == "":
|
|
||||||
continue
|
|
||||||
if part in (".", ".."):
|
|
||||||
raise ApiError(400, "INVALID_PATH", "folder must not contain '.' or '..'", {"folder": folder})
|
|
||||||
if ":" in part:
|
|
||||||
raise ApiError(400, "INVALID_PATH", "folder must be a relative path", {"folder": folder})
|
|
||||||
segments.append(part)
|
|
||||||
return "/".join(segments)
|
|
||||||
|
|
||||||
|
|
||||||
def _rel_path(folder: str | None, title: str) -> tuple[str, str]:
|
def _rel_path(folder: str | None, title: str) -> tuple[str, str]:
|
||||||
"""由 folder + title 生成安全的相对路径,返回 (rel_path, 清洗后的 folder)。"""
|
"""由 folder + title 生成安全的相对路径,返回 (rel_path, 清洗后的 folder)。"""
|
||||||
clean_folder = _normalize_folder(folder)
|
clean_folder = normalize_folder(folder)
|
||||||
name = _safe_name(title)
|
name = safe_note_filename(title)
|
||||||
if not name.endswith(".md"):
|
|
||||||
name += ".md"
|
|
||||||
rel = f"{clean_folder}/{name}" if clean_folder else name
|
rel = f"{clean_folder}/{name}" if clean_folder else name
|
||||||
return rel, clean_folder
|
return rel, clean_folder
|
||||||
|
|
||||||
|
|
||||||
def _abs_path(rel_path: str) -> Path:
|
|
||||||
"""把相对路径解析为 Vault 内的绝对路径;越界即报 400,杜绝路径逃逸。"""
|
|
||||||
if not rel_path or "\x00" in rel_path:
|
|
||||||
raise ApiError(400, "INVALID_PATH", "invalid file path", {"file_path": rel_path})
|
|
||||||
root = _vault().resolve()
|
|
||||||
candidate = (_vault() / rel_path).resolve()
|
|
||||||
if not candidate.is_relative_to(root):
|
|
||||||
raise ApiError(400, "INVALID_PATH", "path escapes vault", {"file_path": rel_path})
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
|
|
||||||
def _read_markdown(rel_path: str) -> str:
|
def _read_markdown(rel_path: str) -> str:
|
||||||
path = _abs_path(rel_path)
|
path = resolve_in_vault(rel_path)
|
||||||
return path.read_text(encoding="utf-8") if path.exists() else ""
|
return path.read_text(encoding="utf-8") if path.exists() else ""
|
||||||
|
|
||||||
|
|
||||||
def _write_markdown(rel_path: str, markdown: str) -> None:
|
def _write_markdown(rel_path: str, markdown: str) -> None:
|
||||||
path = _abs_path(rel_path)
|
path = resolve_in_vault(rel_path)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
path.write_text(markdown, encoding="utf-8")
|
path.write_text(markdown, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def _create_markdown(rel_path: str, markdown: str) -> None:
|
def _create_markdown(rel_path: str, markdown: str) -> None:
|
||||||
"""排他创建 Markdown;目标已存在时返回资源冲突,不覆盖用户文件。"""
|
"""排他创建 Markdown;目标已存在时返回资源冲突,不覆盖用户文件。"""
|
||||||
path = _abs_path(rel_path)
|
path = resolve_in_vault(rel_path)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
try:
|
try:
|
||||||
with path.open("x", encoding="utf-8") as handle:
|
with path.open("x", encoding="utf-8") as handle:
|
||||||
@@ -102,7 +66,7 @@ def _create_markdown(rel_path: str, markdown: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _delete_markdown(rel_path: str) -> None:
|
def _delete_markdown(rel_path: str) -> None:
|
||||||
path = _abs_path(rel_path)
|
path = resolve_in_vault(rel_path)
|
||||||
if path.exists():
|
if path.exists():
|
||||||
path.unlink()
|
path.unlink()
|
||||||
|
|
||||||
@@ -222,7 +186,7 @@ async def move_note(note_id: str, *, folder: str) -> Note:
|
|||||||
if record is None:
|
if record is None:
|
||||||
raise ApiError(404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id})
|
raise ApiError(404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id})
|
||||||
|
|
||||||
clean_folder = _normalize_folder(folder)
|
clean_folder = normalize_folder(folder)
|
||||||
filename = Path(record.file_path).name
|
filename = Path(record.file_path).name
|
||||||
new_rel_path = f"{clean_folder}/{filename}" if clean_folder else filename
|
new_rel_path = f"{clean_folder}/{filename}" if clean_folder else filename
|
||||||
if new_rel_path == record.file_path:
|
if new_rel_path == record.file_path:
|
||||||
@@ -230,8 +194,8 @@ async def move_note(note_id: str, *, folder: str) -> Note:
|
|||||||
assert note is not None
|
assert note is not None
|
||||||
return note
|
return note
|
||||||
|
|
||||||
source = _abs_path(record.file_path)
|
source = resolve_in_vault(record.file_path)
|
||||||
target = _abs_path(new_rel_path)
|
target = resolve_in_vault(new_rel_path)
|
||||||
if not source.is_file():
|
if not source.is_file():
|
||||||
raise ApiError(
|
raise ApiError(
|
||||||
409, "NOTE_FILE_MISSING", "note file is missing from the Vault",
|
409, "NOTE_FILE_MISSING", "note file is missing from the Vault",
|
||||||
@@ -267,13 +231,69 @@ async def move_note(note_id: str, *, folder: str) -> Note:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@serialized_vault_mutation
|
||||||
|
async def rename_note(note_id: str, *, file_name: str) -> Note:
|
||||||
|
"""重命名 Markdown 文件并保留 note_id、Block 与向量身份。"""
|
||||||
|
|
||||||
|
record = repository.get_note_record(note_id)
|
||||||
|
if record is None:
|
||||||
|
raise ApiError(404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id})
|
||||||
|
|
||||||
|
normalized = normalize_entry_name(file_name, markdown=True)
|
||||||
|
source = resolve_in_vault(record.file_path)
|
||||||
|
folder = normalize_folder(record.folder)
|
||||||
|
new_file_path = f"{folder}/{normalized}" if folder else normalized
|
||||||
|
target = resolve_in_vault(new_file_path)
|
||||||
|
if new_file_path == record.file_path:
|
||||||
|
note = await get_note(note_id)
|
||||||
|
assert note is not None
|
||||||
|
return note
|
||||||
|
if not source.is_file():
|
||||||
|
raise ApiError(
|
||||||
|
409,
|
||||||
|
"NOTE_FILE_MISSING",
|
||||||
|
"note file is missing from the Vault",
|
||||||
|
{"note_id": note_id, "file_path": record.file_path},
|
||||||
|
)
|
||||||
|
if target.exists():
|
||||||
|
raise ApiError(
|
||||||
|
409,
|
||||||
|
"RESOURCE_CONFLICT",
|
||||||
|
"a note already exists with the requested file name",
|
||||||
|
{"note_id": note_id, "file_path": new_file_path},
|
||||||
|
)
|
||||||
|
|
||||||
|
source.replace(target)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
with transaction(conn):
|
||||||
|
repository.update_note_location(
|
||||||
|
conn=conn,
|
||||||
|
note_id=note_id,
|
||||||
|
title=Path(normalized).stem,
|
||||||
|
file_path=new_file_path,
|
||||||
|
folder=folder,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
except BaseException:
|
||||||
|
target.replace(source)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
note = await get_note(note_id)
|
||||||
|
assert note is not None
|
||||||
|
return note
|
||||||
|
|
||||||
|
|
||||||
@serialized_vault_mutation
|
@serialized_vault_mutation
|
||||||
async def delete_note(note_id: str) -> bool:
|
async def delete_note(note_id: str) -> bool:
|
||||||
record = repository.get_note_record(note_id)
|
record = repository.get_note_record(note_id)
|
||||||
if record is None:
|
if record is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
path = _abs_path(record.file_path)
|
path = resolve_in_vault(record.file_path)
|
||||||
tombstone = path.with_name(f".{path.name}.{uuid4().hex}.deleting") if path.exists() else None
|
tombstone = path.with_name(f".{path.name}.{uuid4().hex}.deleting") if path.exists() else None
|
||||||
if tombstone is not None:
|
if tombstone is not None:
|
||||||
path.replace(tombstone)
|
path.replace(tombstone)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"""转写适配层;第一阶段消费文本附件或桌面 Host 预生成的旁路文本。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
@@ -13,6 +15,7 @@ MAX_JOBS = 100
|
|||||||
|
|
||||||
|
|
||||||
def create_transcription(attachment_id: str, language: str | None = None) -> TranscriptionJob:
|
def create_transcription(attachment_id: str, language: str | None = None) -> TranscriptionJob:
|
||||||
|
# TODO(ai-core): 第二阶段接入本地 ASR 队列后,保留相同 Job 契约替换此同步降级实现。
|
||||||
del language # 预生成 transcript 暂不需要语言识别。
|
del language # 预生成 transcript 暂不需要语言识别。
|
||||||
source = attachment_path(attachment_id)
|
source = attachment_path(attachment_id)
|
||||||
transcript = source if source.suffix.lower() in {".txt", ".md"} else Path(f"{source}.txt")
|
transcript = source if source.suffix.lower() in {".txt", ".md"} else Path(f"{source}.txt")
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Vault 相对路径校验;所有文件操作必须先经过本模块。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.errors import ApiError
|
||||||
|
|
||||||
|
_INVALID_FILE_CHARS = re.compile(r'[\\/:*?"<>|]')
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_folder(folder: str | None) -> str:
|
||||||
|
"""返回使用 `/` 的安全相对目录;根目录表示为空字符串。"""
|
||||||
|
|
||||||
|
if not folder or folder in {"/", "\\"}:
|
||||||
|
return ""
|
||||||
|
if "\x00" in folder:
|
||||||
|
raise ApiError(400, "INVALID_PATH", "folder must not contain NUL bytes")
|
||||||
|
segments: list[str] = []
|
||||||
|
for part in re.split(r"[\\/]+", folder):
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
if part in {".", ".."} or ":" in part:
|
||||||
|
raise ApiError(
|
||||||
|
400,
|
||||||
|
"INVALID_PATH",
|
||||||
|
"folder must be a relative path without '.' or '..' segments",
|
||||||
|
{"folder": folder},
|
||||||
|
)
|
||||||
|
segments.append(part)
|
||||||
|
return "/".join(segments)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_entry_name(name: str, *, markdown: bool = False) -> str:
|
||||||
|
"""校验单个目录项名称;不静默接受路径分隔符或保留段。"""
|
||||||
|
|
||||||
|
value = name.strip()
|
||||||
|
if not value or value in {".", ".."} or "\x00" in value:
|
||||||
|
raise ApiError(400, "INVALID_PATH", "entry name is invalid", {"name": name})
|
||||||
|
if _INVALID_FILE_CHARS.search(value):
|
||||||
|
raise ApiError(
|
||||||
|
400,
|
||||||
|
"INVALID_PATH",
|
||||||
|
"entry name contains unsupported characters",
|
||||||
|
{"name": name},
|
||||||
|
)
|
||||||
|
if markdown and not value.lower().endswith(".md"):
|
||||||
|
value += ".md"
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def safe_note_filename(title: str) -> str:
|
||||||
|
"""为创建笔记保留原有的宽松清洗行为。"""
|
||||||
|
|
||||||
|
value = _INVALID_FILE_CHARS.sub("_", title).strip() or "untitled"
|
||||||
|
return value if value.lower().endswith(".md") else f"{value}.md"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_in_vault(relative_path: str) -> Path:
|
||||||
|
"""把相对路径解析到当前 Vault,并拒绝符号链接/`..` 导致的越界。"""
|
||||||
|
|
||||||
|
if not relative_path or "\x00" in relative_path:
|
||||||
|
raise ApiError(
|
||||||
|
400, "INVALID_PATH", "invalid Vault-relative path", {"path": relative_path}
|
||||||
|
)
|
||||||
|
root = get_settings().vault_path.resolve()
|
||||||
|
candidate = (root / relative_path.replace("\\", "/").lstrip("/")).resolve()
|
||||||
|
if not candidate.is_relative_to(root):
|
||||||
|
raise ApiError(
|
||||||
|
400, "INVALID_PATH", "path escapes Vault", {"path": relative_path}
|
||||||
|
)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def relative_to_vault(path: Path) -> str:
|
||||||
|
return path.resolve().relative_to(get_settings().vault_path.resolve()).as_posix()
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
"""Web 联调 Workspace:把单一配置 Vault 映射为前端可用的真实文件树。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app import repository
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.contracts import (
|
||||||
|
IndexRebuildRequest,
|
||||||
|
OperationResponse,
|
||||||
|
WorkspaceEntry,
|
||||||
|
WorkspaceInfo,
|
||||||
|
WorkspaceSnapshot,
|
||||||
|
)
|
||||||
|
from app.database.db import connect, transaction
|
||||||
|
from app.errors import ApiError
|
||||||
|
from app.retrieval.vectorstore import SqliteVecStore
|
||||||
|
from app.services import index_service
|
||||||
|
from app.services.coordination import serialized_vault_mutation
|
||||||
|
from app.services.vault_paths import normalize_entry_name, normalize_folder, resolve_in_vault
|
||||||
|
|
||||||
|
vector_store = SqliteVecStore()
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_id(kind: str, path: str) -> str:
|
||||||
|
digest = hashlib.sha256(f"{kind}:{path}".encode("utf-8")).hexdigest()[:16]
|
||||||
|
return f"{kind}_{digest}"
|
||||||
|
|
||||||
|
|
||||||
|
def _disk_markdown_paths() -> set[str]:
|
||||||
|
root = get_settings().vault_path
|
||||||
|
if not root.exists():
|
||||||
|
return set()
|
||||||
|
resolved_root = root.resolve()
|
||||||
|
paths: set[str] = set()
|
||||||
|
for path in root.rglob("*.md"):
|
||||||
|
if path.is_symlink():
|
||||||
|
continue
|
||||||
|
resolved = path.resolve()
|
||||||
|
if resolved.is_file() and resolved.is_relative_to(resolved_root):
|
||||||
|
paths.add(resolved.relative_to(resolved_root).as_posix())
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def get_workspace_info() -> WorkspaceInfo:
|
||||||
|
root = get_settings().vault_path.resolve()
|
||||||
|
disk_paths = _disk_markdown_paths()
|
||||||
|
indexed_paths = {item.file_path for item in repository.list_note_locations()}
|
||||||
|
return WorkspaceInfo(
|
||||||
|
name=root.name or "Vault",
|
||||||
|
path=str(root),
|
||||||
|
file_count=len(disk_paths),
|
||||||
|
indexed_note_count=len(indexed_paths),
|
||||||
|
requires_refresh=disk_paths != indexed_paths,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tree(directory: Path, locations: dict[str, repository.NoteLocation]) -> list[WorkspaceEntry]:
|
||||||
|
if not directory.exists():
|
||||||
|
return []
|
||||||
|
root = get_settings().vault_path.resolve()
|
||||||
|
entries: list[WorkspaceEntry] = []
|
||||||
|
children = sorted(
|
||||||
|
directory.iterdir(), key=lambda item: (not item.is_dir(), item.name.casefold())
|
||||||
|
)
|
||||||
|
for child in children:
|
||||||
|
if child.name.startswith(".") or child.is_symlink():
|
||||||
|
continue
|
||||||
|
resolved = child.resolve()
|
||||||
|
if not resolved.is_relative_to(root):
|
||||||
|
continue
|
||||||
|
relative = resolved.relative_to(root).as_posix()
|
||||||
|
public_path = f"/{relative}"
|
||||||
|
if resolved.is_dir():
|
||||||
|
entries.append(
|
||||||
|
WorkspaceEntry(
|
||||||
|
entry_id=_entry_id("folder", relative),
|
||||||
|
name=child.name,
|
||||||
|
path=public_path,
|
||||||
|
type="folder",
|
||||||
|
children=_tree(resolved, locations),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif resolved.is_file() and child.suffix.lower() == ".md":
|
||||||
|
location = locations.get(relative)
|
||||||
|
entries.append(
|
||||||
|
WorkspaceEntry(
|
||||||
|
entry_id=location.note_id if location else _entry_id("file", relative),
|
||||||
|
note_id=location.note_id if location else None,
|
||||||
|
name=child.name,
|
||||||
|
path=public_path,
|
||||||
|
type="file",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def get_workspace_tree() -> list[WorkspaceEntry]:
|
||||||
|
locations = {item.file_path: item for item in repository.list_note_locations()}
|
||||||
|
return _tree(get_settings().vault_path.resolve(), locations)
|
||||||
|
|
||||||
|
|
||||||
|
async def open_workspace(requested_path: str | None) -> WorkspaceSnapshot:
|
||||||
|
"""打开当前配置 Vault;发现未索引文件时先执行一次安全全量刷新。"""
|
||||||
|
|
||||||
|
root = get_settings().vault_path.resolve()
|
||||||
|
if requested_path and Path(requested_path).resolve() != root:
|
||||||
|
raise ApiError(
|
||||||
|
409,
|
||||||
|
"WORKSPACE_PATH_MISMATCH",
|
||||||
|
"Web development mode can only open the backend configured Vault.",
|
||||||
|
{"configured_path": str(root)},
|
||||||
|
)
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
info = get_workspace_info()
|
||||||
|
if info.requires_refresh:
|
||||||
|
await index_service.rebuild(IndexRebuildRequest())
|
||||||
|
info = get_workspace_info()
|
||||||
|
return WorkspaceSnapshot(workspace=info, items=get_workspace_tree())
|
||||||
|
|
||||||
|
|
||||||
|
@serialized_vault_mutation
|
||||||
|
async def create_folder(parent: str, name: str) -> WorkspaceEntry:
|
||||||
|
clean_parent = normalize_folder(parent)
|
||||||
|
clean_name = normalize_entry_name(name)
|
||||||
|
relative = f"{clean_parent}/{clean_name}" if clean_parent else clean_name
|
||||||
|
target = resolve_in_vault(relative)
|
||||||
|
if not clean_parent:
|
||||||
|
get_settings().vault_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
if target.exists():
|
||||||
|
raise ApiError(
|
||||||
|
409, "RESOURCE_CONFLICT", "folder already exists", {"path": relative}
|
||||||
|
)
|
||||||
|
if not target.parent.is_dir():
|
||||||
|
raise ApiError(
|
||||||
|
404,
|
||||||
|
"RESOURCE_NOT_FOUND",
|
||||||
|
"parent folder not found",
|
||||||
|
{"parent": clean_parent},
|
||||||
|
)
|
||||||
|
target.mkdir(parents=False)
|
||||||
|
return WorkspaceEntry(
|
||||||
|
entry_id=_entry_id("folder", relative),
|
||||||
|
name=clean_name,
|
||||||
|
path=f"/{relative}",
|
||||||
|
type="folder",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@serialized_vault_mutation
|
||||||
|
async def rename_folder(path: str, new_name: str) -> WorkspaceEntry:
|
||||||
|
old_folder = normalize_folder(path)
|
||||||
|
if not old_folder:
|
||||||
|
raise ApiError(400, "INVALID_PATH", "the Vault root cannot be renamed")
|
||||||
|
clean_name = normalize_entry_name(new_name)
|
||||||
|
parent = Path(old_folder).parent.as_posix()
|
||||||
|
parent = "" if parent == "." else parent
|
||||||
|
new_folder = f"{parent}/{clean_name}" if parent else clean_name
|
||||||
|
source = resolve_in_vault(old_folder)
|
||||||
|
target = resolve_in_vault(new_folder)
|
||||||
|
if not source.is_dir() or source.is_symlink():
|
||||||
|
raise ApiError(404, "RESOURCE_NOT_FOUND", "folder not found", {"path": path})
|
||||||
|
if target.exists():
|
||||||
|
raise ApiError(
|
||||||
|
409, "RESOURCE_CONFLICT", "target folder already exists", {"path": new_folder}
|
||||||
|
)
|
||||||
|
|
||||||
|
affected = [
|
||||||
|
item
|
||||||
|
for item in repository.list_note_locations()
|
||||||
|
if item.folder == old_folder or item.folder.startswith(f"{old_folder}/")
|
||||||
|
]
|
||||||
|
source.replace(target)
|
||||||
|
conn = connect()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
try:
|
||||||
|
with transaction(conn):
|
||||||
|
for item in affected:
|
||||||
|
file_suffix = item.file_path[len(old_folder) :].lstrip("/")
|
||||||
|
folder_suffix = item.folder[len(old_folder) :].lstrip("/")
|
||||||
|
repository.update_note_location(
|
||||||
|
conn=conn,
|
||||||
|
note_id=item.note_id,
|
||||||
|
title=item.title,
|
||||||
|
file_path=f"{new_folder}/{file_suffix}",
|
||||||
|
folder=(
|
||||||
|
f"{new_folder}/{folder_suffix}" if folder_suffix else new_folder
|
||||||
|
),
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
except BaseException:
|
||||||
|
target.replace(source)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return WorkspaceEntry(
|
||||||
|
entry_id=_entry_id("folder", new_folder),
|
||||||
|
name=clean_name,
|
||||||
|
path=f"/{new_folder}",
|
||||||
|
type="folder",
|
||||||
|
children=_tree(target, {item.file_path: item for item in repository.list_note_locations()}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@serialized_vault_mutation
|
||||||
|
async def delete_folder(path: str) -> OperationResponse:
|
||||||
|
folder = normalize_folder(path)
|
||||||
|
if not folder:
|
||||||
|
raise ApiError(400, "INVALID_PATH", "the Vault root cannot be deleted")
|
||||||
|
source = resolve_in_vault(folder)
|
||||||
|
if not source.is_dir() or source.is_symlink():
|
||||||
|
raise ApiError(404, "RESOURCE_NOT_FOUND", "folder not found", {"path": path})
|
||||||
|
|
||||||
|
affected = [
|
||||||
|
item
|
||||||
|
for item in repository.list_note_locations()
|
||||||
|
if item.folder == folder or item.folder.startswith(f"{folder}/")
|
||||||
|
]
|
||||||
|
tombstone = source.with_name(f".{source.name}.{uuid4().hex}.deleting")
|
||||||
|
source.replace(tombstone)
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
with transaction(conn):
|
||||||
|
block_ids: list[str] = []
|
||||||
|
for item in affected:
|
||||||
|
block_ids.extend(repository.delete_note(item.note_id, conn=conn))
|
||||||
|
await vector_store.delete(block_ids, conn=conn)
|
||||||
|
except BaseException:
|
||||||
|
tombstone.replace(source)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
shutil.rmtree(tombstone)
|
||||||
|
except OSError:
|
||||||
|
# 已提交的删除不回滚;隐藏 tombstone 可由后续维护任务清理。
|
||||||
|
pass
|
||||||
|
return OperationResponse(
|
||||||
|
status="completed",
|
||||||
|
resource_id=_entry_id("folder", folder),
|
||||||
|
message=f"deleted folder and {len(affected)} indexed notes",
|
||||||
|
)
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.agent.trace_repository import AgentTraceRepository
|
||||||
from app.agent.permissions import PermissionMode
|
from app.agent.permissions import PermissionMode
|
||||||
from app.agent.tools import ToolExecutionContext
|
from app.agent.tools import ToolExecutionContext
|
||||||
from app.container import build_container
|
from app.container import build_container
|
||||||
|
from app.database.db import connect
|
||||||
|
from app.errors import ApiError
|
||||||
|
from app.routes import agent_events
|
||||||
from app.contracts import (
|
from app.contracts import (
|
||||||
AgentEventType,
|
AgentEventType,
|
||||||
|
AgentRun,
|
||||||
AgentRunCreateRequest,
|
AgentRunCreateRequest,
|
||||||
AgentRunStatus,
|
AgentRunStatus,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
@@ -108,8 +116,200 @@ def test_permission_confirmation_resumes_agent() -> None:
|
|||||||
created.run_id, request_id, "allow_once"
|
created.run_id, request_id, "allow_once"
|
||||||
)
|
)
|
||||||
completed = await container.agent.wait(created.run_id)
|
completed = await container.agent.wait(created.run_id)
|
||||||
|
events = [event async for event in container.agent.events(created.run_id)]
|
||||||
assert completed.status == AgentRunStatus.completed
|
assert completed.status == AgentRunStatus.completed
|
||||||
assert completed.tool_results[0].success is True
|
assert completed.tool_results[0].success is True
|
||||||
|
assert AgentEventType.permission_resolved in {event.event for event in events}
|
||||||
|
|
||||||
|
run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_trace_persists_and_replays_from_sequence() -> None:
|
||||||
|
async def scenario() -> None:
|
||||||
|
first = build_container()
|
||||||
|
created = await first.agent.create_run(
|
||||||
|
AgentRunCreateRequest(
|
||||||
|
input="persistent trace",
|
||||||
|
provider_id="mock",
|
||||||
|
model="mock-1",
|
||||||
|
metadata={"suite": "agent-benchmark-v1"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
completed = await first.agent.wait(created.run_id)
|
||||||
|
|
||||||
|
restarted = build_container()
|
||||||
|
restored = restarted.agent.get_run(created.run_id)
|
||||||
|
first_page = restarted.agent.get_trace(
|
||||||
|
created.run_id, after_sequence=-1, limit=2
|
||||||
|
)
|
||||||
|
second_page = restarted.agent.get_trace(
|
||||||
|
created.run_id,
|
||||||
|
after_sequence=first_page.next_sequence,
|
||||||
|
limit=100,
|
||||||
|
)
|
||||||
|
replay = [
|
||||||
|
event
|
||||||
|
async for event in restarted.agent.events(
|
||||||
|
created.run_id, after_sequence=first_page.next_sequence
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert completed.status == restored.status == AgentRunStatus.completed
|
||||||
|
assert first_page.has_more is True
|
||||||
|
assert [item.sequence for item in first_page.items] == [0, 1]
|
||||||
|
assert second_page.items[0].sequence == 2
|
||||||
|
assert replay == second_page.items
|
||||||
|
assert first_page.summary.model_calls == 1
|
||||||
|
assert first_page.summary.token_usage == completed.token_usage
|
||||||
|
assert first_page.config_snapshot["metadata"] == {
|
||||||
|
"suite": "agent-benchmark-v1"
|
||||||
|
}
|
||||||
|
assert second_page.items[-1].event == AgentEventType.run_completed
|
||||||
|
|
||||||
|
run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_interrupted_persisted_run_is_closed_after_restart() -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
request = AgentRunCreateRequest(
|
||||||
|
input="interrupted",
|
||||||
|
provider_id="mock",
|
||||||
|
model="mock-1",
|
||||||
|
)
|
||||||
|
persisted = AgentRun(
|
||||||
|
run_id="run_interrupted",
|
||||||
|
status=AgentRunStatus.running,
|
||||||
|
input=request.input,
|
||||||
|
provider_id=request.provider_id,
|
||||||
|
model=request.model,
|
||||||
|
max_steps=request.max_steps,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
AgentTraceRepository().create_run(persisted, request, {"model": "mock-1"})
|
||||||
|
|
||||||
|
restarted = build_container()
|
||||||
|
recovered = restarted.agent.get_run(persisted.run_id)
|
||||||
|
events = run(
|
||||||
|
_collect_events(restarted.agent.events(persisted.run_id, after_sequence=-1))
|
||||||
|
)
|
||||||
|
|
||||||
|
assert recovered.status == AgentRunStatus.failed
|
||||||
|
assert recovered.error_code == "AGENT_PROCESS_RESTARTED"
|
||||||
|
assert events[-1].event == AgentEventType.run_failed
|
||||||
|
assert events[-1].sequence == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_trace_redacts_secrets_and_truncates_large_values() -> None:
|
||||||
|
async def scenario() -> None:
|
||||||
|
container = build_container()
|
||||||
|
secret = "sk-should-not-be-stored"
|
||||||
|
created = await container.agent.create_run(
|
||||||
|
AgentRunCreateRequest(
|
||||||
|
input=f'/tool system.echo {{"text":"{"x" * 4200}","api_key":"{secret}"}}',
|
||||||
|
provider_id="mock",
|
||||||
|
model="mock-1",
|
||||||
|
allowed_tools=["system.echo"],
|
||||||
|
metadata={"authorization": secret},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await container.agent.wait(created.run_id)
|
||||||
|
trace = container.agent.get_trace(
|
||||||
|
created.run_id, after_sequence=-1, limit=100
|
||||||
|
)
|
||||||
|
tool_call = next(
|
||||||
|
item for item in trace.items if item.event == AgentEventType.tool_call
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tool_call.data["arguments"]["api_key"] == "[REDACTED]"
|
||||||
|
assert str(tool_call.data["arguments"]["text"]).endswith("...[TRUNCATED]")
|
||||||
|
assert trace.config_snapshot["metadata"]["authorization"] == "[REDACTED]"
|
||||||
|
assert secret not in trace.model_dump_json()
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
stored_row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT run_json, request_json, config_snapshot_json
|
||||||
|
FROM agent_runs WHERE run_id = ?
|
||||||
|
""",
|
||||||
|
(created.run_id,),
|
||||||
|
).fetchone()
|
||||||
|
stored = "\n".join(str(value) for value in stored_row)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
assert secret not in stored
|
||||||
|
|
||||||
|
run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_persisted_agent_run_preserves_long_input_and_output() -> None:
|
||||||
|
"""审计事件可以限长,但重启后读取的 AgentRun 不能丢失正文。"""
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
long_input = "输入" * 2_500
|
||||||
|
long_output = "输出" * 2_500
|
||||||
|
request = AgentRunCreateRequest(
|
||||||
|
input=long_input,
|
||||||
|
provider_id="mock",
|
||||||
|
model="mock-1",
|
||||||
|
)
|
||||||
|
persisted = AgentRun(
|
||||||
|
run_id="run_long_content",
|
||||||
|
status=AgentRunStatus.completed,
|
||||||
|
input=long_input,
|
||||||
|
output=long_output,
|
||||||
|
provider_id=request.provider_id,
|
||||||
|
model=request.model,
|
||||||
|
max_steps=request.max_steps,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
repository = AgentTraceRepository()
|
||||||
|
repository.create_run(persisted, request, {"model": request.model})
|
||||||
|
|
||||||
|
restored = repository.get_run(persisted.run_id)
|
||||||
|
|
||||||
|
assert restored is not None
|
||||||
|
assert restored.input == long_input
|
||||||
|
assert restored.output == long_output
|
||||||
|
|
||||||
|
|
||||||
|
async def _collect_events(iterator):
|
||||||
|
return [event async for event in iterator]
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_sse_uses_last_event_id_and_emits_event_ids(monkeypatch) -> None:
|
||||||
|
async def scenario() -> None:
|
||||||
|
test_container = build_container()
|
||||||
|
monkeypatch.setattr("app.routes.container", test_container)
|
||||||
|
created = await test_container.agent.create_run(
|
||||||
|
AgentRunCreateRequest(
|
||||||
|
input="resume sse",
|
||||||
|
provider_id="mock",
|
||||||
|
model="mock-1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await test_container.agent.wait(created.run_id)
|
||||||
|
|
||||||
|
response = await agent_events(
|
||||||
|
created.run_id, after_sequence=None, last_event_id="1"
|
||||||
|
)
|
||||||
|
chunks = [chunk async for chunk in response.body_iterator]
|
||||||
|
body = "".join(
|
||||||
|
chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk
|
||||||
|
for chunk in chunks
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "id: 0\n" not in body
|
||||||
|
assert "id: 1\n" not in body
|
||||||
|
assert "id: 2\n" in body
|
||||||
|
assert "event: RunCompleted" in body
|
||||||
|
|
||||||
|
with pytest.raises(ApiError) as error:
|
||||||
|
await agent_events(
|
||||||
|
created.run_id, after_sequence=None, last_event_id="invalid"
|
||||||
|
)
|
||||||
|
assert error.value.code == "TRACE_CURSOR_INVALID"
|
||||||
|
|
||||||
run(scenario())
|
run(scenario())
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ def test_openapi_contains_documented_frontend_interfaces() -> None:
|
|||||||
"/api/agent/runs",
|
"/api/agent/runs",
|
||||||
"/api/agent/runs/{run_id}/cancel",
|
"/api/agent/runs/{run_id}/cancel",
|
||||||
"/api/agent/runs/{run_id}/events",
|
"/api/agent/runs/{run_id}/events",
|
||||||
|
"/api/agent/runs/{run_id}/trace",
|
||||||
"/api/skills",
|
"/api/skills",
|
||||||
"/api/plugins",
|
"/api/plugins",
|
||||||
"/api/plugins/install",
|
"/api/plugins/install",
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.contracts import (
|
||||||
|
FolderCreateRequest,
|
||||||
|
FolderDeleteRequest,
|
||||||
|
FolderRenameRequest,
|
||||||
|
NoteCreateRequest,
|
||||||
|
NoteRenameRequest,
|
||||||
|
WorkspaceOpenRequest,
|
||||||
|
)
|
||||||
|
from app.errors import ApiError
|
||||||
|
from app.routes import (
|
||||||
|
create_note,
|
||||||
|
create_workspace_folder,
|
||||||
|
delete_workspace_folder,
|
||||||
|
get_note,
|
||||||
|
get_workspace_tree,
|
||||||
|
open_workspace,
|
||||||
|
rename_note,
|
||||||
|
rename_workspace_folder,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_workspace_indexes_real_markdown_and_returns_tree() -> None:
|
||||||
|
vault = get_settings().vault_path
|
||||||
|
note_path = vault / "课程" / "操作系统.md"
|
||||||
|
note_path.parent.mkdir(parents=True)
|
||||||
|
note_path.write_text("# 操作系统\n\n进程调度。\n", encoding="utf-8")
|
||||||
|
|
||||||
|
snapshot = asyncio.run(open_workspace(WorkspaceOpenRequest()))
|
||||||
|
|
||||||
|
assert snapshot.workspace.path == str(vault.resolve())
|
||||||
|
assert snapshot.workspace.requires_refresh is False
|
||||||
|
assert snapshot.workspace.file_count == snapshot.workspace.indexed_note_count == 1
|
||||||
|
folder = snapshot.items[0]
|
||||||
|
assert folder.path == "/课程"
|
||||||
|
assert folder.children[0].path == "/课程/操作系统.md"
|
||||||
|
assert folder.children[0].note_id is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_workspace_rejects_unconfigured_path() -> None:
|
||||||
|
with pytest.raises(ApiError) as error:
|
||||||
|
asyncio.run(open_workspace(WorkspaceOpenRequest(path="C:/another-vault")))
|
||||||
|
|
||||||
|
assert error.value.code == "WORKSPACE_PATH_MISMATCH"
|
||||||
|
|
||||||
|
|
||||||
|
def test_note_rename_preserves_identity_and_content() -> None:
|
||||||
|
created = asyncio.run(
|
||||||
|
create_note(
|
||||||
|
NoteCreateRequest(
|
||||||
|
title="旧名称", markdown="# 标题不变\n\n真实正文。\n", folder="课程"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
renamed = asyncio.run(
|
||||||
|
rename_note(created.note_id, NoteRenameRequest(file_name="新名称.md"))
|
||||||
|
)
|
||||||
|
|
||||||
|
assert renamed.note_id == created.note_id
|
||||||
|
assert renamed.file_path == "课程/新名称.md"
|
||||||
|
assert renamed.title == "新名称"
|
||||||
|
assert renamed.markdown == "# 标题不变\n\n真实正文。\n"
|
||||||
|
assert not (get_settings().vault_path / "课程" / "旧名称.md").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_folder_lifecycle_updates_database_and_vectors() -> None:
|
||||||
|
folder = asyncio.run(
|
||||||
|
create_workspace_folder(FolderCreateRequest(parent="/", name="课程"))
|
||||||
|
)
|
||||||
|
created = asyncio.run(
|
||||||
|
create_note(
|
||||||
|
NoteCreateRequest(title="网络", markdown="# 网络\n\nTCP。\n", folder="课程")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
renamed_folder = asyncio.run(
|
||||||
|
rename_workspace_folder(
|
||||||
|
FolderRenameRequest(path=folder.path, new_name="计算机课程")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
moved_note = asyncio.run(get_note(created.note_id))
|
||||||
|
|
||||||
|
assert renamed_folder.path == "/计算机课程"
|
||||||
|
assert moved_note.note_id == created.note_id
|
||||||
|
assert moved_note.file_path == "计算机课程/网络.md"
|
||||||
|
assert asyncio.run(get_workspace_tree())[0].children[0].note_id == created.note_id
|
||||||
|
|
||||||
|
response = asyncio.run(
|
||||||
|
delete_workspace_folder(FolderDeleteRequest(path=renamed_folder.path))
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == "completed"
|
||||||
|
with pytest.raises(ApiError) as error:
|
||||||
|
asyncio.run(get_note(created.note_id))
|
||||||
|
assert error.value.code == "RESOURCE_NOT_FOUND"
|
||||||
|
assert asyncio.run(get_workspace_tree()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_openapi_paths_are_published() -> None:
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
paths = app.openapi()["paths"]
|
||||||
|
assert {
|
||||||
|
"/api/workspace",
|
||||||
|
"/api/workspace/open",
|
||||||
|
"/api/workspace/tree",
|
||||||
|
"/api/workspace/folders",
|
||||||
|
"/api/workspace/folders/rename",
|
||||||
|
"/api/workspace/folders/delete",
|
||||||
|
"/api/notes/{note_id}/rename",
|
||||||
|
} <= paths.keys()
|
||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#171717" />
|
<meta name="theme-color" content="#171717" />
|
||||||
<title>Notes Agent</title>
|
<title>NotesAgent</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ defineExpose({ openCitation })
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
background: var(--color-background-primary);
|
background: var(--color-background-secondary);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,5 +89,6 @@ defineExpose({ openCitation })
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--color-background-primary);
|
background: var(--color-background-primary);
|
||||||
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -89,13 +89,17 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.command-backdrop { position: fixed; inset: 0; z-index: var(--z-modal); display: flex; justify-content: center; align-items: flex-start; padding-top: 12vh; background: var(--color-background-overlay); }
|
.command-backdrop { position: fixed; inset: 0; z-index: var(--z-modal); display: flex; justify-content: center; align-items: flex-start; padding-top: 12vh; background: var(--color-background-overlay); animation: command-backdrop-in var(--motion-fast) both; }
|
||||||
.command-palette { width: min(600px, calc(100vw - 32px)); overflow: hidden; border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); background: var(--color-surface-elevated); box-shadow: var(--shadow-xl); }
|
.command-palette { width: min(620px, calc(100vw - 32px)); overflow: hidden; border: 1px solid var(--color-border-default); border-radius: var(--radius-xl); background: var(--color-surface-elevated); box-shadow: var(--shadow-xl); animation: command-palette-in var(--motion-normal) both; }
|
||||||
.command-input { width: 100%; padding: var(--space-lg); border: 0; border-bottom: 1px solid var(--color-border-default); outline: 0; background: transparent; font-size: var(--font-size-xl); }
|
.command-input { width: 100%; padding: var(--space-xl); border: 0; border-bottom: 1px solid var(--color-border-default); outline: 0; background: transparent; color: var(--color-text-primary); font-size: var(--font-size-xl); }
|
||||||
.command-list { max-height: 360px; overflow: auto; padding: var(--space-sm); }
|
.command-list { max-height: 360px; overflow: auto; padding: var(--space-sm); }
|
||||||
.command-list button { display: flex; justify-content: space-between; width: 100%; padding: var(--space-md); border-radius: var(--radius-md); text-align: left; }
|
.command-list button { display: flex; justify-content: space-between; width: 100%; padding: var(--space-md) var(--space-lg); border-radius: var(--radius-md); text-align: left; transition: color var(--motion-fast), background-color var(--motion-fast), transform var(--motion-fast); }
|
||||||
.command-list button:hover, .command-list button:focus { outline: 0; background: var(--color-accent-soft); color: var(--color-accent-primary); }
|
.command-list button:hover, .command-list button:focus { outline: 0; background: var(--color-accent-soft); color: var(--color-accent-primary); }
|
||||||
|
.command-list button:hover { transform: translateX(2px); }
|
||||||
.command-list small, .command-list p, footer { color: var(--color-text-tertiary); }
|
.command-list small, .command-list p, footer { color: var(--color-text-tertiary); }
|
||||||
.command-list p { padding: var(--space-xl); text-align: center; }
|
.command-list p { padding: var(--space-xl); text-align: center; }
|
||||||
footer { display: flex; gap: var(--space-lg); padding: var(--space-sm) var(--space-lg); border-top: 1px solid var(--color-border-subtle); font-size: var(--font-size-xs); }
|
footer { display: flex; gap: var(--space-lg); padding: var(--space-sm) var(--space-lg); border-top: 1px solid var(--color-border-subtle); font-size: var(--font-size-xs); }
|
||||||
|
|
||||||
|
@keyframes command-backdrop-in { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
@keyframes command-palette-in { from { opacity: 0; transform: translateY(-8px) scale(.99); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -17,24 +17,33 @@ watch(() => props.source, async (source) => {
|
|||||||
<div class="markdown-content" v-html="html" />
|
<div class="markdown-content" v-html="html" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style>
|
||||||
.markdown-content { white-space: normal; user-select: text; }
|
.markdown-content { white-space: normal; user-select: text; }
|
||||||
.markdown-content :deep(p), .markdown-content :deep(ul), .markdown-content :deep(ol), .markdown-content :deep(pre), .markdown-content :deep(blockquote) { margin: .65em 0; }
|
.markdown-content p, .markdown-content ul, .markdown-content ol, .markdown-content pre, .markdown-content blockquote { margin: .65em 0; }
|
||||||
.markdown-content :deep(h1), .markdown-content :deep(h2), .markdown-content :deep(h3) { margin: 1em 0 .5em; line-height: var(--line-height-tight); }
|
.markdown-content h1, .markdown-content h2, .markdown-content h3 { margin: 1em 0 .5em; line-height: var(--line-height-tight); }
|
||||||
.markdown-content :deep(ul) { padding-left: 1.5em; list-style: disc; }
|
.markdown-content ul { padding-left: 1.5em; list-style: disc; }
|
||||||
.markdown-content :deep(ol) { padding-left: 1.5em; list-style: decimal; }
|
.markdown-content ol { padding-left: 1.5em; list-style: decimal; }
|
||||||
.markdown-content :deep(.shiki) { overflow: auto; padding: var(--space-md); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); }
|
.markdown-content li::marker { color: var(--color-markdown-marker); font-weight: 700; }
|
||||||
.markdown-content :deep(code) { padding: .1em .3em; border-radius: var(--radius-sm); background: var(--color-background-tertiary); font-family: var(--font-ui-mono); }
|
.markdown-content .shiki { overflow: auto; margin: .85em 0; padding: 16px; border: 1px solid var(--color-code-border); border-radius: 6px; background: var(--color-code-background) !important; color: var(--color-code-text); font-family: var(--font-ui-mono); font-size: .875em; line-height: 1.45; tab-size: 4; }
|
||||||
.markdown-content :deep(pre code) { padding: 0; background: transparent; }
|
.markdown-content code { padding: .1em .3em; border-radius: var(--radius-sm); background: var(--color-background-tertiary); font-family: var(--font-ui-mono); }
|
||||||
.markdown-content :deep(blockquote) { padding-left: 1em; border-left: 3px solid var(--color-accent-primary); color: var(--color-text-secondary); }
|
.markdown-content .shiki code { display: block; min-width: max-content; padding: 0; background: transparent; font: inherit; }
|
||||||
.markdown-content :deep(table) { width: 100%; margin: .65em 0; border-collapse: collapse; }
|
.markdown-content .shiki .line { display: block; min-height: 1.45em; }
|
||||||
.markdown-content :deep(th), .markdown-content :deep(td) { padding: .45em .65em; border: 1px solid var(--color-border-default); text-align: left; }
|
.markdown-content blockquote { padding-left: 1em; border-left: 3px solid var(--color-accent-primary); color: var(--color-text-secondary); }
|
||||||
.markdown-content :deep(img) { max-width: 100%; }
|
.markdown-content table { width: 100%; margin: .65em 0; border-collapse: collapse; }
|
||||||
.markdown-content :deep(hr) { margin: 1em 0; border: 0; border-top: 1px solid var(--color-border-default); }
|
.markdown-content th, .markdown-content td { padding: .45em .65em; border: 1px solid var(--color-markdown-grid); text-align: left; }
|
||||||
:global([data-theme='dark']) .markdown-content :deep(.shiki),
|
.markdown-content th { background: var(--color-markdown-table-header); font-weight: 700; }
|
||||||
:global([data-theme='dark']) .markdown-content :deep(.shiki span) {
|
.markdown-content img { max-width: 100%; }
|
||||||
|
.markdown-content hr { margin: 1em 0; border: 0; border-top: 1px solid var(--color-border-default); }
|
||||||
|
[data-code-theme='github-light'] .markdown-content .shiki,
|
||||||
|
[data-code-theme='github-light'] .markdown-content .shiki span {
|
||||||
|
color: var(--shiki-light) !important;
|
||||||
|
font-style: var(--shiki-light-font-style) !important;
|
||||||
|
font-weight: var(--shiki-light-font-weight) !important;
|
||||||
|
text-decoration: var(--shiki-light-text-decoration) !important;
|
||||||
|
}
|
||||||
|
[data-code-theme='github-dark'] .markdown-content .shiki,
|
||||||
|
[data-code-theme='github-dark'] .markdown-content .shiki span {
|
||||||
color: var(--shiki-dark) !important;
|
color: var(--shiki-dark) !important;
|
||||||
background-color: var(--shiki-dark-bg) !important;
|
|
||||||
font-style: var(--shiki-dark-font-style) !important;
|
font-style: var(--shiki-dark-font-style) !important;
|
||||||
font-weight: var(--shiki-dark-font-weight) !important;
|
font-weight: var(--shiki-dark-font-weight) !important;
|
||||||
text-decoration: var(--shiki-dark-text-decoration) !important;
|
text-decoration: var(--shiki-dark-text-decoration) !important;
|
||||||
|
|||||||
@@ -60,13 +60,14 @@ function toggleExpanded() {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.primary-sidebar {
|
.primary-sidebar {
|
||||||
width: 56px;
|
width: var(--sidebar-primary-width);
|
||||||
background: var(--color-background-secondary);
|
background: var(--color-background-secondary);
|
||||||
border-right: 1px solid var(--color-border-subtle);
|
border-right: 1px solid var(--color-border-subtle);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
z-index: var(--z-sidebar);
|
z-index: var(--z-sidebar);
|
||||||
|
transition: width var(--motion-normal), background-color var(--motion-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-sidebar.expanded { width: var(--sidebar-primary-width-expanded); }
|
.primary-sidebar.expanded { width: var(--sidebar-primary-width-expanded); }
|
||||||
@@ -76,7 +77,7 @@ function toggleExpanded() {
|
|||||||
|
|
||||||
.nav-list {
|
.nav-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: var(--space-sm) 0;
|
padding: var(--space-md) 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@@ -87,31 +88,34 @@ function toggleExpanded() {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 50px;
|
height: 48px;
|
||||||
margin: 0 4px;
|
margin: 0 6px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
transition: all var(--motion-fast);
|
border: 1px solid transparent;
|
||||||
|
transition: color var(--motion-fast), background-color var(--motion-fast), border-color var(--motion-fast), transform var(--motion-fast);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-background-hover);
|
background: var(--color-background-hover);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
|
transform: translateX(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: var(--color-accent-soft);
|
background: var(--color-accent-soft);
|
||||||
color: var(--color-accent-primary);
|
color: var(--color-accent-primary);
|
||||||
|
border-color: color-mix(in srgb, var(--color-accent-primary) 16%, transparent);
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -4px;
|
left: -7px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
width: 3px;
|
width: 4px;
|
||||||
height: 24px;
|
height: 22px;
|
||||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||||
background: var(--color-accent-primary);
|
background: var(--color-accent-primary);
|
||||||
}
|
}
|
||||||
@@ -127,6 +131,7 @@ function toggleExpanded() {
|
|||||||
.nav-label {
|
.nav-label {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
font-weight: 550;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-footer {
|
.sidebar-footer {
|
||||||
@@ -134,5 +139,5 @@ function toggleExpanded() {
|
|||||||
border-top: 1px solid var(--color-border-subtle);
|
border-top: 1px solid var(--color-border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.collapse-button { width: calc(100% - 8px); }
|
.collapse-button { width: calc(100% - 12px); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const showSkillToggle = computed(() => routeName.value === 'skills' || routeName
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.secondary-sidebar {
|
.secondary-sidebar {
|
||||||
width: var(--sidebar-secondary-width);
|
width: var(--sidebar-secondary-width);
|
||||||
background: var(--color-background-primary);
|
background: var(--color-surface-secondary);
|
||||||
border-right: 1px solid var(--color-border-default);
|
border-right: 1px solid var(--color-border-default);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -62,36 +62,36 @@ const showSkillToggle = computed(() => routeName.value === 'skills' || routeName
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
padding: var(--space-md) var(--space-lg);
|
padding: var(--space-lg);
|
||||||
border-bottom: 1px solid var(--color-border-subtle);
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-title {
|
.sidebar-title {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-lg);
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
margin: 0 0 var(--space-sm) 0;
|
margin: 0 0 var(--space-md) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-tabs {
|
.sidebar-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
background: var(--color-background-secondary);
|
background: var(--color-background-secondary);
|
||||||
padding: 2px;
|
padding: 3px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 4px 8px;
|
padding: 6px 8px;
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: all var(--motion-fast);
|
transition: color var(--motion-fast), background-color var(--motion-fast), box-shadow var(--motion-fast);
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: var(--color-surface-primary);
|
background: var(--color-surface-primary);
|
||||||
@@ -108,6 +108,7 @@ const showSkillToggle = computed(() => routeName.value === 'skills' || routeName
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -107,8 +107,8 @@ const showEditorInfo = computed(() => route.name === 'workspace')
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 var(--space-md);
|
padding: 0 var(--space-lg);
|
||||||
background: var(--color-background-secondary);
|
background: var(--color-surface-secondary);
|
||||||
border-top: 1px solid var(--color-border-subtle);
|
border-top: 1px solid var(--color-border-subtle);
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
@@ -129,6 +129,7 @@ const showEditorInfo = computed(() => route.name === 'workspace')
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
transition: color var(--motion-fast);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
@@ -140,6 +141,7 @@ const showEditorInfo = computed(() => route.name === 'workspace')
|
|||||||
height: 6px;
|
height: 6px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 0 0 2px var(--color-surface-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-status {
|
.agent-status {
|
||||||
@@ -162,4 +164,9 @@ const showEditorInfo = computed(() => route.name === 'workspace')
|
|||||||
.provider-info {
|
.provider-info {
|
||||||
color: var(--color-text-tertiary);
|
color: var(--color-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.statusbar-left, .statusbar-right { gap: var(--space-sm); }
|
||||||
|
.provider-info { display: none; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const pageTitle = computed(() => {
|
|||||||
themes: '主题管理',
|
themes: '主题管理',
|
||||||
settings: '设置',
|
settings: '设置',
|
||||||
}
|
}
|
||||||
return titles[name] || '知笔知己'
|
return titles[name] || 'NotesAgent'
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentFileName = computed(() => {
|
const currentFileName = computed(() => {
|
||||||
@@ -50,7 +50,7 @@ const isDirty = computed(() => editorStore.saveStatus === 'dirty' || editorStore
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="titlebar-center">
|
<div class="titlebar-center">
|
||||||
<span class="app-name">知笔知己</span>
|
<span class="app-name">NotesAgent</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="titlebar-right">
|
<div class="titlebar-right">
|
||||||
<button class="icon-btn" @click="themeStore.toggleTheme()" :title="themeStore.isDark ? '切换浅色主题' : '切换深色主题'">
|
<button class="icon-btn" @click="themeStore.toggleTheme()" :title="themeStore.isDark ? '切换浅色主题' : '切换深色主题'">
|
||||||
@@ -71,8 +71,8 @@ const isDirty = computed(() => editorStore.saveStatus === 'dirty' || editorStore
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 var(--space-md);
|
padding: 0 var(--space-lg);
|
||||||
background: var(--color-background-secondary);
|
background: var(--color-surface-secondary);
|
||||||
border-bottom: 1px solid var(--color-border-subtle);
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -129,7 +129,13 @@ const isDirty = computed(() => editorStore.saveStatus === 'dirty' || editorStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-name {
|
.app-name {
|
||||||
font-weight: 500;
|
padding: 3px 10px;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--color-surface-primary);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: .04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.titlebar-right {
|
.titlebar-right {
|
||||||
@@ -141,8 +147,8 @@ const isDirty = computed(() => editorStore.saveStatus === 'dirty' || editorStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
width: 28px;
|
width: 30px;
|
||||||
height: 28px;
|
height: 30px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -150,11 +156,12 @@ const isDirty = computed(() => editorStore.saveStatus === 'dirty' || editorStore
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
transition: background var(--motion-fast);
|
transition: background-color var(--motion-fast), color var(--motion-fast), transform var(--motion-fast);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-background-hover);
|
background: var(--color-background-hover);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
|
transform: rotate(8deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +186,7 @@ const isDirty = computed(() => editorStore.saveStatus === 'dirty' || editorStore
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--motion-fast);
|
transition: background-color var(--motion-fast), color var(--motion-fast);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-background-hover);
|
background: var(--color-background-hover);
|
||||||
@@ -190,4 +197,10 @@ const isDirty = computed(() => editorStore.saveStatus === 'dirty' || editorStore
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.titlebar-left, .titlebar-right { min-width: 0; }
|
||||||
|
.titlebar-center, .window-controls { display: none; }
|
||||||
|
.file-name { max-width: 42vw; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface NoteBlock {
|
|||||||
|
|
||||||
export interface FileNode {
|
export interface FileNode {
|
||||||
id: string
|
id: string
|
||||||
|
note_id?: string
|
||||||
name: string
|
name: string
|
||||||
path: string
|
path: string
|
||||||
type: 'file' | 'folder'
|
type: 'file' | 'folder'
|
||||||
@@ -142,6 +143,10 @@ export type AgentEventType =
|
|||||||
| 'PermissionRequired'
|
| 'PermissionRequired'
|
||||||
| 'Usage'
|
| 'Usage'
|
||||||
| 'Citation'
|
| 'Citation'
|
||||||
|
| 'ModelCallStarted'
|
||||||
|
| 'ModelCallCompleted'
|
||||||
|
| 'ModelCallFailed'
|
||||||
|
| 'PermissionResolved'
|
||||||
| 'RunCompleted'
|
| 'RunCompleted'
|
||||||
| 'RunFailed'
|
| 'RunFailed'
|
||||||
| 'RunCancelled'
|
| 'RunCancelled'
|
||||||
@@ -154,6 +159,24 @@ export interface AgentEvent {
|
|||||||
timestamp: string
|
timestamp: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AgentTraceSummary {
|
||||||
|
model_calls: number
|
||||||
|
tool_calls: number
|
||||||
|
duration_ms: number
|
||||||
|
token_usage: number
|
||||||
|
errors: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentTraceResponse {
|
||||||
|
run_id: string
|
||||||
|
status: AgentRunStatus
|
||||||
|
items: AgentEvent[]
|
||||||
|
next_sequence: number
|
||||||
|
has_more: boolean
|
||||||
|
summary: AgentTraceSummary
|
||||||
|
config_snapshot: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
export interface ToolCall {
|
export interface ToolCall {
|
||||||
tool_call_id: string
|
tool_call_id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -329,6 +352,7 @@ export interface ThemeConfig {
|
|||||||
is_dark: boolean
|
is_dark: boolean
|
||||||
author?: string
|
author?: string
|
||||||
builtin: boolean
|
builtin: boolean
|
||||||
|
code_theme?: 'github-light' | 'github-dark'
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Index ============
|
// ============ Index ============
|
||||||
@@ -386,6 +410,29 @@ export interface PageMeta {
|
|||||||
offset: number
|
offset: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApiWorkspaceInfo {
|
||||||
|
vault_id: string
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
file_count: number
|
||||||
|
indexed_note_count: number
|
||||||
|
requires_refresh: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiWorkspaceEntry {
|
||||||
|
entry_id: string
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
type: 'file' | 'folder'
|
||||||
|
note_id?: string | null
|
||||||
|
children: ApiWorkspaceEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiWorkspaceSnapshot {
|
||||||
|
workspace: ApiWorkspaceInfo
|
||||||
|
items: ApiWorkspaceEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface OperationResponse {
|
export interface OperationResponse {
|
||||||
status: 'accepted' | 'completed'
|
status: 'accepted' | 'completed'
|
||||||
resource_id?: string | null
|
resource_id?: string | null
|
||||||
|
|||||||
@@ -108,16 +108,22 @@ function eventText(event: AgentEvent) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.run-form { display: grid; gap: var(--space-xl); max-width: 980px; }
|
.agent-page > * { width: min(100%, 1080px); margin-inline: auto; }
|
||||||
|
.run-form { display: grid; gap: var(--space-xl); }
|
||||||
.tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--space-sm); }
|
.tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--space-sm); }
|
||||||
.tool-option { display: flex; gap: var(--space-sm); padding: var(--space-sm); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); }
|
.tool-option { display: flex; gap: var(--space-sm); padding: var(--space-md); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-primary); cursor: pointer; transition: border-color var(--motion-fast), background-color var(--motion-fast), transform var(--motion-fast), box-shadow var(--motion-fast); }
|
||||||
|
.tool-option:hover { border-color: var(--color-accent-secondary); transform: translateY(-1px); box-shadow: var(--shadow-sm); }
|
||||||
|
.tool-option:has(input:checked) { border-color: var(--color-accent-primary); background: var(--color-accent-soft); box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent-primary) 10%, transparent); }
|
||||||
.tool-option small { display: block; color: var(--color-text-secondary); }
|
.tool-option small { display: block; color: var(--color-text-secondary); }
|
||||||
.tool-option code { display: block; margin: 2px 0; color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
|
.tool-option code { display: block; margin: 2px 0; color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
|
||||||
.network { display: flex; gap: var(--space-sm); }
|
.network { display: flex; gap: var(--space-sm); }
|
||||||
.trace-layout { display: grid; gap: var(--space-lg); }
|
.trace-layout { display: grid; gap: var(--space-lg); }
|
||||||
.run-summary, .event-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); }
|
.run-summary, .event-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); }
|
||||||
.run-summary h2 { margin-top: var(--space-sm); font-family: var(--font-ui-mono); font-size: var(--font-size-lg); }
|
.run-summary h2 { margin-top: var(--space-sm); font-family: var(--font-ui-mono); font-size: var(--font-size-lg); }
|
||||||
.timeline { display: grid; gap: var(--space-md); }
|
.timeline { position: relative; display: grid; gap: var(--space-md); padding-left: var(--space-md); }
|
||||||
|
.timeline::before { content: ''; position: absolute; top: 10px; bottom: 10px; left: 1px; width: 2px; border-radius: var(--radius-full); background: var(--color-border-default); }
|
||||||
|
.event-card { position: relative; }
|
||||||
|
.event-card::before { content: ''; position: absolute; top: 20px; left: calc(-1 * var(--space-md) - 5px); width: 8px; height: 8px; border: 2px solid var(--color-surface-primary); border-radius: var(--radius-full); background: var(--color-accent-primary); box-shadow: 0 0 0 1px var(--color-accent-secondary); }
|
||||||
.event-head { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
|
.event-head { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
|
||||||
.event-text { margin-top: var(--space-md); white-space: pre-wrap; line-height: var(--line-height-relaxed); }
|
.event-text { margin-top: var(--space-md); white-space: pre-wrap; line-height: var(--line-height-relaxed); }
|
||||||
pre { margin-top: var(--space-md); max-height: 260px; overflow: auto; padding: var(--space-md); border-radius: var(--radius-md); background: var(--color-background-secondary); font-family: var(--font-ui-mono); font-size: var(--font-size-xs); white-space: pre-wrap; user-select: text; }
|
pre { margin-top: var(--space-md); max-height: 260px; overflow: auto; padding: var(--space-md); border-radius: var(--radius-md); background: var(--color-background-secondary); font-family: var(--font-ui-mono); font-size: var(--font-size-xs); white-space: pre-wrap; user-select: text; }
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ const eventLabels: Record<AgentEventType, string> = {
|
|||||||
PermissionRequired: '请求权限',
|
PermissionRequired: '请求权限',
|
||||||
Usage: '用量统计',
|
Usage: '用量统计',
|
||||||
Citation: '引用来源',
|
Citation: '引用来源',
|
||||||
|
ModelCallStarted: '模型调用开始',
|
||||||
|
ModelCallCompleted: '模型调用完成',
|
||||||
|
ModelCallFailed: '模型调用失败',
|
||||||
|
PermissionResolved: '权限已处理',
|
||||||
RunCompleted: '运行完成',
|
RunCompleted: '运行完成',
|
||||||
RunFailed: '运行失败',
|
RunFailed: '运行失败',
|
||||||
RunCancelled: '运行取消',
|
RunCancelled: '运行取消',
|
||||||
@@ -86,6 +90,10 @@ const detailLabels: Record<string, string> = {
|
|||||||
total_tokens: '令牌总数',
|
total_tokens: '令牌总数',
|
||||||
status: '状态',
|
status: '状态',
|
||||||
duration_ms: '耗时(毫秒)',
|
duration_ms: '耗时(毫秒)',
|
||||||
|
model_call_id: '模型调用 ID',
|
||||||
|
parent_model_call_id: '上级模型调用 ID',
|
||||||
|
finish_reason: '结束原因',
|
||||||
|
decision: '授权决定',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runStatusLabel(status?: AgentRunStatus): string {
|
export function runStatusLabel(status?: AgentRunStatus): string {
|
||||||
|
|||||||
@@ -94,24 +94,29 @@ async function openCitation(citation: Citation) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.chat-page { display: grid; grid-template-rows: auto auto 1fr auto; height: 100%; min-height: 0; background: var(--color-background-primary); }
|
.chat-page { display: grid; grid-template-rows: auto auto 1fr auto; height: 100%; min-height: 0; background: radial-gradient(circle at 85% -10%, var(--color-accent-soft), transparent 30%), var(--color-background-primary); }
|
||||||
.chat-toolbar { display: flex; align-items: end; flex-wrap: wrap; gap: var(--space-md); padding: var(--space-md) var(--space-xl); border-bottom: 1px solid var(--color-border-default); }
|
.chat-toolbar { display: flex; align-items: end; flex-wrap: wrap; gap: var(--space-md); padding: var(--space-md) var(--space-xl); border-bottom: 1px solid var(--color-border-default); background: var(--color-surface-secondary); box-shadow: var(--shadow-sm); z-index: 1; }
|
||||||
.compact { min-width: 160px; }
|
.compact { min-width: 160px; }
|
||||||
.rag-toggle { display: flex; align-items: center; gap: var(--space-xs); min-height: 36px; color: var(--color-text-secondary); }
|
.rag-toggle { display: flex; align-items: center; gap: var(--space-xs); min-height: 36px; color: var(--color-text-secondary); }
|
||||||
.chat-error { margin: var(--space-md) var(--space-xl) 0; }
|
.chat-error { margin: var(--space-md) var(--space-xl) 0; }
|
||||||
.message-timeline { min-height: 0; overflow: auto; padding: var(--space-xl) max(var(--space-xl), calc((100% - 820px) / 2)); user-select: text; }
|
.message-timeline { min-height: 0; overflow: auto; padding: var(--space-xl) max(var(--space-xl), calc((100% - 820px) / 2)); user-select: text; }
|
||||||
.message { display: grid; grid-template-columns: 36px 1fr; gap: var(--space-md); margin-bottom: var(--space-xl); }
|
.message { display: grid; grid-template-columns: 36px 1fr; gap: var(--space-md); margin-bottom: var(--space-xl); animation: message-in var(--motion-normal) both; }
|
||||||
.avatar { display: grid; place-items: center; width: 34px; height: 34px; border-radius: var(--radius-full); background: var(--color-background-tertiary); font-weight: 700; }
|
.avatar { display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid var(--color-border-default); border-radius: var(--radius-full); background: var(--color-background-tertiary); box-shadow: var(--shadow-sm); font-weight: 700; }
|
||||||
.assistant .avatar { background: var(--color-accent-soft); color: var(--color-accent-primary); }
|
.assistant .avatar { background: var(--color-accent-soft); color: var(--color-accent-primary); }
|
||||||
|
.message-body { min-width: 0; padding: var(--space-md) var(--space-lg); border: 1px solid var(--color-border-subtle); border-radius: 4px var(--radius-lg) var(--radius-lg) var(--radius-lg); background: color-mix(in srgb, var(--color-surface-primary) 88%, transparent); box-shadow: var(--shadow-sm); }
|
||||||
|
.user .message-body { background: var(--color-accent-soft); border-color: color-mix(in srgb, var(--color-accent-primary) 14%, transparent); }
|
||||||
.message-content { white-space: pre-wrap; line-height: var(--line-height-relaxed); }
|
.message-content { white-space: pre-wrap; line-height: var(--line-height-relaxed); }
|
||||||
.thinking { margin-bottom: var(--space-sm); color: var(--color-text-secondary); }.thinking p { margin-top: var(--space-sm); white-space: pre-wrap; }
|
.thinking { margin-bottom: var(--space-sm); color: var(--color-text-secondary); }.thinking p { margin-top: var(--space-sm); white-space: pre-wrap; }
|
||||||
.tool-calls { display: grid; gap: var(--space-sm); margin-top: var(--space-md); }.tool-calls .item-card { display: grid; gap: var(--space-xs); }.tool-calls pre { overflow: auto; font-size: var(--font-size-xs); }
|
.tool-calls { display: grid; gap: var(--space-sm); margin-top: var(--space-md); }.tool-calls .item-card { display: grid; gap: var(--space-xs); }.tool-calls pre { overflow: auto; font-size: var(--font-size-xs); }
|
||||||
.usage { display: block; margin-top: var(--space-xs); color: var(--color-text-tertiary); }
|
.usage { display: block; margin-top: var(--space-xs); color: var(--color-text-tertiary); }
|
||||||
.message time { display: block; margin-top: var(--space-sm); color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
|
.message time { display: block; margin-top: var(--space-sm); color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
|
||||||
.citations { display: grid; gap: var(--space-sm); margin-top: var(--space-md); }
|
.citations { display: grid; gap: var(--space-sm); margin-top: var(--space-md); }
|
||||||
.citation-card { display: flex; align-items: flex-start; gap: var(--space-sm); padding: var(--space-sm); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); text-align: left; }
|
.citation-card { display: flex; align-items: flex-start; gap: var(--space-sm); padding: var(--space-md); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-primary); text-align: left; transition: border-color var(--motion-fast), transform var(--motion-fast), box-shadow var(--motion-fast); }
|
||||||
|
.citation-card:hover { border-color: var(--color-accent-secondary); transform: translateY(-1px); box-shadow: var(--shadow-sm); }
|
||||||
.citation-card small { display: block; margin-top: 2px; color: var(--color-text-secondary); }
|
.citation-card small { display: block; margin-top: 2px; color: var(--color-text-secondary); }
|
||||||
.composer { padding: var(--space-md) max(var(--space-xl), calc((100% - 820px) / 2)); border-top: 1px solid var(--color-border-default); background: var(--color-surface-primary); }
|
.composer { padding: var(--space-md) max(var(--space-xl), calc((100% - 820px) / 2)); border-top: 1px solid var(--color-border-default); background: var(--color-surface-secondary); box-shadow: 0 -8px 24px color-mix(in srgb, var(--color-text-primary) 5%, transparent); }
|
||||||
.composer .textarea { min-height: 72px; }
|
.composer .textarea { min-height: 72px; }
|
||||||
.composer-actions { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); margin-top: var(--space-sm); }
|
.composer-actions { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); margin-top: var(--space-sm); }
|
||||||
|
|
||||||
|
@keyframes message-in { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { nextTick } from 'vue'
|
import { nextTick } from 'vue'
|
||||||
import EditorPane from './EditorPane.vue'
|
import EditorPane from './EditorPane.vue'
|
||||||
import { useEditorStore } from '@/stores/editor'
|
import { useEditorStore } from '@/stores/editor'
|
||||||
|
import * as workspaceService from '@/services/workspaceService'
|
||||||
|
|
||||||
let wrapper: VueWrapper | null = null
|
let wrapper: VueWrapper | null = null
|
||||||
|
|
||||||
@@ -19,20 +20,28 @@ async function waitForText(text: string) {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
|
vi.spyOn(workspaceService, 'readFileContent').mockImplementation(async (filePath) => {
|
||||||
|
if (filePath === '/欢迎使用 NotesAgent.md') {
|
||||||
|
return '# 欢迎使用 NotesAgent\n\n祝你写作愉快'
|
||||||
|
}
|
||||||
|
if (filePath === '/数据结构/红黑树.md') return '# 红黑树\n\n新的文件内容'
|
||||||
|
throw new Error(`Unexpected file path: ${filePath}`)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
wrapper?.unmount()
|
wrapper?.unmount()
|
||||||
wrapper = null
|
wrapper = null
|
||||||
document.body.innerHTML = ''
|
document.body.innerHTML = ''
|
||||||
|
vi.restoreAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('EditorPane file switching', () => {
|
describe('EditorPane file switching', () => {
|
||||||
it('recreates the visual editor with the newly loaded file content', async () => {
|
it('recreates the visual editor with the newly loaded file content', async () => {
|
||||||
const store = useEditorStore()
|
const store = useEditorStore()
|
||||||
await store.loadFile('/欢迎使用知笔知己.md')
|
await store.loadFile('/欢迎使用 NotesAgent.md')
|
||||||
wrapper = mount(EditorPane, { attachTo: document.body })
|
wrapper = mount(EditorPane, { attachTo: document.body })
|
||||||
await waitForText('欢迎使用知笔知己')
|
await waitForText('欢迎使用 NotesAgent')
|
||||||
|
|
||||||
await store.loadFile('/数据结构/红黑树.md')
|
await store.loadFile('/数据结构/红黑树.md')
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useEditorStore } from '@/stores/editor'
|
import { useEditorStore } from '@/stores/editor'
|
||||||
import { useSettingsStore } from '@/stores/settings'
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
|
import { useThemeStore } from '@/stores/theme'
|
||||||
import VisualMarkdownEditor from './VisualMarkdownEditor.vue'
|
import VisualMarkdownEditor from './VisualMarkdownEditor.vue'
|
||||||
|
|
||||||
const editorStore = useEditorStore()
|
const editorStore = useEditorStore()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
|
const themeStore = useThemeStore()
|
||||||
function updateContent(event: Event) {
|
function updateContent(event: Event) {
|
||||||
editorStore.updateContent((event.target as HTMLTextAreaElement).value)
|
editorStore.updateContent((event.target as HTMLTextAreaElement).value)
|
||||||
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
|
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
|
||||||
@@ -12,7 +14,7 @@ function updateContent(event: Event) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VisualMarkdownEditor v-if="editorStore.mode === 'wysiwyg'" :key="editorStore.currentFilePath ?? 'empty'"
|
<VisualMarkdownEditor v-if="editorStore.mode === 'wysiwyg'" :key="`${editorStore.currentFilePath ?? 'empty'}:${themeStore.resolvedCodeBlockTheme}`"
|
||||||
:initial-content="editorStore.content" />
|
:initial-content="editorStore.content" />
|
||||||
<textarea v-else class="editor-pane source" :value="editorStore.content" :spellcheck="false"
|
<textarea v-else class="editor-pane source" :value="editorStore.content" :spellcheck="false"
|
||||||
aria-label="Markdown 源码编辑器" @input="updateContent" />
|
aria-label="Markdown 源码编辑器" @input="updateContent" />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { Link } from '@element-plus/icons-vue'
|
import { Link } from '@element-plus/icons-vue'
|
||||||
import { Crepe } from '@milkdown/crepe'
|
import { Crepe } from '@milkdown/crepe'
|
||||||
|
import { oneDark } from '@codemirror/theme-one-dark'
|
||||||
import {
|
import {
|
||||||
createCodeBlockCommand,
|
createCodeBlockCommand,
|
||||||
toggleEmphasisCommand,
|
toggleEmphasisCommand,
|
||||||
@@ -19,6 +20,7 @@ import { callCommand } from '@milkdown/kit/utils'
|
|||||||
import AppIcon from '@/components/common/AppIcon.vue'
|
import AppIcon from '@/components/common/AppIcon.vue'
|
||||||
import { useEditorStore } from '@/stores/editor'
|
import { useEditorStore } from '@/stores/editor'
|
||||||
import { useSettingsStore } from '@/stores/settings'
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
|
import { useThemeStore } from '@/stores/theme'
|
||||||
import { applyMarkdownFontSize, fontSizeMarkdownPlugin } from './fontSizeMarkdown'
|
import { applyMarkdownFontSize, fontSizeMarkdownPlugin } from './fontSizeMarkdown'
|
||||||
import '@milkdown/crepe/theme/common/style.css'
|
import '@milkdown/crepe/theme/common/style.css'
|
||||||
import '@milkdown/crepe/theme/frame.css'
|
import '@milkdown/crepe/theme/frame.css'
|
||||||
@@ -26,6 +28,7 @@ import '@milkdown/crepe/theme/frame.css'
|
|||||||
const props = defineProps<{ initialContent: string }>()
|
const props = defineProps<{ initialContent: string }>()
|
||||||
const editorStore = useEditorStore()
|
const editorStore = useEditorStore()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
|
const themeStore = useThemeStore()
|
||||||
const editorRoot = ref<HTMLElement | null>(null)
|
const editorRoot = ref<HTMLElement | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const fontSizeInput = ref(16)
|
const fontSizeInput = ref(16)
|
||||||
@@ -36,6 +39,7 @@ type ToolbarCommand = 'bold' | 'italic' | 'ordered-list' | 'bullet-list' | 'inli
|
|||||||
function runCommand(command: ToolbarCommand) {
|
function runCommand(command: ToolbarCommand) {
|
||||||
const editor = crepe?.editor
|
const editor = crepe?.editor
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
|
// 顶部工具栏复用 Milkdown 命令,因此选区与浮动工具栏共享同一文档事务。
|
||||||
const actions = {
|
const actions = {
|
||||||
bold: callCommand(toggleStrongCommand.key),
|
bold: callCommand(toggleStrongCommand.key),
|
||||||
italic: callCommand(toggleEmphasisCommand.key),
|
italic: callCommand(toggleEmphasisCommand.key),
|
||||||
@@ -52,6 +56,7 @@ function runCommand(command: ToolbarCommand) {
|
|||||||
|
|
||||||
function applyLink() {
|
function applyLink() {
|
||||||
if (!crepe) return
|
if (!crepe) return
|
||||||
|
// TODO(editor): 用受控 Element Plus 对话框替换 prompt,补充 URL 校验和键盘焦点管理。
|
||||||
const href = window.prompt('请输入链接地址', 'https://')?.trim()
|
const href = window.prompt('请输入链接地址', 'https://')?.trim()
|
||||||
if (!href) return
|
if (!href) return
|
||||||
|
|
||||||
@@ -104,6 +109,7 @@ onMounted(async () => {
|
|||||||
featureConfigs: {
|
featureConfigs: {
|
||||||
[Crepe.Feature.Placeholder]: { text: '开始记录你的想法…' },
|
[Crepe.Feature.Placeholder]: { text: '开始记录你的想法…' },
|
||||||
[Crepe.Feature.CodeMirror]: {
|
[Crepe.Feature.CodeMirror]: {
|
||||||
|
theme: themeStore.resolvedCodeBlockTheme === 'github-dark' ? oneDark : [],
|
||||||
previewOnlyByDefault: false,
|
previewOnlyByDefault: false,
|
||||||
searchPlaceholder: '搜索语言',
|
searchPlaceholder: '搜索语言',
|
||||||
noResultText: '没有匹配的语言',
|
noResultText: '没有匹配的语言',
|
||||||
@@ -158,6 +164,7 @@ onMounted(async () => {
|
|||||||
crepe.editor.use(fontSizeMarkdownPlugin)
|
crepe.editor.use(fontSizeMarkdownPlugin)
|
||||||
crepe.on((listener) => {
|
crepe.on((listener) => {
|
||||||
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
|
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
|
||||||
|
// 忽略编辑器初始化/回显事件,防止无内容变化时触发自动保存循环。
|
||||||
if (markdown === previousMarkdown || markdown === editorStore.content) return
|
if (markdown === previousMarkdown || markdown === editorStore.content) return
|
||||||
editorStore.updateContent(markdown)
|
editorStore.updateContent(markdown)
|
||||||
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
|
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
|
||||||
@@ -250,7 +257,7 @@ defineExpose({ getEditor: () => crepe?.editor })
|
|||||||
--crepe-color-surface-low: var(--color-background-secondary);
|
--crepe-color-surface-low: var(--color-background-secondary);
|
||||||
--crepe-color-on-surface: var(--color-text-primary);
|
--crepe-color-on-surface: var(--color-text-primary);
|
||||||
--crepe-color-on-surface-variant: var(--color-text-secondary);
|
--crepe-color-on-surface-variant: var(--color-text-secondary);
|
||||||
--crepe-color-outline: var(--color-border-default);
|
--crepe-color-outline: var(--color-markdown-grid);
|
||||||
--crepe-color-primary: var(--color-accent-primary);
|
--crepe-color-primary: var(--color-accent-primary);
|
||||||
--crepe-color-secondary: var(--color-accent-soft);
|
--crepe-color-secondary: var(--color-accent-soft);
|
||||||
--crepe-color-on-secondary: var(--color-text-primary);
|
--crepe-color-on-secondary: var(--color-text-primary);
|
||||||
@@ -269,14 +276,21 @@ defineExpose({ getEditor: () => crepe?.editor })
|
|||||||
.milkdown-host :deep(.ProseMirror p) { font-weight: 400; }
|
.milkdown-host :deep(.ProseMirror p) { font-weight: 400; }
|
||||||
.milkdown-host :deep(.ProseMirror h1), .milkdown-host :deep(.ProseMirror h2), .milkdown-host :deep(.ProseMirror h3), .milkdown-host :deep(.ProseMirror h4), .milkdown-host :deep(.ProseMirror h5), .milkdown-host :deep(.ProseMirror h6) { font-weight: 700; }
|
.milkdown-host :deep(.ProseMirror h1), .milkdown-host :deep(.ProseMirror h2), .milkdown-host :deep(.ProseMirror h3), .milkdown-host :deep(.ProseMirror h4), .milkdown-host :deep(.ProseMirror h5), .milkdown-host :deep(.ProseMirror h6) { font-weight: 700; }
|
||||||
.milkdown-host :deep(.font-size-marker) { display: none; }
|
.milkdown-host :deep(.font-size-marker) { display: none; }
|
||||||
|
.milkdown-host :deep(.milkdown-code-block) { overflow: hidden; border: 1px solid var(--color-code-border); border-radius: 6px; background: var(--color-code-background); color: var(--color-code-text); }
|
||||||
|
.milkdown-host :deep(.milkdown-code-block .cm-editor),
|
||||||
|
.milkdown-host :deep(.milkdown-code-block .cm-gutters),
|
||||||
|
.milkdown-host :deep(.milkdown-code-block .cm-panel) { background: var(--color-code-background); }
|
||||||
|
.milkdown-host :deep(.milkdown-code-block .cm-content) { caret-color: var(--color-code-text); font-family: var(--font-editor-mono); }
|
||||||
|
.milkdown-host :deep(.milkdown-code-block .language-button) { color: var(--color-code-muted); }
|
||||||
:global(.milkdown-toolbar) { border: 1px solid var(--color-border-default) !important; background: var(--color-surface-elevated) !important; box-shadow: var(--shadow-md) !important; }
|
:global(.milkdown-toolbar) { border: 1px solid var(--color-border-default) !important; background: var(--color-surface-elevated) !important; box-shadow: var(--shadow-md) !important; }
|
||||||
:global(.milkdown-toolbar .toolbar-item svg), :global(.milkdown-toolbar .toolbar-item.active svg) { color: var(--color-text-primary) !important; fill: var(--color-text-primary) !important; opacity: 1 !important; }
|
:global(.milkdown-toolbar .toolbar-item svg), :global(.milkdown-toolbar .toolbar-item.active svg) { color: var(--color-text-primary) !important; fill: var(--color-text-primary) !important; opacity: 1 !important; }
|
||||||
:global(.milkdown-toolbar .toolbar-item:hover svg), :global(.milkdown-toolbar .toolbar-item.active svg) { color: var(--color-accent-primary) !important; fill: var(--color-accent-primary) !important; }
|
:global(.milkdown-toolbar .toolbar-item:hover svg), :global(.milkdown-toolbar .toolbar-item.active svg) { color: var(--color-accent-primary) !important; fill: var(--color-accent-primary) !important; }
|
||||||
:global([data-theme='light']) .milkdown-host :deep(.milkdown-table-block th),
|
.milkdown-host :deep(.milkdown-table-block th),
|
||||||
:global([data-theme='light']) .milkdown-host :deep(.milkdown-table-block td) { border-color: var(--color-text-tertiary); }
|
.milkdown-host :deep(.milkdown-table-block td) { border-color: var(--color-markdown-grid); }
|
||||||
:global([data-theme='light']) .milkdown-host :deep(.milkdown-list-item-block li .label-wrapper) { color: var(--color-text-secondary); font-weight: 600; }
|
.milkdown-host :deep(.milkdown-table-block th) { background: var(--color-markdown-table-header); font-weight: 700; }
|
||||||
:global([data-theme='light']) .milkdown-host :deep(.milkdown-list-item-block li .label-wrapper svg) { fill: var(--color-text-secondary); }
|
.milkdown-host :deep(.milkdown-list-item-block li .label-wrapper) { color: var(--color-markdown-marker); font-weight: 700; }
|
||||||
|
.milkdown-host :deep(.milkdown-list-item-block li .label-wrapper svg) { fill: var(--color-markdown-marker); }
|
||||||
.milkdown-host :deep(code) { font-family: var(--font-editor-mono); }
|
.milkdown-host :deep(code) { font-family: var(--font-editor-mono); }
|
||||||
:global([data-theme='dark']) .milkdown-host :deep(.milkdown) { color-scheme: dark; }
|
:global([data-theme='dark'] .milkdown-host .milkdown) { color-scheme: dark; }
|
||||||
@media (max-width: 680px) { .toolbar-select select { min-width: 46px; width: 46px; } }
|
@media (max-width: 680px) { .toolbar-select select { min-width: 46px; width: 46px; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -66,13 +66,16 @@ async function openResult(result: SearchResult) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.search-page > * { width: min(100%, 1040px); margin-inline: auto; }
|
||||||
.search-form { display: grid; grid-template-columns: 1fr auto; gap: var(--space-md); margin-bottom: var(--space-lg); }
|
.search-form { display: grid; grid-template-columns: 1fr auto; gap: var(--space-md); margin-bottom: var(--space-lg); }
|
||||||
.search-input { height: 44px; font-size: var(--font-size-lg); }
|
.search-input { height: 44px; font-size: var(--font-size-lg); }
|
||||||
.advanced { grid-column: 1 / -1; }
|
.advanced { grid-column: 1 / -1; }
|
||||||
.results-header, .result-title, .result-meta { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); }
|
.results-header, .result-title, .result-meta { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); }
|
||||||
.results-header { margin: var(--space-xl) 0 var(--space-md); color: var(--color-text-secondary); }
|
.results-header { margin: var(--space-xl) 0 var(--space-md); color: var(--color-text-secondary); }
|
||||||
.result-list { display: grid; gap: var(--space-md); }
|
.result-list { display: grid; gap: var(--space-md); }
|
||||||
.result-card { cursor: pointer; }
|
.result-card { position: relative; cursor: pointer; overflow: hidden; }
|
||||||
|
.result-card::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 3px; background: var(--color-accent-primary); opacity: 0; transform: scaleY(.45); transition: opacity var(--motion-fast), transform var(--motion-fast); }
|
||||||
|
.result-card:hover::before { opacity: 1; transform: scaleY(1); }
|
||||||
.snippet { margin: var(--space-md) 0; line-height: var(--line-height-relaxed); }
|
.snippet { margin: var(--space-md) 0; line-height: var(--line-height-relaxed); }
|
||||||
.result-meta { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
|
.result-meta { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
|
||||||
@media (max-width: 700px) { .search-form { grid-template-columns: 1fr; } .advanced { grid-column: auto; } }
|
@media (max-width: 700px) { .search-form { grid-template-columns: 1fr; } .advanced { grid-column: auto; } }
|
||||||
|
|||||||
@@ -181,11 +181,12 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
|
|||||||
.settings-page { max-width: 1120px; margin: 0 auto; }
|
.settings-page { max-width: 1120px; margin: 0 auto; }
|
||||||
.settings-section { display: grid; gap: var(--space-md); }
|
.settings-section { display: grid; gap: var(--space-md); }
|
||||||
.settings-section h2 { margin-bottom: var(--space-sm); }
|
.settings-section h2 { margin-bottom: var(--space-sm); }
|
||||||
.setting-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); min-height: 54px; padding: var(--space-sm) 0; border-bottom: 1px solid var(--color-border-subtle); }
|
.setting-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); min-height: 58px; padding: var(--space-sm) var(--space-md); border-bottom: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); transition: background-color var(--motion-fast); }
|
||||||
|
.setting-row:hover { background: var(--color-background-secondary); }
|
||||||
.setting-row small { display: block; color: var(--color-text-tertiary); }.short { width: min(220px, 45%); }
|
.setting-row small { display: block; color: var(--color-text-tertiary); }.short { width: min(220px, 45%); }
|
||||||
.section-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-lg); }
|
.section-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-lg); }
|
||||||
.provider-list { display: grid; gap: var(--space-md); }.provider-card { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); }.provider-main { min-width: 0; flex: 1; }.provider-card p, .provider-card .tag-list { margin-top: var(--space-sm); }
|
.provider-list { display: grid; gap: var(--space-md); }.provider-card { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); }.provider-main { min-width: 0; flex: 1; }.provider-card p, .provider-card .tag-list { margin-top: var(--space-sm); }
|
||||||
.model-picker { display: flex; align-items: center; gap: var(--space-sm); margin-top: var(--space-md); }.model-picker label { white-space: nowrap; font-weight: 600; }.model-picker .select { width: min(360px, 100%); }.provider-actions { flex-wrap: wrap; justify-content: flex-end; }.error-text { color: var(--color-danger, #d33); }
|
.model-picker { display: flex; align-items: center; gap: var(--space-sm); margin-top: var(--space-md); }.model-picker label { white-space: nowrap; font-weight: 600; }.model-picker .select { width: min(360px, 100%); }.provider-actions { flex-wrap: wrap; justify-content: flex-end; }.error-text { color: var(--color-error); }
|
||||||
.test-result { color: var(--color-info); }.index-summary, .diagnostic-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-md); }.index-summary > div { padding: var(--space-lg); border-radius: var(--radius-md); background: var(--color-background-secondary); }.index-summary strong, .index-summary small { display: block; }.index-summary strong { font-size: var(--font-size-3xl); }
|
.test-result { color: var(--color-info); }.index-summary, .diagnostic-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-md); }.index-summary > div { padding: var(--space-lg); border-radius: var(--radius-md); background: var(--color-background-secondary); }.index-summary strong, .index-summary small { display: block; }.index-summary strong { font-size: var(--font-size-3xl); }
|
||||||
.section-description { margin-top: calc(-1 * var(--space-md)); }.diagnostic-grid { grid-template-columns: repeat(2, 1fr); }.diagnostic-grid h3 { margin: var(--space-md) 0 var(--space-xs); }.diagnostic-actions { margin-top: var(--space-md); }
|
.section-description { margin-top: calc(-1 * var(--space-md)); }.diagnostic-grid { grid-template-columns: repeat(2, 1fr); }.diagnostic-grid h3 { margin: var(--space-md) 0 var(--space-xs); }.diagnostic-actions { margin-top: var(--space-md); }
|
||||||
@media (max-width: 700px) { .provider-card, .setting-row, .model-picker { align-items: flex-start; flex-direction: column; }.short, .model-picker .select { width: 100%; }.index-summary, .diagnostic-grid { grid-template-columns: 1fr; }.provider-actions { justify-content: flex-start; } }
|
@media (max-width: 700px) { .provider-card, .setting-row, .model-picker { align-items: flex-start; flex-direction: column; }.short, .model-picker .select { width: 100%; }.index-summary, .diagnostic-grid { grid-template-columns: 1fr; }.provider-actions { justify-content: flex-start; } }
|
||||||
|
|||||||
@@ -50,10 +50,11 @@ async function remove(task: TaskItem) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.task-list { display: grid; gap: var(--space-md); }
|
.task-list { display: grid; gap: var(--space-md); width: min(100%, 980px); margin-inline: auto; }
|
||||||
.task-card { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: var(--space-md); }
|
.task-card { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: var(--space-md); }
|
||||||
.status-check { width: 26px; height: 26px; border: 2px solid var(--color-border-default); border-radius: var(--radius-full); }
|
.status-check { width: 28px; height: 28px; border: 2px solid var(--color-border-default); border-radius: var(--radius-full); transition: border-color var(--motion-fast), background-color var(--motion-fast), color var(--motion-fast), transform var(--motion-fast); }
|
||||||
.status-check.done { border-color: var(--color-success); background: var(--color-success); color: white; }
|
.status-check:hover { border-color: var(--color-success); transform: scale(1.06); }
|
||||||
|
.status-check.done { border-color: var(--color-success); background: var(--color-success); color: white; box-shadow: 0 3px 10px color-mix(in srgb, var(--color-success) 24%, transparent); }
|
||||||
.task-title { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-sm); }
|
.task-title { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-sm); }
|
||||||
.task-content p { margin: var(--space-xs) 0; }
|
.task-content p { margin: var(--space-xs) 0; }
|
||||||
.task-content .subtle { display: flex; flex-wrap: wrap; gap: var(--space-md); }
|
.task-content .subtle { display: flex; flex-wrap: wrap; gap: var(--space-md); }
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import MarkdownContent from '@/components/common/MarkdownContent.vue'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
|
const shikiPreview = `\`\`\`typescript
|
||||||
|
const notes = await search('本地优先')
|
||||||
|
\`\`\``
|
||||||
|
const codeThemeLabel = computed(() => themeStore.resolvedCodeBlockTheme === 'github-dark'
|
||||||
|
? 'Shiki · GitHub Dark'
|
||||||
|
: 'Shiki · GitHub Light')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -13,7 +22,20 @@ const themeStore = useThemeStore()
|
|||||||
<p class="subtle">v{{ theme.version }} · {{ theme.builtin ? '内置主题' : theme.author }}</p>
|
<p class="subtle">v{{ theme.version }} · {{ theme.builtin ? '内置主题' : theme.author }}</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel preference-panel"><h2 class="panel-title">编辑器外观</h2><div class="form-grid"><div class="field"><label>字号:{{ themeStore.fontEditorSize }}px</label><input v-model.number="themeStore.fontEditorSize" type="range" min="12" max="24" /></div><div class="field"><label>行高:{{ themeStore.lineHeight }}</label><input v-model.number="themeStore.lineHeight" type="range" min="1.2" max="2.2" step="0.1" /></div><div class="field"><label>字体</label><select v-model="themeStore.fontEditorFamily" class="select"><option value="system-ui">系统字体</option><option value="serif">衬线字体</option><option value="var(--font-ui-mono)">等宽字体</option></select></div></div><div class="editor-preview" :style="{ fontSize: `${themeStore.fontEditorSize}px`, lineHeight: themeStore.lineHeight, fontFamily: themeStore.fontEditorFamily }"><h3>主题预览</h3><p>知识的价值不只在于保存,更在于被重新发现和使用。</p><code>const notes = await search('本地优先')</code></div></div>
|
<div class="panel preference-panel">
|
||||||
|
<h2 class="panel-title">编辑器外观</h2>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field"><label>字号:{{ themeStore.fontEditorSize }}px</label><input v-model.number="themeStore.fontEditorSize" type="range" min="12" max="24" /></div>
|
||||||
|
<div class="field"><label>行高:{{ themeStore.lineHeight }}</label><input v-model.number="themeStore.lineHeight" type="range" min="1.2" max="2.2" step="0.1" /></div>
|
||||||
|
<div class="field"><label>字体</label><select v-model="themeStore.fontEditorFamily" class="select"><option value="system-ui">系统字体</option><option value="serif">衬线字体</option><option value="var(--font-ui-mono)">等宽字体</option></select></div>
|
||||||
|
<div class="field"><label>代码块样式</label><select v-model="themeStore.codeBlockTheme" class="select"><option value="auto">跟随主题</option><option value="github-light">GitHub Light</option><option value="github-dark">GitHub Dark</option></select><small>Markdown 渲染使用对应的 Shiki GitHub 主题</small></div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-preview" :style="{ fontSize: `${themeStore.fontEditorSize}px`, lineHeight: themeStore.lineHeight, fontFamily: themeStore.fontEditorFamily }">
|
||||||
|
<div class="preview-heading"><h3>主题预览</h3><span class="badge info">{{ codeThemeLabel }}</span></div>
|
||||||
|
<p>知识的价值不只在于保存,更在于被重新发现和使用。</p>
|
||||||
|
<MarkdownContent class="code-theme-preview" :source="shikiPreview" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -28,5 +50,8 @@ const themeStore = useThemeStore()
|
|||||||
.theme-info { display: flex; justify-content: space-between; gap: var(--space-md); }
|
.theme-info { display: flex; justify-content: space-between; gap: var(--space-md); }
|
||||||
.preference-panel { display: grid; gap: var(--space-xl); }
|
.preference-panel { display: grid; gap: var(--space-xl); }
|
||||||
.editor-preview { padding: var(--space-xl); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-background-secondary); }
|
.editor-preview { padding: var(--space-xl); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-background-secondary); }
|
||||||
.editor-preview p { margin: var(--space-sm) 0; }.editor-preview code { color: var(--color-accent-primary); }
|
.editor-preview p { margin: var(--space-sm) 0; }
|
||||||
|
.preview-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); }
|
||||||
|
.field small { color: var(--color-text-tertiary); }
|
||||||
|
.code-theme-preview { margin-top: var(--space-md); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import { useWorkspaceStore } from '@/stores/workspace'
|
import { useWorkspaceStore } from '@/stores/workspace'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
import { useSettingsStore } from '@/stores/settings'
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
import { ArrowRight, Document, Folder, FolderOpened, Moon, Plus, Sunny } from '@element-plus/icons-vue'
|
import { ArrowRight, Document, Folder, FolderOpened, Moon, Sunny } from '@element-plus/icons-vue'
|
||||||
import AppIcon from '@/components/common/AppIcon.vue'
|
import AppIcon from '@/components/common/AppIcon.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -13,17 +13,19 @@ const themeStore = useThemeStore()
|
|||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const showCreateDialog = ref(false)
|
|
||||||
const newVaultName = ref('')
|
|
||||||
const newVaultPath = ref('')
|
|
||||||
const aiCoreStatus = ref<'checking' | 'running' | 'stopped'>('checking')
|
const aiCoreStatus = ref<'checking' | 'running' | 'stopped'>('checking')
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([workspaceStore.loadRecentVaults(), settingsStore.loadDiagnostics()])
|
await Promise.allSettled([workspaceStore.loadRecentVaults(), settingsStore.loadDiagnostics()])
|
||||||
const lastVaultPath = localStorage.getItem('last-vault-path')
|
const lastVaultPath = localStorage.getItem('last-vault-path')
|
||||||
if (settingsStore.restoreLastVault && lastVaultPath) {
|
if (settingsStore.restoreLastVault && lastVaultPath) {
|
||||||
await openVault(lastVaultPath)
|
try {
|
||||||
return
|
await openVault(lastVaultPath)
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
// Mock 阶段保存的旧路径可能与当前后端 Vault 不同,清除后让用户重新选择。
|
||||||
|
localStorage.removeItem('last-vault-path')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
aiCoreStatus.value = settingsStore.aiCoreStatus === 'running' ? 'running' : 'stopped'
|
aiCoreStatus.value = settingsStore.aiCoreStatus === 'running' ? 'running' : 'stopped'
|
||||||
@@ -41,24 +43,8 @@ async function openVault(path: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openFolderPicker() {
|
async function openFolderPicker() {
|
||||||
// In Tauri this would use the native dialog
|
const configured = workspaceStore.recentVaults[0]
|
||||||
// For web dev, simulate
|
if (configured) await openVault(configured.path)
|
||||||
const path = prompt('请输入 Vault 路径(开发模式)', '/Users/demo/Documents/MyVault')
|
|
||||||
if (path) {
|
|
||||||
await openVault(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createVault() {
|
|
||||||
if (!newVaultName.value || !newVaultPath.value) return
|
|
||||||
isLoading.value = true
|
|
||||||
try {
|
|
||||||
await workspaceStore.createVault(newVaultPath.value, newVaultName.value)
|
|
||||||
router.push('/workspace')
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
showCreateDialog.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -68,13 +54,13 @@ async function createVault() {
|
|||||||
<div class="entry-container">
|
<div class="entry-container">
|
||||||
<div class="brand-section">
|
<div class="brand-section">
|
||||||
<div class="logo"><AppIcon :icon="Document" :size="56" /></div>
|
<div class="logo"><AppIcon :icon="Document" :size="56" /></div>
|
||||||
<h1 class="app-title">知笔知己</h1>
|
<h1 class="app-title">NotesAgent</h1>
|
||||||
<p class="app-subtitle">本地优先的 AI 笔记软件</p>
|
<p class="app-subtitle">本地优先的 AI 笔记软件</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="vault-card">
|
<div class="vault-card">
|
||||||
<h2 class="card-title">选择知识库</h2>
|
<h2 class="card-title">选择知识库</h2>
|
||||||
<p class="card-desc">选择一个本地 Vault 开始你的知识之旅</p>
|
<p class="card-desc">Web 联调模式连接 AI Core 当前配置的 Vault</p>
|
||||||
|
|
||||||
<div v-if="workspaceStore.recentVaults.length" class="recent-vaults">
|
<div v-if="workspaceStore.recentVaults.length" class="recent-vaults">
|
||||||
<div class="section-label">最近打开</div>
|
<div class="section-label">最近打开</div>
|
||||||
@@ -97,11 +83,8 @@ async function createVault() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-primary" @click="openFolderPicker" :disabled="isLoading">
|
<button class="btn btn-primary" @click="openFolderPicker" :disabled="isLoading || !workspaceStore.recentVaults.length">
|
||||||
<AppIcon :icon="FolderOpened" /> 打开本地 Vault
|
<AppIcon :icon="FolderOpened" /> 打开后端 Vault
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary" @click="showCreateDialog = true" :disabled="isLoading">
|
|
||||||
<AppIcon :icon="Plus" /> 创建新 Vault
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -122,24 +105,6 @@ async function createVault() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create Vault Dialog -->
|
|
||||||
<div v-if="showCreateDialog" class="dialog-overlay" @click.self="showCreateDialog = false">
|
|
||||||
<div class="dialog">
|
|
||||||
<h3>创建新 Vault</h3>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Vault 名称</label>
|
|
||||||
<input v-model="newVaultName" type="text" placeholder="我的知识库" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>存储路径</label>
|
|
||||||
<input v-model="newVaultPath" type="text" placeholder="/path/to/vault" />
|
|
||||||
</div>
|
|
||||||
<div class="dialog-actions">
|
|
||||||
<button class="btn btn-secondary" @click="showCreateDialog = false">取消</button>
|
|
||||||
<button class="btn btn-primary" @click="createVault" :disabled="!newVaultName || !newVaultPath">创建</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -161,7 +126,7 @@ async function createVault() {
|
|||||||
background:
|
background:
|
||||||
radial-gradient(circle at 20% 30%, var(--color-accent-soft) 0%, transparent 50%),
|
radial-gradient(circle at 20% 30%, var(--color-accent-soft) 0%, transparent 50%),
|
||||||
radial-gradient(circle at 80% 70%, var(--color-info-soft) 0%, transparent 50%);
|
radial-gradient(circle at 80% 70%, var(--color-info-soft) 0%, transparent 50%);
|
||||||
opacity: 0.5;
|
opacity: 0.62;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-container {
|
.entry-container {
|
||||||
@@ -173,6 +138,7 @@ async function createVault() {
|
|||||||
gap: 32px;
|
gap: 32px;
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
|
animation: entry-in var(--motion-slow) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-section {
|
.brand-section {
|
||||||
@@ -180,8 +146,16 @@ async function createVault() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
font-size: 64px;
|
display: inline-grid;
|
||||||
margin-bottom: 12px;
|
place-items: center;
|
||||||
|
width: 84px;
|
||||||
|
height: 84px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent-primary) 18%, transparent);
|
||||||
|
border-radius: 24px;
|
||||||
|
background: var(--color-surface-primary);
|
||||||
|
color: var(--color-accent-primary);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-title {
|
.app-title {
|
||||||
@@ -206,7 +180,7 @@ async function createVault() {
|
|||||||
border: 1px solid var(--color-border-default);
|
border: 1px solid var(--color-border-default);
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
padding: var(--space-2xl);
|
padding: var(--space-2xl);
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
@@ -247,11 +221,13 @@ async function createVault() {
|
|||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition: all var(--motion-fast);
|
transition: background-color var(--motion-fast), border-color var(--motion-fast), box-shadow var(--motion-fast), transform var(--motion-fast);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-accent-soft);
|
background: var(--color-accent-soft);
|
||||||
border-color: var(--color-accent-secondary);
|
border-color: var(--color-accent-secondary);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
@@ -288,8 +264,11 @@ async function createVault() {
|
|||||||
.vault-arrow {
|
.vault-arrow {
|
||||||
color: var(--color-text-tertiary);
|
color: var(--color-text-tertiary);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
|
transition: color var(--motion-fast), transform var(--motion-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vault-item:hover .vault-arrow { color: var(--color-accent-primary); transform: translateX(3px); }
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -307,7 +286,7 @@ async function createVault() {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--motion-fast);
|
transition: background-color var(--motion-fast), border-color var(--motion-fast), box-shadow var(--motion-fast), transform var(--motion-fast);
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
@@ -321,6 +300,8 @@ async function createVault() {
|
|||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background: var(--color-accent-primary-hover);
|
background: var(--color-accent-primary-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 7px 18px color-mix(in srgb, var(--color-accent-primary) 25%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,61 +373,5 @@ async function createVault() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-overlay {
|
@keyframes entry-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: var(--color-background-overlay);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: var(--z-modal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog {
|
|
||||||
background: var(--color-surface-primary);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: var(--space-xl);
|
|
||||||
width: 90%;
|
|
||||||
max-width: 400px;
|
|
||||||
box-shadow: var(--shadow-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog h3 {
|
|
||||||
margin: 0 0 var(--space-lg) 0;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
|
|
||||||
label {
|
|
||||||
display: block;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
margin-bottom: var(--space-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: var(--color-background-secondary);
|
|
||||||
border: 1px solid var(--color-border-default);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
outline: none;
|
|
||||||
transition: border-color var(--motion-fast);
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-color: var(--color-border-focus);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
margin-top: var(--space-lg);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
import FileTreePanel from './FileTreePanel.vue'
|
import FileTreePanel from './FileTreePanel.vue'
|
||||||
import { useEditorStore } from '@/stores/editor'
|
import { useEditorStore } from '@/stores/editor'
|
||||||
import { useWorkspaceStore } from '@/stores/workspace'
|
import { useWorkspaceStore } from '@/stores/workspace'
|
||||||
|
import * as workspaceService from '@/services/workspaceService'
|
||||||
|
|
||||||
let wrapper: VueWrapper | null = null
|
let wrapper: VueWrapper | null = null
|
||||||
|
|
||||||
@@ -21,12 +22,26 @@ async function waitForPath(path: string) {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
|
vi.spyOn(workspaceService, 'openVault').mockResolvedValue({ path: 'C:/vault', name: 'vault' })
|
||||||
|
vi.spyOn(workspaceService, 'getFileTree').mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'folder-data', name: '数据结构', path: '/数据结构', type: 'folder', is_open: true,
|
||||||
|
children: [
|
||||||
|
{ id: 'note-rbt', note_id: 'note-rbt', name: '红黑树.md', path: '/数据结构/红黑树.md', type: 'file' },
|
||||||
|
{ id: 'note-bst', note_id: 'note-bst', name: '二叉搜索树.md', path: '/数据结构/二叉搜索树.md', type: 'file' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
vi.spyOn(workspaceService, 'readFileContent').mockImplementation(async (path) =>
|
||||||
|
path.includes('红黑树') ? '# 红黑树\n' : '# 二叉搜索树\n'
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
wrapper?.unmount()
|
wrapper?.unmount()
|
||||||
wrapper = null
|
wrapper = null
|
||||||
document.body.innerHTML = ''
|
document.body.innerHTML = ''
|
||||||
|
vi.restoreAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('FileTreePanel file switching', () => {
|
describe('FileTreePanel file switching', () => {
|
||||||
@@ -40,7 +55,7 @@ describe('FileTreePanel file switching', () => {
|
|||||||
|
|
||||||
const workspaceStore = useWorkspaceStore()
|
const workspaceStore = useWorkspaceStore()
|
||||||
const editorStore = useEditorStore()
|
const editorStore = useEditorStore()
|
||||||
await workspaceStore.openVault('/mock-vault')
|
await workspaceStore.openVault('C:/vault')
|
||||||
wrapper = mount(FileTreePanel, { attachTo: document.body, global: { plugins: [router] } })
|
wrapper = mount(FileTreePanel, { attachTo: document.body, global: { plugins: [router] } })
|
||||||
|
|
||||||
const findNode = (name: string) => wrapper!.findAll('.tree-node').find((node) => node.text().includes(name))!
|
const findNode = (name: string) => wrapper!.findAll('.tree-node').find((node) => node.text().includes(name))!
|
||||||
|
|||||||
@@ -133,5 +133,5 @@ button:hover { background: var(--color-background-secondary); }
|
|||||||
.tree { padding: var(--space-xs); }
|
.tree { padding: var(--space-xs); }
|
||||||
.context-menu { position: fixed; z-index: 1000; display: grid; min-width: 130px; padding: var(--space-xs); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-background-primary); box-shadow: var(--shadow-md); }
|
.context-menu { position: fixed; z-index: 1000; display: grid; min-width: 130px; padding: var(--space-xs); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-background-primary); box-shadow: var(--shadow-md); }
|
||||||
.context-menu button { text-align: left; }
|
.context-menu button { text-align: left; }
|
||||||
.context-menu .danger { color: var(--color-danger, #d33); }
|
.context-menu .danger { color: var(--color-error); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -17,16 +17,16 @@ afterEach(() => {
|
|||||||
wrapper = null
|
wrapper = null
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('WorkspaceView initial file', () => {
|
describe('WorkspaceView empty state', () => {
|
||||||
it('does not overwrite a file selected while the welcome note is loading', async () => {
|
it('does not fabricate a Mock welcome note when no backend file is selected', async () => {
|
||||||
const workspaceStore = useWorkspaceStore()
|
const workspaceStore = useWorkspaceStore()
|
||||||
wrapper = mount(WorkspaceView, {
|
wrapper = mount(WorkspaceView, {
|
||||||
global: { stubs: { EditorHeader: true, EditorPane: true } },
|
global: { stubs: { EditorHeader: true, EditorPane: true } },
|
||||||
})
|
})
|
||||||
|
|
||||||
workspaceStore.openFile('/数据结构/红黑树.md')
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
|
||||||
expect(workspaceStore.activeFilePath).toBe('/数据结构/红黑树.md')
|
expect(workspaceStore.activeFilePath).toBeNull()
|
||||||
|
expect(wrapper.find('.empty-workspace').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,28 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
|
||||||
import { useWorkspaceStore } from '@/stores/workspace'
|
import { useWorkspaceStore } from '@/stores/workspace'
|
||||||
import { useEditorStore } from '@/stores/editor'
|
|
||||||
import EditorHeader from '@/features/editor/EditorHeader.vue'
|
import EditorHeader from '@/features/editor/EditorHeader.vue'
|
||||||
import EditorPane from '@/features/editor/EditorPane.vue'
|
import EditorPane from '@/features/editor/EditorPane.vue'
|
||||||
import { EditPen } from '@element-plus/icons-vue'
|
import { EditPen } from '@element-plus/icons-vue'
|
||||||
import AppIcon from '@/components/common/AppIcon.vue'
|
import AppIcon from '@/components/common/AppIcon.vue'
|
||||||
|
|
||||||
const workspaceStore = useWorkspaceStore()
|
const workspaceStore = useWorkspaceStore()
|
||||||
const editorStore = useEditorStore()
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (!workspaceStore.fileTree.length && workspaceStore.hasVault) {
|
|
||||||
// Already loaded
|
|
||||||
}
|
|
||||||
if (!workspaceStore.activeFilePath && workspaceStore.fileTree.length === 0) {
|
|
||||||
void editorStore.loadFile('/欢迎使用知笔知己.md').then(() => {
|
|
||||||
// 默认文件加载期间用户可能已经点击了其他文件,不能覆盖用户的选择。
|
|
||||||
if (!workspaceStore.activeFilePath && editorStore.currentFilePath === '/欢迎使用知笔知己.md') {
|
|
||||||
workspaceStore.openFile('/欢迎使用知笔知己.md')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -59,6 +42,11 @@ onMounted(() => {
|
|||||||
|
|
||||||
.empty-content {
|
.empty-content {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
padding: var(--space-3xl);
|
||||||
|
border: 1px dashed var(--color-border-default);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: var(--color-background-secondary);
|
||||||
|
animation: workspace-empty-in var(--motion-normal) both;
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
@@ -75,4 +63,6 @@ onMounted(() => {
|
|||||||
font-size: 48px;
|
font-size: 48px;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes workspace-empty-in { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ router.beforeEach((to, _from, next) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
router.afterEach((to) => {
|
router.afterEach((to) => {
|
||||||
const baseTitle = '知笔知己'
|
const baseTitle = 'NotesAgent'
|
||||||
const title = to.meta.title as string | undefined
|
const title = to.meta.title as string | undefined
|
||||||
document.title = title ? `${title} · ${baseTitle}` : baseTitle
|
document.title = title ? `${title} · ${baseTitle}` : baseTitle
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import apiClient from './apiClient'
|
import apiClient from './apiClient'
|
||||||
import { SseClient } from './sseClient'
|
import { SseClient } from './sseClient'
|
||||||
import type { AgentRun, AgentEvent, ApiAgentRun, OperationResponse, PageMeta, ToolDefinition, PermissionRequest } from '@/contracts'
|
import type { AgentRun, AgentEvent, AgentTraceResponse, ApiAgentRun, OperationResponse, PageMeta, ToolDefinition, PermissionRequest } from '@/contracts'
|
||||||
|
|
||||||
function toAgentRun(run: ApiAgentRun): AgentRun {
|
function toAgentRun(run: ApiAgentRun): AgentRun {
|
||||||
|
// API 的 token_usage 是累计值,UI 模型预留了输入/输出拆分字段。
|
||||||
return {
|
return {
|
||||||
run_id: run.run_id,
|
run_id: run.run_id,
|
||||||
status: run.status,
|
status: run.status,
|
||||||
@@ -53,6 +54,13 @@ export async function cancelAgentRun(runId: string): Promise<OperationResponse>
|
|||||||
return apiClient.post(`/api/agent/runs/${runId}/cancel`)
|
return apiClient.post(`/api/agent/runs/${runId}/cancel`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAgentTrace(
|
||||||
|
runId: string,
|
||||||
|
params?: { after_sequence?: number; limit?: number },
|
||||||
|
): Promise<AgentTraceResponse> {
|
||||||
|
return apiClient.get(`/api/agent/runs/${runId}/trace`, { params })
|
||||||
|
}
|
||||||
|
|
||||||
export async function listTools(): Promise<ToolDefinition[]> {
|
export async function listTools(): Promise<ToolDefinition[]> {
|
||||||
const response = await apiClient.get<{ items: ToolDefinition[] }>('/api/tools')
|
const response = await apiClient.get<{ items: ToolDefinition[] }>('/api/tools')
|
||||||
return response.items
|
return response.items
|
||||||
@@ -65,11 +73,14 @@ export function streamAgentEvents(
|
|||||||
onError?: (error: Error) => void
|
onError?: (error: Error) => void
|
||||||
onDone?: () => void
|
onDone?: () => void
|
||||||
onOpen?: () => void
|
onOpen?: () => void
|
||||||
}
|
},
|
||||||
|
afterSequence = -1,
|
||||||
): SseClient {
|
): SseClient {
|
||||||
|
// 将通用 SSE 包装成领域事件,Store 无需了解传输层 envelope。
|
||||||
const client = new SseClient({
|
const client = new SseClient({
|
||||||
url: `/api/agent/runs/${runId}/events`,
|
url: `/api/agent/runs/${runId}/events?after_sequence=${afterSequence}`,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
lastEventId: afterSequence >= 0 ? String(afterSequence) : undefined,
|
||||||
onEvent: (eventName, data) => {
|
onEvent: (eventName, data) => {
|
||||||
handlers.onEvent?.({
|
handlers.onEvent?.({
|
||||||
event: eventName as AgentEvent['event'],
|
event: eventName as AgentEvent['event'],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ApiError, ErrorResponse } from '@/contracts'
|
import type { ApiError, ErrorResponse } from '@/contracts'
|
||||||
|
|
||||||
|
// 所有 HTTP 请求都经过此边界,以统一地址、请求追踪和错误契约。
|
||||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? import.meta.env.VITE_API_BASE ?? ''
|
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? import.meta.env.VITE_API_BASE ?? ''
|
||||||
|
|
||||||
export function resolveApiUrl(path: string): string {
|
export function resolveApiUrl(path: string): string {
|
||||||
@@ -63,6 +64,7 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
|||||||
return resp as unknown as T
|
return resp as unknown as T
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 后端约定返回 ErrorResponse;代理或网关的非 JSON 错误仍降级为 HTTP 状态码。
|
||||||
let errBody: ErrorResponse | null = null
|
let errBody: ErrorResponse | null = null
|
||||||
try {
|
try {
|
||||||
errBody = (await resp.json()) as ErrorResponse
|
errBody = (await resp.json()) as ErrorResponse
|
||||||
|
|||||||
@@ -37,3 +37,7 @@ export async function deleteNote(noteId: string): Promise<OperationResponse> {
|
|||||||
export async function moveNote(noteId: string, folder: string): Promise<ApiNote> {
|
export async function moveNote(noteId: string, folder: string): Promise<ApiNote> {
|
||||||
return apiClient.post(`/api/notes/${noteId}/move`, { folder })
|
return apiClient.post(`/api/notes/${noteId}/move`, { folder })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function renameNote(noteId: string, fileName: string): Promise<ApiNote> {
|
||||||
|
return apiClient.post(`/api/notes/${noteId}/rename`, { file_name: fileName })
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export const mockPlugins: Plugin[] = [
|
|||||||
version: '1.3.2',
|
version: '1.3.2',
|
||||||
description: '接入 GitHub API,支持搜索 Issue、查看 PR 和管理仓库',
|
description: '接入 GitHub API,支持搜索 Issue、查看 PR 和管理仓库',
|
||||||
icon: '',
|
icon: '',
|
||||||
author: '知笔知己团队',
|
author: 'NotesAgent 团队',
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
permissions: ['notes.read', 'network.request'],
|
permissions: ['notes.read', 'network.request'],
|
||||||
@@ -117,7 +117,7 @@ export const mockPlugins: Plugin[] = [
|
|||||||
version: '2.1.0',
|
version: '2.1.0',
|
||||||
description: '导入 PDF 文档,提取文本和目录结构生成笔记',
|
description: '导入 PDF 文档,提取文本和目录结构生成笔记',
|
||||||
icon: '',
|
icon: '',
|
||||||
author: '知笔知己团队',
|
author: 'NotesAgent 团队',
|
||||||
status: 'error',
|
status: 'error',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
permissions: ['notes.write', 'attachments.read'],
|
permissions: ['notes.write', 'attachments.read'],
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export const mockSkills: Skill[] = [
|
|||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
description: '根据课程笔记生成复习要点和练习题,帮助高效备考',
|
description: '根据课程笔记生成复习要点和练习题,帮助高效备考',
|
||||||
icon: '',
|
icon: '',
|
||||||
author: '知笔知己团队',
|
author: 'NotesAgent 团队',
|
||||||
permissions: ['notes.search', 'notes.read', 'tasks.create'],
|
permissions: ['notes.search', 'notes.read', 'tasks.create'],
|
||||||
tools: ['notes.search', 'notes.read', 'tasks.create'],
|
tools: ['notes.search', 'notes.read', 'tasks.create'],
|
||||||
retrieval_config: { top_k: 10, rerank: true, citation: true },
|
retrieval_config: { top_k: 10, rerank: true, citation: true },
|
||||||
@@ -64,7 +64,7 @@ export const mockSkills: Skill[] = [
|
|||||||
version: '1.1.0',
|
version: '1.1.0',
|
||||||
description: '从音频或文本中提取会议要点、行动项和待办任务',
|
description: '从音频或文本中提取会议要点、行动项和待办任务',
|
||||||
icon: '',
|
icon: '',
|
||||||
author: '知笔知己团队',
|
author: 'NotesAgent 团队',
|
||||||
permissions: ['notes.search', 'notes.write', 'tasks.write', 'attachments.read'],
|
permissions: ['notes.search', 'notes.write', 'tasks.write', 'attachments.read'],
|
||||||
tools: ['notes.search', 'notes.create', 'tasks.create', 'attachments.read'],
|
tools: ['notes.search', 'notes.create', 'tasks.create', 'attachments.read'],
|
||||||
retrieval_config: { top_k: 5, rerank: false, citation: true },
|
retrieval_config: { top_k: 5, rerank: false, citation: true },
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { SseClient } from './sseClient'
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SseClient resumable event transport', () => {
|
||||||
|
it('sends Last-Event-ID and exposes the returned SSE id', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
'id: 3\nevent: ModelCallCompleted\ndata: {"sequence":3,"data":{"duration_ms":12}}\n\n',
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
const received = vi.fn()
|
||||||
|
const client = new SseClient({
|
||||||
|
url: '/api/agent/runs/run-1/events?after_sequence=2',
|
||||||
|
method: 'GET',
|
||||||
|
lastEventId: '2',
|
||||||
|
onEvent: received,
|
||||||
|
})
|
||||||
|
|
||||||
|
await client.connect()
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'/api/agent/runs/run-1/events?after_sequence=2',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'GET',
|
||||||
|
headers: expect.objectContaining({ 'Last-Event-ID': '2' }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(received).toHaveBeenCalledWith(
|
||||||
|
'ModelCallCompleted',
|
||||||
|
{ sequence: 3, data: { duration_ms: 12 } },
|
||||||
|
'3',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
import { resolveApiUrl } from './apiClient'
|
import { resolveApiUrl } from './apiClient'
|
||||||
|
|
||||||
export type SseEventHandler = (event: string, data: Record<string, unknown>) => void
|
export type SseEventHandler = (
|
||||||
|
event: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
eventId?: string,
|
||||||
|
) => void
|
||||||
|
|
||||||
export interface SseClientOptions {
|
export interface SseClientOptions {
|
||||||
url: string
|
url: string
|
||||||
method?: string
|
method?: string
|
||||||
body?: unknown
|
body?: unknown
|
||||||
token?: string
|
token?: string
|
||||||
|
lastEventId?: string
|
||||||
onEvent?: SseEventHandler
|
onEvent?: SseEventHandler
|
||||||
onError?: (error: Error) => void
|
onError?: (error: Error) => void
|
||||||
onOpen?: () => void
|
onOpen?: () => void
|
||||||
@@ -26,7 +31,7 @@ export class SseClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async connect() {
|
async connect() {
|
||||||
const { url, method = 'POST', body, token, onEvent, onError, onOpen, onDone } = this.options
|
const { url, method = 'POST', body, token, lastEventId, onEvent, onError, onOpen, onDone } = this.options
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
@@ -38,6 +43,9 @@ export class SseClient {
|
|||||||
if (token) {
|
if (token) {
|
||||||
headers['Authorization'] = `Bearer ${token}`
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
|
if (lastEventId !== undefined) {
|
||||||
|
headers['Last-Event-ID'] = lastEventId
|
||||||
|
}
|
||||||
|
|
||||||
const resp = await fetch(resolveApiUrl(url), {
|
const resp = await fetch(resolveApiUrl(url), {
|
||||||
method,
|
method,
|
||||||
@@ -54,19 +62,22 @@ export class SseClient {
|
|||||||
this.connected = true
|
this.connected = true
|
||||||
onOpen?.()
|
onOpen?.()
|
||||||
|
|
||||||
|
// 一个 UTF-8 字符或 SSE 行可能横跨多个网络分片,必须累积后再按空行派发。
|
||||||
const decoder = new TextDecoder('utf-8')
|
const decoder = new TextDecoder('utf-8')
|
||||||
let eventName = 'message'
|
let eventName = 'message'
|
||||||
|
let eventId: string | undefined
|
||||||
let dataLines: string[] = []
|
let dataLines: string[] = []
|
||||||
let doneNotified = false
|
let doneNotified = false
|
||||||
|
|
||||||
const dispatchEvent = () => {
|
const dispatchEvent = () => {
|
||||||
if (!dataLines.length) {
|
if (!dataLines.length) {
|
||||||
eventName = 'message'
|
eventName = 'message'
|
||||||
|
eventId = undefined
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(dataLines.join('\n')) as Record<string, unknown>
|
const data = JSON.parse(dataLines.join('\n')) as Record<string, unknown>
|
||||||
onEvent?.(eventName, data)
|
onEvent?.(eventName, data, eventId)
|
||||||
if (!doneNotified && ['Done', 'RunCompleted', 'RunFailed', 'RunCancelled'].includes(eventName)) {
|
if (!doneNotified && ['Done', 'RunCompleted', 'RunFailed', 'RunCancelled'].includes(eventName)) {
|
||||||
doneNotified = true
|
doneNotified = true
|
||||||
onDone?.()
|
onDone?.()
|
||||||
@@ -75,6 +86,7 @@ export class SseClient {
|
|||||||
onError?.(error instanceof Error ? error : new Error('Malformed SSE data'))
|
onError?.(error instanceof Error ? error : new Error('Malformed SSE data'))
|
||||||
}
|
}
|
||||||
eventName = 'message'
|
eventName = 'message'
|
||||||
|
eventId = undefined
|
||||||
dataLines = []
|
dataLines = []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +98,7 @@ export class SseClient {
|
|||||||
let fieldValue = separator === -1 ? '' : line.slice(separator + 1)
|
let fieldValue = separator === -1 ? '' : line.slice(separator + 1)
|
||||||
if (fieldValue.startsWith(' ')) fieldValue = fieldValue.slice(1)
|
if (fieldValue.startsWith(' ')) fieldValue = fieldValue.slice(1)
|
||||||
if (field === 'event') eventName = fieldValue
|
if (field === 'event') eventName = fieldValue
|
||||||
|
if (field === 'id') eventId = fieldValue
|
||||||
if (field === 'data') dataLines.push(fieldValue)
|
if (field === 'data') dataLines.push(fieldValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +130,8 @@ export class SseClient {
|
|||||||
this.controller.abort()
|
this.controller.abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO(streaming): 桌面网络策略确定后,在 Store 层增加有上限的指数退避重连。
|
||||||
|
|
||||||
isConnected() {
|
isConnected() {
|
||||||
return this.connected
|
return this.connected
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ApiErrorClass } from './apiClient'
|
||||||
|
import * as workspaceService from './workspaceService'
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200) {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceSnapshot = {
|
||||||
|
workspace: {
|
||||||
|
vault_id: 'default',
|
||||||
|
name: 'vault',
|
||||||
|
path: 'C:\\data\\vault',
|
||||||
|
file_count: 1,
|
||||||
|
indexed_note_count: 1,
|
||||||
|
requires_refresh: false,
|
||||||
|
},
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
entry_id: 'folder-course',
|
||||||
|
name: '课程',
|
||||||
|
path: '/课程',
|
||||||
|
type: 'folder',
|
||||||
|
note_id: null,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
entry_id: 'note-os',
|
||||||
|
note_id: 'note-os',
|
||||||
|
name: '操作系统.md',
|
||||||
|
path: '/课程/操作系统.md',
|
||||||
|
type: 'file',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn())
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('workspaceService backend adapter', () => {
|
||||||
|
it('opens the configured Vault and reads/saves Markdown through Note API', async () => {
|
||||||
|
const fetchMock = vi.mocked(fetch)
|
||||||
|
fetchMock.mockImplementation(async (input, init) => {
|
||||||
|
const url = String(input)
|
||||||
|
if (url === '/api/workspace/open') return jsonResponse(workspaceSnapshot)
|
||||||
|
if (url === '/api/notes/note-os' && init?.method === 'GET') {
|
||||||
|
return jsonResponse({
|
||||||
|
note_id: 'note-os', title: '操作系统', file_path: '课程/操作系统.md', tags: [],
|
||||||
|
created_at: '2026-08-31T00:00:00Z', updated_at: '2026-08-31T00:00:00Z',
|
||||||
|
markdown: '# 操作系统\n', blocks: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url === '/api/notes/note-os' && init?.method === 'PATCH') {
|
||||||
|
return jsonResponse({})
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected request: ${init?.method} ${url}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const vault = await workspaceService.openVault('C:\\data\\vault')
|
||||||
|
const tree = await workspaceService.getFileTree()
|
||||||
|
const markdown = await workspaceService.readFileContent('/课程/操作系统.md')
|
||||||
|
await workspaceService.saveFileContent('/课程/操作系统.md', '# 已更新\n')
|
||||||
|
|
||||||
|
expect(vault).toEqual({ path: 'C:\\data\\vault', name: 'vault' })
|
||||||
|
expect(tree[0].children?.[0]).toMatchObject({
|
||||||
|
id: 'note-os', note_id: 'note-os', path: '/课程/操作系统.md', type: 'file',
|
||||||
|
})
|
||||||
|
expect(markdown).toBe('# 操作系统\n')
|
||||||
|
const patchCall = fetchMock.mock.calls.find(([, init]) => init?.method === 'PATCH')
|
||||||
|
expect(JSON.parse(String(patchCall?.[1]?.body))).toEqual({ markdown: '# 已更新\n' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates notes and folders with Vault-relative paths', async () => {
|
||||||
|
const fetchMock = vi.mocked(fetch)
|
||||||
|
fetchMock.mockImplementation(async (input, init) => {
|
||||||
|
const url = String(input)
|
||||||
|
if (url === '/api/workspace/open') return jsonResponse(workspaceSnapshot)
|
||||||
|
if (url === '/api/notes' && init?.method === 'POST') {
|
||||||
|
return jsonResponse({
|
||||||
|
note_id: 'note-new', title: '新笔记', file_path: '课程/新笔记.md', tags: [],
|
||||||
|
created_at: '2026-08-31T00:00:00Z', updated_at: '2026-08-31T00:00:00Z',
|
||||||
|
markdown: '# 新笔记\n', blocks: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url === '/api/workspace/folders' && init?.method === 'POST') {
|
||||||
|
return jsonResponse({
|
||||||
|
entry_id: 'folder-child', name: '子目录', path: '/课程/子目录', type: 'folder',
|
||||||
|
note_id: null, children: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected request: ${init?.method} ${url}`)
|
||||||
|
})
|
||||||
|
await workspaceService.openVault('C:\\data\\vault')
|
||||||
|
|
||||||
|
const note = await workspaceService.createFile('/课程', '新笔记.md', '# 新笔记\n')
|
||||||
|
const folder = await workspaceService.createFolder('/课程', '子目录')
|
||||||
|
|
||||||
|
expect(note).toMatchObject({ id: 'note-new', path: '/课程/新笔记.md' })
|
||||||
|
expect(folder).toMatchObject({ id: 'folder-child', path: '/课程/子目录' })
|
||||||
|
const bodies = fetchMock.mock.calls
|
||||||
|
.filter(([, init]) => init?.method === 'POST')
|
||||||
|
.map(([, init]) => JSON.parse(String(init?.body)))
|
||||||
|
expect(bodies).toContainEqual({ title: '新笔记', folder: '课程', markdown: '# 新笔记\n' })
|
||||||
|
expect(bodies).toContainEqual({ parent: '课程', name: '子目录' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports backend connectivity errors instead of falling back to Mock data', async () => {
|
||||||
|
vi.mocked(fetch).mockRejectedValue(new Error('offline'))
|
||||||
|
|
||||||
|
await expect(workspaceService.getWorkspaceInfo()).rejects.toEqual(
|
||||||
|
expect.objectContaining<Partial<ApiErrorClass>>({ code: 'NETWORK_ERROR' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,255 +1,171 @@
|
|||||||
import type { FileNode } from '@/contracts'
|
import type {
|
||||||
|
ApiNote,
|
||||||
// Mock workspace service for web dev mode
|
ApiWorkspaceEntry,
|
||||||
// In Tauri environment this will use Tauri IPC commands
|
ApiWorkspaceInfo,
|
||||||
|
ApiWorkspaceSnapshot,
|
||||||
|
FileNode,
|
||||||
|
OperationResponse,
|
||||||
|
} from '@/contracts'
|
||||||
|
import apiClient from './apiClient'
|
||||||
|
import * as noteService from './noteService'
|
||||||
|
|
||||||
|
/** Web 联调只连接 AI Core 配置的单一 Vault;多 Vault 选择由 Tauri Host 接管。 */
|
||||||
export interface VaultInfo {
|
export interface VaultInfo {
|
||||||
path: string
|
path: string
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const MOCK_VAULTS: VaultInfo[] = [
|
let cachedTree: FileNode[] | null = null
|
||||||
{ path: '/Users/demo/Documents/MyVault', name: '我的知识库' },
|
const noteIdByPath = new Map<string, string>()
|
||||||
{ path: '/Users/demo/Documents/StudyNotes', name: '学习笔记' },
|
const typeByPath = new Map<string, FileNode['type']>()
|
||||||
]
|
|
||||||
|
|
||||||
const MOCK_FILE_TREE: FileNode[] = [
|
function normalizePublicPath(path: string): string {
|
||||||
{
|
const normalized = path.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
|
||||||
id: 'f-data',
|
return normalized ? `/${normalized}` : '/'
|
||||||
name: '数据结构',
|
}
|
||||||
path: '/数据结构',
|
|
||||||
type: 'folder',
|
function relativePath(path: string): string {
|
||||||
is_open: true,
|
return normalizePublicPath(path).replace(/^\//, '')
|
||||||
children: [
|
}
|
||||||
{ id: 'n-rbt', name: '红黑树.md', path: '/数据结构/红黑树.md', type: 'file' },
|
|
||||||
{ id: 'n-bst', name: '二叉搜索树.md', path: '/数据结构/二叉搜索树.md', type: 'file' },
|
function toFileNode(entry: ApiWorkspaceEntry): FileNode {
|
||||||
{
|
const path = normalizePublicPath(entry.path)
|
||||||
id: 'f-list',
|
const node: FileNode = {
|
||||||
name: '链表',
|
id: entry.entry_id,
|
||||||
path: '/数据结构/链表',
|
note_id: entry.note_id ?? undefined,
|
||||||
type: 'folder',
|
name: entry.name,
|
||||||
is_open: false,
|
path,
|
||||||
children: [
|
type: entry.type,
|
||||||
{ id: 'n-slist', name: '单链表.md', path: '/数据结构/链表/单链表.md', type: 'file' },
|
|
||||||
{ id: 'n-dlist', name: '双向链表.md', path: '/数据结构/链表/双向链表.md', type: 'file' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'f-os',
|
|
||||||
name: '操作系统',
|
|
||||||
path: '/操作系统',
|
|
||||||
type: 'folder',
|
|
||||||
is_open: false,
|
is_open: false,
|
||||||
children: [
|
children: entry.type === 'folder' ? entry.children.map(toFileNode) : undefined,
|
||||||
{ id: 'n-deadlock', name: '死锁.md', path: '/操作系统/死锁.md', type: 'file' },
|
|
||||||
{ id: 'n-sched', name: '进程调度.md', path: '/操作系统/进程调度.md', type: 'file' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'f-net',
|
|
||||||
name: '计算机网络',
|
|
||||||
path: '/计算机网络',
|
|
||||||
type: 'folder',
|
|
||||||
is_open: false,
|
|
||||||
children: [
|
|
||||||
{ id: 'n-tcp', name: 'TCP_IP.md', path: '/计算机网络/TCP_IP.md', type: 'file' },
|
|
||||||
{ id: 'n-http', name: 'HTTP协议.md', path: '/计算机网络/HTTP协议.md', type: 'file' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ id: 'n-welcome', name: '欢迎使用知笔知己.md', path: '/欢迎使用知笔知己.md', type: 'file' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const mockFileContents = new Map<string, string>()
|
|
||||||
|
|
||||||
function rememberContent(path: string, content: string): Promise<string> {
|
|
||||||
mockFileContents.set(path, content)
|
|
||||||
return Promise.resolve(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRecentVaults(): Promise<VaultInfo[]> {
|
|
||||||
return Promise.resolve(MOCK_VAULTS)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function openVault(path: string): Promise<VaultInfo> {
|
|
||||||
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Vault'
|
|
||||||
return Promise.resolve({ path, name })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createVault(path: string, name: string): Promise<VaultInfo> {
|
|
||||||
return Promise.resolve({ path, name })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getFileTree(): Promise<FileNode[]> {
|
|
||||||
return Promise.resolve(JSON.parse(JSON.stringify(MOCK_FILE_TREE)))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readFileContent(filePath: string): Promise<string> {
|
|
||||||
const saved = mockFileContents.get(filePath)
|
|
||||||
if (saved !== undefined) return Promise.resolve(saved)
|
|
||||||
const name = filePath.split('/').pop() || 'Untitled'
|
|
||||||
if (name === '欢迎使用知笔知己.md') {
|
|
||||||
return rememberContent(filePath, `# 欢迎使用知笔知己
|
|
||||||
|
|
||||||
这是一款本地优先的 AI 笔记软件,支持 Markdown 编辑、智能检索、RAG 问答和 Agent 助手。
|
|
||||||
|
|
||||||
## 核心特性
|
|
||||||
|
|
||||||
- **本地优先**:所有笔记以 Markdown 格式保存在本地,数据完全由你掌控
|
|
||||||
- **混合检索**:FTS5 全文检索 + 向量语义检索,精准定位知识
|
|
||||||
- **AI 问答**:基于 RAG 技术,让 AI 基于你的笔记回答问题
|
|
||||||
- **Agent 助手**:通过工具调用,AI 可以帮你管理笔记、创建任务
|
|
||||||
- **Skill 系统**:将常用 AI 工作流保存为可复用的 Skill
|
|
||||||
- **插件扩展**:通过 Plugin 扩展应用能力
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
1. 在左侧文件树中创建你的第一篇笔记
|
|
||||||
2. 使用 \`Ctrl+P\` 打开命令面板
|
|
||||||
3. 使用搜索功能快速找到你的笔记
|
|
||||||
4. 打开 AI 对话,开始与你的知识对话
|
|
||||||
|
|
||||||
> 提示:你可以在设置中配置你的模型提供商,开始使用 AI 功能。
|
|
||||||
|
|
||||||
## 编辑器模式
|
|
||||||
|
|
||||||
- **所见即所得模式**:使用 Milkdown 提供流畅的 Markdown 编辑体验
|
|
||||||
- **源码模式**:使用 CodeMirror 6 编辑原始 Markdown 源码
|
|
||||||
|
|
||||||
点击右上角按钮可以切换编辑模式。
|
|
||||||
|
|
||||||
## 代码示例
|
|
||||||
|
|
||||||
\`\`\`python
|
|
||||||
def quick_sort(arr):
|
|
||||||
if len(arr) <= 1:
|
|
||||||
return arr
|
|
||||||
pivot = arr[len(arr) // 2]
|
|
||||||
left = [x for x in arr if x < pivot]
|
|
||||||
middle = [x for x in arr if x == pivot]
|
|
||||||
right = [x for x in arr if x > pivot]
|
|
||||||
return quick_sort(left) + middle + quick_sort(right)
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## 任务列表
|
|
||||||
|
|
||||||
- [x] 完成项目初始化
|
|
||||||
- [x] 设计技术架构
|
|
||||||
- [ ] 实现前端界面
|
|
||||||
- [ ] 接入后端 AI Core
|
|
||||||
- [ ] 性能优化与测试
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
祝你写作愉快!
|
|
||||||
`)
|
|
||||||
}
|
}
|
||||||
if (name === '红黑树.md') {
|
typeByPath.set(path, entry.type)
|
||||||
return rememberContent(filePath, `# 红黑树
|
if (entry.note_id) noteIdByPath.set(path, entry.note_id)
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
红黑树(Red-Black Tree)是一种自平衡二叉搜索树,每个节点带有颜色属性(红色或黑色)。
|
function cacheEntries(entries: ApiWorkspaceEntry[]): FileNode[] {
|
||||||
|
noteIdByPath.clear()
|
||||||
|
typeByPath.clear()
|
||||||
|
cachedTree = entries.map(toFileNode)
|
||||||
|
return cachedTree
|
||||||
|
}
|
||||||
|
|
||||||
## 性质
|
function nodeFromNote(note: ApiNote): FileNode {
|
||||||
|
const path = normalizePublicPath(note.file_path)
|
||||||
1. 每个节点是红色或黑色
|
noteIdByPath.set(path, note.note_id)
|
||||||
2. 根节点是黑色
|
typeByPath.set(path, 'file')
|
||||||
3. 所有叶子节点(NIL)是黑色
|
return {
|
||||||
4. 如果一个节点是红色,则它的两个子节点都是黑色
|
id: note.note_id,
|
||||||
5. 从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点
|
note_id: note.note_id,
|
||||||
|
name: path.split('/').at(-1) || note.title,
|
||||||
这些性质确保了红黑树的关键特性:**从根到叶子的最长可能路径不会超过最短可能路径的两倍长**。
|
path,
|
||||||
|
type: 'file',
|
||||||
## 插入操作
|
|
||||||
|
|
||||||
插入后可能破坏红黑性质,需要通过变色和旋转来修复。
|
|
||||||
|
|
||||||
### 情况1:叔叔节点是红色
|
|
||||||
|
|
||||||
将父节点和叔叔节点设为黑色,将祖父节点设为红色,当前节点上移到祖父节点,继续向上调整。
|
|
||||||
|
|
||||||
### 情况2:叔叔节点是黑色,且当前节点是右孩子
|
|
||||||
|
|
||||||
以父节点为支点左旋,将当前节点转换为左孩子,进入情况3。
|
|
||||||
|
|
||||||
### 情况3:叔叔节点是黑色,且当前节点是左孩子
|
|
||||||
|
|
||||||
以祖父节点为支点右旋,将父节点设为黑色,祖父节点设为红色。
|
|
||||||
|
|
||||||
## 与 AVL 树对比
|
|
||||||
|
|
||||||
| 特性 | AVL 树 | 红黑树 |
|
|
||||||
|------|--------|--------|
|
|
||||||
| 平衡严格度 | 高度差 ≤ 1 | 黑色高度相同 |
|
|
||||||
| 查找速度 | 更快 | 略慢 |
|
|
||||||
| 插入删除 | 旋转更多 | 旋转更少 |
|
|
||||||
| 适用场景 | 读多写少 | 读写均衡 |
|
|
||||||
|
|
||||||
## 应用场景
|
|
||||||
|
|
||||||
- C++ STL 的 map/set
|
|
||||||
- Java 的 TreeMap
|
|
||||||
- Linux 内核的完全公平调度器
|
|
||||||
`)
|
|
||||||
}
|
}
|
||||||
return rememberContent(filePath, `# ${name.replace('.md', '')}
|
|
||||||
|
|
||||||
这是一篇示例笔记。
|
|
||||||
|
|
||||||
## 第一部分
|
|
||||||
|
|
||||||
这里是笔记的内容。
|
|
||||||
|
|
||||||
## 第二部分
|
|
||||||
|
|
||||||
更多内容...
|
|
||||||
|
|
||||||
> 引用内容示例
|
|
||||||
|
|
||||||
\`\`\`javascript
|
|
||||||
console.log('Hello, Notes Agent!');
|
|
||||||
\`\`\`
|
|
||||||
`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveFileContent(filePath: string, content: string): Promise<void> {
|
async function requireNoteId(filePath: string): Promise<string> {
|
||||||
console.debug(`[workspaceService] Save ${filePath}, ${content.length} chars`)
|
const path = normalizePublicPath(filePath)
|
||||||
mockFileContents.set(filePath, content)
|
let noteId = noteIdByPath.get(path)
|
||||||
return Promise.resolve()
|
if (!noteId) {
|
||||||
}
|
await refreshTree()
|
||||||
|
noteId = noteIdByPath.get(path)
|
||||||
export function createFile(folderPath: string, name: string, content = ''): Promise<FileNode> {
|
|
||||||
const path = `${folderPath === '/' ? '' : folderPath}/${name}`
|
|
||||||
const id = `n-${Date.now()}`
|
|
||||||
mockFileContents.set(path, content)
|
|
||||||
return Promise.resolve({ id, name, path, type: 'file' })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createFolder(parentPath: string, name: string): Promise<FileNode> {
|
|
||||||
const path = `${parentPath === '/' ? '' : parentPath}/${name}`
|
|
||||||
const id = `f-${Date.now()}`
|
|
||||||
return Promise.resolve({ id, name, path, type: 'folder', is_open: true, children: [] })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renameFile(oldPath: string, newName: string): Promise<void> {
|
|
||||||
const separator = oldPath.lastIndexOf('/')
|
|
||||||
const newPath = `${oldPath.slice(0, separator + 1)}${newName}`
|
|
||||||
for (const [path, content] of [...mockFileContents]) {
|
|
||||||
if (path === oldPath || path.startsWith(`${oldPath}/`)) {
|
|
||||||
mockFileContents.delete(path)
|
|
||||||
mockFileContents.set(`${newPath}${path.slice(oldPath.length)}`, content)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return Promise.resolve()
|
if (!noteId) throw new Error(`笔记尚未建立后端索引:${path}`)
|
||||||
|
return noteId
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteFile(path: string): Promise<void> {
|
export async function getWorkspaceInfo(): Promise<ApiWorkspaceInfo> {
|
||||||
for (const filePath of [...mockFileContents.keys()]) {
|
return apiClient.get('/api/workspace')
|
||||||
if (filePath === path || filePath.startsWith(`${path}/`)) mockFileContents.delete(filePath)
|
}
|
||||||
|
|
||||||
|
export async function getRecentVaults(): Promise<VaultInfo[]> {
|
||||||
|
const workspace = await getWorkspaceInfo()
|
||||||
|
return [{ path: workspace.path, name: workspace.name }]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openVault(path: string): Promise<VaultInfo> {
|
||||||
|
const snapshot = await apiClient.post<ApiWorkspaceSnapshot>('/api/workspace/open', { path })
|
||||||
|
cacheEntries(snapshot.items)
|
||||||
|
return { path: snapshot.workspace.path, name: snapshot.workspace.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createVault(path: string, name: string): Promise<VaultInfo> {
|
||||||
|
// Web 模式不能创建任意本地目录;路径匹配时等价于初始化后端配置的 Vault。
|
||||||
|
void name
|
||||||
|
return openVault(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshTree(): Promise<FileNode[]> {
|
||||||
|
const entries = await apiClient.get<ApiWorkspaceEntry[]>('/api/workspace/tree')
|
||||||
|
return cacheEntries(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFileTree(): Promise<FileNode[]> {
|
||||||
|
return cachedTree ?? refreshTree()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readFileContent(filePath: string): Promise<string> {
|
||||||
|
const note = await noteService.getNote(await requireNoteId(filePath))
|
||||||
|
return note.markdown
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveFileContent(filePath: string, content: string): Promise<void> {
|
||||||
|
await noteService.updateNote(await requireNoteId(filePath), { markdown: content })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFile(
|
||||||
|
folderPath: string,
|
||||||
|
name: string,
|
||||||
|
content = '',
|
||||||
|
): Promise<FileNode> {
|
||||||
|
const title = name.replace(/\.md$/i, '')
|
||||||
|
const note = await noteService.createNote({
|
||||||
|
title,
|
||||||
|
folder: relativePath(folderPath),
|
||||||
|
markdown: content,
|
||||||
|
})
|
||||||
|
return nodeFromNote(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFolder(parentPath: string, name: string): Promise<FileNode> {
|
||||||
|
const entry = await apiClient.post<ApiWorkspaceEntry>('/api/workspace/folders', {
|
||||||
|
parent: relativePath(parentPath),
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
return toFileNode(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameFile(oldPath: string, newName: string): Promise<void> {
|
||||||
|
const path = normalizePublicPath(oldPath)
|
||||||
|
if (typeByPath.get(path) === 'folder') {
|
||||||
|
await apiClient.post('/api/workspace/folders/rename', {
|
||||||
|
path: relativePath(path),
|
||||||
|
new_name: newName,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await noteService.renameNote(await requireNoteId(path), newName)
|
||||||
}
|
}
|
||||||
return Promise.resolve()
|
await refreshTree()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function moveFile(sourcePath: string, targetPath: string): Promise<void> {
|
export async function deleteFile(pathValue: string): Promise<void> {
|
||||||
return Promise.resolve()
|
const path = normalizePublicPath(pathValue)
|
||||||
|
if (typeByPath.get(path) === 'folder') {
|
||||||
|
await apiClient.post<OperationResponse>('/api/workspace/folders/delete', {
|
||||||
|
path: relativePath(path),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await noteService.deleteNote(await requireNoteId(path))
|
||||||
|
}
|
||||||
|
await refreshTree()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveFile(sourcePath: string, targetPath: string): Promise<void> {
|
||||||
|
const source = normalizePublicPath(sourcePath)
|
||||||
|
if (typeByPath.get(source) !== 'file') {
|
||||||
|
throw new Error('当前阶段只支持移动笔记文件。')
|
||||||
|
}
|
||||||
|
await noteService.moveNote(await requireNoteId(source), relativePath(targetPath))
|
||||||
|
await refreshTree()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export const useAgentStore = defineStore('agent', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function processEvent(event: AgentEvent) {
|
function processEvent(event: AgentEvent) {
|
||||||
|
// 服务端会先回放历史再发送实时事件,以 run_id + sequence 去重保证幂等。
|
||||||
if (events.value.some((item) => item.run_id === event.run_id && item.sequence === event.sequence)) return
|
if (events.value.some((item) => item.run_id === event.run_id && item.sequence === event.sequence)) return
|
||||||
events.value.push(event)
|
events.value.push(event)
|
||||||
events.value.sort((a, b) => a.sequence - b.sequence)
|
events.value.sort((a, b) => a.sequence - b.sequence)
|
||||||
@@ -100,6 +101,7 @@ export const useAgentStore = defineStore('agent', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function subscribe(runId: string) {
|
function subscribe(runId: string) {
|
||||||
|
// 任一时刻只保留当前运行的事件流,防止切换详情后旧事件污染新页面。
|
||||||
eventStream?.cancel()
|
eventStream?.cancel()
|
||||||
isRunning.value = true
|
isRunning.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
const selectedModel = ref('mock-1')
|
const selectedModel = ref('mock-1')
|
||||||
let sseClient: SseClient | null = null
|
let sseClient: SseClient | null = null
|
||||||
|
|
||||||
|
// TODO(chat): 会话持久化接口完成后移除 mockConversations/mockMessages 数据源。
|
||||||
|
|
||||||
const activeConversation = computed(() =>
|
const activeConversation = computed(() =>
|
||||||
conversations.value.find((c) => c.conversation_id === activeConversationId.value) || null
|
conversations.value.find((c) => c.conversation_id === activeConversationId.value) || null
|
||||||
)
|
)
|
||||||
@@ -56,6 +58,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
inputText.value = ''
|
inputText.value = ''
|
||||||
isStreaming.value = true
|
isStreaming.value = true
|
||||||
|
|
||||||
|
// 先插入占位消息,随后将 SSE 增量原位合并,避免每个 token 重建消息列表。
|
||||||
const aiMsg: ChatMessage = {
|
const aiMsg: ChatMessage = {
|
||||||
message_id: `msg-${Date.now() + 1}`,
|
message_id: `msg-${Date.now() + 1}`,
|
||||||
conversation_id: conversationId,
|
conversation_id: conversationId,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
async function save() {
|
async function save() {
|
||||||
if (!currentFilePath.value) return
|
if (!currentFilePath.value) return
|
||||||
if (pendingSave) return pendingSave
|
if (pendingSave) return pendingSave
|
||||||
|
// 保存路径与正文都取快照;请求完成时用户可能已继续输入或切换文件。
|
||||||
const targetPath = currentFilePath.value
|
const targetPath = currentFilePath.value
|
||||||
const snapshot = content.value
|
const snapshot = content.value
|
||||||
saveStatus.value = 'saving'
|
saveStatus.value = 'saving'
|
||||||
@@ -82,6 +83,7 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') {
|
if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') {
|
||||||
throw new Error('当前文件保存失败,已阻止切换以避免内容丢失。')
|
throw new Error('当前文件保存失败,已阻止切换以避免内容丢失。')
|
||||||
}
|
}
|
||||||
|
// 版本号使较慢的旧读取不能覆盖用户后选择的新文件。
|
||||||
const version = ++loadVersion
|
const version = ++loadVersion
|
||||||
const previousStatus = saveStatus.value
|
const previousStatus = saveStatus.value
|
||||||
saveStatus.value = 'saving'
|
saveStatus.value = 'saving'
|
||||||
@@ -117,6 +119,8 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO(editor): 桌面文件监听接入后提供冲突对比/合并界面,而非只阻止切换。
|
||||||
|
|
||||||
function closeFile() {
|
function closeFile() {
|
||||||
loadVersion++
|
loadVersion++
|
||||||
if (saveTimer) clearTimeout(saveTimer)
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import { useThemeStore } from './theme'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
document.documentElement.removeAttribute('data-theme')
|
||||||
|
document.documentElement.removeAttribute('data-code-theme')
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
configurable: true,
|
||||||
|
value: () => ({ matches: false }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('代码块主题偏好', () => {
|
||||||
|
it('跟随应用主题选择对应的 GitHub 代码主题', async () => {
|
||||||
|
const store = useThemeStore()
|
||||||
|
store.applyTheme('dark')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(store.resolvedCodeBlockTheme).toBe('github-dark')
|
||||||
|
expect(document.documentElement.dataset.codeTheme).toBe('github-dark')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('允许代码块主题独立于应用主题', async () => {
|
||||||
|
const store = useThemeStore()
|
||||||
|
store.applyTheme('dark')
|
||||||
|
store.codeBlockTheme = 'github-light'
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(store.resolvedCodeBlockTheme).toBe('github-light')
|
||||||
|
expect(document.documentElement.dataset.codeTheme).toBe('github-light')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('恢复持久化的代码块主题偏好', async () => {
|
||||||
|
localStorage.setItem('editor-appearance', JSON.stringify({ codeBlockTheme: 'github-dark' }))
|
||||||
|
const store = useThemeStore()
|
||||||
|
store.initTheme()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(store.codeBlockTheme).toBe('github-dark')
|
||||||
|
expect(document.documentElement.dataset.codeTheme).toBe('github-dark')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,17 +3,24 @@ import { ref, computed, watch } from 'vue'
|
|||||||
import type { ThemeConfig } from '@/contracts'
|
import type { ThemeConfig } from '@/contracts'
|
||||||
|
|
||||||
const builtinThemes: ThemeConfig[] = [
|
const builtinThemes: ThemeConfig[] = [
|
||||||
{ theme_id: 'light', name: '浅色', version: '1.0.0', description: '默认浅色主题', is_dark: false, builtin: true },
|
{ theme_id: 'light', name: '浅色', version: '1.0.0', description: '默认浅色主题', is_dark: false, builtin: true, code_theme: 'github-light' },
|
||||||
{ theme_id: 'dark', name: '深色', version: '1.0.0', description: '默认深色主题', is_dark: true, builtin: true },
|
{ theme_id: 'dark', name: '深色', version: '1.0.0', description: '默认深色主题', is_dark: true, builtin: true, code_theme: 'github-dark' },
|
||||||
{ theme_id: 'sepia', name: '护眼', version: '1.0.0', description: '护眼暖色调', is_dark: false, builtin: true },
|
{ theme_id: 'sepia', name: '护眼', version: '1.0.0', description: '护眼暖色调', is_dark: false, builtin: true, code_theme: 'github-light' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export type CodeBlockThemePreference = 'auto' | 'github-light' | 'github-dark'
|
||||||
|
|
||||||
|
function isCodeBlockThemePreference(value: unknown): value is CodeBlockThemePreference {
|
||||||
|
return value === 'auto' || value === 'github-light' || value === 'github-dark'
|
||||||
|
}
|
||||||
|
|
||||||
export const useThemeStore = defineStore('theme', () => {
|
export const useThemeStore = defineStore('theme', () => {
|
||||||
const themes = ref<ThemeConfig[]>(builtinThemes)
|
const themes = ref<ThemeConfig[]>(builtinThemes)
|
||||||
const currentThemeId = ref<string>('light')
|
const currentThemeId = ref<string>('light')
|
||||||
const fontEditorSize = ref(15)
|
const fontEditorSize = ref(15)
|
||||||
const fontEditorFamily = ref('system-ui')
|
const fontEditorFamily = ref('system-ui')
|
||||||
const lineHeight = ref(1.7)
|
const lineHeight = ref(1.7)
|
||||||
|
const codeBlockTheme = ref<CodeBlockThemePreference>('auto')
|
||||||
let appearanceHydrated = false
|
let appearanceHydrated = false
|
||||||
|
|
||||||
const currentTheme = computed(() =>
|
const currentTheme = computed(() =>
|
||||||
@@ -21,6 +28,10 @@ export const useThemeStore = defineStore('theme', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const isDark = computed(() => currentTheme.value?.is_dark || false)
|
const isDark = computed(() => currentTheme.value?.is_dark || false)
|
||||||
|
const resolvedCodeBlockTheme = computed<'github-light' | 'github-dark'>(() => {
|
||||||
|
if (codeBlockTheme.value !== 'auto') return codeBlockTheme.value
|
||||||
|
return currentTheme.value?.code_theme ?? (isDark.value ? 'github-dark' : 'github-light')
|
||||||
|
})
|
||||||
|
|
||||||
function applyTheme(themeId: string) {
|
function applyTheme(themeId: string) {
|
||||||
const theme = themes.value.find((t) => t.theme_id === themeId)
|
const theme = themes.value.find((t) => t.theme_id === themeId)
|
||||||
@@ -38,13 +49,15 @@ export const useThemeStore = defineStore('theme', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initTheme() {
|
function initTheme() {
|
||||||
|
// 先恢复外观再开放 watch 持久化,避免 immediate watcher 覆盖本地设置。
|
||||||
const savedAppearance = localStorage.getItem('editor-appearance')
|
const savedAppearance = localStorage.getItem('editor-appearance')
|
||||||
if (savedAppearance) {
|
if (savedAppearance) {
|
||||||
try {
|
try {
|
||||||
const value = JSON.parse(savedAppearance) as { size?: number; family?: string; lineHeight?: number }
|
const value = JSON.parse(savedAppearance) as { size?: number; family?: string; lineHeight?: number; codeBlockTheme?: unknown }
|
||||||
if (value.size) fontEditorSize.value = value.size
|
if (value.size) fontEditorSize.value = value.size
|
||||||
if (value.family) fontEditorFamily.value = value.family
|
if (value.family) fontEditorFamily.value = value.family
|
||||||
if (value.lineHeight) lineHeight.value = value.lineHeight
|
if (value.lineHeight) lineHeight.value = value.lineHeight
|
||||||
|
if (isCodeBlockThemePreference(value.codeBlockTheme)) codeBlockTheme.value = value.codeBlockTheme
|
||||||
} catch { localStorage.removeItem('editor-appearance') }
|
} catch { localStorage.removeItem('editor-appearance') }
|
||||||
}
|
}
|
||||||
const saved = localStorage.getItem('theme')
|
const saved = localStorage.getItem('theme')
|
||||||
@@ -67,12 +80,21 @@ export const useThemeStore = defineStore('theme', () => {
|
|||||||
fontEditorSize.value = 15
|
fontEditorSize.value = 15
|
||||||
fontEditorFamily.value = 'system-ui'
|
fontEditorFamily.value = 'system-ui'
|
||||||
lineHeight.value = 1.7
|
lineHeight.value = 1.7
|
||||||
|
codeBlockTheme.value = 'auto'
|
||||||
}
|
}
|
||||||
|
|
||||||
const persistAppearance = () => localStorage.setItem('editor-appearance', JSON.stringify({
|
const persistAppearance = () => localStorage.setItem('editor-appearance', JSON.stringify({
|
||||||
size: fontEditorSize.value, family: fontEditorFamily.value, lineHeight: lineHeight.value,
|
size: fontEditorSize.value,
|
||||||
|
family: fontEditorFamily.value,
|
||||||
|
lineHeight: lineHeight.value,
|
||||||
|
codeBlockTheme: codeBlockTheme.value,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
watch(resolvedCodeBlockTheme, (theme) => {
|
||||||
|
// CSS 与 Shiki 共用该属性,确保代码块背景和 token 配色始终成套切换。
|
||||||
|
document.documentElement.setAttribute('data-code-theme', theme)
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
watch(fontEditorSize, (v) => {
|
watch(fontEditorSize, (v) => {
|
||||||
document.documentElement.style.setProperty('--font-editor-size', `${v}px`)
|
document.documentElement.style.setProperty('--font-editor-size', `${v}px`)
|
||||||
if (appearanceHydrated) persistAppearance()
|
if (appearanceHydrated) persistAppearance()
|
||||||
@@ -88,6 +110,10 @@ export const useThemeStore = defineStore('theme', () => {
|
|||||||
if (appearanceHydrated) persistAppearance()
|
if (appearanceHydrated) persistAppearance()
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
|
watch(codeBlockTheme, () => {
|
||||||
|
if (appearanceHydrated) persistAppearance()
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
themes,
|
themes,
|
||||||
currentThemeId,
|
currentThemeId,
|
||||||
@@ -96,6 +122,8 @@ export const useThemeStore = defineStore('theme', () => {
|
|||||||
fontEditorSize,
|
fontEditorSize,
|
||||||
fontEditorFamily,
|
fontEditorFamily,
|
||||||
lineHeight,
|
lineHeight,
|
||||||
|
codeBlockTheme,
|
||||||
|
resolvedCodeBlockTheme,
|
||||||
applyTheme,
|
applyTheme,
|
||||||
initTheme,
|
initTheme,
|
||||||
toggleTheme,
|
toggleTheme,
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
|
|||||||
function renamePath(oldPath: string, newPath: string, newName: string) {
|
function renamePath(oldPath: string, newPath: string, newName: string) {
|
||||||
const node = findNodeByPath(fileTree.value, oldPath)
|
const node = findNodeByPath(fileTree.value, oldPath)
|
||||||
if (!node) return
|
if (!node) return
|
||||||
|
// 文件夹重命名必须同步改写所有后代、标签页和当前文件路径。
|
||||||
const updateNodePath = (current: FileNode) => {
|
const updateNodePath = (current: FileNode) => {
|
||||||
if (current.path === oldPath) current.name = newName
|
if (current.path === oldPath) current.name = newName
|
||||||
if (current.path === oldPath || current.path.startsWith(`${oldPath}/`)) {
|
if (current.path === oldPath || current.path.startsWith(`${oldPath}/`)) {
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
.feature-page {
|
.feature-page {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: var(--space-2xl);
|
padding: clamp(var(--space-lg), 3vw, var(--space-3xl));
|
||||||
background: var(--color-background-primary);
|
background:
|
||||||
|
radial-gradient(circle at 92% -8%, var(--color-accent-soft) 0, transparent 28%),
|
||||||
|
var(--color-background-primary);
|
||||||
user-select: text;
|
user-select: text;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
animation: page-in var(--motion-normal) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-page,
|
||||||
|
.workspace-view {
|
||||||
|
animation: page-in var(--motion-normal) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-header {
|
.feature-header {
|
||||||
@@ -11,13 +20,15 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-lg);
|
gap: var(--space-lg);
|
||||||
margin-bottom: var(--space-xl);
|
margin: 0 auto var(--space-2xl);
|
||||||
|
max-width: 1180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-header h1 { font-size: var(--font-size-3xl); line-height: 1.2; }
|
.feature-header h1 { font-size: clamp(var(--font-size-2xl), 2.3vw, 30px); line-height: 1.15; letter-spacing: -.025em; }
|
||||||
.feature-header p { margin-top: var(--space-xs); color: var(--color-text-secondary); }
|
.feature-header p { max-width: 680px; margin-top: var(--space-sm); color: var(--color-text-secondary); }
|
||||||
.feature-actions, .inline-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-sm); }
|
.feature-actions, .inline-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-sm); }
|
||||||
.feature-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: var(--space-lg); }
|
.feature-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: var(--space-lg); max-width: 1180px; margin-inline: auto; }
|
||||||
|
.feature-page > .panel { max-width: 1180px; margin-inline: auto; }
|
||||||
.split-view { display: grid; grid-template-columns: minmax(260px, .8fr) minmax(360px, 1.7fr); gap: var(--space-lg); min-height: 0; }
|
.split-view { display: grid; grid-template-columns: minmax(260px, .8fr) minmax(360px, 1.7fr); gap: var(--space-lg); min-height: 0; }
|
||||||
|
|
||||||
.panel, .item-card {
|
.panel, .item-card {
|
||||||
@@ -27,10 +38,13 @@
|
|||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel { padding: var(--space-xl); }
|
.panel { padding: clamp(var(--space-lg), 2.2vw, var(--space-2xl)); }
|
||||||
.item-card { padding: var(--space-lg); transition: border-color var(--motion-fast), transform var(--motion-fast); }
|
.item-card {
|
||||||
.item-card:hover { border-color: var(--color-accent-secondary); }
|
padding: var(--space-lg);
|
||||||
.item-card.selected { border-color: var(--color-accent-primary); box-shadow: 0 0 0 2px var(--color-accent-soft); }
|
transition: border-color var(--motion-fast), box-shadow var(--motion-fast), transform var(--motion-fast), background-color var(--motion-fast);
|
||||||
|
}
|
||||||
|
.item-card:hover { border-color: var(--color-accent-secondary); box-shadow: var(--shadow-md); transform: translateY(-2px); }
|
||||||
|
.item-card.selected { border-color: var(--color-accent-primary); box-shadow: 0 0 0 3px var(--color-accent-soft), var(--shadow-md); }
|
||||||
.panel-title { margin-bottom: var(--space-md); font-size: var(--font-size-xl); }
|
.panel-title { margin-bottom: var(--space-md); font-size: var(--font-size-xl); }
|
||||||
.muted { color: var(--color-text-secondary); }
|
.muted { color: var(--color-text-secondary); }
|
||||||
.subtle { color: var(--color-text-tertiary); font-size: var(--font-size-sm); }
|
.subtle { color: var(--color-text-tertiary); font-size: var(--font-size-sm); }
|
||||||
@@ -41,14 +55,18 @@
|
|||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
transition: background-color var(--motion-fast), border-color var(--motion-fast), color var(--motion-fast), box-shadow var(--motion-fast), transform var(--motion-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-primary { background: var(--color-accent-primary); color: var(--color-text-inverse); }
|
.button-primary { background: var(--color-accent-primary); color: var(--color-text-inverse); box-shadow: 0 4px 12px color-mix(in srgb, var(--color-accent-primary) 24%, transparent); }
|
||||||
.button-primary:hover { background: var(--color-accent-primary-hover); }
|
.button-primary:hover { background: var(--color-accent-primary-hover); transform: translateY(-1px); box-shadow: 0 7px 18px color-mix(in srgb, var(--color-accent-primary) 28%, transparent); }
|
||||||
.button-secondary { border-color: var(--color-border-default); background: var(--color-surface-primary); }
|
.button-secondary { border-color: var(--color-border-default); background: var(--color-surface-primary); }
|
||||||
.button-secondary:hover, .icon-button:hover { background: var(--color-background-hover); }
|
.button-secondary:hover, .icon-button:hover { border-color: var(--color-accent-secondary); background: var(--color-background-hover); color: var(--color-accent-primary); transform: translateY(-1px); }
|
||||||
.button-danger { background: var(--color-error-soft); color: var(--color-error); }
|
.button-danger { background: var(--color-error-soft); color: var(--color-error); }
|
||||||
button:disabled { cursor: not-allowed; opacity: .55; }
|
.button-danger:hover { transform: translateY(-1px); box-shadow: 0 5px 14px color-mix(in srgb, var(--color-error) 18%, transparent); }
|
||||||
|
button:active:not(:disabled) { transform: translateY(0); }
|
||||||
|
button:disabled { cursor: not-allowed; opacity: .55; box-shadow: none; transform: none; }
|
||||||
|
|
||||||
.field { display: grid; gap: var(--space-xs); }
|
.field { display: grid; gap: var(--space-xs); }
|
||||||
.field label { color: var(--color-text-secondary); font-size: var(--font-size-sm); font-weight: 600; }
|
.field label { color: var(--color-text-secondary); font-size: var(--font-size-sm); font-weight: 600; }
|
||||||
@@ -59,10 +77,12 @@ button:disabled { cursor: not-allowed; opacity: .55; }
|
|||||||
outline: none;
|
outline: none;
|
||||||
background: var(--color-background-primary);
|
background: var(--color-background-primary);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
|
transition: border-color var(--motion-fast), box-shadow var(--motion-fast), background-color var(--motion-fast);
|
||||||
}
|
}
|
||||||
.input, .select { height: 36px; padding: 0 var(--space-md); }
|
.input, .select { height: 38px; padding: 0 var(--space-md); }
|
||||||
.textarea { min-height: 100px; padding: var(--space-md); resize: vertical; }
|
.textarea { min-height: 100px; padding: var(--space-md); resize: vertical; }
|
||||||
.input:focus, .select:focus, .textarea:focus { border-color: var(--color-border-focus); box-shadow: 0 0 0 2px var(--color-accent-soft); }
|
.input:hover, .select:hover, .textarea:hover { border-color: var(--color-text-tertiary); }
|
||||||
|
.input:focus, .select:focus, .textarea:focus { border-color: var(--color-border-focus); box-shadow: 0 0 0 3px var(--color-accent-soft); background: var(--color-surface-primary); }
|
||||||
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--space-md); }
|
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--space-md); }
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
@@ -75,6 +95,7 @@ button:disabled { cursor: not-allowed; opacity: .55; }
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
letter-spacing: .01em;
|
||||||
}
|
}
|
||||||
.badge.success { background: var(--color-success-soft); color: var(--color-success); }
|
.badge.success { background: var(--color-success-soft); color: var(--color-success); }
|
||||||
.badge.warning { background: var(--color-warning-soft); color: var(--color-warning); }
|
.badge.warning { background: var(--color-warning-soft); color: var(--color-warning); }
|
||||||
@@ -82,25 +103,45 @@ button:disabled { cursor: not-allowed; opacity: .55; }
|
|||||||
.badge.info { background: var(--color-info-soft); color: var(--color-info); }
|
.badge.info { background: var(--color-info-soft); color: var(--color-info); }
|
||||||
.tag-list { display: flex; flex-wrap: wrap; gap: var(--space-xs); }
|
.tag-list { display: flex; flex-wrap: wrap; gap: var(--space-xs); }
|
||||||
|
|
||||||
.empty-state { display: grid; place-items: center; min-height: 220px; padding: var(--space-2xl); text-align: center; color: var(--color-text-secondary); }
|
.empty-state { display: grid; place-items: center; min-height: 220px; padding: var(--space-3xl); border: 1px dashed var(--color-border-default); border-radius: var(--radius-xl); background: color-mix(in srgb, var(--color-background-secondary) 74%, transparent); text-align: center; color: var(--color-text-secondary); }
|
||||||
.empty-state strong { display: block; margin-bottom: var(--space-xs); color: var(--color-text-primary); font-size: var(--font-size-xl); }
|
.empty-state strong { display: block; margin-bottom: var(--space-xs); color: var(--color-text-primary); font-size: var(--font-size-xl); }
|
||||||
.error-banner { margin-bottom: var(--space-lg); padding: var(--space-md); border-radius: var(--radius-md); background: var(--color-error-soft); color: var(--color-error); }
|
.error-banner, .notice-banner { border: 1px solid transparent; animation: notice-in var(--motion-normal) both; }
|
||||||
.notice-banner { margin-bottom: var(--space-lg); padding: var(--space-md); border-radius: var(--radius-md); background: var(--color-info-soft); color: var(--color-info); }
|
.error-banner { margin-bottom: var(--space-lg); padding: var(--space-md) var(--space-lg); border-color: color-mix(in srgb, var(--color-error) 22%, transparent); border-radius: var(--radius-md); background: var(--color-error-soft); color: var(--color-error); }
|
||||||
|
.notice-banner { margin-bottom: var(--space-lg); padding: var(--space-md) var(--space-lg); border-color: color-mix(in srgb, var(--color-info) 22%, transparent); border-radius: var(--radius-md); background: var(--color-info-soft); color: var(--color-info); }
|
||||||
|
|
||||||
.sidebar-panel { padding: var(--space-md); }
|
.sidebar-panel { padding: var(--space-md); }
|
||||||
.sidebar-panel .input, .sidebar-panel .select { margin-bottom: var(--space-sm); }
|
.sidebar-panel .input, .sidebar-panel .select { margin-bottom: var(--space-sm); }
|
||||||
.sidebar-list { display: grid; gap: var(--space-xs); }
|
.sidebar-list { display: grid; gap: var(--space-xs); }
|
||||||
.sidebar-list-item { padding: var(--space-sm); border-radius: var(--radius-md); cursor: pointer; }
|
.sidebar-list-item { padding: 9px var(--space-sm); border: 1px solid transparent; border-radius: var(--radius-md); cursor: pointer; transition: color var(--motion-fast), background-color var(--motion-fast), border-color var(--motion-fast), transform var(--motion-fast); }
|
||||||
.sidebar-list-item:hover, .sidebar-list-item.active { background: var(--color-background-hover); }
|
.sidebar-list-item:hover { background: var(--color-background-hover); transform: translateX(2px); }
|
||||||
|
.sidebar-list-item.active { border-color: color-mix(in srgb, var(--color-accent-primary) 18%, transparent); background: var(--color-accent-soft); color: var(--color-accent-primary); }
|
||||||
|
|
||||||
.modal-backdrop { position: fixed; inset: 0; z-index: var(--z-modal); display: grid; place-items: center; padding: var(--space-xl); background: var(--color-background-overlay); }
|
.modal-backdrop { position: fixed; inset: 0; z-index: var(--z-modal); display: grid; place-items: center; padding: var(--space-xl); background: var(--color-background-overlay); animation: backdrop-in var(--motion-fast) both; }
|
||||||
.modal { width: min(560px, 100%); max-height: 85vh; overflow: auto; padding: var(--space-xl); border-radius: var(--radius-lg); background: var(--color-surface-elevated); box-shadow: var(--shadow-xl); }
|
.modal { width: min(560px, 100%); max-height: 85vh; overflow: auto; padding: var(--space-2xl); border: 1px solid var(--color-border-default); border-radius: var(--radius-xl); background: var(--color-surface-elevated); box-shadow: var(--shadow-xl); animation: modal-in var(--motion-normal) both; }
|
||||||
.modal h2 { margin-bottom: var(--space-lg); }
|
.modal h2 { margin-bottom: var(--space-lg); }
|
||||||
.modal form { display: grid; gap: var(--space-md); }
|
.modal form { display: grid; gap: var(--space-md); }
|
||||||
|
|
||||||
.settings-nav { display: flex; flex-wrap: wrap; gap: var(--space-xs); margin-bottom: var(--space-xl); border-bottom: 1px solid var(--color-border-default); }
|
.settings-nav { display: flex; flex-wrap: wrap; gap: var(--space-xs); margin-bottom: var(--space-xl); padding: var(--space-xs); border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); background: var(--color-background-secondary); }
|
||||||
.settings-nav button { padding: var(--space-sm) var(--space-md); border-bottom: 2px solid transparent; color: var(--color-text-secondary); }
|
.settings-nav button { padding: 8px var(--space-md); border-radius: var(--radius-md); color: var(--color-text-secondary); transition: color var(--motion-fast), background-color var(--motion-fast), box-shadow var(--motion-fast); }
|
||||||
.settings-nav button.active { border-color: var(--color-accent-primary); color: var(--color-accent-primary); }
|
.settings-nav button:hover { color: var(--color-text-primary); background: var(--color-background-hover); }
|
||||||
|
.settings-nav button.active { background: var(--color-surface-primary); color: var(--color-accent-primary); box-shadow: var(--shadow-sm); }
|
||||||
|
|
||||||
|
@keyframes page-in {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes notice-in {
|
||||||
|
from { opacity: 0; transform: translateY(-4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes backdrop-in { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
|
||||||
|
@keyframes modal-in {
|
||||||
|
from { opacity: 0; transform: translateY(8px) scale(.985); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.feature-page { padding: var(--space-lg); }
|
.feature-page { padding: var(--space-lg); }
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/// <reference types="node" />
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const globalStyles = [
|
||||||
|
new URL('./tokens.css', import.meta.url),
|
||||||
|
new URL('./features.css', import.meta.url),
|
||||||
|
].map((path) => readFileSync(path, 'utf8')).join('\n')
|
||||||
|
|
||||||
|
const markdownStyles = [
|
||||||
|
new URL('../features/editor/VisualMarkdownEditor.vue', import.meta.url),
|
||||||
|
new URL('../features/themes/ThemesView.vue', import.meta.url),
|
||||||
|
new URL('../components/common/MarkdownContent.vue', import.meta.url),
|
||||||
|
].map((path) => readFileSync(path, 'utf8')).join('\n')
|
||||||
|
|
||||||
|
describe('轻量动效基线', () => {
|
||||||
|
it('为减少动态效果偏好提供全局回退', () => {
|
||||||
|
expect(globalStyles).toContain('@media (prefers-reduced-motion: reduce)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('不使用全属性过渡或高成本模糊滤镜', () => {
|
||||||
|
expect(globalStyles).not.toMatch(/transition:\s*all\b/)
|
||||||
|
expect(globalStyles).not.toMatch(/(?:backdrop-)?filter\s*:/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('页面入场只改变透明度和变换', () => {
|
||||||
|
const pageAnimation = globalStyles.match(/@keyframes page-in\s*{[\s\S]*?\n}/)?.[0] ?? ''
|
||||||
|
expect(pageAnimation).toContain('opacity')
|
||||||
|
expect(pageAnimation).toContain('transform')
|
||||||
|
expect(pageAnimation).not.toMatch(/(?:width|height|margin|padding|top|left)\s*:/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Markdown 序号和表格使用独立的高对比度主题变量', () => {
|
||||||
|
expect(globalStyles).toContain('--color-markdown-grid:')
|
||||||
|
expect(globalStyles).toContain('--color-markdown-marker:')
|
||||||
|
expect(markdownStyles).toContain('var(--color-markdown-grid)')
|
||||||
|
expect(markdownStyles).toContain('var(--color-markdown-marker)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('不混用可能丢失后代选择器的 scoped global 写法', () => {
|
||||||
|
expect(markdownStyles).not.toMatch(/:global\([^\n]+\)\s+\./)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -44,17 +44,22 @@
|
|||||||
--color-border-focus: #5b67f1;
|
--color-border-focus: #5b67f1;
|
||||||
--color-border-disabled: #eef0f3;
|
--color-border-disabled: #eef0f3;
|
||||||
|
|
||||||
|
/* Markdown */
|
||||||
|
--color-markdown-grid: #8b949e;
|
||||||
|
--color-markdown-marker: #343b44;
|
||||||
|
--color-markdown-table-header: #eef0f3;
|
||||||
|
|
||||||
/* Shadow */
|
/* Shadow */
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
|
--shadow-sm: 0 1px 2px rgba(31, 35, 40, 0.05), 0 1px 5px rgba(31, 35, 40, 0.03);
|
||||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
--shadow-md: 0 8px 22px rgba(31, 35, 40, 0.08), 0 2px 6px rgba(31, 35, 40, 0.04);
|
||||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
|
--shadow-lg: 0 16px 36px rgba(31, 35, 40, 0.11), 0 4px 12px rgba(31, 35, 40, 0.05);
|
||||||
--shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.16);
|
--shadow-xl: 0 24px 64px rgba(31, 35, 40, 0.18), 0 8px 20px rgba(31, 35, 40, 0.08);
|
||||||
|
|
||||||
/* Radius */
|
/* Radius */
|
||||||
--radius-sm: 4px;
|
--radius-sm: 6px;
|
||||||
--radius-md: 6px;
|
--radius-md: 9px;
|
||||||
--radius-lg: 10px;
|
--radius-lg: 13px;
|
||||||
--radius-xl: 14px;
|
--radius-xl: 18px;
|
||||||
--radius-full: 9999px;
|
--radius-full: 9999px;
|
||||||
|
|
||||||
/* Spacing */
|
/* Spacing */
|
||||||
@@ -100,16 +105,16 @@
|
|||||||
--z-titlebar: 500;
|
--z-titlebar: 500;
|
||||||
|
|
||||||
/* Motion */
|
/* Motion */
|
||||||
--motion-fast: 120ms cubic-bezier(0.4, 0, 0.2, 1);
|
--motion-fast: 120ms cubic-bezier(0.2, 0, 0, 1);
|
||||||
--motion-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
--motion-normal: 190ms cubic-bezier(0.2, 0, 0, 1);
|
||||||
--motion-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
--motion-slow: 260ms cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
|
||||||
/* Layout */
|
/* Layout */
|
||||||
--titlebar-height: 38px;
|
--titlebar-height: 42px;
|
||||||
--sidebar-primary-width: 52px;
|
--sidebar-primary-width: 58px;
|
||||||
--sidebar-primary-width-expanded: 180px;
|
--sidebar-primary-width-expanded: 180px;
|
||||||
--sidebar-secondary-width: 260px;
|
--sidebar-secondary-width: 272px;
|
||||||
--statusbar-height: 26px;
|
--statusbar-height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='dark'] {
|
[data-theme='dark'] {
|
||||||
@@ -152,6 +157,10 @@
|
|||||||
--color-border-focus: #7d8bff;
|
--color-border-focus: #7d8bff;
|
||||||
--color-border-disabled: #21262d;
|
--color-border-disabled: #21262d;
|
||||||
|
|
||||||
|
--color-markdown-grid: #6e7681;
|
||||||
|
--color-markdown-marker: #c9d1d9;
|
||||||
|
--color-markdown-table-header: #21262d;
|
||||||
|
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
|
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||||
@@ -172,12 +181,29 @@
|
|||||||
--color-text-tertiary: #9a8a72;
|
--color-text-tertiary: #9a8a72;
|
||||||
--color-border-default: #ddcfad;
|
--color-border-default: #ddcfad;
|
||||||
--color-border-subtle: #eadfc4;
|
--color-border-subtle: #eadfc4;
|
||||||
|
--color-markdown-grid: #9c8353;
|
||||||
|
--color-markdown-marker: #554735;
|
||||||
|
--color-markdown-table-header: #eadbb8;
|
||||||
--color-accent-primary: #8a5b32;
|
--color-accent-primary: #8a5b32;
|
||||||
--color-accent-primary-hover: #704724;
|
--color-accent-primary-hover: #704724;
|
||||||
--color-accent-soft: #edddbd;
|
--color-accent-soft: #edddbd;
|
||||||
--color-text-link: #7b512e;
|
--color-text-link: #7b512e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-code-theme='github-light'] {
|
||||||
|
--color-code-background: #f6f8fa;
|
||||||
|
--color-code-border: #d0d7de;
|
||||||
|
--color-code-text: #24292f;
|
||||||
|
--color-code-muted: #57606a;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-code-theme='github-dark'] {
|
||||||
|
--color-code-background: #161b22;
|
||||||
|
--color-code-border: #30363d;
|
||||||
|
--color-code-text: #c9d1d9;
|
||||||
|
--color-code-muted: #8b949e;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -221,6 +247,25 @@ button {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
a,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='checkbox'],
|
||||||
|
input[type='radio'],
|
||||||
|
input[type='range'] {
|
||||||
|
accent-color: var(--color-accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-border-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
textarea,
|
textarea,
|
||||||
select {
|
select {
|
||||||
@@ -261,7 +306,25 @@ ol {
|
|||||||
color: var(--color-accent-primary);
|
color: var(--color-accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[data-theme='dark'] ::selection {
|
[data-theme='dark'] ::selection {
|
||||||
background: var(--color-accent-primary);
|
background: var(--color-accent-primary);
|
||||||
color: var(--color-text-inverse);
|
color: var(--color-text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
:root {
|
||||||
|
--sidebar-primary-width-expanded: 160px;
|
||||||
|
--sidebar-secondary-width: 224px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { highlightCode } from './markdown'
|
||||||
|
|
||||||
|
describe('Shiki GitHub 双主题', () => {
|
||||||
|
it('一次渲染同时生成 GitHub Light 和 GitHub Dark 颜色变量', async () => {
|
||||||
|
const html = await highlightCode('const answer = 42', 'typescript')
|
||||||
|
|
||||||
|
expect(html).toContain('github-light')
|
||||||
|
expect(html).toContain('github-dark')
|
||||||
|
expect(html).toContain('--shiki-light')
|
||||||
|
expect(html).toContain('--shiki-dark')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -16,6 +16,7 @@ import githubLight from '@shikijs/themes/github-light'
|
|||||||
|
|
||||||
marked.setOptions({ gfm: true, breaks: true })
|
marked.setOptions({ gfm: true, breaks: true })
|
||||||
|
|
||||||
|
// Highlighter 是昂贵的单例;复用初始化 Promise,避免每个代码块重复加载语法与主题。
|
||||||
const highlighter = createHighlighterCore({
|
const highlighter = createHighlighterCore({
|
||||||
themes: [githubLight, githubDark],
|
themes: [githubLight, githubDark],
|
||||||
langs: [markdown, html, css, javascript, typescript, json, python, shell, sql],
|
langs: [markdown, html, css, javascript, typescript, json, python, shell, sql],
|
||||||
@@ -47,5 +48,8 @@ export async function renderMarkdown(source: string): Promise<string> {
|
|||||||
code.parentElement?.replaceWith(fragment)
|
code.parentElement?.replaceWith(fragment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Markdown 可能来自模型或外部笔记,高亮完成后仍必须在最终出口统一净化。
|
||||||
return DOMPurify.sanitize(documentNode.body.innerHTML, { USE_PROFILES: { html: true } })
|
return DOMPurify.sanitize(documentNode.body.innerHTML, { USE_PROFILES: { html: true } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO(performance): 编辑器首屏稳定后评估将 Shiki 延迟加载或迁移到 Web Worker。
|
||||||
|
|||||||
Reference in New Issue
Block a user