Compare commits

...
7 Commits
Author SHA1 Message Date
yxxandClaude Code 64af1f5165 fix(export): 修复 PR #17 审阅问题(1 P1 + 5 P2)
- P1 链接/图片 URL 协议白名单校验,危险协议降级为纯文本 + warning
- P2 图片 AST 字段映射(src=attrs.url,alt 取 children 文本)
- P2 原始 HTML 块转义保留,正文不丢失 + warning
- P2 过期/淘汰/重启清理导出产物文件
- P2 解析与渲染移入 asyncio.to_thread,运行中取消生效
- P2 function-plot 围栏别名补全
- 回归测试覆盖全部修复

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 22:24:16 +08:00
yxx 7eae7fba00 Merge remote-tracking branch 'origin/main' into feat/export-service 2026-09-04 10:59:52 +08:00
Kronecker e52e909c41 Merge pull request 'Fix/frontend live data' (#16) from fix/frontend-live-data into main
Reviewed-on: #16
2026-09-04 08:34:50 +08:00
admin 8480ed7f5e fix(chat): 阻止页面卸载后的异步初始化修改模型选择 2026-09-04 08:30:45 +08:00
admin 150cf0d994 fix(chat): 保留页面切换后的提供商与模型选择 2026-09-04 08:24:49 +08:00
admin 9f621371b8 fix(frontend): 汉化MCP工具展示并折叠原始说明 2026-09-04 07:52:53 +08:00
admin c04f4c1989 fix(frontend): 移除运行时演示数据并接入真实后端状态 2026-09-04 07:47:05 +08:00
44 changed files with 775 additions and 898 deletions
+2
View File
@@ -1000,6 +1000,8 @@ class TranscriptionJob(Contract):
class IndexStatus(Contract): class IndexStatus(Contract):
total_notes: int = 0
total_blocks: int = 0
status: Literal["idle", "queued", "running", "failed"] = "idle" status: Literal["idle", "queued", "running", "failed"] = "idle"
pending_jobs: int = 0 pending_jobs: int = 0
active_job_id: str | None = None active_job_id: str | None = None
+44 -7
View File
@@ -8,12 +8,28 @@ from __future__ import annotations
import html import html
from datetime import datetime from datetime import datetime
from urllib.parse import urlparse
from app.contracts import ExportOptions from app.contracts import ExportOptions
from app.export.document import Document, DocumentNode, ExportResult from app.export.document import Document, DocumentNode, ExportResult
_MERMAID_WARNING = "mermaid 需前端渲染,已保留为占位代码块" _MERMAID_WARNING = "mermaid 需前端渲染,已保留为占位代码块"
_FUNCTION_PLOT_WARNING = "函数图像渲染将在后续版本提供,已保留为占位代码块" _FUNCTION_PLOT_WARNING = "函数图像渲染将在后续版本提供,已保留为占位代码块"
_RAW_HTML_WARNING = "原始 HTML 已按纯文本转义保留"
# 链接/图片地址允许的协议;无 scheme 的相对地址视为安全,其余协议一律降级
_ALLOWED_URL_SCHEMES = frozenset({"http", "https", "mailto"})
def _safe_url(url: str) -> str | None:
"""校验 URL 协议;安全返回原串,不安全返回 None。"""
url = url.strip()
if not url:
return None
scheme = urlparse(url).scheme.lower()
if scheme and scheme not in _ALLOWED_URL_SCHEMES:
return None
return url
_BASE_CSS = """ _BASE_CSS = """
body { margin: 0; background: #f6f7f9; color: #1f2328; font: 15px/1.7 -apple-system, 'Segoe UI', 'Microsoft YaHei', sans-serif; } body { margin: 0; background: #f6f7f9; color: #1f2328; font: 15px/1.7 -apple-system, 'Segoe UI', 'Microsoft YaHei', sans-serif; }
@@ -46,7 +62,8 @@ hr { border: none; border-top: 1px solid #d0d7de; margin: 1.4em 0; }
class HtmlExporter: class HtmlExporter:
"""实现 DocumentExporter:递归渲染 Document AST 为完整 HTML5 文档。""" """实现 DocumentExporter:递归渲染 Document AST 为完整 HTML5 文档。"""
async def export(self, document: Document, options: ExportOptions) -> ExportResult: def render(self, document: Document, options: ExportOptions) -> ExportResult:
"""同步渲染;CPU 密集,调用方应放入线程执行,避免阻塞事件循环。"""
self._options = options self._options = options
warnings: list[str] = [] warnings: list[str] = []
body = self._render_children(document.children, warnings) body = self._render_children(document.children, warnings)
@@ -55,6 +72,10 @@ class HtmlExporter:
content=content.encode("utf-8"), mime_type="text/html", warnings=warnings content=content.encode("utf-8"), mime_type="text/html", warnings=warnings
) )
async def export(self, document: Document, options: ExportOptions) -> ExportResult:
"""契约要求的 async 接口;渲染本身同步,直接转发到 render。"""
return self.render(document, options)
def _assemble( def _assemble(
self, document: Document, options: ExportOptions, body: str, warnings: list[str] self, document: Document, options: ExportOptions, body: str, warnings: list[str]
) -> str: ) -> str:
@@ -179,6 +200,11 @@ class HtmlExporter:
def _render_math_block(self, node: DocumentNode, warnings: list[str]) -> str: def _render_math_block(self, node: DocumentNode, warnings: list[str]) -> str:
return f'<div class="math-block">$${html.escape(node.text)}$$</div>' return f'<div class="math-block">$${html.escape(node.text)}$$</div>'
def _render_html_block(self, node: DocumentNode, warnings: list[str]) -> str:
# 原始 HTML 不可信,转义为纯文本展示,保证正文不丢且无注入风险
warnings.append(_RAW_HTML_WARNING)
return f'<div class="raw-html">{html.escape(node.text)}</div>'
# --- 行内 --- # --- 行内 ---
def _render_text(self, node: DocumentNode, warnings: list[str]) -> str: def _render_text(self, node: DocumentNode, warnings: list[str]) -> str:
return html.escape(node.text) return html.escape(node.text)
@@ -190,21 +216,32 @@ class HtmlExporter:
return f"<strong>{self._render_children(node.children, warnings)}</strong>" return f"<strong>{self._render_children(node.children, warnings)}</strong>"
def _render_link(self, node: DocumentNode, warnings: list[str]) -> str: def _render_link(self, node: DocumentNode, warnings: list[str]) -> str:
href = html.escape(str(node.attributes.get("href") or "")) inner = self._render_children(node.children, warnings)
href = str(node.attributes.get("href") or "")
safe_href = _safe_url(href)
if safe_href is None:
# 危险协议(如 javascript:)降级为纯文本,不输出可点击链接
warnings.append(f"链接协议不安全,已降级为纯文本:{href!r}")
return inner
title = str(node.attributes.get("title") or "") title = str(node.attributes.get("title") or "")
attrs = [f'href="{href}"'] attrs = [f'href="{html.escape(safe_href)}"']
if title: if title:
attrs.append(f'title="{html.escape(title)}"') attrs.append(f'title="{html.escape(title)}"')
return f"<a {' '.join(attrs)}>{self._render_children(node.children, warnings)}</a>" return f"<a {' '.join(attrs)}>{inner}</a>"
def _render_codespan(self, node: DocumentNode, warnings: list[str]) -> str: def _render_codespan(self, node: DocumentNode, warnings: list[str]) -> str:
return f"<code>{html.escape(node.text)}</code>" return f"<code>{html.escape(node.text)}</code>"
def _render_image(self, node: DocumentNode, warnings: list[str]) -> str: def _render_image(self, node: DocumentNode, warnings: list[str]) -> str:
src = html.escape(str(node.attributes.get("src") or "")) src = str(node.attributes.get("src") or "")
alt = html.escape(str(node.attributes.get("alt") or "")) alt = str(node.attributes.get("alt") or "")
safe_src = _safe_url(src)
if safe_src is None:
# 危险协议(如 data:/javascript:)跳过图片,仅输出 alt 文本
warnings.append(f"图片地址不安全,已跳过:{src!r}")
return html.escape(alt) if alt else ""
title = str(node.attributes.get("title") or "") title = str(node.attributes.get("title") or "")
attrs = [f'src="{src}"', f'alt="{alt}"'] attrs = [f'src="{html.escape(safe_src)}"', f'alt="{html.escape(alt)}"']
if title: if title:
attrs.append(f'title="{html.escape(title)}"') attrs.append(f'title="{html.escape(title)}"')
return f"<img {' '.join(attrs)}>" return f"<img {' '.join(attrs)}>"
+21 -6
View File
@@ -15,7 +15,7 @@ _PLUGINS = ["table", "math", "url", "task_lists"]
# fenced code 语言分流:命中则转为专用节点,其余按普通代码块 # fenced code 语言分流:命中则转为专用节点,其余按普通代码块
_MERMAID_LANG = "mermaid" _MERMAID_LANG = "mermaid"
_FUNCTION_PLOT_LANGS = {"function_plot", "functionplot"} _FUNCTION_PLOT_LANGS = {"function-plot", "function_plot", "functionplot"}
def parse_document(markdown: str) -> Document: def parse_document(markdown: str) -> Document:
@@ -85,10 +85,19 @@ class _AstMapper:
return DocumentNode(type="thematic_break", node_id=self.next_id()) return DocumentNode(type="thematic_break", node_id=self.next_id())
if kind == "blank_line": if kind == "blank_line":
return None return None
# 未知块级 token(如 block_html)保守保留原文,避免静默丢失 if kind == "block_html":
# 原始 HTML 块降级为纯文本节点,由 HtmlExporter 转义并记 warning,避免静默丢失正文
return DocumentNode(
type="html_block", node_id=self.next_id(), text=token.get("raw", "")
)
# 未知块级 token 保守保留原文;映射为带 text 子节点的 paragraph,避免被渲染层丢弃
raw = token.get("raw", "") raw = token.get("raw", "")
if raw: if raw:
return DocumentNode(type="paragraph", node_id=self.next_id(), text=raw) return DocumentNode(
type="paragraph",
node_id=self.next_id(),
children=[DocumentNode(type="text", node_id=self.next_id(), text=raw)],
)
return None return None
def map_list_item(self, token: dict) -> DocumentNode: def map_list_item(self, token: dict) -> DocumentNode:
@@ -144,10 +153,16 @@ class _AstMapper:
if kind == "codespan": if kind == "codespan":
return DocumentNode(type="codespan", node_id=self.next_id(), text=token.get("raw", "")) return DocumentNode(type="codespan", node_id=self.next_id(), text=token.get("raw", ""))
if kind == "image": if kind == "image":
# mistune 图片 tokensrc 在 attrs.urlalt 来自 children 的文本,title 在 attrs.title
attrs = token.get("attrs", {}) attrs = token.get("attrs", {})
attributes = {"src": attrs.get("src", "")} alt = "".join(
if attrs.get("alt"): child.get("raw", "")
attributes["alt"] = attrs["alt"] for child in token.get("children", [])
if child.get("type") == "text"
)
attributes = {"src": attrs.get("url", "")}
if alt:
attributes["alt"] = alt
if attrs.get("title"): if attrs.get("title"):
attributes["title"] = attrs["title"] attributes["title"] = attrs["title"]
return DocumentNode(type="image", node_id=self.next_id(), attributes=attributes) return DocumentNode(type="image", node_id=self.next_id(), attributes=attributes)
+42 -6
View File
@@ -28,7 +28,7 @@ from app.contracts import (
ExportStatus, ExportStatus,
) )
from app.errors import ApiError from app.errors import ApiError
from app.export.document import Document from app.export.document import Document, ExportResult
from app.export.exporters.html import HtmlExporter from app.export.exporters.html import HtmlExporter
from app.export.markdown import parse_document from app.export.markdown import parse_document
from app.services import note_service from app.services import note_service
@@ -61,10 +61,44 @@ def _safe_download_name(title: str) -> str:
return name[:80] return name[:80]
def _export_path(job_id: str) -> Path:
return get_settings().exports_path / f"{job_id}.html"
def _delete_file(job_id: str) -> None:
"""删除导出产物文件;文件不存在时忽略。"""
try:
_export_path(job_id).unlink(missing_ok=True)
except OSError:
logger.warning("Failed to delete export file: %s", job_id)
def cleanup_orphan_files() -> int:
"""清理 exports 目录下无对应内存任务的孤立产物(服务重启后调用)。"""
exports_dir = get_settings().exports_path
if not exports_dir.is_dir():
return 0
removed = 0
for path in exports_dir.glob("*.html"):
if path.stem not in _jobs:
try:
path.unlink()
removed += 1
except OSError:
logger.warning("Failed to delete orphan export file: %s", path)
return removed
def _render_document(document: Document, options: ExportOptions) -> ExportResult:
"""同步渲染辅助,供 asyncio.to_thread 调用;每次新建实例避免跨线程复用。"""
return HtmlExporter().render(document, options)
def _forget(job_id: str) -> None: def _forget(job_id: str) -> None:
_jobs.pop(job_id, None) _jobs.pop(job_id, None)
_tasks.pop(job_id, None) _tasks.pop(job_id, None)
_cancel_flags.pop(job_id, None) _cancel_flags.pop(job_id, None)
_delete_file(job_id)
def _evict_terminal() -> bool: def _evict_terminal() -> bool:
@@ -166,19 +200,20 @@ async def _execute(
if cancel_event.is_set(): if cancel_event.is_set():
raise ExportCancelled() raise ExportCancelled()
document = parse_document(markdown) # 解析与渲染都是 CPU 密集的同步工作,放入线程执行避免阻塞事件循环,
# 使运行中的取消能在渲染边界生效;写文件前再次检查取消。
document = await asyncio.to_thread(parse_document, markdown)
document.attributes["title"] = title document.attributes["title"] = title
if metadata: if metadata:
document.attributes["metadata"] = metadata document.attributes["metadata"] = metadata
exporter = HtmlExporter() result = await asyncio.to_thread(_render_document, document, options)
result = await exporter.export(document, options)
if cancel_event.is_set(): if cancel_event.is_set():
raise ExportCancelled() raise ExportCancelled()
out_dir = get_settings().exports_path out_dir = get_settings().exports_path
out_dir.mkdir(parents=True, exist_ok=True) out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"{job_id}.html" path = _export_path(job_id)
path.write_bytes(result.content) path.write_bytes(result.content)
completed_at = _now() completed_at = _now()
@@ -260,8 +295,9 @@ def get_export_file(job_id: str) -> Path:
404, "EXPORT_JOB_NOT_FOUND", "export file not ready", {"job_id": job_id} 404, "EXPORT_JOB_NOT_FOUND", "export file not ready", {"job_id": job_id}
) )
if job.file.expires_at <= _now(): if job.file.expires_at <= _now():
_forget(job_id) # 过期即清理内存记录与产物文件
raise ApiError(410, "EXPORT_FILE_EXPIRED", "export file has expired", {"job_id": job_id}) raise ApiError(410, "EXPORT_FILE_EXPIRED", "export file has expired", {"job_id": job_id})
return get_settings().exports_path / f"{job_id}.html" return _export_path(job_id)
async def wait_for_export(job_id: str) -> ExportJob | None: async def wait_for_export(job_id: str) -> ExportJob | None:
+3
View File
@@ -8,6 +8,7 @@ from starlette.exceptions import HTTPException as StarletteHttpException
from app.config import get_settings from app.config import get_settings
from app.container import container from app.container import container
from app.errors import ApiError, api_error_handler, http_error_handler, validation_error_handler from app.errors import ApiError, api_error_handler, http_error_handler, validation_error_handler
from app.export import service as export_service
from app.routes import router as api_router from app.routes import router as api_router
from app.schemas import HealthResponse, ServiceStatusResponse from app.schemas import HealthResponse, ServiceStatusResponse
@@ -16,6 +17,8 @@ settings = get_settings()
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI): async def lifespan(_: FastAPI):
# 重启后内存注册表为空,清理上一次运行遗留的导出产物,避免磁盘垃圾堆积。
export_service.cleanup_orphan_files()
yield yield
# 第三方 MCP Server 必须跟随 AI Core 退出,不能遗留孤儿进程。 # 第三方 MCP Server 必须跟随 AI Core 退出,不能遗留孤儿进程。
container.plugins.shutdown() container.plugins.shutdown()
+7
View File
@@ -127,6 +127,13 @@ from app.services.attachment_service import attachment_path
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
@router.get("/permissions/policy", tags=["Permissions"])
async def get_permission_policy() -> dict[str, str]:
from app.agent.permissions import KNOWN_PERMISSIONS
return {permission: container.permissions.policy.mode_for(permission).value
for permission in sorted(KNOWN_PERMISSIONS)}
async def mcp_call_async(operation): async def mcp_call_async(operation):
"""Even registry reads can wait on lifecycle locks; keep all MCP work off the event loop.""" """Even registry reads can wait on lifecycle locks; keep all MCP work off the event loop."""
try: try:
+4 -1
View File
@@ -126,9 +126,12 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
def get_status() -> IndexStatus: def get_status() -> IndexStatus:
counts = repository.stats()
if _active_job_id is not None: if _active_job_id is not None:
return IndexStatus(status="running", pending_jobs=0, active_job_id=_active_job_id) return IndexStatus(status="running", pending_jobs=0, active_job_id=_active_job_id,
total_notes=counts["notes"], total_blocks=counts["blocks"])
return IndexStatus( return IndexStatus(
total_notes=counts["notes"], total_blocks=counts["blocks"],
status="failed" if _last_error else "idle", status="failed" if _last_error else "idle",
pending_jobs=0, pending_jobs=0,
last_completed_at=_last_completed_at, last_completed_at=_last_completed_at,
+111
View File
@@ -16,6 +16,7 @@ from pydantic import ValidationError
from app.config import get_settings from app.config import get_settings
from app.contracts import ( from app.contracts import (
ExportFormat, ExportFormat,
ExportJob,
ExportOptions, ExportOptions,
ExportRequest, ExportRequest,
ExportSource, ExportSource,
@@ -133,6 +134,21 @@ def test_parse_document_table_and_math() -> None:
assert "math_block" in kinds assert "math_block" in kinds
def test_parse_document_image_maps_src_alt_title() -> None:
doc = parse_document('![替代文本](https://a.b/img.png "标题")')
img = doc.children[0].children[0]
assert img.type == "image"
assert img.attributes["src"] == "https://a.b/img.png"
assert img.attributes["alt"] == "替代文本"
assert img.attributes["title"] == "标题"
def test_parse_document_function_plot_dash_alias() -> None:
doc = parse_document("```function-plot\ny = x^2\n```")
assert doc.children[0].type == "function_plot"
assert doc.children[0].text == "y = x^2"
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# HtmlExporter # HtmlExporter
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -162,6 +178,38 @@ def test_html_exporter_marks_mermaid_and_function_plot() -> None:
assert any("mermaid" in w for w in result.warnings) assert any("mermaid" in w for w in result.warnings)
def test_html_exporter_rejects_unsafe_link_protocol() -> None:
result = asyncio.run(
HtmlExporter().export(parse_document("[点我](javascript:alert(1))"), ExportOptions())
)
html = result.content.decode("utf-8")
assert "javascript:" not in html
assert "点我" in html
assert any("不安全" in w for w in result.warnings)
def test_html_exporter_rejects_unsafe_image_protocol() -> None:
result = asyncio.run(
HtmlExporter().export(parse_document("![alt](data:text/html,<script>)"), ExportOptions())
)
html = result.content.decode("utf-8")
assert "data:" not in html
assert "<img" not in html
assert "alt" in html
assert any("不安全" in w for w in result.warnings)
def test_html_exporter_preserves_raw_html_block() -> None:
result = asyncio.run(
HtmlExporter().export(parse_document("<div>重要正文</div>"), ExportOptions())
)
html = result.content.decode("utf-8")
assert "重要正文" in html
assert "<div>" not in html
assert "&lt;div&gt;重要正文&lt;/div&gt;" in html
assert any("原始 HTML" in w for w in result.warnings)
def test_html_exporter_include_title_and_metadata() -> None: def test_html_exporter_include_title_and_metadata() -> None:
doc = parse_document("正文") doc = parse_document("正文")
doc.attributes["title"] = "操作系统复习" doc.attributes["title"] = "操作系统复习"
@@ -274,10 +322,73 @@ def test_export_file_expired_410() -> None:
return job.job_id return job.job_id
job_id = asyncio.run(_go()) job_id = asyncio.run(_go())
path = get_settings().exports_path / f"{job_id}.html"
with pytest.raises(ApiError) as exc: with pytest.raises(ApiError) as exc:
export_service.get_export_file(job_id) export_service.get_export_file(job_id)
assert exc.value.status_code == 410 assert exc.value.status_code == 410
assert exc.value.code == "EXPORT_FILE_EXPIRED" assert exc.value.code == "EXPORT_FILE_EXPIRED"
assert not path.exists() # 过期即清理产物文件
assert export_service.get_export(job_id) is None # 内存记录一并清理
def test_export_eviction_deletes_file() -> None:
finished = _create_and_wait(_markdown_request("# 淘汰"))
victim_path = get_settings().exports_path / f"{finished.job_id}.html"
assert victim_path.exists()
# 塞满 MAX_JOBS 个终态任务,下一次 create 会淘汰最旧的终态(finished 最先插入)
for i in range(export_service.MAX_JOBS):
export_service._jobs[f"export_fake_{i}"] = ExportJob(
job_id=f"export_fake_{i}",
status=ExportStatus.completed,
format=ExportFormat.html,
created_at=datetime.now(timezone.utc),
)
_create_and_wait(_markdown_request("# 触发淘汰"))
assert not victim_path.exists()
def test_cleanup_orphan_files() -> None:
exports_dir = get_settings().exports_path
exports_dir.mkdir(parents=True, exist_ok=True)
orphan = exports_dir / "export_orphan.html"
orphan.write_text("stale", encoding="utf-8")
finished = _create_and_wait(_markdown_request("# 保留"))
keep_path = exports_dir / f"{finished.job_id}.html"
assert keep_path.exists()
removed = export_service.cleanup_orphan_files()
assert removed >= 1
assert not orphan.exists()
assert keep_path.exists() # 仍在注册表中的任务文件保留
def test_export_cancel_during_running(monkeypatch) -> None:
import threading
import time
real_parse = parse_document
started = threading.Event()
def slow_parse(markdown: str):
started.set()
time.sleep(0.1)
return real_parse(markdown)
monkeypatch.setattr(export_service, "parse_document", slow_parse)
async def _go():
job = await export_service.create_export(_markdown_request("# 运行中取消"))
while not started.is_set():
await asyncio.sleep(0)
export_service.cancel_export(job.job_id)
return await export_service.wait_for_export(job.job_id)
finished = asyncio.run(_go())
assert finished.status == ExportStatus.cancelled
assert finished.file is None
assert not (get_settings().exports_path / f"{finished.job_id}.html").exists()
def test_export_list_and_get() -> None: def test_export_list_and_get() -> None:
@@ -0,0 +1,31 @@
import asyncio
from fastapi.testclient import TestClient
from app.main import app
from app.container import container
from app.agent.permissions import PermissionMode
from app.services.note_service import create_note
def test_index_status_returns_real_counts():
with TestClient(app) as client:
initial = client.get('/api/index/status').json()
assert (initial['total_notes'], initial['total_blocks']) == (0, 0)
note = asyncio.run(create_note(title='Real note', markdown='# Real note\n\ncontent', folder=None, tags=[]))
result = client.get('/api/index/status').json()
assert result['total_notes'] == 1
assert result['total_blocks'] == len(note.blocks)
def test_permissions_endpoint_reads_effective_backend_policy():
policy = container.permissions.policy
original = policy.mode_for('attachments.read')
try:
policy.set_rule('attachments.read', PermissionMode.deny)
with TestClient(app) as client:
response = client.get('/api/permissions/policy')
assert response.status_code == 200
assert response.json()['attachments.read'] == 'deny'
finally:
policy.set_rule('attachments.read', original)
@@ -190,3 +190,9 @@ RunCancelled
- 接入业务模块时保持当前路径和 Contract,不在 Router 中直接实现数据库、Provider 或 Agent 逻辑。 - 接入业务模块时保持当前路径和 Contract,不在 Router 中直接实现数据库、Provider 或 Agent 逻辑。
第二阶段开发保持本文件中已有路径兼容,并按 `第二阶段接口契约-开发版.md` 增加子资源、可选字段和事件。接口完成后先更新 OpenAPI 与本文件,再将第二阶段文档中的状态改为已实现。 第二阶段开发保持本文件中已有路径兼容,并按 `第二阶段接口契约-开发版.md` 增加子资源、可选字段和事件。接口完成后先更新 OpenAPI 与本文件,再将第二阶段文档中的状态改为已实现。
### 前端真实状态补充(2026-09-04)
- `GET /api/index/status` 额外返回 `total_notes: int``total_blocks: int`,来自当前 SQLite 索引;未建立内容索引时为 0。
- `GET /api/permissions/policy` 返回 `Record<string, "allow" | "confirm" | "deny">`,值取自后端当前生效的 PermissionPolicy。此接口只读,不提供全局修改能力,运行时权限确认仍使用既有 Agent permission endpoint。
@@ -1,6 +1,6 @@
# 前端壳子与接口层开发说明 # 前端壳子与接口层开发说明
> 更新日期:2026-09-02 > 更新日期:2026-09-04
> 适用范围:Vue 3 + TypeScript 页面、Workspace、公共 Service、FastAPI 接口适配和 SSE。 > 适用范围:Vue 3 + TypeScript 页面、Workspace、公共 Service、FastAPI 接口适配和 SSE。
> 文档用途:帮助团队理解当前前端可用能力、模块边界、启动方式和后续页面开发入口。 > 文档用途:帮助团队理解当前前端可用能力、模块边界、启动方式和后续页面开发入口。
@@ -153,12 +153,7 @@ Service 已适配当前 FastAPI Contract
- 识别 `Done``RunCompleted``RunFailed``RunCancelled` - 识别 `Done``RunCompleted``RunFailed``RunCancelled`
- 支持 AbortController 主动取消。 - 支持 AbortController 主动取消。
Chat Store 已从定时器模拟输出切换为真实 `/api/chat` SSE。默认离线联调配置为: Chat Store 使用真实 `/api/chat` SSE。提供商从后端配置加载,前端不展示后端内置测试 Provider,也不预选模拟模型;模型 ID 使用所选提供商保存的默认值,并支持手动输入。
```text
provider_id = mock
model = mock-1
```
## 8. 环境和启动 ## 8. 环境和启动
@@ -207,3 +202,34 @@ Vite 当前会提示 Chat 与 Workspace 的部分异步 Chunk 超过 500 kB
- Workspace 接入 Tauri 后,需要增加路径规范化、写入失败恢复和外部修改冲突测试; - Workspace 接入 Tauri 后,需要增加路径规范化、写入失败恢复和外部修改冲突测试;
- 页面新增交互必须经过键盘、空状态、加载状态、错误状态和窄窗口检查; - 页面新增交互必须经过键盘、空状态、加载状态、错误状态和窄窗口检查;
- Workspace 的 Milkdown 写作模式与 CodeMirror 源码模式共享同一 Markdown 数据源;后续修改编辑器时不得改变 Store/Service 边界,并必须保留文件切换、自动保存和选区格式化回归测试。 - Workspace 的 Milkdown 写作模式与 CodeMirror 源码模式共享同一 Markdown 数据源;后续修改编辑器时不得改变 Store/Service 边界,并必须保留文件切换、自动保存和选区格式化回归测试。
## 阶段 F 前:前端真实数据清理
已删除运行时的聊天示例、Agent Run/Event/Tool/权限示例、Provider/Model、Task、Skill、Plugin、IndexStatus 常量和 searchMock。测试文件中的隔离桩保留,仅用于自动化验证。
- 所有业务 Store 从空集合开始,由真实 API 填充;连接失败显示错误,不回退演示记录。
- 普通聊天仅显示用户实际输入和 SSE 响应;当前会话列表保留在页面会话内,刷新后清空,后端暂无聊天历史持久化接口。切换会话保留本次会话内的真实消息,取消旧流并屏蔽迟到回调。
- 聊天页移除尚未接入的知识库与 Skill 开关,知识库工具和 Skill 通过 Agent 使用。
- 设置页不再伪造健康状态、版本、42 篇笔记/318 个 Block、模型名称和索引能力开关。状态未获取时显示 unknown/未获取;应用版本来自 package.json,后端版本来自 /api/status。
- GET /api/index/status 增加 total_notes、total_blocks,直接读取 SQLite 的当前索引统计。
- GET /api/permissions/policy 返回 PermissionPolicy 的实际生效值。设置页只读展示;全局策略编辑暂未开放,运行权限确认仍走原有 Agent 接口。
- 删除模拟重启成功逻辑,说明 Web 端不具备进程重启能力;索引页面只保留后端已实现的全量重建。
- Task DTO 不再填充后端未返回的优先级和来源,Agent Token 用量不再把未知输入/输出拆分填成 0。
- Plugin/Skill/Provider 无记录时显示空状态,模型发现失败时允许使用真实的手动模型 ID。
验证:前端 81 项测试、类型检查与生产构建通过;后端 454 项测试通过。新增测试覆盖空初始状态、离线错误、真实统计与权限、测试 Provider 过滤、真实聊天历史及旧流隔离。本次未调用真实付费推理 API。
### MCP 工具中文展示补充
Agent 工具列表按 `mcp.<server_id>.<remote_name>` 的远程工具名匹配中文展示,支持 `web_search`(网页搜索)、`understand_image`(图像理解),并补充 `text.uppercase`(文本转大写)。此映射只影响界面,工具调用与权限选择仍使用完整原始 ID。
卡片默认显示三行摘要,完整服务原文可展开查看,展开操作不会改变工具选择。服务已提供中文说明时优先保留;未收录的 MCP 工具明确提示暂无中文说明,不将本地摘要当作服务协议或自动翻译结果。原始说明及其中的参数规则完整保留。
验证:前端 84 项测试、类型检查与生产构建通过。新增回归覆盖不同服务器命名空间、未知工具、服务中文说明、原文完整性,以及选择工具时保留原始 ID。
### 聊天模型选择审阅修复
返回聊天页时保留仍启用的提供商与手动模型 ID,仅刷新其模型列表;未选择、已删除或已禁用的提供商才回退到默认值。提供商加载失败时保留当前选择并展示错误。新增页面重新挂载与异常分支回归,前端共 89 项测试通过。
补充卸载时序修复:提供商或技能加载期间离开聊天页后,旧页面的初始化回调不再修改聊天选择,迟到错误也不再更新旧页面。两种加载延迟均通过先失败、修复后通过的回归测试,并验证返回页面后的默认模型和发送按钮状态;前端共 91 项测试通过。
+2 -1
View File
@@ -39,11 +39,12 @@ const saveStatusColor = computed(() => {
const indexStatusText = computed(() => { const indexStatusText = computed(() => {
const s = settingsStore.indexStatus.status const s = settingsStore.indexStatus.status
return s === 'idle' ? '索引就绪' : s === 'indexing' ? `索引中 (${settingsStore.indexStatus.pending_jobs})` : '索引错误' return s === 'unknown' ? '索引状态未获取' : s === 'idle' ? '索引就绪' : s === 'indexing' ? `索引中 (${settingsStore.indexStatus.pending_jobs})` : '索引错误'
}) })
const aiCoreStatusText = computed(() => { const aiCoreStatusText = computed(() => {
const map: Record<string, string> = { const map: Record<string, string> = {
unknown: 'AI Core 状态未获取',
starting: 'AI Core 启动中', starting: 'AI Core 启动中',
running: 'AI Core 运行中', running: 'AI Core 运行中',
stopped: 'AI Core 已停止', stopped: 'AI Core 已停止',
+12 -10
View File
@@ -207,8 +207,8 @@ export interface PermissionRequest {
} }
export interface TokenUsage { export interface TokenUsage {
input_tokens: number input_tokens?: number
output_tokens: number output_tokens?: number
total_tokens: number total_tokens: number
} }
@@ -462,11 +462,11 @@ export interface TaskItem {
title: string title: string
description?: string description?: string
status: TaskStatus status: TaskStatus
priority: TaskPriority priority?: TaskPriority
due_date?: string due_date?: string
note_id?: string note_id?: string
note_title?: string note_title?: string
source: TaskSource source?: TaskSource
created_at: string created_at: string
updated_at: string updated_at: string
} }
@@ -487,12 +487,12 @@ export interface ThemeConfig {
// ============ Index ============ // ============ Index ============
export interface IndexStatus { export interface IndexStatus {
status: 'idle' | 'indexing' | 'error' status: 'unknown' | 'idle' | 'indexing' | 'error'
pending_jobs: number pending_jobs: number
total_notes: number total_notes: number | null
total_blocks: number total_blocks: number | null
fts_enabled: boolean fts_enabled?: boolean
vector_enabled: boolean vector_enabled?: boolean
embedding_model?: string embedding_model?: string
reranker_model?: string reranker_model?: string
last_indexed_at?: string last_indexed_at?: string
@@ -527,7 +527,7 @@ export type SaveStatus =
| 'external_changed' | 'external_changed'
| 'conflict' | 'conflict'
export type AiCoreStatus = 'starting' | 'running' | 'stopped' | 'error' export type AiCoreStatus = 'unknown' | 'starting' | 'running' | 'stopped' | 'error'
// ============ FastAPI wire contracts ============ // ============ FastAPI wire contracts ============
// UI view models above may contain presentation-only fields. Services must use // UI view models above may contain presentation-only fields. Services must use
@@ -777,6 +777,8 @@ export interface ApiTask {
} }
export interface ApiIndexStatus { export interface ApiIndexStatus {
total_notes: number
total_blocks: number
status: 'idle' | 'queued' | 'running' | 'failed' status: 'idle' | 'queued' | 'running' | 'failed'
pending_jobs: number pending_jobs: number
active_job_id?: string | null active_job_id?: string | null
+14 -14
View File
@@ -5,7 +5,8 @@ import { useAgentStore } from '@/stores/agent'
import { useProviderStore } from '@/stores/provider' import { useProviderStore } from '@/stores/provider'
import { useSkillStore } from '@/stores/skill' import { useSkillStore } from '@/stores/skill'
import type { AgentEvent } from '@/contracts' import type { AgentEvent } from '@/contracts'
import { eventLabel, localizeDetails, permissionLabel, runStatusLabel, toolDescription, toolLabel } from './labels' import { eventLabel, localizeDetails, permissionLabel, runStatusLabel, toolLabel } from './labels'
import ToolOption from './ToolOption.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -14,7 +15,7 @@ const providerStore = useProviderStore()
const skillStore = useSkillStore() const skillStore = useSkillStore()
const pageError = ref('') const pageError = ref('')
const form = reactive({ const form = reactive({
input: '', provider_id: 'mock', model: 'mock-1', skill_id: '', max_steps: 10, input: '', provider_id: '', model: '', skill_id: '', max_steps: 10,
tool_timeout_seconds: 30, run_timeout_seconds: 300, token_budget: 8000, tool_timeout_seconds: 30, run_timeout_seconds: 300, token_budget: 8000,
allow_network: false, max_concurrent_tools: 1, allowed_tools: [] as string[], allow_network: false, max_concurrent_tools: 1, allowed_tools: [] as string[],
}) })
@@ -25,7 +26,7 @@ const isNewRun = computed(() => !route.params.runId)
onMounted(async () => { onMounted(async () => {
try { try {
await Promise.all([providerStore.loadProviders(), skillStore.loadSkills(), agentStore.loadTools()]) await Promise.all([providerStore.loadProviders(), skillStore.loadSkills(), agentStore.loadTools()])
await providerStore.loadModels(form.provider_id) form.provider_id = providerStore.defaultProviderId
} catch (error) { pageError.value = error instanceof Error ? error.message : '智能体配置加载失败' } } catch (error) { pageError.value = error instanceof Error ? error.message : '智能体配置加载失败' }
}) })
@@ -35,7 +36,10 @@ watch(() => route.params.runId, async (runId) => {
}, { immediate: true }) }, { immediate: true })
watch(() => form.provider_id, async (providerId) => { watch(() => form.provider_id, async (providerId) => {
try { await providerStore.loadModels(providerId); form.model = models.value[0]?.model_id ?? '' } catch { /* page keeps current selection */ } form.model = providerStore.providers.find(p => p.provider_id === providerId)?.default_model ?? ''
if (!providerId) return
try { await providerStore.loadModels(providerId) }
catch (error) { if (form.provider_id === providerId) pageError.value = error instanceof Error ? error.message : '模型列表加载失败,请手动填写模型 ID。' }
}) })
function toggleTool(name: string) { function toggleTool(name: string) {
@@ -47,6 +51,7 @@ function toggleTool(name: string) {
async function createRun() { async function createRun() {
pageError.value = '' pageError.value = ''
try { try {
if (!form.provider_id || !form.model.trim()) throw new Error('请选择提供商并填写模型 ID。')
const run = await agentStore.createRun({ const run = await agentStore.createRun({
input: form.input, provider_id: form.provider_id, model: form.model, input: form.input, provider_id: form.provider_id, model: form.model,
skill_id: form.skill_id || undefined, allowed_tools: form.allowed_tools, skill_id: form.skill_id || undefined, allowed_tools: form.allowed_tools,
@@ -71,12 +76,12 @@ function eventText(event: AgentEvent) {
<section class="feature-page agent-page"> <section class="feature-page agent-page">
<header class="feature-header"><div><h1>{{ isNewRun ? '创建智能体运行' : '智能体执行轨迹' }}</h1><p>配置执行边界并实时查看模型工具和权限事件</p></div> <header class="feature-header"><div><h1>{{ isNewRun ? '创建智能体运行' : '智能体执行轨迹' }}</h1><p>配置执行边界并实时查看模型工具和权限事件</p></div>
<button v-if="!isNewRun" class="button-secondary" @click="router.push({ name: 'agent' })">新建运行</button></header> <button v-if="!isNewRun" class="button-secondary" @click="router.push({ name: 'agent' })">新建运行</button></header>
<div v-if="pageError || agentStore.error" class="error-banner">{{ pageError || agentStore.error }}</div> <div v-if="pageError || agentStore.error || providerStore.error" class="error-banner">{{ pageError || agentStore.error || providerStore.error }}</div>
<form v-if="isNewRun" class="panel run-form" @submit.prevent="createRun"> <form v-if="isNewRun" class="panel run-form" @submit.prevent="createRun">
<div class="field"><label>任务</label><textarea v-model="form.input" class="textarea" required placeholder="描述希望智能体完成的任务" /></div> <div class="field"><label>任务</label><textarea v-model="form.input" class="textarea" required placeholder="描述希望智能体完成的任务" /></div>
<div class="form-grid"> <div class="form-grid">
<div class="field"><label>模型提供商</label><select v-model="form.provider_id" class="select"><option v-for="p in providerStore.enabledProviders" :key="p.provider_id" :value="p.provider_id">{{ p.name }}</option></select></div> <div class="field"><label>模型提供商</label><select v-model="form.provider_id" class="select"><option v-for="p in providerStore.enabledProviders" :key="p.provider_id" :value="p.provider_id">{{ p.name }}</option></select></div>
<div class="field"><label>模型</label><select v-model="form.model" class="select"><option v-for="m in models" :key="m.model_id" :value="m.model_id">{{ m.name }}</option></select></div> <div class="field"><label>模型</label><input v-model="form.model" class="input" list="agent-models" placeholder="填写模型 ID" required /><datalist id="agent-models"><option v-for="m in models" :key="m.model_id" :value="m.model_id">{{ m.name }}</option></datalist></div>
<div class="field"><label>技能</label><select v-model="form.skill_id" class="select"><option value="">不使用技能</option><option v-for="s in skillStore.readySkills" :key="s.skill_id" :value="s.skill_id">{{ s.name }}</option></select></div> <div class="field"><label>技能</label><select v-model="form.skill_id" class="select"><option value="">不使用技能</option><option v-for="s in skillStore.readySkills" :key="s.skill_id" :value="s.skill_id">{{ s.name }}</option></select></div>
<div class="field"><label>最大步骤</label><input v-model.number="form.max_steps" class="input" type="number" min="1" max="100" /></div> <div class="field"><label>最大步骤</label><input v-model.number="form.max_steps" class="input" type="number" min="1" max="100" /></div>
<div class="field"><label>工具超时</label><input v-model.number="form.tool_timeout_seconds" class="input" type="number" min="1" /></div> <div class="field"><label>工具超时</label><input v-model.number="form.tool_timeout_seconds" class="input" type="number" min="1" /></div>
@@ -84,9 +89,9 @@ function eventText(event: AgentEvent) {
<div class="field"><label>令牌预算</label><input v-model.number="form.token_budget" class="input" type="number" min="1" /></div> <div class="field"><label>令牌预算</label><input v-model.number="form.token_budget" class="input" type="number" min="1" /></div>
<div class="field"><label>最大并发工具</label><input v-model.number="form.max_concurrent_tools" class="input" type="number" min="1" /></div> <div class="field"><label>最大并发工具</label><input v-model.number="form.max_concurrent_tools" class="input" type="number" min="1" /></div>
</div> </div>
<div class="field"><label>允许使用的工具</label><div class="tool-grid"><label v-for="tool in agentStore.tools" :key="tool.name" class="tool-option"><input type="checkbox" :checked="form.allowed_tools.includes(tool.name)" @change="toggleTool(tool.name)" /><span><strong>{{ toolLabel(tool.name) }}</strong><code>{{ tool.name }}</code><small>{{ toolDescription(tool.name, tool.description) }}</small></span></label></div></div> <div class="field"><label>允许使用的工具</label><div class="tool-grid"><ToolOption v-for="tool in agentStore.tools" :key="tool.name" :name="tool.name" :description="tool.description" :selected="form.allowed_tools.includes(tool.name)" @toggle="toggleTool" /></div></div>
<label class="network"><input v-model="form.allow_network" type="checkbox" /> 允许本次运行调用网络工具</label> <label class="network"><input v-model="form.allow_network" type="checkbox" /> 允许本次运行调用网络工具</label>
<div class="inline-actions"><button class="button-primary" :disabled="agentStore.isCreating || !form.input.trim()">{{ agentStore.isCreating ? '创建中…' : '创建并运行' }}</button></div> <div class="inline-actions"><button class="button-primary" :disabled="agentStore.isCreating || !form.input.trim() || !form.provider_id || !form.model.trim()">{{ agentStore.isCreating ? '创建中…' : '创建并运行' }}</button></div>
</form> </form>
<div v-else class="trace-layout"> <div v-else class="trace-layout">
@@ -110,12 +115,7 @@ function eventText(event: AgentEvent) {
<style scoped> <style scoped>
.agent-page > * { width: min(100%, 1080px); margin-inline: auto; } .agent-page > * { width: min(100%, 1080px); margin-inline: auto; }
.run-form { display: grid; gap: var(--space-xl); } .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; align-items: start; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--space-sm); }
.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 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); }
@@ -0,0 +1,19 @@
// @vitest-environment happy-dom
import { mount } from '@vue/test-utils'
import { expect, it } from 'vitest'
import ToolOption from './ToolOption.vue'
it('shows Chinese summaries, preserves raw metadata and emits the original tool ID', async () => {
const name = 'mcp.9ca7ee21603a.web_search'
const description = 'Search the web. query: string. ' + 'Full provider instructions. '.repeat(40)
const wrapper = mount(ToolOption, { props: { name, description, selected: false } })
expect(wrapper.get('strong').text()).toBe('网页搜索')
expect(wrapper.get('code').text()).toBe(name)
expect(wrapper.get('.tool-summary').text()).toContain('搜索关键词')
expect(wrapper.get('details').attributes('open')).toBeUndefined()
expect(wrapper.get('details p').element.textContent).toBe(description)
await wrapper.get('summary').trigger('click')
expect(wrapper.emitted('toggle')).toBeUndefined()
await wrapper.get('input').setValue(true)
expect(wrapper.emitted('toggle')).toEqual([[name]])
})
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { computed } from 'vue'
import { toolDescription, toolLabel } from './labels'
const props = defineProps<{ name: string; description: string; selected: boolean }>()
const emit = defineEmits<{ toggle: [name: string] }>()
const summary = computed(() => toolDescription(props.name, props.description))
const showOriginal = computed(() => props.description.length > 0)
</script>
<template>
<article class="tool-choice" :class="{ selected }">
<label class="tool-selection">
<input type="checkbox" :checked="selected" @change="emit('toggle', name)" />
<span class="tool-copy">
<strong>{{ toolLabel(name) }}</strong>
<code>{{ name }}</code>
<small class="tool-summary">{{ summary }}</small>
</span>
</label>
<details v-if="showOriginal" class="tool-original">
<summary>查看服务原文与参数</summary>
<p>{{ description }}</p>
</details>
</article>
</template>
<style scoped>
.tool-choice { min-width: 0; padding: var(--space-md); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-primary); }
.tool-choice.selected { border-color: var(--color-accent-primary); background: var(--color-accent-soft); }
.tool-selection { display: flex; align-items: flex-start; gap: var(--space-sm); cursor: pointer; }
.tool-selection input { flex-shrink: 0; margin-top: 4px; }
.tool-copy { min-width: 0; overflow-wrap: anywhere; }
.tool-copy strong, .tool-copy code, .tool-summary { display: block; }
.tool-copy code { margin: 3px 0; color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
.tool-summary { color: var(--color-text-secondary); line-height: 1.6; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; }
.tool-original { margin-top: var(--space-sm); font-size: var(--font-size-xs); }
.tool-original summary { cursor: pointer; color: var(--color-text-secondary); }
.tool-original p { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 240px; overflow: auto; margin-top: var(--space-sm); user-select: text; }
</style>
@@ -9,6 +9,21 @@ import {
} from './labels' } from './labels'
describe('智能体页面中文标签', () => { describe('智能体页面中文标签', () => {
it('按 MCP 远程工具名匹配中文,不依赖服务器 ID', () => {
for (const server of ['9ca7ee21603a', 'another-server']) {
expect(toolLabel(`mcp.${server}.web_search`)).toBe('网页搜索')
expect(toolLabel(`mcp.${server}.understand_image`)).toBe('图像理解')
expect(toolDescription(`mcp.${server}.web_search`, 'Search the web')).toContain('搜索关键词')
}
expect(toolLabel('text.uppercase')).toBe('文本转大写')
expect(toolDescription('text.uppercase', 'Convert input text to uppercase.')).toContain('大写')
})
it('保留服务端中文,未知工具不编造翻译或套用内置工具语义', () => {
expect(toolDescription('mcp.server.web_search', '仅搜索指定站点。')).toBe('仅搜索指定站点。')
expect(toolDescription('mcp.server.custom_action', 'Private action')).toContain('暂无中文说明')
expect(toolLabel('mcp.server.notes.delete')).toBe('MCP 工具 · notes.delete')
})
it('转换运行状态和事件名称', () => { it('转换运行状态和事件名称', () => {
expect(runStatusLabel('waiting_permission')).toBe('等待授权') expect(runStatusLabel('waiting_permission')).toBe('等待授权')
expect(eventLabel('ToolCall')).toBe('调用工具') expect(eventLabel('ToolCall')).toBe('调用工具')
+27 -1
View File
@@ -42,6 +42,7 @@ const toolLabels: Record<string, string> = {
'tasks.list': '列出任务', 'tasks.list': '列出任务',
'attachments.read': '读取附件', 'attachments.read': '读取附件',
'audio.transcribe': '音频转写', 'audio.transcribe': '音频转写',
'text.uppercase': '文本转大写',
} }
const toolDescriptions: Record<string, string> = { const toolDescriptions: Record<string, string> = {
@@ -58,7 +59,25 @@ const toolDescriptions: Record<string, string> = {
'tasks.update': '更新已有任务。', 'tasks.update': '更新已有任务。',
'tasks.list': '列出已持久化的任务。', 'tasks.list': '列出已持久化的任务。',
'attachments.read': '读取由宿主管理的 UTF-8 附件。', 'attachments.read': '读取由宿主管理的 UTF-8 附件。',
'audio.transcribe': '读取音频附件已有的宿主转写结果。', 'audio.transcribe': '将音频转写为文本,按模型路由使用 API 或本地后端。',
'text.uppercase': '将输入文本中的字母转换为大写。',
}
// MCP IDs contain a server-specific namespace. Localize the remote tool name
// for presentation only; requests must keep using the complete original ID.
const mcpTools: Record<string, { label: string; description: string }> = {
web_search: {
label: '网页搜索',
description: '搜索实时或外部网页信息。输入搜索关键词;结果包含标题、链接、摘要等信息。时效性问题可在关键词中加入日期,完整参数以服务原文为准。',
},
understand_image: {
label: '图像理解',
description: '根据提示词分析图片、描述内容或提取信息。输入分析要求和图片地址或本地路径;支持的格式与路径规则请查看服务原文。',
},
}
function mcpName(name: string): string | undefined {
return /^mcp\.[^.]+\.(.+)$/.exec(name)?.[1]
} }
const permissionLabels: Record<string, string> = { const permissionLabels: Record<string, string> = {
@@ -105,10 +124,17 @@ export function eventLabel(event: AgentEventType): string {
} }
export function toolLabel(name: string): string { export function toolLabel(name: string): string {
const remote = mcpName(name)
if (remote) return mcpTools[remote]?.label ?? `MCP 工具 · ${remote}`
return toolLabels[name] ?? name return toolLabels[name] ?? name
} }
export function toolDescription(name: string, fallback: string): string { export function toolDescription(name: string, fallback: string): string {
const remote = mcpName(name)
if (remote) {
if (/\p{Script=Han}/u.test(fallback)) return fallback
return mcpTools[remote]?.description ?? '暂无中文说明,请展开查看服务原文。'
}
return toolDescriptions[name] ?? fallback return toolDescriptions[name] ?? fallback
} }
@@ -0,0 +1,89 @@
// @vitest-environment happy-dom
import { beforeEach, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { useChatStore } from '@/stores/chat'
import { useProviderStore } from '@/stores/provider'
import { useSkillStore } from '@/stores/skill'
import ChatView from './ChatView.vue'
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/stores/editor', () => ({ useEditorStore: () => ({}) }))
vi.mock('@/stores/workspace', () => ({ useWorkspaceStore: () => ({}) }))
vi.mock('@/components/common/MarkdownContent.vue', () => ({ default: { template: '<div />' } }))
beforeEach(() => {
setActivePinia(createPinia())
const providers = useProviderStore()
providers.providers = ['a', 'b'].map(id => ({
provider_id: id, provider_type: 'openai_compatible', name: id,
default_model: `${id}-default`, enabled: true, capabilities: { chat: true }, has_credential: false,
}))
providers.defaultProviderId = 'a'
vi.spyOn(providers, 'loadProviders').mockResolvedValue(undefined)
vi.spyOn(providers, 'loadModels').mockResolvedValue([])
vi.spyOn(useSkillStore(), 'loadSkills').mockResolvedValue(undefined)
})
it('preserves the selected provider and manual model after leaving and returning to chat', async () => {
const chat = useChatStore()
const first = mount(ChatView)
await flushPromises()
await first.get('select').setValue('b')
await first.get('input[list="chat-models"]').setValue('b-manual')
first.unmount()
const returned = mount(ChatView)
await flushPromises()
expect(chat.selectedProviderId).toBe('b')
expect(chat.selectedModel).toBe('b-manual')
expect(useProviderStore().loadModels).toHaveBeenLastCalledWith('b')
returned.unmount()
})
it.each(['missing', 'disabled', 'unselected'])('uses the default when the selected provider is %s', async state => {
const chat = useChatStore()
chat.selectedProviderId = state === 'unselected' ? '' : state === 'missing' ? 'deleted' : 'b'
chat.selectedModel = 'old-model'
if (state === 'disabled') useProviderStore().providers[1]!.enabled = false
const wrapper = mount(ChatView)
await flushPromises()
expect(chat.selectedProviderId).toBe('a')
expect(chat.selectedModel).toBe('a-default')
wrapper.unmount()
})
it('preserves the selection when provider discovery fails', async () => {
const chat = useChatStore()
chat.selectedProviderId = 'b'
chat.selectedModel = 'b-manual'
useProviderStore().error = 'offline'
const wrapper = mount(ChatView)
await flushPromises()
expect(chat.selectedProviderId).toBe('b')
expect(chat.selectedModel).toBe('b-manual')
expect(wrapper.get('.error-banner').text()).toBe('offline')
wrapper.unmount()
})
it.each(['providers', 'skills'])('ignores initialization after unmount while %s are loading', async source => {
const chat = useChatStore()
let finish!: () => void
const pending = new Promise<void>(resolve => { finish = resolve })
if (source === 'providers') vi.mocked(useProviderStore().loadProviders).mockReturnValueOnce(pending)
else vi.mocked(useSkillStore().loadSkills).mockReturnValueOnce(pending)
const first = mount(ChatView)
first.unmount()
finish()
await flushPromises()
expect(chat.selectedProviderId).toBe('')
expect(chat.selectedModel).toBe('')
expect(useProviderStore().loadModels).not.toHaveBeenCalled()
const returned = mount(ChatView)
await flushPromises()
expect(chat.selectedProviderId).toBe('a')
expect(chat.selectedModel).toBe('a-default')
await returned.get('textarea').setValue('hello')
expect(returned.get('button.button-primary').attributes('disabled')).toBeUndefined()
returned.unmount()
})
+27 -21
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import type { Citation } from '@/contracts' import type { Citation } from '@/contracts'
import { useChatStore } from '@/stores/chat' import { useChatStore } from '@/stores/chat'
@@ -16,26 +16,37 @@ const workspaceStore = useWorkspaceStore()
const editorStore = useEditorStore() const editorStore = useEditorStore()
const router = useRouter() const router = useRouter()
const loadError = ref('') const loadError = ref('')
let disposed = false
onBeforeUnmount(() => { disposed = true })
const availableModels = computed(() => providerStore.modelsByProvider[chatStore.selectedProviderId] ?? []) const availableModels = computed(() => providerStore.modelsByProvider[chatStore.selectedProviderId] ?? [])
onMounted(async () => { onMounted(async () => {
try { try {
await Promise.all([providerStore.loadProviders(), skillStore.loadSkills()]) await Promise.all([providerStore.loadProviders(), skillStore.loadSkills()])
await providerStore.loadModels(chatStore.selectedProviderId) if (disposed || providerStore.error) return
const selected = providerStore.enabledProviders.find(p => p.provider_id === chatStore.selectedProviderId)
if (!selected) {
chatStore.selectedProviderId = providerStore.defaultProviderId
} else {
await refreshModels(selected.provider_id)
}
} catch (error) { } catch (error) {
loadError.value = error instanceof Error ? error.message : '无法加载 AI 配置,当前展示本地数据。' if (disposed) return
loadError.value = error instanceof Error ? error.message : '无法加载 AI 配置,请检查后端连接。'
} }
}) })
async function refreshModels(providerId: string) {
loadError.value = ''
if (!providerId) return
try { await providerStore.loadModels(providerId) }
catch (error) { if (!disposed && chatStore.selectedProviderId === providerId) loadError.value = error instanceof Error ? error.message : '模型列表加载失败,请手动填写模型 ID。' }
}
watch(() => chatStore.selectedProviderId, async (providerId) => { watch(() => chatStore.selectedProviderId, async (providerId) => {
try { chatStore.selectedModel = providerStore.providers.find(p => p.provider_id === providerId)?.default_model ?? ''
await providerStore.loadModels(providerId) await refreshModels(providerId)
const firstModel = providerStore.modelsByProvider[providerId]?.[0]
if (firstModel) chatStore.selectedModel = firstModel.model_id
} catch (error) {
loadError.value = error instanceof Error ? error.message : '模型列表加载失败'
}
}) })
function send() { void chatStore.sendMessage(chatStore.inputText) } function send() { void chatStore.sendMessage(chatStore.inputText) }
@@ -54,17 +65,12 @@ async function openCitation(citation: Citation) {
<div class="field compact"><label>Provider</label><select v-model="chatStore.selectedProviderId" class="select"> <div class="field compact"><label>Provider</label><select v-model="chatStore.selectedProviderId" class="select">
<option v-for="provider in providerStore.enabledProviders" :key="provider.provider_id" :value="provider.provider_id">{{ provider.name }}</option> <option v-for="provider in providerStore.enabledProviders" :key="provider.provider_id" :value="provider.provider_id">{{ provider.name }}</option>
</select></div> </select></div>
<div class="field compact"><label>Model</label><select v-model="chatStore.selectedModel" class="select"> <div class="field compact"><label>模型 ID</label><input v-model="chatStore.selectedModel" class="input" list="chat-models" placeholder="填写模型 ID" /><datalist id="chat-models"><option v-for="model in availableModels" :key="model.model_id" :value="model.model_id">{{ model.name }}</option></datalist></div>
<option v-for="model in availableModels" :key="model.model_id" :value="model.model_id">{{ model.name }}</option> <span class="subtle">知识库问答与技能请使用智能体普通聊天尚未接入这些能力</span>
</select></div>
<div class="field compact"><label>Skill</label><select v-model="chatStore.selectedSkillId" class="select">
<option :value="null">不使用 Skill</option><option v-for="skill in skillStore.enabledSkills" :key="skill.skill_id" :value="skill.skill_id">{{ skill.name }}</option>
</select></div>
<label class="rag-toggle"><input v-model="chatStore.useRag" type="checkbox" /> 使用知识库</label>
</header> </header>
<div v-if="loadError" class="error-banner chat-error">{{ loadError }}</div> <div v-if="loadError || providerStore.error" class="error-banner chat-error">{{ loadError || providerStore.error }}</div>
<main class="message-timeline"> <main class="message-timeline">
<div v-if="!chatStore.messages.length" class="empty-state"><div><strong>开始一段知识对话</strong><p>可以直接提问也可以打开 RAG 让模型基于当前 Vault 回答</p></div></div> <div v-if="!chatStore.messages.length" class="empty-state"><div><strong>开始一段知识对话</strong><p>请先配置模型提供商聊天记录仅保留在本次页面会话中</p></div></div>
<article v-for="message in chatStore.messages" :key="message.message_id" class="message" :class="message.role"> <article v-for="message in chatStore.messages" :key="message.message_id" class="message" :class="message.role">
<div class="avatar">{{ message.role === 'user' ? '你' : 'AI' }}</div> <div class="avatar">{{ message.role === 'user' ? '你' : 'AI' }}</div>
<div class="message-body"> <div class="message-body">
@@ -78,7 +84,7 @@ async function openCitation(citation: Citation) {
</button> </button>
</div> </div>
<time>{{ new Date(message.created_at).toLocaleTimeString() }}</time> <time>{{ new Date(message.created_at).toLocaleTimeString() }}</time>
<small v-if="message.usage" class="usage">Token {{ message.usage.total_tokens }}输入 {{ message.usage.input_tokens }} / 输出 {{ message.usage.output_tokens }}</small> <small v-if="message.usage" class="usage">Token {{ message.usage.total_tokens }}<span v-if="message.usage.input_tokens !== undefined && message.usage.output_tokens !== undefined">输入 {{ message.usage.input_tokens }} / 输出 {{ message.usage.output_tokens }}</span></small>
</div> </div>
</article> </article>
</main> </main>
@@ -87,7 +93,7 @@ async function openCitation(citation: Citation) {
@keydown.ctrl.enter.prevent="send" /> @keydown.ctrl.enter.prevent="send" />
<div class="composer-actions"><span class="subtle">回答可能包含错误请核对 Citation</span> <div class="composer-actions"><span class="subtle">回答可能包含错误请核对 Citation</span>
<button v-if="chatStore.isStreaming" class="button-danger" @click="chatStore.stopGeneration">停止</button> <button v-if="chatStore.isStreaming" class="button-danger" @click="chatStore.stopGeneration">停止</button>
<button v-else class="button-primary" :disabled="!chatStore.inputText.trim()" @click="send">发送</button> <button v-else class="button-primary" :disabled="!chatStore.inputText.trim() || !chatStore.selectedProviderId || !chatStore.selectedModel.trim()" @click="send">发送</button>
</div> </div>
</footer> </footer>
</section> </section>
@@ -27,6 +27,7 @@ async function uninstall(id: string, name: string) { if (!confirm(`卸载“${na
<div v-if="pluginStore.selectedPlugin.dependent_skills?.length" class="notice-banner last-error">依赖此插件的 Skill{{ pluginStore.selectedPlugin.dependent_skills.join('') }}</div> <div v-if="pluginStore.selectedPlugin.dependent_skills?.length" class="notice-banner last-error">依赖此插件的 Skill{{ pluginStore.selectedPlugin.dependent_skills.join('') }}</div>
<PluginMcpPanel :plugin="pluginStore.selectedPlugin" /> <PluginMcpPanel :plugin="pluginStore.selectedPlugin" />
</div> </div>
<div v-else-if="!pluginStore.plugins.length" class="empty-state"><div><strong>{{ pluginStore.isLoading ? '正在加载…' : pluginStore.error ? '加载失败' : '尚未安装' }}</strong><button class="button-secondary" @click="pluginStore.loadPlugins">重新加载</button></div></div>
<div v-else class="feature-grid"><article v-for="plugin in pluginStore.plugins" :key="plugin.plugin_id" class="item-card extension-card" @click="pluginStore.selectPlugin(plugin.plugin_id)"><div class="extension-title"><AppIcon :icon="Connection" :size="22" /><div><strong>{{ plugin.name }}</strong><p>v{{ plugin.version }}</p></div><span class="badge" :class="{ success: plugin.status === 'ready', error: plugin.status === 'error', warning: plugin.status === 'permission_required' }">{{ plugin.status }}</span></div><p class="muted">{{ plugin.description }}</p><p class="subtle">{{ plugin.permissions.length }} 项权限 · {{ plugin.contributions.length }} Contribution</p></article></div> <div v-else class="feature-grid"><article v-for="plugin in pluginStore.plugins" :key="plugin.plugin_id" class="item-card extension-card" @click="pluginStore.selectPlugin(plugin.plugin_id)"><div class="extension-title"><AppIcon :icon="Connection" :size="22" /><div><strong>{{ plugin.name }}</strong><p>v{{ plugin.version }}</p></div><span class="badge" :class="{ success: plugin.status === 'ready', error: plugin.status === 'error', warning: plugin.status === 'permission_required' }">{{ plugin.status }}</span></div><p class="muted">{{ plugin.description }}</p><p class="subtle">{{ plugin.permissions.length }} 项权限 · {{ plugin.contributions.length }} Contribution</p></article></div>
</section> </section>
</template> </template>
@@ -109,7 +109,7 @@ async function save() {
error.value = '' error.value = ''
saving.value = true saving.value = true
try { try {
if (!form.name.trim() || (form.provider_type !== 'mock' && !form.base_url.trim())) throw new Error('请填写名称和 Base URL。') if (!form.name.trim() || !form.base_url.trim()) throw new Error('请填写名称和 Base URL。')
if (selectedPreset.value?.requires_credential && !apiKey.value.trim() && !configured.value) throw new Error('请输入 API Key。密钥将由后端加密保存。') if (selectedPreset.value?.requires_credential && !apiKey.value.trim() && !configured.value) throw new Error('请输入 API Key。密钥将由后端加密保存。')
// Snapshot before awaiting: closing/unmounting must never create a provider with a changed draft. // Snapshot before awaiting: closing/unmounting must never create a provider with a changed draft.
const data = { provider_type: form.provider_type, name: form.name.trim(), base_url: form.base_url.trim() || undefined, default_model: form.default_model.trim(), enabled: form.enabled, capabilities: {}, has_credential: false } const data = { provider_type: form.provider_type, name: form.name.trim(), base_url: form.base_url.trim() || undefined, default_model: form.default_model.trim(), enabled: form.enabled, capabilities: {}, has_credential: false }
@@ -147,9 +147,9 @@ async function save() {
<ProviderPresetSelector :presets="presets" :model-value="form.preset_id" @update:model-value="applyPreset" /> <ProviderPresetSelector :presets="presets" :model-value="form.preset_id" @update:model-value="applyPreset" />
<p v-if="selectedPreset?.description" class="subtle">{{ selectedPreset.description }}</p> <p v-if="selectedPreset?.description" class="subtle">{{ selectedPreset.description }}</p>
<div class="form-grid"> <div class="form-grid">
<label class="field"><span>接入协议</span><select v-model="form.provider_type" class="select" data-field="protocol" @change="changeConnection"><option value="openai_compatible">OpenAI Compatible</option><option value="openai_chat">OpenAI Chat</option><option value="openai_responses">OpenAI Responses</option><option value="anthropic_messages">Anthropic Messages</option><option value="ollama">Ollama</option><option v-if="provider?.provider_type === 'mock'" value="mock">Mock</option></select></label> <label class="field"><span>接入协议</span><select v-model="form.provider_type" class="select" data-field="protocol" @change="changeConnection"><option value="openai_compatible">OpenAI Compatible</option><option value="openai_chat">OpenAI Chat</option><option value="openai_responses">OpenAI Responses</option><option value="anthropic_messages">Anthropic Messages</option><option value="ollama">Ollama</option></select></label>
<label class="field"><span>名称</span><input v-model="form.name" class="input" data-field="name" required /></label> <label class="field"><span>名称</span><input v-model="form.name" class="input" data-field="name" required /></label>
<label class="field wide"><span>Base URL</span><input v-model="form.base_url" class="input" data-field="base-url" placeholder="https://api.example.com/v1" :required="form.provider_type !== 'mock'" @change="changeConnection" /></label> <label class="field wide"><span>Base URL</span><input v-model="form.base_url" class="input" data-field="base-url" placeholder="https://api.example.com/v1" required @change="changeConnection" /></label>
<label class="field wide"><span>API Key</span><input v-model="apiKey" class="input" type="password" autocomplete="new-password" spellcheck="false" :placeholder="configured ? '已配置,留空表示不修改' : '请输入 API Key(无鉴权服务可留空)'" /><small class="subtle">密钥由本地 AI Core 加密保存提供商配置仅保存独立的凭据引用</small></label> <label class="field wide"><span>API Key</span><input v-model="apiKey" class="input" type="password" autocomplete="new-password" spellcheck="false" :placeholder="configured ? '已配置,留空表示不修改' : '请输入 API Key(无鉴权服务可留空)'" /><small class="subtle">密钥由本地 AI Core 加密保存提供商配置仅保存独立的凭据引用</small></label>
<p v-if="credentialLoading" class="subtle wide" role="status">正在检查凭据状态</p> <p v-if="credentialLoading" class="subtle wide" role="status">正在检查凭据状态</p>
<p v-if="credentialError" class="error-text wide" role="alert">{{ credentialError }}</p> <p v-if="credentialError" class="error-text wide" role="alert">{{ credentialError }}</p>
@@ -70,6 +70,7 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
<button class="button-primary" @click="openProvider()">新增 Provider</button> <button class="button-primary" @click="openProvider()">新增 Provider</button>
</div> </div>
<div v-if="providerStore.error || providerAction" class="error-banner">{{ providerStore.error || providerAction }}</div> <div v-if="providerStore.error || providerAction" class="error-banner">{{ providerStore.error || providerAction }}</div>
<p v-if="!providerStore.providers.length" class="subtle">{{ providerStore.isLoading ? '正在加载提供商' : '尚无可用提供商请添加真实 API 或本地 Ollama 配置' }}</p>
<div class="provider-list"> <div class="provider-list">
<article v-for="provider in providerStore.providers" :key="provider.provider_id" class="item-card provider-card"> <article v-for="provider in providerStore.providers" :key="provider.provider_id" class="item-card provider-card">
<div class="provider-main"> <div class="provider-main">
@@ -91,17 +92,17 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
<button class="button-secondary" :disabled="providerStore.modelLoadingByProvider[provider.provider_id]" @click="refreshModels(provider)">{{ providerStore.modelLoadingByProvider[provider.provider_id] ? '获取中' : '刷新模型' }}</button> <button class="button-secondary" :disabled="providerStore.modelLoadingByProvider[provider.provider_id]" @click="refreshModels(provider)">{{ providerStore.modelLoadingByProvider[provider.provider_id] ? '获取中' : '刷新模型' }}</button>
<button class="button-secondary" @click="testProvider(provider)">测试</button> <button class="button-secondary" @click="testProvider(provider)">测试</button>
<button class="button-secondary" @click="openProvider(provider)">编辑</button> <button class="button-secondary" @click="openProvider(provider)">编辑</button>
<button class="button-danger" :disabled="provider.provider_id === 'mock'" @click="removeProvider(provider)">删除</button> <button class="button-danger" @click="removeProvider(provider)">删除</button>
</div> </div>
</article> </article>
</div> </div>
</div> </div>
<div v-else-if="activeSection === 'index'" class="panel settings-section"><h2>索引与模型</h2><div class="index-summary"><div><span class="badge" :class="{ success: settingsStore.indexStatus.status === 'idle', error: settingsStore.indexStatus.status === 'error' }">{{ settingsStore.indexStatus.status }}</span><p>待处理任务 {{ settingsStore.indexStatus.pending_jobs }}</p></div><div><strong>{{ settingsStore.indexStatus.total_notes }}</strong><small>笔记</small></div><div><strong>{{ settingsStore.indexStatus.total_blocks }}</strong><small>Block</small></div></div><div v-if="settingsStore.indexStatus.error" class="error-banner">{{ settingsStore.indexStatus.error }}</div><div class="inline-actions"><button class="button-primary" @click="settingsStore.rebuildIndex('full')">重建全部</button><button class="button-secondary" @click="settingsStore.rebuildIndex('fts')">重建文本索引</button><button class="button-secondary" @click="settingsStore.rebuildIndex('vector')">重建向量索引</button></div><ModelRoutingSettings /></div> <div v-else-if="activeSection === 'index'" class="panel settings-section"><h2>索引与模型</h2><div class="index-summary"><div><span class="badge" :class="{ success: settingsStore.indexStatus.status === 'idle', error: settingsStore.indexStatus.status === 'error' }">{{ settingsStore.indexStatus.status }}</span><p>待处理任务 {{ settingsStore.indexStatus.pending_jobs }}</p></div><div><strong>{{ settingsStore.indexStatus.total_notes ?? '未获取' }}</strong><small>笔记</small></div><div><strong>{{ settingsStore.indexStatus.total_blocks ?? '未获取' }}</strong><small>Block</small></div></div><div v-if="settingsStore.indexStatus.error" class="error-banner">{{ settingsStore.indexStatus.error }}</div><div class="inline-actions"><button class="button-primary" @click="settingsStore.rebuildIndex('full')">重建全部</button><span class="subtle">当前后端支持全量重建</span></div><ModelRoutingSettings /></div>
<div v-else-if="activeSection === 'permissions'" class="panel settings-section"><h2>权限策略</h2><p class="muted section-description">高影响能力默认需要确认。未知权限由后端拒绝。</p><div class="permission-list"><div v-for="(policy, permission) in settingsStore.permissionPolicy" :key="permission" class="setting-row"><span><strong>{{ permission }}</strong></span><select :value="policy" class="select short" @change="settingsStore.setPermission(String(permission), ($event.target as HTMLSelectElement).value as 'allow' | 'confirm' | 'deny')"><option value="allow">允许</option><option value="confirm">每次确认</option><option value="deny">拒绝</option></select></div></div></div> <div v-else-if="activeSection === 'permissions'" class="panel settings-section"><h2>权限策略</h2><p class="muted section-description">以下为后端当前生效的权限策略;全局策略编辑尚未开放,运行时按实际权限请求确认。</p><p v-if="!Object.keys(settingsStore.permissionPolicy).length" class="subtle">尚未获取权限策略,请检查后端连接并重新检测。</p><div class="permission-list"><div v-for="(policy, permission) in settingsStore.permissionPolicy" :key="permission" class="setting-row"><span><strong>{{ permission }}</strong></span><span>{{ policy === 'allow' ? '允许' : policy === 'confirm' ? '每次确认' : '拒绝' }}</span></div></div></div>
<div v-else class="panel settings-section"><h2>AI Core 诊断</h2><div v-if="settingsStore.diagnosticsError" class="error-banner">{{ settingsStore.diagnosticsError }}</div><div class="diagnostic-grid"><div class="item-card"><span class="badge" :class="{ success: settingsStore.aiCoreStatus === 'running', error: settingsStore.aiCoreStatus === 'error' }">{{ settingsStore.aiCoreStatus }}</span><h3>Sidecar 状态</h3><p class="subtle">AI Core 不可用时,Markdown 编辑仍可继续使用。</p></div><div class="item-card"><strong>{{ settingsStore.aiCoreAddress }}</strong><h3>开发 API 地址</h3><p class="subtle">正式桌面环境由 Sidecar Manager 动态提供。</p></div></div><div class="inline-actions diagnostic-actions"><button class="button-primary" @click="settingsStore.loadDiagnostics">重新检测</button><button class="button-secondary" @click="settingsStore.restartAiCore">重启 AI Core</button></div></div> <div v-else class="panel settings-section"><h2>AI Core 诊断</h2><div v-if="settingsStore.diagnosticsError" class="error-banner">{{ settingsStore.diagnosticsError }}</div><div class="diagnostic-grid"><div class="item-card"><span class="badge" :class="{ success: settingsStore.aiCoreStatus === 'running', error: settingsStore.aiCoreStatus === 'error' }">{{ settingsStore.aiCoreStatus }}</span><h3>AI Core 连接状态</h3><p class="subtle">AI Core 不可用时,Markdown 编辑仍可继续使用。</p></div><div class="item-card"><strong>{{ settingsStore.aiCoreAddress }}</strong><h3>开发 API 地址</h3><p class="subtle">正式桌面环境由 Sidecar Manager 动态提供。</p></div></div><div class="inline-actions diagnostic-actions"><button class="button-primary" @click="settingsStore.loadDiagnostics">重新检测</button><span class="subtle">当前 Web 端不支持重启后端进程请在运行后端的终端中操作</span></div></div>
<ProviderForm v-if="showProviderForm" :provider="editingProvider" :models="editingProvider ? providerStore.modelsByProvider[editingProvider.provider_id] : []" @close="showProviderForm = false" @saved="providerSaved" /> <ProviderForm v-if="showProviderForm" :provider="editingProvider" :models="editingProvider ? providerStore.modelsByProvider[editingProvider.provider_id] : []" @close="showProviderForm = false" @saved="providerSaved" />
</section> </section>
@@ -32,6 +32,7 @@ async function uninstall(skillId: string, name: string) {
<div class="detail-grid"><div><h3>工具</h3><div class="tag-list"><span v-for="tool in skillStore.selectedSkill.tools" :key="tool" class="badge info">{{ tool }}</span></div></div><div><h3>权限</h3><div class="tag-list"><span v-for="permission in skillStore.selectedSkill.permissions" :key="permission" class="badge warning">{{ permission }}</span></div></div><div><h3>检索配置</h3><pre>{{ JSON.stringify(skillStore.selectedSkill.retrieval_config, null, 2) }}</pre></div><div><h3>模型能力</h3><div class="tag-list"><span v-for="cap in skillStore.selectedSkill.model_requirements?.capabilities" :key="cap" class="badge">{{ cap }}</span></div></div></div> <div class="detail-grid"><div><h3>工具</h3><div class="tag-list"><span v-for="tool in skillStore.selectedSkill.tools" :key="tool" class="badge info">{{ tool }}</span></div></div><div><h3>权限</h3><div class="tag-list"><span v-for="permission in skillStore.selectedSkill.permissions" :key="permission" class="badge warning">{{ permission }}</span></div></div><div><h3>检索配置</h3><pre>{{ JSON.stringify(skillStore.selectedSkill.retrieval_config, null, 2) }}</pre></div><div><h3>模型能力</h3><div class="tag-list"><span v-for="cap in skillStore.selectedSkill.model_requirements?.capabilities" :key="cap" class="badge">{{ cap }}</span></div></div></div>
<div v-if="skillStore.selectedSkill.missing_dependencies?.length" class="error-banner dependencies">缺失依赖{{ skillStore.selectedSkill.missing_dependencies.join('') }}</div> <div v-if="skillStore.selectedSkill.missing_dependencies?.length" class="error-banner dependencies">缺失依赖{{ skillStore.selectedSkill.missing_dependencies.join('') }}</div>
</div> </div>
<div v-else-if="!skillStore.skills.length" class="empty-state"><div><strong>{{ skillStore.isLoading ? '正在加载…' : skillStore.error ? '加载失败' : '尚未安装' }}</strong><button class="button-secondary" @click="skillStore.loadSkills">重新加载</button></div></div>
<div v-else class="feature-grid"><article v-for="skill in skillStore.skills" :key="skill.skill_id" class="item-card extension-card" @click="skillStore.selectSkill(skill.skill_id)"><div class="extension-title"><AppIcon :icon="Lightning" :size="22" /><div><strong>{{ skill.name }}</strong><p>v{{ skill.version }}</p></div><span class="badge" :class="{ success: skill.status === 'ready', warning: skill.status === 'dependency_missing' }">{{ skill.status }}</span></div><p class="muted">{{ skill.description }}</p><div class="tag-list"><span v-for="permission in skill.permissions.slice(0, 3)" :key="permission" class="badge">{{ permission }}</span></div></article></div> <div v-else class="feature-grid"><article v-for="skill in skillStore.skills" :key="skill.skill_id" class="item-card extension-card" @click="skillStore.selectSkill(skill.skill_id)"><div class="extension-title"><AppIcon :icon="Lightning" :size="22" /><div><strong>{{ skill.name }}</strong><p>v{{ skill.version }}</p></div><span class="badge" :class="{ success: skill.status === 'ready', warning: skill.status === 'dependency_missing' }">{{ skill.status }}</span></div><p class="muted">{{ skill.description }}</p><div class="tag-list"><span v-for="permission in skill.permissions.slice(0, 3)" :key="permission" class="badge">{{ permission }}</span></div></article></div>
</section> </section>
</template> </template>
+3 -3
View File
@@ -23,13 +23,13 @@ onMounted(async () => {
await openVault(lastVaultPath) await openVault(lastVaultPath)
return return
} catch { } catch {
// Mock Vault // Vault
localStorage.removeItem('last-vault-path') localStorage.removeItem('last-vault-path')
} }
} }
setTimeout(() => { {
aiCoreStatus.value = settingsStore.aiCoreStatus === 'running' ? 'running' : 'stopped' aiCoreStatus.value = settingsStore.aiCoreStatus === 'running' ? 'running' : 'stopped'
}, 800) }
}) })
async function openVault(path: string) { async function openVault(path: string) {
+1 -207
View File
@@ -1,6 +1,6 @@
import apiClient from './apiClient' import apiClient from './apiClient'
import { SseClient } from './sseClient' import { SseClient } from './sseClient'
import type { AgentRun, AgentEvent, AgentTraceResponse, ApiAgentRun, OperationResponse, PageMeta, ToolDefinition, PermissionRequest } from '@/contracts' import type { AgentRun, AgentEvent, AgentTraceResponse, ApiAgentRun, OperationResponse, PageMeta, ToolDefinition } from '@/contracts'
function toAgentRun(run: ApiAgentRun): AgentRun { function toAgentRun(run: ApiAgentRun): AgentRun {
// API 的 token_usage 是累计值,UI 模型预留了输入/输出拆分字段。 // API 的 token_usage 是累计值,UI 模型预留了输入/输出拆分字段。
@@ -10,8 +10,6 @@ function toAgentRun(run: ApiAgentRun): AgentRun {
current_step: run.current_step, current_step: run.current_step,
max_steps: run.max_steps, max_steps: run.max_steps,
token_usage: { token_usage: {
input_tokens: 0,
output_tokens: 0,
total_tokens: run.token_usage, total_tokens: run.token_usage,
}, },
started_at: run.created_at, started_at: run.created_at,
@@ -107,207 +105,3 @@ export async function respondToPermission(
decision, decision,
}) })
} }
export const mockTools: ToolDefinition[] = [
{
name: 'notes.search',
description: '搜索笔记,支持关键词和语义检索',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: '搜索关键词' },
limit: { type: 'number', description: '返回结果数量' },
},
required: ['query'],
},
source: 'builtin',
},
{
name: 'notes.read',
description: '读取指定笔记的完整内容',
parameters: {
type: 'object',
properties: {
note_id: { type: 'string' },
},
required: ['note_id'],
},
source: 'builtin',
},
{
name: 'notes.create',
description: '创建新笔记',
parameters: {
type: 'object',
properties: {
title: { type: 'string' },
content: { type: 'string' },
folder_path: { type: 'string' },
},
required: ['title', 'content'],
},
source: 'builtin',
},
{
name: 'rag.search',
description: '基于 RAG 的语义检索,返回相关知识片段',
parameters: {
type: 'object',
properties: {
query: { type: 'string' },
top_k: { type: 'number' },
},
required: ['query'],
},
source: 'builtin',
},
{
name: 'tasks.create',
description: '创建任务',
parameters: {
type: 'object',
properties: {
title: { type: 'string' },
description: { type: 'string' },
priority: { type: 'string', enum: ['low', 'medium', 'high'] },
},
required: ['title'],
},
source: 'builtin',
},
{
name: 'system.echo',
description: '回显输入内容(测试用)',
parameters: {
type: 'object',
properties: {
text: { type: 'string' },
},
required: ['text'],
},
source: 'builtin',
},
{
name: 'math.add',
description: '两数相加(测试用)',
parameters: {
type: 'object',
properties: {
a: { type: 'number' },
b: { type: 'number' },
},
required: ['a', 'b'],
},
source: 'builtin',
},
]
export const mockAgentRuns: AgentRun[] = [
{
run_id: 'run-1',
status: 'completed',
current_step: 3,
max_steps: 10,
token_usage: { input_tokens: 2340, output_tokens: 890, total_tokens: 3230 },
started_at: '2026-08-25T11:00:00Z',
completed_at: '2026-08-25T11:02:30Z',
},
{
run_id: 'run-2',
status: 'running',
current_step: 2,
max_steps: 10,
token_usage: { input_tokens: 1500, output_tokens: 420, total_tokens: 1920 },
started_at: '2026-08-26T09:30:00Z',
},
]
export const mockAgentEvents: AgentEvent[] = [
{
event: 'RunStarted',
sequence: 1,
run_id: 'run-1',
data: { task: '帮我整理红黑树的核心知识点' },
timestamp: '2026-08-25T11:00:00Z',
},
{
event: 'ThinkingDelta',
sequence: 2,
run_id: 'run-1',
data: { text: '我需要先搜索笔记中关于红黑树的内容...' },
timestamp: '2026-08-25T11:00:01Z',
},
{
event: 'ToolCall',
sequence: 3,
run_id: 'run-1',
data: {
tool_call_id: 'tc-1',
name: 'notes.search',
parameters: { query: '红黑树 插入 删除', limit: 5 },
status: 'running',
},
timestamp: '2026-08-25T11:00:02Z',
},
{
event: 'ToolResult',
sequence: 4,
run_id: 'run-1',
data: {
tool_call_id: 'tc-1',
name: 'notes.search',
status: 'completed',
result: '找到 5 条相关结果,包括红黑树性质、插入操作、删除操作等...',
duration_ms: 320,
},
timestamp: '2026-08-25T11:00:02Z',
},
{
event: 'Citation',
sequence: 5,
run_id: 'run-1',
data: {
note_id: 'n-rbt',
block_id: 'b1',
heading_path: '数据结构 / 红黑树 / 性质',
},
timestamp: '2026-08-25T11:00:03Z',
},
{
event: 'ThinkingDelta',
sequence: 6,
run_id: 'run-1',
data: { text: '搜索结果很全面,让我整理一下结构...' },
timestamp: '2026-08-25T11:00:03Z',
},
{
event: 'TextDelta',
sequence: 7,
run_id: 'run-1',
data: { text: '## 红黑树核心知识点整理\n\n### 1. 基本性质\n红黑树是一种自平衡二叉搜索树,每个节点带有颜色属性...' },
timestamp: '2026-08-25T11:00:04Z',
},
{
event: 'Usage',
sequence: 8,
run_id: 'run-1',
data: { input_tokens: 2340, output_tokens: 890, total_tokens: 3230 },
timestamp: '2026-08-25T11:02:30Z',
},
{
event: 'RunCompleted',
sequence: 9,
run_id: 'run-1',
data: { message: 'Task completed successfully' },
timestamp: '2026-08-25T11:02:30Z',
},
]
export const mockPermissionRequest: PermissionRequest = {
request_id: 'perm-1',
run_id: 'run-2',
tool_name: 'notes.create',
permission: 'notes.write',
parameters: { title: '红黑树知识点总结', folder_path: '/数据结构' },
impact: '将在你的知识库中创建一篇新笔记',
}
+1 -85
View File
@@ -1,5 +1,5 @@
import { SseClient } from './sseClient' import { SseClient } from './sseClient'
import type { Conversation, ChatMessage, ModelEvent } from '@/contracts' import type { ModelEvent } from '@/contracts'
export interface ChatRequest { export interface ChatRequest {
provider_id: string provider_id: string
@@ -46,87 +46,3 @@ export function streamChat(
client.connect().catch(() => {}) client.connect().catch(() => {})
return client return client
} }
export const mockConversations: Conversation[] = [
{
conversation_id: 'conv-1',
title: '关于红黑树的讨论',
created_at: '2026-08-25T10:00:00Z',
updated_at: '2026-08-25T10:30:00Z',
message_count: 6,
},
{
conversation_id: 'conv-2',
title: '死锁避免算法',
created_at: '2026-08-24T14:00:00Z',
updated_at: '2026-08-24T15:20:00Z',
message_count: 4,
},
{
conversation_id: 'conv-3',
title: 'TCP三次握手',
created_at: '2026-08-22T09:00:00Z',
updated_at: '2026-08-22T09:15:00Z',
message_count: 3,
},
]
export const mockMessages: Record<string, ChatMessage[]> = {
'conv-1': [
{
message_id: 'msg-1',
conversation_id: 'conv-1',
role: 'user',
content: '红黑树的插入操作有哪些情况需要调整?',
created_at: '2026-08-25T10:00:00Z',
},
{
message_id: 'msg-2',
conversation_id: 'conv-1',
role: 'assistant',
content:
'红黑树插入后可能违反红黑性质,需要通过旋转和变色来修复。主要有以下几种情况:\n\n**情况1:叔叔节点是红色**\n- 将父节点和叔叔节点设为黑色\n- 将祖父节点设为红色\n- 当前节点上移到祖父节点,继续向上调整\n\n**情况2:叔叔节点是黑色,且当前节点是右孩子**\n- 以父节点为支点左旋\n- 将当前节点转换为左孩子,进入情况3\n\n**情况3:叔叔节点是黑色,且当前节点是左孩子**\n- 以祖父节点为支点右旋\n- 将父节点设为黑色,祖父节点设为红色\n\n以上三种情况需要从插入节点开始向上递归处理,直到根节点。',
created_at: '2026-08-25T10:01:00Z',
citations: [
{
note_id: 'n-rbt',
block_id: 'b1',
file_path: '/数据结构/红黑树.md',
heading_path: '数据结构 / 红黑树 / 插入操作',
content: '插入后可能破坏红黑性质,需要通过变色和旋转来修复...',
},
{
note_id: 'n-rbt',
block_id: 'b2',
file_path: '/数据结构/红黑树.md',
heading_path: '数据结构 / 红黑树 / 五种情况',
content: 'Case 1: 叔叔为红色,直接变色...',
},
],
},
{
message_id: 'msg-3',
conversation_id: 'conv-1',
role: 'user',
content: '能对比一下 AVL 树和红黑树的性能差异吗?',
created_at: '2026-08-25T10:10:00Z',
},
{
message_id: 'msg-4',
conversation_id: 'conv-1',
role: 'assistant',
content:
'AVL 树和红黑树都是平衡二叉搜索树,但它们的平衡策略和性能特点有所不同:\n\n| 特性 | AVL 树 | 红黑树 |\n|------|--------|--------|\n| 严格平衡 | 高度差 ≤ 1 | 黑色高度相同 |\n| 查找速度 | 更快(严格平衡) | 略慢但仍为 O(log n) |\n| 插入/删除 | 旋转更多 | 旋转更少(颜色调整为主) |\n| 存储开销 | 每个节点存高度(int) | 每个节点存颜色(1 bit) |\n| 适用场景 | 读多写少 | 读写均衡或写较多 |\n\n**总结:**\n- 如果应用以查询为主,很少修改,AVL 树可能更优\n- 如果插入删除频繁,红黑树更合适,重平衡开销更低\n- 红黑树在工业界应用更广泛(C++ STL 的 map/set、Java 的 TreeMap 等)',
created_at: '2026-08-25T10:11:00Z',
citations: [
{
note_id: 'n-rbt',
block_id: 'b3',
file_path: '/数据结构/红黑树.md',
heading_path: '数据结构 / 红黑树 / 与AVL树对比',
content: '红黑树相比AVL树,牺牲了部分平衡性以换取更少的旋转操作...',
},
],
},
],
}
+2 -16
View File
@@ -5,10 +5,8 @@ function toIndexStatus(status: ApiIndexStatus): IndexStatus {
return { return {
status: status.status === 'idle' ? 'idle' : status.status === 'failed' ? 'error' : 'indexing', status: status.status === 'idle' ? 'idle' : status.status === 'failed' ? 'error' : 'indexing',
pending_jobs: status.pending_jobs, pending_jobs: status.pending_jobs,
total_notes: 0, total_notes: status.total_notes ?? null,
total_blocks: 0, total_blocks: status.total_blocks ?? null,
fts_enabled: true,
vector_enabled: true,
last_indexed_at: status.last_completed_at ?? undefined, last_indexed_at: status.last_completed_at ?? undefined,
error: status.error_message ?? undefined, error: status.error_message ?? undefined,
} }
@@ -26,15 +24,3 @@ export async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full'): P
export async function getIndexJob(jobId: string): Promise<ApiIndexJob> { export async function getIndexJob(jobId: string): Promise<ApiIndexJob> {
return apiClient.get(`/api/index/jobs/${jobId}`) return apiClient.get(`/api/index/jobs/${jobId}`)
} }
export const mockIndexStatus: IndexStatus = {
status: 'idle',
pending_jobs: 0,
total_notes: 42,
total_blocks: 318,
fts_enabled: true,
vector_enabled: true,
embedding_model: 'bge-m3',
reranker_model: 'bge-reranker-base',
last_indexed_at: new Date().toISOString(),
}
-89
View File
@@ -126,92 +126,3 @@ export async function deletePluginSecret(pluginId: string, key: string): Promise
export async function uninstallPlugin(pluginId: string): Promise<OperationResponse> { export async function uninstallPlugin(pluginId: string): Promise<OperationResponse> {
return apiClient.delete(`/api/plugins/${pluginId}`) return apiClient.delete(`/api/plugins/${pluginId}`)
} }
export const mockPlugins: Plugin[] = [
{
plugin_id: 'github-integration',
name: 'GitHub 集成',
version: '1.3.2',
description: '接入 GitHub API,支持搜索 Issue、查看 PR 和管理仓库',
icon: '',
author: 'NotesAgent 团队',
status: 'ready',
enabled: true,
permissions: ['notes.read', 'network.request'],
contributions: [
{ type: 'tool', id: 'github.search_issues', name: '搜索 Issue', description: '搜索 GitHub 仓库中的 Issue' },
{ type: 'tool', id: 'github.get_pr', name: '获取 PR 详情', description: '获取 Pull Request 的详细信息' },
{ type: 'command', id: 'github.open_repo', name: '打开仓库', description: '在浏览器中打开对应 GitHub 仓库' },
],
backend_type: 'mcp',
transport: 'stdio',
dependent_skills: ['research-assistant'],
},
{
plugin_id: 'translator',
name: '翻译助手',
version: '1.0.0',
description: '提供多语言翻译能力,支持文档批量翻译',
icon: '',
author: '社区贡献',
status: 'ready',
enabled: false,
permissions: ['notes.read', 'notes.write', 'network.request'],
contributions: [
{ type: 'tool', id: 'translator.translate', name: '翻译文本', description: '翻译指定文本到目标语言' },
{ type: 'command', id: 'translator.translate_note', name: '翻译当前笔记', description: '翻译当前打开的笔记' },
{ type: 'settings_section', id: 'translator.settings', name: '翻译设置', description: '配置翻译服务和默认语言' },
],
backend_type: 'mcp',
transport: 'stdio',
},
{
plugin_id: 'kanban',
name: '看板视图',
version: '0.8.0',
description: '为任务提供看板视图,支持拖拽排序和多维度筛选',
icon: '',
author: '社区贡献',
status: 'installed',
enabled: false,
permissions: ['tasks.read', 'tasks.write'],
contributions: [
{ type: 'sidebar_panel', id: 'kanban.panel', name: '任务看板', description: '以看板方式查看和管理任务' },
],
backend_type: 'internal_rpc',
},
{
plugin_id: 'pdf-importer',
name: 'PDF 导入',
version: '2.1.0',
description: '导入 PDF 文档,提取文本和目录结构生成笔记',
icon: '',
author: 'NotesAgent 团队',
status: 'error',
enabled: false,
permissions: ['notes.write', 'attachments.read'],
contributions: [
{ type: 'importer', id: 'pdf.import', name: 'PDF 导入器', description: '从 PDF 文件导入内容' },
],
backend_type: 'mcp',
transport: 'stdio',
last_error: 'PDF 解析库初始化失败,请检查 Python 依赖',
},
{
plugin_id: 'calendar',
name: '日历同步',
version: '0.5.0',
description: '同步日历事件,自动生成相关笔记和任务提醒',
icon: '',
author: '社区贡献',
status: 'dependency_missing',
enabled: false,
permissions: ['tasks.read', 'tasks.write', 'network.request'],
contributions: [
{ type: 'tool', id: 'calendar.events', name: '日历事件', description: '获取日历事件列表' },
{ type: 'sidebar_panel', id: 'calendar.widget', name: '日历小部件', description: '侧边栏日历视图' },
],
backend_type: 'mcp',
transport: 'http',
},
]
+2 -101
View File
@@ -15,7 +15,7 @@ function toProvider(provider: ApiProviderConfig): ProviderConfig {
enabled: provider.enabled, enabled: provider.enabled,
capabilities: capabilityMap(provider.capabilities), capabilities: capabilityMap(provider.capabilities),
credential_id: provider.credential_id ?? undefined, credential_id: provider.credential_id ?? undefined,
has_credential: Boolean(provider.credential_id) || provider.provider_type === 'mock', has_credential: Boolean(provider.credential_id),
} }
} }
@@ -25,7 +25,7 @@ function toModel(model: ApiModelInfo): ModelInfo {
export async function listProviders(): Promise<ProviderConfig[]> { export async function listProviders(): Promise<ProviderConfig[]> {
const response = await apiClient.get<{ items: ApiProviderConfig[] }>('/api/providers') const response = await apiClient.get<{ items: ApiProviderConfig[] }>('/api/providers')
return response.items.map(toProvider) return response.items.filter(provider => provider.provider_type !== 'mock').map(toProvider)
} }
export async function getProvider(providerId: string): Promise<ProviderConfig> { export async function getProvider(providerId: string): Promise<ProviderConfig> {
@@ -97,102 +97,3 @@ export async function testProvider(providerId: string): Promise<TestResult> {
return { success: false, error_code: e.code || 'TEST_FAILED', error_message: e.message } return { success: false, error_code: e.code || 'TEST_FAILED', error_message: e.message }
} }
} }
export const mockProviders: ProviderConfig[] = [
{
provider_id: 'mock',
provider_type: 'mock',
name: 'Mock Provider (测试)',
default_model: 'mock-1',
enabled: true,
has_credential: true,
capabilities: {
chat: true,
tool_calling: true,
streaming: true,
vision: false,
reasoning: false,
structured_output: true,
embedding: false,
},
},
{
provider_id: 'openai-compat-1',
provider_type: 'openai_compatible',
name: 'OpenAI 兼容服务',
base_url: 'https://api.openai.com/v1',
default_model: 'gpt-4o-mini',
enabled: true,
has_credential: true,
capabilities: {
chat: true,
tool_calling: true,
streaming: true,
vision: true,
reasoning: false,
structured_output: true,
embedding: true,
},
},
{
provider_id: 'ollama-local',
provider_type: 'ollama',
name: 'Ollama (本地)',
base_url: 'http://127.0.0.1:11434',
default_model: 'qwen2.5:7b',
enabled: false,
has_credential: false,
capabilities: {
chat: true,
tool_calling: false,
streaming: true,
vision: false,
reasoning: false,
structured_output: false,
embedding: true,
},
},
]
export const mockModels: Record<string, ModelInfo[]> = {
mock: [
{
model_id: 'mock-1',
name: 'Mock Model v1',
capabilities: { chat: true, tool_calling: true, streaming: true, structured_output: true },
context_window: 8192,
},
],
'openai-compat-1': [
{
model_id: 'gpt-4o-mini',
name: 'GPT-4o Mini',
capabilities: { chat: true, tool_calling: true, streaming: true, vision: true, structured_output: true },
context_window: 128000,
},
{
model_id: 'gpt-4o',
name: 'GPT-4o',
capabilities: { chat: true, tool_calling: true, streaming: true, vision: true, structured_output: true, reasoning: true },
context_window: 128000,
},
{
model_id: 'text-embedding-3-small',
name: 'Text Embedding 3 Small',
capabilities: { embedding: true },
},
],
'ollama-local': [
{
model_id: 'qwen2.5:7b',
name: 'Qwen 2.5 7B',
capabilities: { chat: true, streaming: true },
context_window: 32768,
},
{
model_id: 'bge-m3',
name: 'BGE M3',
capabilities: { embedding: true },
},
],
}
-66
View File
@@ -35,69 +35,3 @@ export async function search(request: SearchRequest): Promise<{
mode: response.mode, mode: response.mode,
} }
} }
export async function searchMock(
query: string,
mode: 'fts' | 'vector' | 'hybrid' = 'hybrid'
): Promise<{
results: SearchResult[]
total: number
mode: 'fts' | 'vector' | 'hybrid'
}> {
await new Promise((r) => setTimeout(r, 300))
if (!query.trim()) return { results: [], total: 0, mode }
const results: SearchResult[] = [
{
block_id: 'b1',
note_id: 'n-rbt',
note_title: '红黑树',
file_path: '/数据结构/红黑树.md',
heading_path: '数据结构 / 红黑树 / 插入操作',
snippet: '插入后可能破坏红黑性质,需要通过变色和旋转来修复...',
score: 0.95,
match_type: 'hybrid',
tags: ['数据结构', '树'],
},
{
block_id: 'b2',
note_id: 'n-rbt',
note_title: '红黑树',
file_path: '/数据结构/红黑树.md',
heading_path: '数据结构 / 红黑树 / 性质',
snippet: '红黑树是一种自平衡二叉搜索树,每个节点带有颜色属性(红或黑)...',
score: 0.87,
match_type: 'fts',
tags: ['数据结构'],
},
{
block_id: 'b3',
note_id: 'n-bst',
note_title: '二叉搜索树',
file_path: '/数据结构/二叉搜索树.md',
heading_path: '数据结构 / 二叉搜索树 / 基本操作',
snippet: '二叉搜索树的插入需要先找到合适的位置,再添加新节点...',
score: 0.72,
match_type: 'vector',
tags: ['数据结构', '树'],
},
{
block_id: 'b4',
note_id: 'n-deadlock',
note_title: '死锁',
file_path: '/操作系统/死锁.md',
heading_path: '操作系统 / 死锁 / 必要条件',
snippet: '死锁的四个必要条件:互斥、占有并等待、不可抢占、循环等待...',
score: 0.45,
match_type: 'vector',
tags: ['操作系统'],
},
]
const filtered = results.filter(
(r) =>
r.note_title.includes(query) ||
r.snippet.includes(query) ||
r.heading_path.includes(query) ||
query.length > 1
)
return { results: filtered, total: filtered.length, mode }
}
-74
View File
@@ -42,77 +42,3 @@ export async function disableSkill(skillId: string): Promise<Skill> {
export async function uninstallSkill(skillId: string): Promise<OperationResponse> { export async function uninstallSkill(skillId: string): Promise<OperationResponse> {
return apiClient.delete(`/api/skills/${skillId}`) return apiClient.delete(`/api/skills/${skillId}`)
} }
export const mockSkills: Skill[] = [
{
skill_id: 'exam-review',
name: '期末复习助手',
version: '1.0.0',
description: '根据课程笔记生成复习要点和练习题,帮助高效备考',
icon: '',
author: 'NotesAgent 团队',
permissions: ['notes.search', 'notes.read', 'tasks.create'],
tools: ['notes.search', 'notes.read', 'tasks.create'],
retrieval_config: { top_k: 10, rerank: true, citation: true },
model_requirements: { capabilities: ['chat', 'tool_calling'] },
status: 'ready',
enabled: true,
},
{
skill_id: 'meeting-summary',
name: '会议纪要生成',
version: '1.1.0',
description: '从音频或文本中提取会议要点、行动项和待办任务',
icon: '',
author: 'NotesAgent 团队',
permissions: ['notes.search', 'notes.write', 'tasks.write', 'attachments.read'],
tools: ['notes.search', 'notes.create', 'tasks.create', 'attachments.read'],
retrieval_config: { top_k: 5, rerank: false, citation: true },
model_requirements: { capabilities: ['chat', 'tool_calling', 'structured_output'] },
status: 'ready',
enabled: true,
},
{
skill_id: 'code-explainer',
name: '代码解读助手',
version: '0.9.0',
description: '分析代码片段,解释功能、复杂度和优化建议',
icon: '',
author: '社区贡献',
permissions: ['notes.search', 'notes.read'],
tools: ['notes.search', 'notes.read', 'rag.search'],
retrieval_config: { top_k: 8, rerank: true, citation: true },
model_requirements: { capabilities: ['chat', 'tool_calling'] },
status: 'installed',
enabled: false,
},
{
skill_id: 'research-assistant',
name: '文献研究助手',
version: '1.2.0',
description: '自动整理文献笔记,生成研究综述和引用关系图',
icon: '',
author: '社区贡献',
permissions: ['notes.search', 'notes.read', 'notes.write'],
tools: ['notes.search', 'notes.read', 'notes.create', 'rag.search'],
retrieval_config: { top_k: 15, rerank: true, citation: true },
model_requirements: { capabilities: ['chat', 'tool_calling', 'reasoning'] },
status: 'dependency_missing',
enabled: false,
missing_dependencies: ['文献引用插件', '知识图谱插件'],
},
{
skill_id: 'language-tutor',
name: '语言学习助手',
version: '0.5.0',
description: '基于你的学习笔记生成语言练习和记忆卡片',
icon: '',
author: '社区贡献',
permissions: ['notes.search', 'notes.read', 'tasks.create'],
tools: ['notes.search', 'notes.read', 'tasks.create'],
retrieval_config: { top_k: 6, rerank: false, citation: false },
model_requirements: { capabilities: ['chat'] },
status: 'ready',
enabled: true,
},
]
+8 -17
View File
@@ -1,23 +1,14 @@
import apiClient from './apiClient' import apiClient from './apiClient'
import type { SystemStatus } from '@/contracts' import type { SystemStatus } from '@/contracts'
export async function healthCheck(): Promise<{ status: string }> { export function healthCheck(): Promise<{ status: string }> {
try { return apiClient.get('/health')
return await apiClient.get<{ status: string }>('/health')
} catch {
return { status: 'unavailable' }
}
} }
export async function getStatus(): Promise<SystemStatus> { export function getStatus(): Promise<SystemStatus> {
try { return apiClient.get('/api/status')
return await apiClient.get<SystemStatus>('/api/status') }
} catch {
return { export function getPermissionPolicy(): Promise<Record<string, 'allow' | 'confirm' | 'deny'>> {
status: 'ok', return apiClient.get('/api/permissions/policy')
name: 'notes-agent',
version: '0.1.0',
environment: import.meta.env.DEV ? 'development' : 'production',
}
}
} }
+1 -67
View File
@@ -1,5 +1,5 @@
import apiClient from './apiClient' import apiClient from './apiClient'
import type { ApiTask, OperationResponse, PageMeta, TaskItem, TaskStatus, TaskPriority } from '@/contracts' import type { ApiTask, OperationResponse, PageMeta, TaskItem, TaskStatus } from '@/contracts'
function toTask(task: ApiTask): TaskItem { function toTask(task: ApiTask): TaskItem {
return { return {
@@ -7,10 +7,8 @@ function toTask(task: ApiTask): TaskItem {
title: task.title, title: task.title,
description: task.description, description: task.description,
status: task.status, status: task.status,
priority: 'medium',
due_date: task.due_at ?? undefined, due_date: task.due_at ?? undefined,
note_id: task.note_id ?? undefined, note_id: task.note_id ?? undefined,
source: 'user',
created_at: task.created_at, created_at: task.created_at,
updated_at: task.updated_at, updated_at: task.updated_at,
} }
@@ -60,67 +58,3 @@ export async function updateTask(
export async function deleteTask(taskId: string): Promise<OperationResponse> { export async function deleteTask(taskId: string): Promise<OperationResponse> {
return apiClient.delete(`/api/tasks/${taskId}`) return apiClient.delete(`/api/tasks/${taskId}`)
} }
export const mockTasks: TaskItem[] = [
{
task_id: 't-1',
title: '完成红黑树章节复习',
description: '整理插入、删除操作的所有情况,准备期末复习',
status: 'todo',
priority: 'high',
due_date: '2026-08-30T23:59:00Z',
note_id: 'n-rbt',
note_title: '红黑树',
source: 'user',
created_at: '2026-08-20T10:00:00Z',
updated_at: '2026-08-25T14:30:00Z',
},
{
task_id: 't-2',
title: '理解死锁的银行家算法',
description: '推导银行家算法的安全性检查过程',
status: 'in_progress',
priority: 'medium',
note_id: 'n-deadlock',
note_title: '死锁',
source: 'agent',
created_at: '2026-08-22T09:00:00Z',
updated_at: '2026-08-24T16:00:00Z',
},
{
task_id: 't-3',
title: 'TCP 三次握手与四次挥手',
description: '',
status: 'done',
priority: 'high',
note_id: 'n-tcp',
note_title: 'TCP_IP',
source: 'user',
created_at: '2026-08-15T08:00:00Z',
updated_at: '2026-08-18T20:00:00Z',
},
{
task_id: 't-4',
title: 'HTTP 状态码整理',
description: '整理常见 HTTP 状态码及含义',
status: 'todo',
priority: 'low',
note_id: 'n-http',
note_title: 'HTTP协议',
source: 'note',
created_at: '2026-08-10T10:00:00Z',
updated_at: '2026-08-10T10:00:00Z',
},
{
task_id: 't-5',
title: '链表操作实现练习',
description: '实现单链表和双向链表的基本操作',
status: 'todo',
priority: 'medium',
note_id: 'n-slist',
note_title: '单链表',
source: 'agent',
created_at: '2026-08-23T11:00:00Z',
updated_at: '2026-08-23T11:00:00Z',
},
]
+15 -13
View File
@@ -1,21 +1,21 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { AgentRun, AgentEvent, ToolDefinition, PermissionRequest, ToolCall } from '@/contracts' import type { AgentRun, AgentEvent, ToolDefinition, PermissionRequest, ToolCall } from '@/contracts'
import { mockAgentRuns, mockAgentEvents, mockTools, mockPermissionRequest } from '@/services/agentService'
import * as agentService from '@/services/agentService' import * as agentService from '@/services/agentService'
import type { SseClient } from '@/services/sseClient' import type { SseClient } from '@/services/sseClient'
export const useAgentStore = defineStore('agent', () => { export const useAgentStore = defineStore('agent', () => {
const runs = ref<AgentRun[]>(mockAgentRuns) const runs = ref<AgentRun[]>([])
const activeRunId = ref<string | null>('run-1') const activeRunId = ref<string | null>(null)
const events = ref<AgentEvent[]>(mockAgentEvents.filter((e) => e.run_id === 'run-1')) const events = ref<AgentEvent[]>([])
const tools = ref<ToolDefinition[]>(mockTools) const tools = ref<ToolDefinition[]>([])
const isCreating = ref(false) const isCreating = ref(false)
const isRunning = ref(false) const isRunning = ref(false)
const permissionRequest = ref<PermissionRequest | null>(null) const permissionRequest = ref<PermissionRequest | null>(null)
const toolCalls = ref<ToolCall[]>([]) const toolCalls = ref<ToolCall[]>([])
const error = ref<string | null>(null) const error = ref<string | null>(null)
let eventStream: SseClient | null = null let eventStream: SseClient | null = null
let selectionVersion = 0
const activeRun = computed(() => const activeRun = computed(() =>
runs.value.find((r) => r.run_id === activeRunId.value) || null runs.value.find((r) => r.run_id === activeRunId.value) || null
@@ -40,9 +40,15 @@ export const useAgentStore = defineStore('agent', () => {
} }
async function loadRun(runId: string) { async function loadRun(runId: string) {
const version = ++selectionVersion
eventStream?.cancel() eventStream?.cancel()
activeRunId.value = runId activeRunId.value = runId
events.value = []
toolCalls.value = []
permissionRequest.value = null
isRunning.value = false
const run = await agentService.getAgentRun(runId) const run = await agentService.getAgentRun(runId)
if (version !== selectionVersion) return
const existingIndex = runs.value.findIndex((item) => item.run_id === runId) const existingIndex = runs.value.findIndex((item) => item.run_id === runId)
if (existingIndex >= 0) runs.value[existingIndex] = run if (existingIndex >= 0) runs.value[existingIndex] = run
else runs.value.unshift(run) else runs.value.unshift(run)
@@ -106,9 +112,9 @@ export const useAgentStore = defineStore('agent', () => {
isRunning.value = true isRunning.value = true
error.value = null error.value = null
eventStream = agentService.streamAgentEvents(runId, { eventStream = agentService.streamAgentEvents(runId, {
onEvent: processEvent, onEvent(event) { if (activeRunId.value === runId) processEvent(event) },
onError(streamError) { error.value = streamError.message; isRunning.value = false }, onError(streamError) { if (activeRunId.value === runId) { error.value = streamError.message; isRunning.value = false } },
onDone() { isRunning.value = false; eventStream = null }, onDone() { if (activeRunId.value === runId) { isRunning.value = false; eventStream = null } },
}) })
} }
@@ -116,6 +122,7 @@ export const useAgentStore = defineStore('agent', () => {
isCreating.value = true isCreating.value = true
try { try {
const run = await agentService.createAgentRun(request) const run = await agentService.createAgentRun(request)
selectionVersion++
runs.value.unshift(run) runs.value.unshift(run)
activeRunId.value = run.run_id activeRunId.value = run.run_id
events.value = [] events.value = []
@@ -143,10 +150,6 @@ export const useAgentStore = defineStore('agent', () => {
permissionRequest.value = null permissionRequest.value = null
} }
function showPermissionDemo() {
permissionRequest.value = mockPermissionRequest
}
return { return {
runs, runs,
activeRunId, activeRunId,
@@ -166,6 +169,5 @@ export const useAgentStore = defineStore('agent', () => {
createRun, createRun,
cancelRun, cancelRun,
respondPermission, respondPermission,
showPermissionDemo,
} }
}) })
+43
View File
@@ -0,0 +1,43 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useChatStore } from './chat'
import { streamChat } from '@/services/chatService'
import type { SseClient } from '@/services/sseClient'
vi.mock('@/services/chatService', () => ({ streamChat: vi.fn() }))
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(streamChat).mockReset().mockReturnValue({ cancel: vi.fn() } as unknown as SseClient)
})
it('sends real user history, applies streaming changes, and restores it when switching conversations', async () => {
const store = useChatStore()
store.selectedProviderId = 'real'
store.selectedModel = 'configured-model'
await store.sendMessage('user input')
const [request, handlers] = vi.mocked(streamChat).mock.calls[0]!
expect(request.messages).toEqual([{ role: 'user', content: 'user input' }])
handlers.onEvent?.({ event: 'TextDelta', sequence: 0, timestamp: '', data: { text: 'real response' } })
expect(store.messages[1]?.content).toBe('real response')
handlers.onDone?.()
const id = store.activeConversationId!
store.createNewConversation()
expect(store.messages).toEqual([])
await store.setActiveConversation(id)
expect(store.messages.map(m => m.content)).toEqual(['user input', 'real response'])
})
it('does not send without a provider and ignores late callbacks from a cancelled conversation', async () => {
const store = useChatStore()
await store.sendMessage('no provider')
expect(streamChat).not.toHaveBeenCalled()
store.selectedProviderId = 'real'
store.selectedModel = 'configured-model'
await store.sendMessage('first')
const old = vi.mocked(streamChat).mock.calls[0]![1]
store.createNewConversation()
await store.sendMessage('second')
old.onDone?.()
expect(store.isStreaming).toBe(true)
expect(store.messages[0]?.content).toBe('second')
})
+43 -21
View File
@@ -1,22 +1,24 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed, reactive } from 'vue'
import type { ChatMessage, Conversation } from '@/contracts' import type { ChatMessage, Conversation } from '@/contracts'
import { mockConversations, mockMessages, streamChat } from '@/services/chatService' import { streamChat } from '@/services/chatService'
import type { SseClient } from '@/services/sseClient' import type { SseClient } from '@/services/sseClient'
export const useChatStore = defineStore('chat', () => { export const useChatStore = defineStore('chat', () => {
const conversations = ref<Conversation[]>(mockConversations) const conversations = ref<Conversation[]>([])
const activeConversationId = ref<string | null>('conv-1') const activeConversationId = ref<string | null>(null)
const messages = ref<ChatMessage[]>(mockMessages['conv-1'] || []) const messages = ref<ChatMessage[]>([])
const isStreaming = ref(false) const isStreaming = ref(false)
const inputText = ref('') const inputText = ref('')
const useRag = ref(true) const useRag = ref(false)
const selectedSkillId = ref<string | null>(null) const selectedSkillId = ref<string | null>(null)
const selectedProviderId = ref('mock') const selectedProviderId = ref('')
const selectedModel = ref('mock-1') const selectedModel = ref('')
let sseClient: SseClient | null = null let sseClient: SseClient | null = null
let streamVersion = 0
// TODO(chat): 会话持久化接口完成后移除 mockConversations/mockMessages 数据源。 // User-created conversations live in this browser session; no fabricated history.
const history = reactive<Record<string, ChatMessage[]>>({})
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
@@ -27,13 +29,14 @@ export const useChatStore = defineStore('chat', () => {
) )
async function setActiveConversation(id: string) { async function setActiveConversation(id: string) {
stopGeneration()
activeConversationId.value = id activeConversationId.value = id
messages.value = mockMessages[id] || [] messages.value = history[id] ?? []
} }
async function sendMessage(text: string) { async function sendMessage(text: string) {
if (!text.trim() || isStreaming.value) return if (!text.trim() || isStreaming.value || !selectedProviderId.value || !selectedModel.value) return
const conversationId = activeConversationId.value || `conv-${Date.now()}` const conversationId = activeConversationId.value || crypto.randomUUID()
if (!activeConversationId.value) { if (!activeConversationId.value) {
const newConv: Conversation = { const newConv: Conversation = {
@@ -47,8 +50,10 @@ export const useChatStore = defineStore('chat', () => {
activeConversationId.value = conversationId activeConversationId.value = conversationId
} }
history[conversationId] = messages.value
const conversationMessages = messages.value
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
message_id: `msg-${Date.now()}`, message_id: crypto.randomUUID(),
conversation_id: conversationId, conversation_id: conversationId,
role: 'user', role: 'user',
content: text, content: text,
@@ -57,29 +62,34 @@ export const useChatStore = defineStore('chat', () => {
messages.value.push(userMsg) messages.value.push(userMsg)
inputText.value = '' inputText.value = ''
isStreaming.value = true isStreaming.value = true
const conversation = conversations.value.find(c => c.conversation_id === conversationId)
if (conversation) { conversation.updated_at = new Date().toISOString(); conversation.message_count = messages.value.length }
// 先插入占位消息,随后将 SSE 增量原位合并,避免每个 token 重建消息列表。 // 先插入占位消息,随后将 SSE 增量原位合并,避免每个 token 重建消息列表。
const aiMsg: ChatMessage = { const aiMsg = reactive<ChatMessage>({
message_id: `msg-${Date.now() + 1}`, message_id: crypto.randomUUID(),
conversation_id: conversationId, conversation_id: conversationId,
role: 'assistant', role: 'assistant',
content: '', content: '',
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
citations: [], citations: [],
tool_calls: [], tool_calls: [],
} })
messages.value.push(aiMsg) messages.value.push(aiMsg)
const version = ++streamVersion
const argumentBuffers = new Map<string, string>()
sseClient = streamChat({ sseClient = streamChat({
provider_id: selectedProviderId.value, provider_id: selectedProviderId.value,
model: selectedModel.value, model: selectedModel.value,
conversation_id: conversationId, conversation_id: conversationId,
use_rag: useRag.value, use_rag: useRag.value,
messages: messages.value messages: messages.value
.filter((message) => message !== aiMsg) .filter((message) => message.message_id !== aiMsg.message_id)
.map((message) => ({ role: message.role, content: message.content })), .map((message) => ({ role: message.role, content: message.content })),
}, { }, {
onEvent(event) { onEvent(event) {
if (version !== streamVersion) return
if (event.event === 'TextDelta') aiMsg.content += String(event.data.text ?? '') if (event.event === 'TextDelta') aiMsg.content += String(event.data.text ?? '')
if (event.event === 'ThinkingDelta') aiMsg.thinking = `${aiMsg.thinking ?? ''}${String(event.data.text ?? '')}` if (event.event === 'ThinkingDelta') aiMsg.thinking = `${aiMsg.thinking ?? ''}${String(event.data.text ?? '')}`
if (event.event === 'ToolCallStart') { if (event.event === 'ToolCallStart') {
@@ -92,6 +102,11 @@ export const useChatStore = defineStore('chat', () => {
} }
if (event.event === 'ToolCallDelta') { if (event.event === 'ToolCallDelta') {
const call = aiMsg.tool_calls?.find((item) => item.tool_call_id === event.data.tool_call_id) const call = aiMsg.tool_calls?.find((item) => item.tool_call_id === event.data.tool_call_id)
if (call && typeof event.data.arguments_delta === 'string') {
const buffer = (argumentBuffers.get(call.tool_call_id) ?? '') + event.data.arguments_delta
argumentBuffers.set(call.tool_call_id, buffer)
try { call.parameters = JSON.parse(buffer) } catch { /* incomplete JSON fragment */ }
}
if (call && event.data.arguments && typeof event.data.arguments === 'object') { if (call && event.data.arguments && typeof event.data.arguments === 'object') {
Object.assign(call.parameters, event.data.arguments) Object.assign(call.parameters, event.data.arguments)
} }
@@ -116,14 +131,16 @@ export const useChatStore = defineStore('chat', () => {
if (event.event === 'Error') aiMsg.content += `\n\n生成失败:${String(event.data.message ?? '未知错误')}` if (event.event === 'Error') aiMsg.content += `\n\n生成失败:${String(event.data.message ?? '未知错误')}`
}, },
onError(error) { onError(error) {
if (version !== streamVersion) return
aiMsg.content += `\n\n连接失败:${error.message}` aiMsg.content += `\n\n连接失败:${error.message}`
isStreaming.value = false isStreaming.value = false
sseClient = null sseClient = null
}, },
onDone() { onDone() {
if (version !== streamVersion) return
const conversation = conversations.value.find((item) => item.conversation_id === conversationId) const conversation = conversations.value.find((item) => item.conversation_id === conversationId)
if (conversation) { if (conversation) {
conversation.message_count = messages.value.length conversation.message_count = conversationMessages.length
conversation.updated_at = new Date().toISOString() conversation.updated_at = new Date().toISOString()
} }
isStreaming.value = false isStreaming.value = false
@@ -133,6 +150,7 @@ export const useChatStore = defineStore('chat', () => {
} }
function stopGeneration() { function stopGeneration() {
streamVersion++
if (sseClient) { if (sseClient) {
sseClient.cancel() sseClient.cancel()
sseClient = null sseClient = null
@@ -141,8 +159,9 @@ export const useChatStore = defineStore('chat', () => {
} }
function createNewConversation() { function createNewConversation() {
stopGeneration()
const newConv: Conversation = { const newConv: Conversation = {
conversation_id: `conv-${Date.now()}`, conversation_id: crypto.randomUUID(),
title: '新对话', title: '新对话',
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
@@ -150,16 +169,19 @@ export const useChatStore = defineStore('chat', () => {
} }
conversations.value.unshift(newConv) conversations.value.unshift(newConv)
activeConversationId.value = newConv.conversation_id activeConversationId.value = newConv.conversation_id
messages.value = [] history[newConv.conversation_id] = []
messages.value = history[newConv.conversation_id]
} }
function deleteConversation(id: string) { function deleteConversation(id: string) {
if (activeConversationId.value === id) stopGeneration()
delete history[id]
const idx = conversations.value.findIndex((c) => c.conversation_id === id) const idx = conversations.value.findIndex((c) => c.conversation_id === id)
if (idx > -1) { if (idx > -1) {
conversations.value.splice(idx, 1) conversations.value.splice(idx, 1)
if (activeConversationId.value === id) { if (activeConversationId.value === id) {
activeConversationId.value = conversations.value[0]?.conversation_id || null activeConversationId.value = conversations.value[0]?.conversation_id || null
messages.value = conversations.value[0] ? mockMessages[conversations.value[0].conversation_id] || [] : [] messages.value = conversations.value[0] ? history[conversations.value[0].conversation_id] || [] : []
} }
} }
} }
+67
View File
@@ -0,0 +1,67 @@
// @vitest-environment happy-dom
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useAgentStore } from './agent'
import { useChatStore } from './chat'
import { useTaskStore } from './task'
import { usePluginStore } from './plugin'
import { useSkillStore } from './skill'
import { useProviderStore } from './provider'
import { useSettingsStore } from './settings'
import { listProviders } from '@/services/providerService'
import { getStatus } from '@/services/systemService'
beforeEach(() => { setActivePinia(createPinia()); localStorage.clear() })
afterEach(() => vi.unstubAllGlobals())
describe('runtime data sources', () => {
it('starts with no fabricated domain records or healthy diagnostics', () => {
expect(useAgentStore().runs).toEqual([])
expect(useAgentStore().events).toEqual([])
expect(useAgentStore().tools).toEqual([])
expect(useAgentStore().permissionRequest).toBeNull()
expect(useChatStore().conversations).toEqual([])
expect(useChatStore().messages).toEqual([])
expect(useTaskStore().tasks).toEqual([])
expect(usePluginStore().plugins).toEqual([])
expect(useSkillStore().skills).toEqual([])
expect(useProviderStore().providers).toEqual([])
expect(useProviderStore().defaultProviderId).toBe('')
expect(useSettingsStore().aiCoreStatus).toBe('unknown')
expect(useSettingsStore().indexStatus.total_notes).toBeNull()
expect(useSettingsStore().permissionPolicy).toEqual({})
})
it('keeps initial collections empty and exposes errors when the API is offline', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')))
const stores = [useTaskStore(), usePluginStore(), useSkillStore(), useProviderStore()] as const
await Promise.all([stores[0].loadTasks(), stores[1].loadPlugins(), stores[2].loadSkills(), stores[3].loadProviders()])
expect(stores.every(store => store.error)).toBe(true)
await useSettingsStore().loadDiagnostics()
expect(useSettingsStore().aiCoreStatus).toBe('error')
expect(useSettingsStore().indexStatus.total_blocks).toBeNull()
expect(useSettingsStore().diagnosticsError).toBeTruthy()
await expect(getStatus()).rejects.toThrow()
})
it('renders backend counts and effective permissions and excludes the test provider', async () => {
const data: Record<string, unknown> = {
'/health': { status: 'ok' }, '/api/status': { version: '9.2.1' },
'/api/index/status': { status: 'idle', pending_jobs: 0, total_notes: 7, total_blocks: 19 },
'/api/permissions/policy': { 'attachments.read': 'allow' },
'/api/providers': { items: [
{ provider_id: 'mock', provider_type: 'mock', capabilities: [] },
{ provider_id: 'real', name: 'Real', provider_type: 'ollama', capabilities: [], enabled: true, default_model: 'installed-model' },
] },
}
vi.stubGlobal('fetch', vi.fn(async (url: string) => new Response(JSON.stringify(data[url]), { status: 200, headers: { "content-type": "application/json" } })))
expect((await listProviders()).map(p => p.provider_id)).toEqual(['real'])
await useProviderStore().loadProviders()
expect(useProviderStore().defaultProviderId).toBe('real')
await useSettingsStore().loadDiagnostics()
expect(useSettingsStore().indexStatus.total_notes).toBe(7)
expect(useSettingsStore().indexStatus.total_blocks).toBe(19)
expect(useSettingsStore().aiCoreVersion).toBe('9.2.1')
expect(useSettingsStore().permissionPolicy).toEqual({ 'attachments.read': 'allow' })
})
})
+1 -1
View File
@@ -4,7 +4,7 @@ import type { Plugin } from '@/contracts'
import * as pluginService from '@/services/pluginService' import * as pluginService from '@/services/pluginService'
export const usePluginStore = defineStore('plugin', () => { export const usePluginStore = defineStore('plugin', () => {
const plugins = ref<Plugin[]>(pluginService.mockPlugins) const plugins = ref<Plugin[]>([])
const selectedPluginId = ref<string | null>(null) const selectedPluginId = ref<string | null>(null)
const isLoading = ref(false) const isLoading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
-2
View File
@@ -4,8 +4,6 @@ import { createPinia, setActivePinia } from 'pinia'
import type { ProviderConfig, ProviderPreset } from '@/contracts' import type { ProviderConfig, ProviderPreset } from '@/contracts'
vi.mock('@/services/providerService', () => ({ vi.mock('@/services/providerService', () => ({
mockProviders: [],
mockModels: {},
listProviders: vi.fn(), listProviders: vi.fn(),
listProviderPresets: vi.fn(), listProviderPresets: vi.fn(),
getCredentialStatus: vi.fn(), getCredentialStatus: vi.fn(),
+7 -4
View File
@@ -1,17 +1,17 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { ProviderConfig, ModelInfo, ProviderPreset } from '@/contracts' import type { ProviderConfig, ModelInfo, ProviderPreset } from '@/contracts'
import { createProvider, deleteProvider as deleteProviderRequest, getCredentialStatus, listModels, listProviderPresets, listProviders, mockProviders, mockModels, putCredential, testProvider as testProviderRequest, updateProvider as updateProviderRequest } from '@/services/providerService' import { createProvider, deleteProvider as deleteProviderRequest, getCredentialStatus, listModels, listProviderPresets, listProviders, putCredential, testProvider as testProviderRequest, updateProvider as updateProviderRequest } from '@/services/providerService'
import { ApiErrorClass } from '@/services/apiClient' import { ApiErrorClass } from '@/services/apiClient'
export const useProviderStore = defineStore('provider', () => { export const useProviderStore = defineStore('provider', () => {
const providers = ref<ProviderConfig[]>(mockProviders) const providers = ref<ProviderConfig[]>([])
const presets = ref<ProviderPreset[]>([]) const presets = ref<ProviderPreset[]>([])
const modelsByProvider = ref<Record<string, ModelInfo[]>>(mockModels) const modelsByProvider = ref<Record<string, ModelInfo[]>>({})
const modelLoadingByProvider = ref<Record<string, boolean>>({}) const modelLoadingByProvider = ref<Record<string, boolean>>({})
const modelErrorsByProvider = ref<Record<string, string>>({}) const modelErrorsByProvider = ref<Record<string, string>>({})
const credentialConfiguredById = ref<Record<string, boolean>>({}) const credentialConfiguredById = ref<Record<string, boolean>>({})
const defaultProviderId = ref('mock') const defaultProviderId = ref('')
const isLoading = ref(false) const isLoading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
@@ -24,6 +24,9 @@ export const useProviderStore = defineStore('provider', () => {
isLoading.value = true isLoading.value = true
try { try {
providers.value = await listProviders() providers.value = await listProviders()
if (!enabledProviders.value.some(p => p.provider_id === defaultProviderId.value)) {
defaultProviderId.value = enabledProviders.value[0]?.provider_id ?? ''
}
error.value = null error.value = null
} catch (reason) { } catch (reason) {
error.value = reason instanceof Error ? reason.message : 'Provider 加载失败' error.value = reason instanceof Error ? reason.message : 'Provider 加载失败'
+17 -46
View File
@@ -1,7 +1,8 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import type { AiCoreStatus, IndexStatus } from '@/contracts' import type { AiCoreStatus, IndexStatus } from '@/contracts'
import { mockIndexStatus } from '@/services/indexService' import { resolveApiUrl } from '@/services/apiClient'
import packageInfo from '../../package.json'
import * as indexService from '@/services/indexService' import * as indexService from '@/services/indexService'
import * as systemService from '@/services/systemService' import * as systemService from '@/services/systemService'
@@ -14,8 +15,8 @@ export const useSettingsStore = defineStore('settings', () => {
const restoreLastVault = ref(saved.restoreLastVault !== false) const restoreLastVault = ref(saved.restoreLastVault !== false)
const autoSaveInterval = ref(typeof saved.autoSaveInterval === 'number' ? saved.autoSaveInterval : 1500) const autoSaveInterval = ref(typeof saved.autoSaveInterval === 'number' ? saved.autoSaveInterval : 1500)
const language = ref<'zh-CN' | 'en'>(saved.language === 'en' ? 'en' : 'zh-CN') const language = ref<'zh-CN' | 'en'>(saved.language === 'en' ? 'en' : 'zh-CN')
const appVersion = ref('0.1.0') const appVersion = ref(packageInfo.version)
const aiCoreVersion = ref('0.1.0') const aiCoreVersion = ref('未获取')
// Editor // Editor
const defaultEditorMode = ref<'wysiwyg' | 'source'>(saved.defaultEditorMode === 'source' ? 'source' : 'wysiwyg') const defaultEditorMode = ref<'wysiwyg' | 'source'>(saved.defaultEditorMode === 'source' ? 'source' : 'wysiwyg')
@@ -23,24 +24,15 @@ export const useSettingsStore = defineStore('settings', () => {
const spellCheck = ref(saved.spellCheck === true) const spellCheck = ref(saved.spellCheck === true)
// AI Core // AI Core
const aiCoreStatus = ref<AiCoreStatus>('running') const aiCoreStatus = ref<AiCoreStatus>('unknown')
const aiCoreAddress = ref('http://127.0.0.1:8000') const aiCoreAddress = ref(resolveApiUrl('/api') || '/api')
// Index // Index
const indexStatus = ref<IndexStatus>(mockIndexStatus) const emptyIndex = (): IndexStatus => ({ status: 'unknown', pending_jobs: 0, total_notes: null, total_blocks: null })
const indexStatus = ref<IndexStatus>(emptyIndex())
// Permissions // Permissions
const permissionPolicy = ref<Record<string, 'allow' | 'confirm' | 'deny'>>({ const permissionPolicy = ref<Record<string, 'allow' | 'confirm' | 'deny'>>({})
'notes.read': 'allow',
'notes.search': 'allow',
'notes.write': 'confirm',
'notes.delete': 'confirm',
'tasks.read': 'allow',
'tasks.write': 'confirm',
'attachments.read': 'confirm',
'network.request': 'confirm',
'secrets.use': 'confirm',
})
const diagnosticsError = ref<string | null>(null) const diagnosticsError = ref<string | null>(null)
watch(() => ({ watch(() => ({
@@ -50,18 +42,15 @@ export const useSettingsStore = defineStore('settings', () => {
}), (value) => localStorage.setItem('app-settings', JSON.stringify(value)), { deep: true }) }), (value) => localStorage.setItem('app-settings', JSON.stringify(value)), { deep: true })
async function loadDiagnostics() { async function loadDiagnostics() {
try { const results = await Promise.allSettled([
const [health, status, index] = await Promise.all([ systemService.healthCheck(), systemService.getStatus(), indexService.getIndexStatus(), systemService.getPermissionPolicy(),
systemService.healthCheck(), systemService.getStatus(), indexService.getIndexStatus(),
]) ])
aiCoreStatus.value = health.status === 'ok' ? 'running' : 'error' const [health, status, index, policy] = results
aiCoreVersion.value = status.version aiCoreStatus.value = health.status === 'fulfilled' && health.value.status === 'ok' ? 'running' : 'error'
indexStatus.value = index aiCoreVersion.value = status.status === 'fulfilled' ? status.value.version : '未获取'
diagnosticsError.value = null indexStatus.value = index.status === 'fulfilled' ? index.value : emptyIndex()
} catch (reason) { permissionPolicy.value = policy.status === 'fulfilled' ? policy.value : {}
aiCoreStatus.value = 'error' diagnosticsError.value = results.filter(item => item.status === 'rejected').map(item => item.reason instanceof Error ? item.reason.message : '后端请求失败').join('') || null
diagnosticsError.value = reason instanceof Error ? reason.message : '诊断信息加载失败'
}
} }
function setAutoSaveInterval(ms: number) { function setAutoSaveInterval(ms: number) {
@@ -72,21 +61,6 @@ export const useSettingsStore = defineStore('settings', () => {
defaultEditorMode.value = mode defaultEditorMode.value = mode
} }
function setPermission(permission: string, policy: 'allow' | 'confirm' | 'deny') {
permissionPolicy.value[permission] = policy
}
function setAiCoreStatus(status: AiCoreStatus) {
aiCoreStatus.value = status
}
async function restartAiCore(): Promise<boolean> {
aiCoreStatus.value = 'starting'
await new Promise((r) => setTimeout(r, 1500))
aiCoreStatus.value = 'running'
return true
}
async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full') { async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full') {
indexStatus.value.status = 'indexing' indexStatus.value.status = 'indexing'
try { try {
@@ -115,9 +89,6 @@ export const useSettingsStore = defineStore('settings', () => {
loadDiagnostics, loadDiagnostics,
setAutoSaveInterval, setAutoSaveInterval,
setDefaultEditorMode, setDefaultEditorMode,
setPermission,
setAiCoreStatus,
restartAiCore,
rebuildIndex, rebuildIndex,
} }
}) })
+1 -1
View File
@@ -4,7 +4,7 @@ import type { Skill } from '@/contracts'
import * as skillService from '@/services/skillService' import * as skillService from '@/services/skillService'
export const useSkillStore = defineStore('skill', () => { export const useSkillStore = defineStore('skill', () => {
const skills = ref<Skill[]>(skillService.mockSkills) const skills = ref<Skill[]>([])
const selectedSkillId = ref<string | null>(null) const selectedSkillId = ref<string | null>(null)
const isLoading = ref(false) const isLoading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
+3 -3
View File
@@ -1,10 +1,10 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { TaskItem, TaskStatus, TaskPriority, TaskSource } from '@/contracts' import type { TaskItem, TaskStatus, TaskPriority, TaskSource } from '@/contracts'
import { createTask as createTaskRequest, deleteTask as deleteTaskRequest, listTasks, mockTasks, updateTask as updateTaskRequest } from '@/services/taskService' import { createTask as createTaskRequest, deleteTask as deleteTaskRequest, listTasks, updateTask as updateTaskRequest } from '@/services/taskService'
export const useTaskStore = defineStore('task', () => { export const useTaskStore = defineStore('task', () => {
const tasks = ref<TaskItem[]>(mockTasks) const tasks = ref<TaskItem[]>([])
const filterStatus = ref<TaskStatus | 'all'>('all') const filterStatus = ref<TaskStatus | 'all'>('all')
const filterPriority = ref<TaskPriority | 'all'>('all') const filterPriority = ref<TaskPriority | 'all'>('all')
const filterSource = ref<TaskSource | 'all'>('all') const filterSource = ref<TaskSource | 'all'>('all')
@@ -47,7 +47,7 @@ export const useTaskStore = defineStore('task', () => {
const task = tasks.value.find((t) => t.task_id === taskId) const task = tasks.value.find((t) => t.task_id === taskId)
if (task) { if (task) {
const updated = await updateTaskRequest(taskId, data) const updated = await updateTaskRequest(taskId, data)
Object.assign(task, updated, data) Object.assign(task, updated)
} }
} }