Compare commits

...
51 changed files with 1115 additions and 149 deletions
+4 -1
View File
@@ -118,7 +118,7 @@ cd frontend
pnpm test pnpm test
``` ```
当前回归基线为后端 71 项测试、前端 14 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 当前回归基线为后端 71 项测试、前端 23 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
构建产物位于 `frontend/dist`,该目录不提交到 Git。 构建产物位于 `frontend/dist`,该目录不提交到 Git。
@@ -128,6 +128,7 @@ pnpm test
| --- | --- | | --- | --- |
| [技术栈说明](docs/AI笔记软件技术栈说明-团队版-v2.2.md) | 目标架构、当前实施边界与模块依赖 | | [技术栈说明](docs/AI笔记软件技术栈说明-团队版-v2.2.md) | 目标架构、当前实施边界与模块依赖 |
| [第一阶段分工表](docs/第一阶段分工表.md) | 成员职责、协作关系与当前交付状态 | | [第一阶段分工表](docs/第一阶段分工表.md) | 成员职责、协作关系与当前交付状态 |
| [第一阶段测试验证操作手册](docs/第一阶段测试验证操作手册.md) | 自动化测试、接口主链路、前端人工验收与记录模板 |
| [后端接口契约](docs/后端接口契约-开发版.md) | HTTP/SSE 接口、错误和当前实现状态 | | [后端接口契约](docs/后端接口契约-开发版.md) | HTTP/SSE 接口、错误和当前实现状态 |
| [AI Core 与 Agent Core](docs/AI-Core与Agent-Core开发说明.md) | Provider、Agent、Tool、Permission 与 Extension Core | | [AI Core 与 Agent Core](docs/AI-Core与Agent-Core开发说明.md) | Provider、Agent、Tool、Permission 与 Extension Core |
| [Knowledge 与 Retrieval Core](docs/Knowledge与Retrieval-Core开发说明.md) | Block、索引、混合检索和 Citation | | [Knowledge 与 Retrieval Core](docs/Knowledge与Retrieval-Core开发说明.md) | Block、索引、混合检索和 Citation |
@@ -135,7 +136,9 @@ pnpm test
| [前端页面需求](docs/前端页面需求说明-开发版.md) | 页面、交互、状态与验收基线 | | [前端页面需求](docs/前端页面需求说明-开发版.md) | 页面、交互、状态与验收基线 |
| [前端实现说明](docs/前端壳子与接口层开发说明.md) | 当前前端目录、Service、SSE 和运行边界 | | [前端实现说明](docs/前端壳子与接口层开发说明.md) | 当前前端目录、Service、SSE 和运行边界 |
| [前端写作体验](docs/前端写作体验优化开发说明.md) | Milkdown、CodeMirror、格式栏和 Shiki | | [前端写作体验](docs/前端写作体验优化开发说明.md) | Milkdown、CodeMirror、格式栏和 Shiki |
| [前端视觉与轻量动效](docs/前端视觉与轻量动效优化开发说明.md) | Design Token、页面美化、性能边界与主题注入约定 |
| [Git 使用细则](docs/Git使用细则-团队开发版.md) | 分支、提交、PR、Review 与合并流程 | | [Git 使用细则](docs/Git使用细则-团队开发版.md) | 分支、提交、PR、Review 与合并流程 |
| [代码注释与 TODO 约定](docs/代码注释与TODO约定.md) | 注释原则、TODO 格式、领域标签与当前待办索引 |
| [后端审阅复盘](docs/后端全面审阅问题与修复复盘.md) | 后端问题原因、后果与修复方案 | | [后端审阅复盘](docs/后端全面审阅问题与修复复盘.md) | 后端问题原因、后果与修复方案 |
| [Knowledge/Retrieval 复盘](docs/Knowledge与Retrieval-Core问题与修复复盘.md) | 检索与事务问题复盘 | | [Knowledge/Retrieval 复盘](docs/Knowledge与Retrieval-Core问题与修复复盘.md) | 检索与事务问题复盘 |
| [前端审阅复盘](docs/前端合并审阅问题与修复复盘.md) | 前端工程、契约和交互问题复盘 | | [前端审阅复盘](docs/前端合并审阅问题与修复复盘.md) | 前端工程、契约和交互问题复盘 |
+6
View File
@@ -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,6 +99,7 @@ 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
+13
View File
@@ -1,3 +1,5 @@
"""Agent 运行时:负责模型轮次、工具调用、权限确认与事件发布。"""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@@ -50,6 +52,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
@@ -60,6 +64,8 @@ class RunRecord:
class AgentRuntime: class AgentRuntime:
"""进程内 Agent 编排器;对外返回深拷贝,避免调用方修改运行状态。"""
def __init__( def __init__(
self, self,
providers: ProviderRegistry, providers: ProviderRegistry,
@@ -98,6 +104,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
@@ -142,6 +149,8 @@ class AgentRuntime:
async def events(self, run_id: str) -> AsyncIterator[AgentEvent]: async def events(self, run_id: str) -> AsyncIterator[AgentEvent]:
record = self._get_record(run_id) record = self._get_record(run_id)
# 先回放快照再订阅实时事件,使晚加入的 SSE 客户端也能恢复界面状态。
# TODO(agent): 持久化事件并支持 Last-Event-ID,进程重启后仍可续传。
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 = [event.model_copy(deep=True) for event in record.events]
@@ -243,6 +252,7 @@ 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:
@@ -313,6 +323,7 @@ class AgentRuntime:
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(
@@ -408,6 +419,7 @@ class AgentRuntime:
timestamp=datetime.now(timezone.utc), timestamp=datetime.now(timezone.utc),
) )
record.events.append(event) record.events.append(event)
# 内存事件只保留最近窗口;完整审计轨迹应由后续持久化层承担。
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:
@@ -448,6 +460,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
+6 -1
View File
@@ -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,
+4
View File
@@ -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()
+7
View File
@@ -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:
@@ -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")
+43
View File
@@ -0,0 +1,43 @@
# 代码注释与 TODO 约定
本文用于统一团队在前后端代码中编写注释和待办项的方式。注释应解释设计意图、边界条件和不明显的取舍,不重复代码本身已经清楚表达的内容。
## 注释原则
- 模块或核心类说明其职责和边界,例如 Agent 编排器、工具执行边界、凭据存储边界。
- 异步流程说明顺序、快照、去重、回滚和竞态处理原因。
- 安全相关流程说明默认拒绝、权限收敛、输入净化和凭据优先级。
- 简单赋值、显然的条件判断、类型定义和展示模板不添加翻译式注释。
- 注释随实现一并维护;实现变化后已经失真的注释应在同一提交中修改或删除。
## TODO 格式
前端使用:
```ts
// TODO(editor): 描述尚未完成的能力、完成条件或替换目标。
```
后端使用:
```python
# TODO(agent): 描述尚未完成的能力、完成条件或替换目标。
```
领域标签使用小写英文,当前约定包括 `agent``ai-core``chat``desktop``editor``extension``performance``security``streaming`。一个 TODO 应对应真实存在的工程缺口;小型清理工作直接完成,不长期保留无负责人、无目标的占位待办。
## 当前待办索引
以下内容可通过 `rg "TODO\\(" backend/app frontend/src` 定位,代码中的注释是最新状态:
| 领域 | 当前边界 |
| --- | --- |
| Agent / Streaming | 运行事件仍在进程内保存,后续需要持久化、`Last-Event-ID` 和断线重连 |
| Security | 本地主密钥目前保存在数据目录,桌面端接入后迁移到系统凭据库 |
| Extension | 扩展安装状态尚未持久化;MCP Host、进程隔离、签名与来源校验属于第二阶段 |
| AI Core | 音频转写当前只读取文本或 Host 预生成旁路文本,后续接入本地 ASR 队列 |
| Desktop | Workspace 仍使用 Web Mock,后续由 Tauri IPC 文件系统适配器替换 |
| Editor / Chat | 待补文件冲突合并、受控链接对话框及会话持久化 |
| Performance | Shiki 已复用单例,后续按首屏指标评估延迟加载或 Web Worker |
TODO 完成后应删除对应代码注释并同步更新本索引;若工作超过一个提交,应建立 Issue,并在 Issue 中引用代码位置,而不是在源码中记录长篇设计讨论。
+7 -1
View File
@@ -71,6 +71,12 @@ Milkdown 自定义插件在写作模式中隐藏 HTML 标记,并通过 ProseMi
代码高亮使用 Shiki 的 JavaScript 正则引擎,并只注册第一阶段常用语言:Markdown、HTML、CSS、JavaScript、TypeScript、JSON、Python、Shell 和 SQL。未知语言回退为 Markdown 语法展示,不阻塞整篇内容渲染。 代码高亮使用 Shiki 的 JavaScript 正则引擎,并只注册第一阶段常用语言:Markdown、HTML、CSS、JavaScript、TypeScript、JSON、Python、Shell 和 SQL。未知语言回退为 Markdown 语法展示,不阻塞整篇内容渲染。
Shiki 同时生成 `github-light``github-dark` 两套 CSS 变量。主题页提供“跟随主题 / GitHub Light / GitHub Dark”选项,通过根节点 `data-code-theme` 切换对应变量,无需重新执行高亮。偏好写入 `editor-appearance`,内置主题和后续主题包也可通过 `ThemeConfig.code_theme` 指定默认代码主题。
代码主题选择器下方使用真实的 `MarkdownContent` 和 Shiki 渲染 TypeScript 示例,选项变化后立即展示对应 GitHub 高亮效果。该预览只存在于主题设置页,不会恢复写作页代码块的额外预览面板。
代码块容器使用 GitHub 风格的背景、边框、6px 圆角、16px 内边距和等宽字体;相关颜色由 `--color-code-*` Token 控制,方便主题商店覆盖。
高亮结果同时生成 `github-light``github-dark` 颜色变量。根节点的 `data-theme` 变化后由 CSS 选择对应颜色,因此切换主题无需重新解析整篇 Markdown。 高亮结果同时生成 `github-light``github-dark` 颜色变量。根节点的 `data-theme` 变化后由 CSS 选择对应颜色,因此切换主题无需重新解析整篇 Markdown。
写作编辑器中的普通代码块进入文档后直接展开 CodeMirror 编辑区,不再先显示 Shiki 预览,也不再提供“编辑代码/查看高亮”切换,减少一次多余操作。公式块仍由 Milkdown 的 LaTeX 功能负责编辑和渲染。 写作编辑器中的普通代码块进入文档后直接展开 CodeMirror 编辑区,不再先显示 Shiki 预览,也不再提供“编辑代码/查看高亮”切换,减少一次多余操作。公式块仍由 Milkdown 的 LaTeX 功能负责编辑和渲染。
@@ -109,7 +115,7 @@ pnpm test
文件切换失效包含两层原因。第一层是旧实现先更新 `currentFilePath`、后等待文件内容,导致编辑器使用新路径和旧内容提前重建;现在改为文件读取成功后一次性提交路径和内容。第二层是工作区欢迎笔记的异步初始化结束后会无条件设为活动文件,可能覆盖用户在此期间的真实点击;现在点击文件时立即同步工作区活动路径,默认初始化仅在用户尚未选择文件且欢迎笔记确实加载成功时提交。文件读取失败时则恢复点击前的活动文件。 文件切换失效包含两层原因。第一层是旧实现先更新 `currentFilePath`、后等待文件内容,导致编辑器使用新路径和旧内容提前重建;现在改为文件读取成功后一次性提交路径和内容。第二层是工作区欢迎笔记的异步初始化结束后会无条件设为活动文件,可能覆盖用户在此期间的真实点击;现在点击文件时立即同步工作区活动路径,默认初始化仅在用户尚未选择文件且欢迎笔记确实加载成功时提交。文件读取失败时则恢复点击前的活动文件。
本地内置浏览器测试运行时因环境资源路径缺失未能启动,因此本次没有把自动化交互测试列为已通过项。合并前建议人工检查一次工具栏选区操作、文件切换同步和亮暗主题下的代码块显示 本地内置浏览器测试运行时因环境资源路径缺失未能启动,因此本次没有把自动化交互测试列为已通过项。合并前建议人工检查一次工具栏选区操作、文件切换同步,以及跟随主题、GitHub Light、GitHub Dark 三种代码块设置下的显示效果
## 5. 后续建议 ## 5. 后续建议
+2 -2
View File
@@ -184,13 +184,13 @@ pnpm build
```text ```text
pnpm build passed pnpm build passed
pnpm test 14 passed pnpm test 23 passed
uv run pytest 71 passed uv run pytest 71 passed
preview smoke HTTP 200 preview smoke HTTP 200
git diff --check passed git diff --check passed
``` ```
当前前端使用 Vitest 执行 Store、Workspace、文件树编辑器组件测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 71 项测试结果,也不涉及产品代码。 当前前端使用 Vitest 执行 Store、Workspace、文件树编辑器组件、智能体标签、轻量动效约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 71 项测试结果,也不涉及产品代码。
Vite 当前会提示 Chat 与 Workspace 的部分异步 Chunk 超过 500 kB,这是 Milkdown、CodeMirror、KaTeX 和 Shiki 等编辑/渲染依赖带来的性能优化项,不影响构建成功或功能正确性;进入桌面打包前应通过手动分包或更细粒度动态加载继续优化。 Vite 当前会提示 Chat 与 Workspace 的部分异步 Chunk 超过 500 kB,这是 Milkdown、CodeMirror、KaTeX 和 Shiki 等编辑/渲染依赖带来的性能优化项,不影响构建成功或功能正确性;进入桌面打包前应通过手动分包或更细粒度动态加载继续优化。
@@ -0,0 +1,113 @@
# 前端视觉与轻量动效优化开发说明
> 更新日期:2026-08-30
> 适用范围:全局 Design Token、App Shell、功能页、卡片、表单、弹窗和轻量交互动效
## 1. 目标
本轮优化不改变页面功能和前后端契约,主要解决原界面层级偏平、组件间距不统一、交互反馈不足的问题,同时为后续主题商店 CSS 注入保留稳定边界。
设计原则:
- 颜色、圆角、阴影、间距和速度继续使用 CSS 变量;
- 页面与弹窗动画只改变 `opacity``transform`
- 不使用背景模糊、连续粒子、视差、复杂 SVG 或大范围布局动画;
- 不使用 `transition: all`,只声明需要变化的属性;
- 尊重系统 `prefers-reduced-motion` 设置;
- 主题只需覆盖现有 Token,不需要了解组件内部动画实现。
## 2. 全局视觉基线
更新 `tokens.css`
- 调整四级圆角和阴影,使卡片、弹窗与导航层次更清楚;
- 调整标题栏、状态栏和双侧栏尺寸;
- 统一更短的运动时间与缓动曲线;
- 增加键盘 `focus-visible` 焦点环;
- 为 checkbox、radio 和 range 使用主题强调色;
- 窄窗口下收缩辅助侧栏与展开导航宽度;
- 在减少动态效果模式下,把动画和过渡缩短到近似即时完成。
更新 `features.css`
- 功能页增加受控内容宽度、响应式留白和低强度主题渐变;
- 卡片增加边框、阴影与最多 2px 的悬浮位移;
- 按钮、输入框、Badge、空状态和通知统一交互反馈;
- 设置页导航改为分段式卡片导航;
- 弹窗与页面增加一次性淡入和轻微位移动画。
## 3. 页面与布局优化
### 3.1 App Shell
- 主侧栏增加明确的 Active 标记、悬浮反馈和宽度过渡;
- 辅助侧栏统一为次级 Surface,并改善标题与标签层级;
- 标题栏应用名改为轻量胶囊标识;
- 状态栏强化状态点和窄窗口降级;
- 命令面板增加轻量入场、圆角、阴影和列表反馈。
### 3.2 业务页面
- Search 结果卡片增加左侧强调线和统一内容宽度;
- Chat 增加消息容器、头像层级、Citation 悬浮反馈和 Composer 顶部阴影;
- Agent Tool 选择卡增加选中状态,Trace 使用轻量时间线;
- Task 完成按钮增加主题化状态反馈;
- Settings 行在悬浮时提供背景提示;
- Workspace 空状态与 Vault 入口增加清晰的层级和一次性入场动画。
## 4. 动效性能边界
允许的常规动效:
```text
opacity
transform: translate / scale / rotate
background-color
border-color
color
box-shadow
```
默认禁止:
```text
transition: all
backdrop-filter / filter 模糊
持续改变 width / height / margin / padding 的动画
无限循环的装饰动画
全屏高频渐变或粒子动画
```
状态栏 Spinner 和 AI Core 检查状态点属于有明确状态含义的循环动画,并会被 `prefers-reduced-motion` 全局规则降级。
## 5. 主题商店接入约定
自定义主题优先覆盖以下 Token
- `--color-background-*`
- `--color-surface-*`
- `--color-text-*`
- `--color-accent-*`
- `--color-border-*`
- `--color-code-*`
- `--shadow-*`
- `--radius-*`
- `--motion-*`
主题 CSS 不应给通配选择器增加动画,不应重新启用高成本滤镜,也不应覆盖 `prefers-reduced-motion` 的降级规则。若主题需要完全静态的界面,可把三个 `--motion-*` Token 设置为接近 0ms。
## 6. 验证
新增 `styles/motion.spec.ts`,防止全局样式重新引入 `transition: all`、高成本模糊滤镜或布局型页面入场动画,并约束 Markdown 表格与列表使用独立的高对比度主题变量。
当前验证结果:
```text
pnpm test 9 files / 23 tests passed
pnpm build passed
git diff --check passed
```
生产构建仍有已有的大 Chunk 警告,主要来自 Milkdown、CodeMirror、KaTeX 和 Shiki;本轮样式及动效未增加 JavaScript 动画库或运行时依赖。
本轮已完成样式静态检查、自动化测试和生产构建。由于本机内置浏览器运行资源路径缺失,亮色/暗色主题的人工页面巡检需在 PR 验收环境补做。
@@ -0,0 +1,376 @@
# 第一阶段测试验证操作手册
> 适用基线:2026-08-30 `main`
> 适用对象:开发、自测、代码审阅、合并验收和 Demo 前检查
> 验证范围:Vue Web 前端、FastAPI、Knowledge/Retrieval Core、AI/Agent Core、Extension Core、Provider 与开发阶段凭据链路
## 1. 验证目标
本手册用于确认第一阶段已经形成可运行的本地知识工作流:
```text
启动前后端
→ 编辑 Markdown
→ 建立或更新索引
→ Search / RAG 返回 Citation
→ Chat 或 Agent 调用统一 Provider
→ Agent 展示 Trace、Tool 和 Permission
→ Skill / Plugin 完成生命周期与 Tool 注册
```
当前不作为第一阶段通过条件的内容:Tauri/Rust Host、Stronghold、真实桌面文件系统、独立 MCP Plugin Host、真实音频模型和 Sync Server。
## 2. 环境准备
最低环境:
| 工具 | 要求 |
| --- | --- |
| Git | 较新稳定版 |
| Node.js | 22 或更高版本 |
| pnpm | 10 或更高版本 |
| Python | 3.11 或更高版本 |
| uv | 较新稳定版 |
在仓库根目录检查版本:
```powershell
git --version
node --version
pnpm --version
python --version
uv --version
```
同步依赖:
```powershell
cd backend
uv sync --frozen
cd ../frontend
pnpm install --frozen-lockfile
cd ..
```
`uv sync` 会自动创建和管理 `backend/.venv`,不需要手动创建或激活虚拟环境。
## 3. 自动化验收
### 3.1 后端测试
```powershell
cd backend
uv run pytest -q -p no:cacheprovider
```
当前基线:
```text
71 passed
```
通过标准:退出码为 0、失败数为 0。用例数可以随功能增加,但不得低于当前基线。
### 3.2 前端测试
```powershell
cd frontend
pnpm test
```
当前基线:
```text
9 test files passed
23 tests passed
```
通过标准:退出码为 0、失败数为 0。测试覆盖 Provider Store、主题偏好、Workspace、文件树、文件切换、可视化编辑器、智能体中文标签、轻量动效性能约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题输出。
### 3.3 类型检查与生产构建
```powershell
cd frontend
pnpm build
```
通过标准:`vue-tsc -b``vite build` 均成功,生成 `frontend/dist`。当前较大的编辑器与 Markdown Chunk 会产生体积警告,该警告不等于构建失败,但应记录在验收结果中。
### 3.4 Git 与文档检查
```powershell
cd ..
git diff --check
git status --short
```
通过标准:`git diff --check` 没有错误。测试产生的 `.venv``node_modules``dist`、凭据和运行数据不得进入提交。
## 4. 启动联调环境
打开两个 PowerShell 终端。
终端 A
```powershell
cd backend
uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
```
终端 B
```powershell
cd frontend
pnpm dev
```
访问:
- 前端:<http://127.0.0.1:5173>
- 健康检查:<http://127.0.0.1:8000/health>
- 服务状态:<http://127.0.0.1:8000/api/status>
- Swagger UI<http://127.0.0.1:8000/docs>
- OpenAPI<http://127.0.0.1:8000/openapi.json>
快速检查:
```powershell
Invoke-RestMethod http://127.0.0.1:8000/health
Invoke-RestMethod http://127.0.0.1:8000/api/status
```
如果 `/docs` 返回 `RESOURCE_NOT_FOUND`,检查启动命令是否在 `backend` 目录执行、端口 8000 是否被其他程序占用,以及浏览器地址是否确实为 `http://127.0.0.1:8000/docs`
## 5. 后端主链路验证
以下命令在第三个 PowerShell 终端执行,保持后端运行。
### 5.1 Note 创建、读取与检索
```powershell
$apiBase = 'http://127.0.0.1:8000/api'
$noteBody = @{
title = '第一阶段验收笔记'
markdown = "# 第一阶段验收`n`nNotes Agent 支持混合检索和可定位引用。"
folder = '验收'
tags = @('phase-1', 'verification')
} | ConvertTo-Json
$note = Invoke-RestMethod -Method Post -Uri "$apiBase/notes" -ContentType 'application/json' -Body $noteBody
$noteId = $note.note_id
Invoke-RestMethod -Uri "$apiBase/notes/$noteId"
```
执行搜索:
```powershell
$searchBody = @{
query = '混合检索'
mode = 'hybrid'
note_ids = @($noteId)
limit = 10
offset = 0
include_snippet = $true
} | ConvertTo-Json
$search = Invoke-RestMethod -Method Post -Uri "$apiBase/search" -ContentType 'application/json' -Body $searchBody
$search.items | Format-Table title, file_path, snippet
```
通过标准:创建响应包含稳定 `note_id`;读取内容一致;搜索至少返回一项,并包含 `note_id``block_id`、文件路径、Snippet 和 Citation 定位信息。
### 5.2 Index 状态与重建
```powershell
Invoke-RestMethod -Uri "$apiBase/index/status"
$indexJob = Invoke-RestMethod -Method Post -Uri "$apiBase/index/rebuild" -ContentType 'application/json' -Body '{"scope":"all","force":false}'
$indexJob
Invoke-RestMethod -Uri "$apiBase/index/jobs/$($indexJob.job_id)"
```
通过标准:状态接口可访问;重建任务最终为 `completed`。重建失败时应返回统一错误并保留可恢复状态,不应留下半成品索引。
### 5.3 Chat SSE
使用内置 Mock Provider,不需要外部 API Key
```powershell
curl.exe --no-buffer -X POST "http://127.0.0.1:8000/api/chat" -H "Content-Type: application/json" --data-raw '{"provider_id":"mock","model":"mock-1","messages":[{"role":"user","content":"请回复第一阶段 Chat 验收成功"}],"use_rag":false}'
```
通过标准:响应类型为 `text/event-stream`,能看到递增 `sequence``TextDelta`,并以 `Done` 终止;不得一次性伪装为流式结果。
### 5.4 Agent Run 与 Trace
```powershell
$runBody = @{
input = '执行第一阶段 Agent 基础验证'
provider_id = 'mock'
model = 'mock-1'
allowed_tools = @('system.echo', 'math.add')
max_steps = 10
tool_timeout_seconds = 30
run_timeout_seconds = 300
max_concurrent_tools = 1
allow_network = $false
} | ConvertTo-Json
$run = Invoke-RestMethod -Method Post -Uri "$apiBase/agent/runs" -ContentType 'application/json' -Body $runBody
Start-Sleep -Milliseconds 300
$runResult = Invoke-RestMethod -Uri "$apiBase/agent/runs/$($run.run_id)"
$runResult
```
订阅事件也可以使用:
```powershell
curl.exe --no-buffer "http://127.0.0.1:8000/api/agent/runs/$($run.run_id)/events"
```
通过标准:Run 最终为 `completed`,Trace 至少包含开始、文本或工具事件和完成事件;事件序号递增。需要权限的 Tool 应进入 `waiting_permission`,用户允许、会话允许或拒绝后能正确恢复或终止。
### 5.5 Tool、Skill 与 Plugin
```powershell
Invoke-RestMethod -Uri "$apiBase/tools"
Invoke-RestMethod -Uri "$apiBase/skills"
Invoke-RestMethod -Uri "$apiBase/plugins"
```
通过标准:内置 Tool Definition 能被列出;内置知识助手 Skill 和示例 Plugin 状态可读取;未知权限、缺失依赖和畸形 Manifest 必须被拒绝,不能静默启用。
### 5.6 Provider 与模型发现
```powershell
Invoke-RestMethod -Uri "$apiBase/providers"
Invoke-RestMethod -Uri "$apiBase/providers/presets"
Invoke-RestMethod -Uri "$apiBase/providers/mock/models"
```
通过标准:预设至少包含 OpenAI、DeepSeek 和 OllamaMock Provider 能返回模型列表。OpenAI/DeepSeek 属于选测项,需要测试人员自己的有效 API Key,真实密钥不得写入命令历史、文档、Issue、截图或提交。
如需验证外部模型,优先在“设置 → 模型提供商”中选择预设并填写 API Key。页面不得回显明文;后端 `GET /api/credentials/{credential_id}` 只返回 `configured` 状态。测试完成后可在 Swagger 中调用对应 DELETE 接口删除测试凭据。
## 6. 前端人工验收
### 6.1 App Shell 与主题
- 主导航、辅助侧栏、标题栏和状态栏正常显示;
- `Ctrl+P` 能打开命令面板并跳转页面;
- 亮色、暗色和护眼主题切换后文字、表格线、列表序号和浮动工具栏均清晰;
- 导航使用统一图标,不出现无意义 Emoji;
- 窄窗口下主要操作仍可访问。
### 6.2 Workspace 与 Markdown
- 能新建、打开、重命名和删除 Web Mock 文件;
- 连续快速点击不同文件时,路径和正文始终一致;
- 文件切换前的未保存内容不会被错误写入新文件;
- 写作模式不展示 Markdown 源码,源码模式可以精确编辑;
- H1–H6、正文、粗体、斜体、有序/无序列表、行内代码、代码块、行内/块公式、链接和字号输入均能修改 Markdown;
- 选择文本后,顶部工具栏和浮动工具栏都对当前选区生效;
- 正文不默认加粗,标题默认加粗;
- 代码块默认展开编辑,不显示额外 Shiki 预览;
- 主题页可在跟随主题、GitHub Light 和 GitHub Dark 间切换代码块样式,刷新后偏好仍保留;
- Chat 等只读 Markdown 区域的代码高亮能跟随亮暗主题;
- 表格、公式和 Markdown HTML 渲染正常,危险 HTML 被 DOMPurify 清理。
### 6.3 Search、Chat 与 Citation
- FTS、Vector 和 Hybrid 查询可切换;
- 向量服务不可用时能降级到 FTS 并显示说明;
- Chat 能展示 Streaming、Thinking、Tool Call、Usage、错误与 Citation
- 点击 Citation 后能打开对应笔记并定位内容;
- 取消生成后页面状态恢复,不继续追加旧请求内容。
### 6.4 智能体页面
- 能新建、查看、切换和取消智能体运行;
- Provider、模型、Skill、Tool 和运行限制可配置;
- 运行状态、事件类型、工具说明、权限弹窗和常用详情字段显示中文;
- `notes.search` 等技术 ID 保留显示,便于与日志对应;
- Permission Request 不会跨 Run 残留;
- Completed、Failed、Cancelled 和连接中断状态均有明确反馈。
### 6.5 设置与扩展管理
- Provider 预设可选择,保存后自动获取模型,也能手动刷新和选择默认模型;
- 凭据缺失和 HTTP 401 会显示可理解的错误,不只显示笼统网络失败;
- Skill、Plugin 的安装、启用、停用、权限和删除操作状态一致;
- AI Core 诊断页能显示健康状态和开发 API 地址;
- 前端不会在 Store、Local Storage 或页面中保存、回显 API Key 明文。
## 7. 清理测试数据
删除本手册创建的验收笔记:
```powershell
Invoke-RestMethod -Method Delete -Uri "$apiBase/notes/$noteId"
```
如果测试了外部 Provider,还应删除临时 Provider 和不再使用的测试凭据。不要直接递归删除整个 `backend/data`,其中可能包含其他成员的本地 Vault、索引和任务数据。
## 8. 通过判定
第一阶段可以标记为“验证通过”需要同时满足:
- 后端测试零失败;
- 前端测试零失败;
- TypeScript 检查和生产构建成功;
- 健康检查、OpenAPI 和主要接口可访问;
- Note → Index/Search → Citation 主链路通过;
- Mock Chat 和 Agent Trace 主链路通过;
- Tool、Skill、Plugin 和 Provider 基础接口通过;
- 前端人工验收没有 P0/P1 缺陷;
- 没有真实密钥、生成目录或运行数据进入 Git;
- 已记录测试环境、提交、结果、警告和遗留问题。
外部 OpenAI/DeepSeek、Tauri、Stronghold、真实文件系统、真实音频模型与 Sync Server 失败或未测,不阻止当前第一阶段 Web 联调基线通过,但必须在验收记录中注明“未纳入本阶段”或“选测未执行”。
## 9. 验收记录模板
```markdown
# 第一阶段验收记录
- 日期:
- 验收人:
- 分支:main
- 提交:
- 操作系统:
- Node / pnpm
- Python / uv
## 自动化结果
- 后端 pytest:通过 / 失败,数量:
- 前端 Vitest:通过 / 失败,数量:
- 前端 build:通过 / 失败:
- git diff --check:通过 / 失败:
## 主链路
- Health / OpenAPI
- Note / Search / Citation
- Chat SSE
- Agent Run / Trace / Permission
- Tool / Skill / Plugin
- Provider / Models
- 前端页面人工验收:
## 选测项
- OpenAI:未测 / 通过 / 失败
- DeepSeek:未测 / 通过 / 失败
- Ollama:未测 / 通过 / 失败
## 警告与遗留问题
-
## 最终结论
- 通过 / 有条件通过 / 不通过
```
+1 -1
View File
@@ -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>
+2 -1
View File
@@ -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>
+9 -2
View File
@@ -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>
+22 -9
View File
@@ -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>
+1
View File
@@ -329,6 +329,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 ============
+9 -3
View File
@@ -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; }
+11 -6
View File
@@ -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>
@@ -30,9 +30,9 @@ afterEach(() => {
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()
+3 -1
View File
@@ -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>
+4 -1
View File
@@ -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; } }
+4 -3
View File
@@ -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); }
+27 -2
View File
@@ -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>
+29 -7
View File
@@ -68,7 +68,7 @@ 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>
@@ -161,7 +161,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 +173,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 +181,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 +215,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 +256,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 +299,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 +321,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 +335,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);
} }
} }
@@ -400,6 +416,7 @@ async function createVault() {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: var(--z-modal); z-index: var(--z-modal);
animation: dialog-backdrop-in var(--motion-fast) both;
} }
.dialog { .dialog {
@@ -409,8 +426,13 @@ async function createVault() {
width: 90%; width: 90%;
max-width: 400px; max-width: 400px;
box-shadow: var(--shadow-xl); box-shadow: var(--shadow-xl);
animation: dialog-in var(--motion-normal) both;
} }
@keyframes entry-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@keyframes dialog-backdrop-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes dialog-in { from { opacity: 0; transform: translateY(8px) scale(.985); } to { opacity: 1; transform: translateY(0) scale(1); } }
.dialog h3 { .dialog h3 {
margin: 0 0 var(--space-lg) 0; margin: 0 0 var(--space-lg) 0;
font-size: 18px; font-size: 18px;
@@ -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>
@@ -15,10 +15,10 @@ onMounted(() => {
// Already loaded // Already loaded
} }
if (!workspaceStore.activeFilePath && workspaceStore.fileTree.length === 0) { if (!workspaceStore.activeFilePath && workspaceStore.fileTree.length === 0) {
void editorStore.loadFile('/欢迎使用知笔知己.md').then(() => { void editorStore.loadFile('/欢迎使用 NotesAgent.md').then(() => {
// //
if (!workspaceStore.activeFilePath && editorStore.currentFilePath === '/欢迎使用知笔知己.md') { if (!workspaceStore.activeFilePath && editorStore.currentFilePath === '/欢迎使用 NotesAgent.md') {
workspaceStore.openFile('/欢迎使用知笔知己.md') workspaceStore.openFile('/欢迎使用 NotesAgent.md')
} }
}) })
} }
@@ -59,6 +59,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 +80,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>
+1 -1
View File
@@ -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
}) })
+2
View File
@@ -3,6 +3,7 @@ import { SseClient } from './sseClient'
import type { AgentRun, AgentEvent, ApiAgentRun, OperationResponse, PageMeta, ToolDefinition, PermissionRequest } from '@/contracts' import type { AgentRun, AgentEvent, 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,
@@ -67,6 +68,7 @@ export function streamAgentEvents(
onOpen?: () => void onOpen?: () => void
} }
): 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`,
method: 'GET', method: 'GET',
+2
View File
@@ -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
+2 -2
View File
@@ -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'],
+2 -2
View File
@@ -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 },
+3
View File
@@ -54,6 +54,7 @@ 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 dataLines: string[] = [] let dataLines: string[] = []
@@ -117,6 +118,8 @@ export class SseClient {
this.controller.abort() this.controller.abort()
} }
// TODO(streaming): Agent 事件持久化后,增加 Last-Event-ID 与指数退避重连。
isConnected() { isConnected() {
return this.connected return this.connected
} }
+9 -6
View File
@@ -1,7 +1,7 @@
import type { FileNode } from '@/contracts' import type { FileNode } from '@/contracts'
// Mock workspace service for web dev mode // Web 开发模式使用内存实现,服务签名保持与未来桌面文件系统适配器一致。
// In Tauri environment this will use Tauri IPC commands // TODO(desktop): Tauri Host 就绪后通过 IPC 替换 Mock,并保留路径规范化与错误映射。
export interface VaultInfo { export interface VaultInfo {
path: string path: string
@@ -58,7 +58,7 @@ const MOCK_FILE_TREE: FileNode[] = [
{ id: 'n-http', name: 'HTTP协议.md', path: '/计算机网络/HTTP协议.md', type: 'file' }, { id: 'n-http', name: 'HTTP协议.md', path: '/计算机网络/HTTP协议.md', type: 'file' },
], ],
}, },
{ id: 'n-welcome', name: '欢迎使用知笔知己.md', path: '/欢迎使用知笔知己.md', type: 'file' }, { id: 'n-welcome', name: '欢迎使用 NotesAgent.md', path: '/欢迎使用 NotesAgent.md', type: 'file' },
] ]
const mockFileContents = new Map<string, string>() const mockFileContents = new Map<string, string>()
@@ -89,8 +89,8 @@ export function readFileContent(filePath: string): Promise<string> {
const saved = mockFileContents.get(filePath) const saved = mockFileContents.get(filePath)
if (saved !== undefined) return Promise.resolve(saved) if (saved !== undefined) return Promise.resolve(saved)
const name = filePath.split('/').pop() || 'Untitled' const name = filePath.split('/').pop() || 'Untitled'
if (name === '欢迎使用知笔知己.md') { if (name === '欢迎使用 NotesAgent.md') {
return rememberContent(filePath, `# 欢迎使用知笔知己 return rememberContent(filePath, `# 欢迎使用 NotesAgent
AI Markdown RAG Agent AI Markdown RAG Agent
@@ -207,7 +207,7 @@ def quick_sort(arr):
> >
\`\`\`javascript \`\`\`javascript
console.log('Hello, Notes Agent!'); console.log('Hello, NotesAgent!');
\`\`\` \`\`\`
`) `)
} }
@@ -251,5 +251,8 @@ export function deleteFile(path: string): Promise<void> {
} }
export function moveFile(sourcePath: string, targetPath: string): Promise<void> { export function moveFile(sourcePath: string, targetPath: string): Promise<void> {
// Mock 文件树由 Store 同步更新;真实实现必须在 Host 侧执行原子移动。
void sourcePath
void targetPath
return Promise.resolve() return Promise.resolve()
} }
+2
View File
@@ -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
+3
View File
@@ -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,
+4
View File
@@ -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)
+47
View File
@@ -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')
})
})
+33 -5
View File
@@ -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,
+1
View File
@@ -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}/`)) {
+67 -26
View File
@@ -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); }
+44
View File
@@ -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+\./)
})
})
+78 -15
View File
@@ -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;
}
}
+13
View File
@@ -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')
})
})
+4
View File
@@ -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。