merge: 补充前后端代码注释与TODO约定
This commit is contained in:
@@ -118,7 +118,7 @@ cd frontend
|
|||||||
pnpm test
|
pnpm test
|
||||||
```
|
```
|
||||||
|
|
||||||
当前回归基线为后端 71 项测试、前端 14 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
|
当前回归基线为后端 71 项测试、前端 23 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
|
||||||
|
|
||||||
构建产物位于 `frontend/dist`,该目录不提交到 Git。
|
构建产物位于 `frontend/dist`,该目录不提交到 Git。
|
||||||
|
|
||||||
@@ -138,6 +138,7 @@ pnpm test
|
|||||||
| [前端写作体验](docs/前端写作体验优化开发说明.md) | Milkdown、CodeMirror、格式栏和 Shiki |
|
| [前端写作体验](docs/前端写作体验优化开发说明.md) | Milkdown、CodeMirror、格式栏和 Shiki |
|
||||||
| [前端视觉与轻量动效](docs/前端视觉与轻量动效优化开发说明.md) | Design Token、页面美化、性能边界与主题注入约定 |
|
| [前端视觉与轻量动效](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) | 前端工程、契约和交互问题复盘 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ class SkillRuntime:
|
|||||||
self._records: dict[str, _SkillRecord] = {}
|
self._records: dict[str, _SkillRecord] = {}
|
||||||
|
|
||||||
def install(self, package_path: str | Path) -> Skill:
|
def install(self, package_path: str | Path) -> Skill:
|
||||||
|
# TODO(extension): 将安装记录持久化,应用重启后从可信包目录恢复状态。
|
||||||
root = _package_dir(package_path)
|
root = _package_dir(package_path)
|
||||||
raw = _read_yaml(root / "skill.yaml")
|
raw = _read_yaml(root / "skill.yaml")
|
||||||
if "id" in raw and "skill_id" not in raw:
|
if "id" in raw and "skill_id" not in raw:
|
||||||
@@ -252,6 +253,7 @@ class PluginRuntime:
|
|||||||
self._records: dict[str, _PluginRecord] = {}
|
self._records: dict[str, _PluginRecord] = {}
|
||||||
|
|
||||||
def install(self, package_path: str | Path) -> Plugin:
|
def install(self, package_path: str | Path) -> Plugin:
|
||||||
|
# 当前只加载声明式清单,不导入或执行插件包中的任意 Python 代码。
|
||||||
root = _package_dir(package_path)
|
root = _package_dir(package_path)
|
||||||
raw = _read_yaml(root / "plugin.yaml")
|
raw = _read_yaml(root / "plugin.yaml")
|
||||||
if "id" in raw and "plugin_id" not in raw:
|
if "id" in raw and "plugin_id" not in raw:
|
||||||
@@ -315,6 +317,7 @@ class PluginRuntime:
|
|||||||
if record.plugin.enabled:
|
if record.plugin.enabled:
|
||||||
return record.plugin.model_copy(deep=True)
|
return record.plugin.model_copy(deep=True)
|
||||||
if record.plugin.manifest.backend.type == "mcp":
|
if record.plugin.manifest.backend.type == "mcp":
|
||||||
|
# TODO(extension): 第二阶段以隔离进程实现 MCP Host,并补充签名与来源校验。
|
||||||
record.plugin.status = PluginStatus.dependency_missing
|
record.plugin.status = PluginStatus.dependency_missing
|
||||||
raise ExtensionError(
|
raise ExtensionError(
|
||||||
"PLUGIN_HOST_UNAVAILABLE",
|
"PLUGIN_HOST_UNAVAILABLE",
|
||||||
@@ -366,6 +369,7 @@ class PluginRuntime:
|
|||||||
)
|
)
|
||||||
record.registered_tools.append(spec.name)
|
record.registered_tools.append(spec.name)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
# 注册过程必须具备回滚语义,防止半启用插件污染全局工具表。
|
||||||
for name in record.registered_tools:
|
for name in record.registered_tools:
|
||||||
self.registry.unregister(name)
|
self.registry.unregister(name)
|
||||||
record.registered_tools.clear()
|
record.registered_tools.clear()
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"""Provider 凭据解析及本地加密存储。"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -43,6 +45,8 @@ class EnvironmentCredentialResolver:
|
|||||||
class EncryptedCredentialStore:
|
class EncryptedCredentialStore:
|
||||||
"""将本地开发凭据作为 Fernet 密文存储,Provider 使用时按 ID 解密。"""
|
"""将本地开发凭据作为 Fernet 密文存储,Provider 使用时按 ID 解密。"""
|
||||||
|
|
||||||
|
# TODO(security): 桌面 Host 接入后将主密钥迁移到系统钥匙串/凭据保险库。
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
@@ -75,6 +79,7 @@ class EncryptedCredentialStore:
|
|||||||
key_path.parent.mkdir(parents=True, exist_ok=True)
|
key_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self._restrict(key_path.parent, 0o700)
|
self._restrict(key_path.parent, 0o700)
|
||||||
if not key_path.exists():
|
if not key_path.exists():
|
||||||
|
# 先写临时文件再原子替换,避免异常退出留下半截主密钥。
|
||||||
temporary = key_path.with_suffix(".tmp")
|
temporary = key_path.with_suffix(".tmp")
|
||||||
temporary.write_bytes(Fernet.generate_key())
|
temporary.write_bytes(Fernet.generate_key())
|
||||||
self._restrict(temporary, 0o600)
|
self._restrict(temporary, 0o600)
|
||||||
@@ -112,6 +117,7 @@ class EncryptedCredentialStore:
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
self._restrict(temporary, 0o600)
|
self._restrict(temporary, 0o600)
|
||||||
|
# 凭据表同样使用原子替换,确保并发读取只会看到完整 JSON。
|
||||||
temporary.replace(store_path)
|
temporary.replace(store_path)
|
||||||
self._restrict(store_path, 0o600)
|
self._restrict(store_path, 0o600)
|
||||||
|
|
||||||
@@ -158,6 +164,7 @@ class ChainedCredentialResolver:
|
|||||||
self._resolvers = resolvers
|
self._resolvers = resolvers
|
||||||
|
|
||||||
def resolve(self, credential_id: str | None) -> str | None:
|
def resolve(self, credential_id: str | None) -> str | None:
|
||||||
|
# 顺序即优先级:调用方可让 Host 注入值覆盖本地开发凭据。
|
||||||
for resolver in self._resolvers:
|
for resolver in self._resolvers:
|
||||||
value = resolver.resolve(credential_id)
|
value = resolver.resolve(credential_id)
|
||||||
if value:
|
if value:
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"""转写适配层;第一阶段消费文本附件或桌面 Host 预生成的旁路文本。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
@@ -13,6 +15,7 @@ MAX_JOBS = 100
|
|||||||
|
|
||||||
|
|
||||||
def create_transcription(attachment_id: str, language: str | None = None) -> TranscriptionJob:
|
def create_transcription(attachment_id: str, language: str | None = None) -> TranscriptionJob:
|
||||||
|
# TODO(ai-core): 第二阶段接入本地 ASR 队列后,保留相同 Job 契约替换此同步降级实现。
|
||||||
del language # 预生成 transcript 暂不需要语言识别。
|
del language # 预生成 transcript 暂不需要语言识别。
|
||||||
source = attachment_path(attachment_id)
|
source = attachment_path(attachment_id)
|
||||||
transcript = source if source.suffix.lower() in {".txt", ".md"} else Path(f"{source}.txt")
|
transcript = source if source.suffix.lower() in {".txt", ".md"} else Path(f"{source}.txt")
|
||||||
|
|||||||
@@ -0,0 +1,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 中引用代码位置,而不是在源码中记录长篇设计讨论。
|
||||||
@@ -39,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),
|
||||||
@@ -55,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
|
||||||
|
|
||||||
@@ -162,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)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export const useAgentStore = defineStore('agent', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function processEvent(event: AgentEvent) {
|
function processEvent(event: AgentEvent) {
|
||||||
|
// 服务端会先回放历史再发送实时事件,以 run_id + sequence 去重保证幂等。
|
||||||
if (events.value.some((item) => item.run_id === event.run_id && item.sequence === event.sequence)) return
|
if (events.value.some((item) => item.run_id === event.run_id && item.sequence === event.sequence)) return
|
||||||
events.value.push(event)
|
events.value.push(event)
|
||||||
events.value.sort((a, b) => a.sequence - b.sequence)
|
events.value.sort((a, b) => a.sequence - b.sequence)
|
||||||
@@ -100,6 +101,7 @@ export const useAgentStore = defineStore('agent', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function subscribe(runId: string) {
|
function subscribe(runId: string) {
|
||||||
|
// 任一时刻只保留当前运行的事件流,防止切换详情后旧事件污染新页面。
|
||||||
eventStream?.cancel()
|
eventStream?.cancel()
|
||||||
isRunning.value = true
|
isRunning.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
const selectedModel = ref('mock-1')
|
const selectedModel = ref('mock-1')
|
||||||
let sseClient: SseClient | null = null
|
let sseClient: SseClient | null = null
|
||||||
|
|
||||||
|
// TODO(chat): 会话持久化接口完成后移除 mockConversations/mockMessages 数据源。
|
||||||
|
|
||||||
const activeConversation = computed(() =>
|
const activeConversation = computed(() =>
|
||||||
conversations.value.find((c) => c.conversation_id === activeConversationId.value) || null
|
conversations.value.find((c) => c.conversation_id === activeConversationId.value) || null
|
||||||
)
|
)
|
||||||
@@ -56,6 +58,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
inputText.value = ''
|
inputText.value = ''
|
||||||
isStreaming.value = true
|
isStreaming.value = true
|
||||||
|
|
||||||
|
// 先插入占位消息,随后将 SSE 增量原位合并,避免每个 token 重建消息列表。
|
||||||
const aiMsg: ChatMessage = {
|
const aiMsg: ChatMessage = {
|
||||||
message_id: `msg-${Date.now() + 1}`,
|
message_id: `msg-${Date.now() + 1}`,
|
||||||
conversation_id: conversationId,
|
conversation_id: conversationId,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
async function save() {
|
async function save() {
|
||||||
if (!currentFilePath.value) return
|
if (!currentFilePath.value) return
|
||||||
if (pendingSave) return pendingSave
|
if (pendingSave) return pendingSave
|
||||||
|
// 保存路径与正文都取快照;请求完成时用户可能已继续输入或切换文件。
|
||||||
const targetPath = currentFilePath.value
|
const targetPath = currentFilePath.value
|
||||||
const snapshot = content.value
|
const snapshot = content.value
|
||||||
saveStatus.value = 'saving'
|
saveStatus.value = 'saving'
|
||||||
@@ -82,6 +83,7 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') {
|
if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') {
|
||||||
throw new Error('当前文件保存失败,已阻止切换以避免内容丢失。')
|
throw new Error('当前文件保存失败,已阻止切换以避免内容丢失。')
|
||||||
}
|
}
|
||||||
|
// 版本号使较慢的旧读取不能覆盖用户后选择的新文件。
|
||||||
const version = ++loadVersion
|
const version = ++loadVersion
|
||||||
const previousStatus = saveStatus.value
|
const previousStatus = saveStatus.value
|
||||||
saveStatus.value = 'saving'
|
saveStatus.value = 'saving'
|
||||||
@@ -117,6 +119,8 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO(editor): 桌面文件监听接入后提供冲突对比/合并界面,而非只阻止切换。
|
||||||
|
|
||||||
function closeFile() {
|
function closeFile() {
|
||||||
loadVersion++
|
loadVersion++
|
||||||
if (saveTimer) clearTimeout(saveTimer)
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ 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 {
|
||||||
@@ -90,6 +91,7 @@ export const useThemeStore = defineStore('theme', () => {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
watch(resolvedCodeBlockTheme, (theme) => {
|
watch(resolvedCodeBlockTheme, (theme) => {
|
||||||
|
// CSS 与 Shiki 共用该属性,确保代码块背景和 token 配色始终成套切换。
|
||||||
document.documentElement.setAttribute('data-code-theme', theme)
|
document.documentElement.setAttribute('data-code-theme', theme)
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
|
|||||||
@@ -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}/`)) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import githubLight from '@shikijs/themes/github-light'
|
|||||||
|
|
||||||
marked.setOptions({ gfm: true, breaks: true })
|
marked.setOptions({ gfm: true, breaks: true })
|
||||||
|
|
||||||
|
// Highlighter 是昂贵的单例;复用初始化 Promise,避免每个代码块重复加载语法与主题。
|
||||||
const highlighter = createHighlighterCore({
|
const highlighter = createHighlighterCore({
|
||||||
themes: [githubLight, githubDark],
|
themes: [githubLight, githubDark],
|
||||||
langs: [markdown, html, css, javascript, typescript, json, python, shell, sql],
|
langs: [markdown, html, css, javascript, typescript, json, python, shell, sql],
|
||||||
@@ -47,5 +48,8 @@ export async function renderMarkdown(source: string): Promise<string> {
|
|||||||
code.parentElement?.replaceWith(fragment)
|
code.parentElement?.replaceWith(fragment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Markdown 可能来自模型或外部笔记,高亮完成后仍必须在最终出口统一净化。
|
||||||
return DOMPurify.sanitize(documentNode.body.innerHTML, { USE_PROFILES: { html: true } })
|
return DOMPurify.sanitize(documentNode.body.innerHTML, { USE_PROFILES: { html: true } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO(performance): 编辑器首屏稳定后评估将 Shiki 延迟加载或迁移到 Web Worker。
|
||||||
|
|||||||
Reference in New Issue
Block a user