Feat(frontend)完善前端中英文支持与表单样式,持久化聊天记录并修复会话并发问题 #24

Merged
Kronecker merged 7 commits from feat/frontend-i18n-spellcheck into main 2026-09-05 15:27:02 +08:00
69 changed files with 1918 additions and 717 deletions
+3 -2
View File
@@ -2,7 +2,7 @@
> 本文件用于团队开发期间快速配置环境、启动项目并了解当前实现状态,不是正式的项目 README。 > 本文件用于团队开发期间快速配置环境、启动项目并了解当前实现状态,不是正式的项目 README。
NotesAgent 是本地优先的 AI 笔记与知识库项目。当前可运行形态为 Vue/Vite Web 前端与 FastAPI AI CoreMarkdown 和附件保存在本地 Vault,SQLite 管理元数据、全文索引、向量空间、搜索历史、会话、任务、Agent Trace、多模态任务及运行诊断。 NotesAgent 是本地优先的 AI 笔记与知识库项目。当前可运行形态为 Vue/Vite Web 前端与 FastAPI AI CoreMarkdown 和附件保存在本地 Vault,SQLite 管理元数据、全文索引、向量空间、搜索历史、AI 会话、任务、Agent Trace、多模态任务及运行诊断。AI 对话已接入知识库检索,会话与消息由后端持久化并供 Web 和桌面客户端共用。
截至 2026-09-05,第一阶段及第二阶段 A~F 的工程范围已经合并到 `main`。当前已完成真实 Workspace、混合检索与知识库问答、Agent/Tool/Permission、Skill/Plugin、MCP 配置与调用、模型提供商与路由、RAG Benchmark,以及本地 Embedding、音频转写和片段级声纹聚类。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统、生产级 MCP 沙箱和 Sync Server 尚未接入。 截至 2026-09-05,第一阶段及第二阶段 A~F 的工程范围已经合并到 `main`。当前已完成真实 Workspace、混合检索与知识库问答、Agent/Tool/Permission、Skill/Plugin、MCP 配置与调用、模型提供商与路由、RAG Benchmark,以及本地 Embedding、音频转写和片段级声纹聚类。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统、生产级 MCP 沙箱和 Sync Server 尚未接入。
@@ -26,6 +26,7 @@ NotesAgent/
- 多模态:API 优先,未配置或响应无效时回退本地;`local_only` 禁止远程调用。任务、修订、事件、来源和回退原因写入 SQLite。 - 多模态:API 优先,未配置或响应无效时回退本地;`local_only` 禁止远程调用。任务、修订、事件、来源和回退原因写入 SQLite。
- 模型运行:默认 CPU,可选 CUDA 12.8 组件;固定模型 revision,按需启动独立子进程,交互检索优先排队,CUDA 初始化或显存失败时用同一冻结配置在 CPU 重试一次。 - 模型运行:默认 CPU,可选 CUDA 12.8 组件;固定模型 revision,按需启动独立子进程,交互检索优先排队,CUDA 初始化或显存失败时用同一冻结配置在 CPU 重试一次。
- 可观测性:输入、输出、缓存命中、推理 Token 与音频用量卡片;本地运行诊断保留最近 200 条,不保存正文、文件路径、密钥或异常全文。 - 可观测性:输入、输出、缓存命中、推理 Token 与音频用量卡片;本地运行诊断保留最近 200 条,不保存正文、文件路径、密钥或异常全文。
- 界面偏好:设置页可即时切换全局中文/英文界面,并控制由系统词典提供的编辑器拼写检查;偏好目前保存于 Web 端设备配置,后续由 Tauri 配置存储接管。
## 本地模型 ## 本地模型
@@ -110,7 +111,7 @@ pnpm test
pnpm build pnpm build
``` ```
阶段 F 合并时的回归基线为后端 559 项、前端 103 项测试通过,TypeScript 类型检查与生产构建通过。存在一条既有 Starlette/httpx 弃用提示和 Vite 大 bundle 提示;测试数量以当前分支实际输出和 CI 为准。 当前回归基线为后端 559 项、前端 106 项测试通过,TypeScript 类型检查与生产构建通过。存在一条既有 Starlette/httpx 弃用提示和 Vite 大 bundle 提示;测试数量以当前分支实际输出和 CI 为准。
## 文档 ## 文档
+2 -2
View File
@@ -27,7 +27,7 @@ uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
| `app/extensions` | Skill、Plugin Host、MCP Registry 与 stdio/HTTP/SSE Bridge | | `app/extensions` | Skill、Plugin Host、MCP Registry 与 stdio/HTTP/SSE Bridge |
| `app/providers` | OpenAI Chat/Compatible、Responses、Anthropic Messages、Ollama 与能力路由 | | `app/providers` | OpenAI Chat/Compatible、Responses、Anthropic Messages、Ollama 与能力路由 |
| `app/local_models` | 模型目录、固定 revision 下载、独立进程、设备回退和队列调度 | | `app/local_models` | 模型目录、固定 revision 下载、独立进程、设备回退和队列调度 |
| `app/services` | 索引、知识库上下文、转写、搜索历史、用量和诊断等应用服务 | | `app/services` | 索引、知识库上下文、聊天记录、转写、搜索历史、用量和诊断等应用服务 |
| `app/benchmarks` | 版本化 RAG Dataset、异步评测、指标与报告 | | `app/benchmarks` | 版本化 RAG Dataset、异步评测、指标与报告 |
## 模型路由 ## 模型路由
@@ -82,7 +82,7 @@ API Key 可由前端设置页写入,也可通过 `OPENAI_API_KEY`、`DEEPSEEK_
uv run pytest uv run pytest
``` ```
阶段 F 合并基线为 559 项测试通过,另有一条既有 Starlette/httpx 弃用提示。真实模型冒烟脚本: 当前基线为 562 项测试通过,另有一条既有 Starlette/httpx 弃用提示。真实模型冒烟脚本:
```powershell ```powershell
.venv/Scripts/python scripts/local-model-smoke.py bekko --download .venv/Scripts/python scripts/local-model-smoke.py bekko --download
+47 -1
View File
@@ -255,11 +255,57 @@ class ModelRequest(Contract):
class ChatRequest(ModelRequest): class ChatRequest(ModelRequest):
conversation_id: str | None = None conversation_id: str | None = Field(default=None, min_length=1, max_length=128)
user_message_id: str | None = Field(default=None, min_length=1, max_length=128)
assistant_message_id: str | None = Field(default=None, min_length=1, max_length=128)
conversation_title: str | None = Field(default=None, max_length=120)
use_rag: bool = True use_rag: bool = True
retrieval: SearchRequest | None = None retrieval: SearchRequest | None = None
class ConversationCreateRequest(Contract):
conversation_id: str | None = Field(default=None, min_length=1, max_length=128)
title: str = Field(min_length=1, max_length=120)
@field_validator("title")
@classmethod
def title_must_not_be_blank(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("title must not be blank")
return value
class Conversation(Contract):
conversation_id: str
title: str
created_at: datetime
updated_at: datetime
message_count: int = 0
class ConversationListResponse(Contract):
items: list[Conversation] = Field(default_factory=list)
page: PageMeta = Field(default_factory=PageMeta)
class ChatMessage(Contract):
message_id: str
conversation_id: str
role: Literal["user", "assistant", "system"]
content: str
created_at: datetime
citations: list[dict[str, Any]] = Field(default_factory=list)
tool_calls: list[dict[str, Any]] = Field(default_factory=list)
thinking: str | None = None
usage: dict[str, Any] | None = None
class ChatMessageListResponse(Contract):
items: list[ChatMessage] = Field(default_factory=list)
page: PageMeta = Field(default_factory=PageMeta)
class ModelEventType(str, Enum): class ModelEventType(str, Enum):
citation = "Citation" citation = "Citation"
text_delta = "TextDelta" text_delta = "TextDelta"
+27
View File
@@ -132,6 +132,33 @@ MIGRATIONS: list[str] = [
""" """
ALTER TABLE blocks ADD COLUMN embedding_local_only INTEGER NOT NULL DEFAULT 0; ALTER TABLE blocks ADD COLUMN embedding_local_only INTEGER NOT NULL DEFAULT 0;
""", """,
# v7: application-owned chat conversations and messages, shared by web and desktop clients.
"""
CREATE TABLE IF NOT EXISTS chat_conversations (
conversation_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_chat_conversations_updated
ON chat_conversations(updated_at DESC);
CREATE TABLE IF NOT EXISTS chat_messages (
message_id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL REFERENCES chat_conversations(conversation_id) ON DELETE CASCADE,
sequence INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
thinking TEXT,
citations_json TEXT NOT NULL DEFAULT '[]',
tool_calls_json TEXT NOT NULL DEFAULT '[]',
usage_json TEXT,
created_at TEXT NOT NULL,
UNIQUE(conversation_id, sequence)
);
CREATE INDEX IF NOT EXISTS idx_chat_messages_conversation
ON chat_messages(conversation_id, sequence);
""",
] ]
+123 -3
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import json
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import aclosing from contextlib import aclosing
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -15,6 +16,10 @@ from app.contracts import (
AgentRunListResponse, AgentRunListResponse,
AgentTraceResponse, AgentTraceResponse,
ChatRequest, ChatRequest,
ChatMessageListResponse,
Conversation,
ConversationCreateRequest,
ConversationListResponse,
BenchmarkDatasetListResponse, BenchmarkDatasetListResponse,
BenchmarkEventType, BenchmarkEventType,
BenchmarkKind, BenchmarkKind,
@@ -321,6 +326,40 @@ async def clear_search_history() -> dict[str, list[str]]:
return {"queries": []} return {"queries": []}
@router.get("/chat/conversations", response_model=ConversationListResponse, tags=["Chat"])
async def list_chat_conversations(
limit: int = Query(default=50, ge=1, le=100), offset: int = Query(default=0, ge=0)
) -> ConversationListResponse:
from app.services import chat_history
items, total = chat_history.list_conversations(limit, offset)
return ConversationListResponse(items=items, page=PageMeta(total=total, limit=limit, offset=offset))
@router.post("/chat/conversations", response_model=Conversation, status_code=201, tags=["Chat"])
async def create_chat_conversation(request: ConversationCreateRequest) -> Conversation:
from app.services import chat_history
return chat_history.create(request.title, request.conversation_id)
@router.get("/chat/conversations/{conversation_id}/messages", response_model=ChatMessageListResponse, tags=["Chat"])
async def list_chat_messages(
conversation_id: str,
limit: int = Query(default=500, ge=1, le=1000),
offset: int = Query(default=0, ge=0),
) -> ChatMessageListResponse:
from app.services import chat_history
items, total = chat_history.list_messages(conversation_id, limit, offset)
return ChatMessageListResponse(items=items, page=PageMeta(total=total, limit=limit, offset=offset))
@router.delete("/chat/conversations/{conversation_id}", response_model=OperationResponse, tags=["Chat"])
async def delete_chat_conversation(conversation_id: str) -> OperationResponse:
from app.services import chat_history
if not chat_history.delete(conversation_id):
raise ApiError(404, "CONVERSATION_NOT_FOUND", "conversation not found", {"conversation_id": conversation_id})
return OperationResponse(status="completed", resource_id=conversation_id, message="deleted")
@router.post( @router.post(
"/chat", "/chat",
response_class=StreamingResponse, response_class=StreamingResponse,
@@ -333,14 +372,38 @@ async def clear_search_history() -> dict[str, list[str]]:
tags=["Chat"], tags=["Chat"],
) )
async def chat(request: ChatRequest) -> StreamingResponse: async def chat(request: ChatRequest) -> StreamingResponse:
from app.services import chat_history
conversation_id = request.conversation_id
assistant_message_id = request.assistant_message_id or f"message_{uuid4().hex}"
if conversation_id:
user_message = next(
(message for message in reversed(request.messages) if message.role.value == "user" and message.content.strip()),
None,
)
if user_message is not None:
chat_history.append_message(
conversation_id,
message_id=request.user_message_id or f"message_{uuid4().hex}",
role="user",
content=user_message.content,
title=request.conversation_title or user_message.content[:30],
)
provider = provider_or_404(request.provider_id) provider = provider_or_404(request.provider_id)
async def stream() -> AsyncIterator[str]: async def stream() -> AsyncIterator[str]:
sequence = 0 sequence = 0
assistant_content = ""
assistant_thinking = ""
citations: list[dict] = []
tool_calls: list[dict] = []
argument_buffers: dict[str, str] = {}
usage: dict | None = None
try: try:
from app.services.chat_context import prepare from app.services.chat_context import prepare
grounded_request, citations = await prepare(request) grounded_request, grounded_citations = await prepare(request)
for citation in citations: for citation in grounded_citations:
citations.append(citation)
event = ModelEvent(event=ModelEventType.citation, sequence=sequence, event = ModelEvent(event=ModelEventType.citation, sequence=sequence,
data=citation, timestamp=utc_now()) data=citation, timestamp=utc_now())
sequence += 1 sequence += 1
@@ -349,13 +412,58 @@ async def chat(request: ChatRequest) -> StreamingResponse:
async for event in events: async for event in events:
event = event.model_copy(update={"sequence": sequence}) event = event.model_copy(update={"sequence": sequence})
sequence += 1 sequence += 1
if event.event == ModelEventType.text_delta:
assistant_content += str(event.data.get("text", ""))
elif event.event == ModelEventType.thinking_delta:
assistant_thinking += str(event.data.get("text", ""))
elif event.event == ModelEventType.tool_call_start:
tool_calls.append({
"tool_call_id": str(event.data.get("tool_call_id", "")),
"name": str(event.data.get("name", "unknown")),
"parameters": event.data.get("arguments") if isinstance(event.data.get("arguments"), dict) else {},
"status": "running",
})
elif event.event == ModelEventType.tool_call_delta:
call_id = str(event.data.get("tool_call_id", ""))
call = next((item for item in tool_calls if item["tool_call_id"] == call_id), None)
if call is not None:
delta = event.data.get("arguments_delta")
if isinstance(delta, str):
argument_buffers[call_id] = argument_buffers.get(call_id, "") + delta
try:
parsed_arguments = json.loads(argument_buffers[call_id])
if isinstance(parsed_arguments, dict):
call["parameters"] = parsed_arguments
except ValueError:
pass
arguments = event.data.get("arguments")
if isinstance(arguments, dict):
call["parameters"].update(arguments)
elif event.event == ModelEventType.tool_call_end:
call_id = str(event.data.get("tool_call_id", ""))
call = next((item for item in tool_calls if item["tool_call_id"] == call_id), None)
if call is not None:
call["status"] = "completed"
elif event.event == ModelEventType.usage:
input_tokens = int(event.data.get("input_tokens", 0))
output_tokens = int(event.data.get("output_tokens", 0))
usage = {"input_tokens": input_tokens, "output_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens}
elif event.event == ModelEventType.error:
if assistant_content:
assistant_content += "\n\n"
assistant_content += str(event.data.get("message", "Model generation failed."))
yield as_sse(event.event.value, event.model_dump_json()) yield as_sse(event.event.value, event.model_dump_json())
except Exception as exc: except Exception as exc:
failure_message = exc.message if isinstance(exc, ApiError) else "知识库检索或模型生成失败,请检查服务状态。"
if assistant_content:
assistant_content += "\n\n"
assistant_content += failure_message
error = ModelEvent( error = ModelEvent(
event=ModelEventType.error, event=ModelEventType.error,
sequence=sequence, sequence=sequence,
data={"code": exc.code if isinstance(exc, ApiError) else "CHAT_FAILED", data={"code": exc.code if isinstance(exc, ApiError) else "CHAT_FAILED",
"message": exc.message if isinstance(exc, ApiError) else "知识库检索或模型生成失败,请检查服务状态。"}, "message": failure_message},
timestamp=utc_now(), timestamp=utc_now(),
) )
done = ModelEvent( done = ModelEvent(
@@ -364,6 +472,18 @@ async def chat(request: ChatRequest) -> StreamingResponse:
) )
yield as_sse(error.event.value, error.model_dump_json()) yield as_sse(error.event.value, error.model_dump_json())
yield as_sse(done.event.value, done.model_dump_json()) yield as_sse(done.event.value, done.model_dump_json())
finally:
if conversation_id and (assistant_content or assistant_thinking or citations or tool_calls):
chat_history.append_message(
conversation_id,
message_id=assistant_message_id,
role="assistant",
content=assistant_content,
thinking=assistant_thinking or None,
citations=citations,
tool_calls=tool_calls,
usage=usage,
)
return StreamingResponse(stream(), media_type="text/event-stream") return StreamingResponse(stream(), media_type="text/event-stream")
+187
View File
@@ -0,0 +1,187 @@
from __future__ import annotations
from contextlib import closing
from datetime import datetime, timezone
import json
import sqlite3
from typing import Any
from uuid import uuid4
from app.contracts import ChatMessage, Conversation
from app.database.db import connect, transaction
from app.errors import ApiError
def _now() -> datetime:
return datetime.now(timezone.utc)
def _conversation(row) -> Conversation:
return Conversation(
conversation_id=row["conversation_id"],
title=row["title"],
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
message_count=row["message_count"],
)
def _message(row) -> ChatMessage:
citations = json.loads(row["citations_json"])
for citation in citations:
if isinstance(citation.get("heading_path"), list):
citation["heading_path"] = " / ".join(str(part) for part in citation["heading_path"])
return ChatMessage(
message_id=row["message_id"],
conversation_id=row["conversation_id"],
role=row["role"],
content=row["content"],
thinking=row["thinking"],
citations=citations,
tool_calls=json.loads(row["tool_calls_json"]),
usage=json.loads(row["usage_json"]) if row["usage_json"] else None,
created_at=datetime.fromisoformat(row["created_at"]),
)
def create(title: str, conversation_id: str | None = None) -> Conversation:
conversation_id = conversation_id or f"conversation_{uuid4().hex}"
now = _now().isoformat()
with closing(connect()) as conn, transaction(conn):
try:
conn.execute(
"INSERT INTO chat_conversations(conversation_id,title,created_at,updated_at) VALUES(?,?,?,?)",
(conversation_id, title.strip(), now, now),
)
except sqlite3.IntegrityError as exc:
raise ApiError(409, "CONVERSATION_ALREADY_EXISTS", "conversation already exists", {"conversation_id": conversation_id}) from exc
result = get(conversation_id)
assert result is not None
return result
def get(conversation_id: str) -> Conversation | None:
with closing(connect()) as conn:
row = conn.execute(
"""SELECT c.*, COUNT(m.message_id) AS message_count
FROM chat_conversations c LEFT JOIN chat_messages m USING(conversation_id)
WHERE c.conversation_id=? GROUP BY c.conversation_id""",
(conversation_id,),
).fetchone()
return _conversation(row) if row else None
def list_conversations(limit: int, offset: int) -> tuple[list[Conversation], int]:
with closing(connect()) as conn:
total = conn.execute("SELECT COUNT(*) FROM chat_conversations").fetchone()[0]
rows = conn.execute(
"""SELECT c.*, COUNT(m.message_id) AS message_count
FROM chat_conversations c LEFT JOIN chat_messages m USING(conversation_id)
GROUP BY c.conversation_id ORDER BY c.updated_at DESC LIMIT ? OFFSET ?""",
(limit, offset),
).fetchall()
return [_conversation(row) for row in rows], total
def list_messages(conversation_id: str, limit: int, offset: int) -> tuple[list[ChatMessage], int]:
if get(conversation_id) is None:
raise ApiError(404, "CONVERSATION_NOT_FOUND", "conversation not found", {"conversation_id": conversation_id})
with closing(connect()) as conn:
total = conn.execute("SELECT COUNT(*) FROM chat_messages WHERE conversation_id=?", (conversation_id,)).fetchone()[0]
rows = conn.execute(
"SELECT * FROM chat_messages WHERE conversation_id=? ORDER BY sequence LIMIT ? OFFSET ?",
(conversation_id, limit, offset),
).fetchall()
return [_message(row) for row in rows], total
def delete(conversation_id: str) -> bool:
with closing(connect()) as conn, transaction(conn):
return conn.execute("DELETE FROM chat_conversations WHERE conversation_id=?", (conversation_id,)).rowcount > 0
def append_message(
conversation_id: str,
*,
message_id: str,
role: str,
content: str,
title: str | None = None,
thinking: str | None = None,
citations: list[dict[str, Any]] | None = None,
tool_calls: list[dict[str, Any]] | None = None,
usage: dict[str, Any] | None = None,
) -> None:
now = _now().isoformat()
clean_title = (title or "").strip() or content[:30].strip() or "New conversation"
with closing(connect()) as conn:
conn.execute("BEGIN IMMEDIATE")
try:
_append_message_in_transaction(
conn, conversation_id, message_id=message_id, role=role, content=content,
title=clean_title, thinking=thinking, citations=citations, tool_calls=tool_calls,
usage=usage, now=now,
)
conn.execute("COMMIT")
except BaseException:
if conn.in_transaction:
conn.execute("ROLLBACK")
raise
def _append_message_in_transaction(
conn,
conversation_id: str,
*,
message_id: str,
role: str,
content: str,
title: str,
thinking: str | None,
citations: list[dict[str, Any]] | None,
tool_calls: list[dict[str, Any]] | None,
usage: dict[str, Any] | None,
now: str,
) -> None:
conversation = conn.execute(
"SELECT 1 FROM chat_conversations WHERE conversation_id=?", (conversation_id,)
).fetchone()
if conversation is None:
# A stream may finish after deletion. Check under BEGIN IMMEDIATE so
# deletion and assistant persistence cannot recreate an orphaned chat.
if role == "assistant":
return
conn.execute(
"INSERT INTO chat_conversations(conversation_id,title,created_at,updated_at) VALUES(?,?,?,?)",
(conversation_id, title, now, now),
)
count = conn.execute(
"SELECT COUNT(*) FROM chat_messages WHERE conversation_id=?", (conversation_id,)
).fetchone()[0]
if count == 0:
conn.execute(
"UPDATE chat_conversations SET title=? WHERE conversation_id=?",
(title, conversation_id),
)
existing = conn.execute(
"SELECT conversation_id FROM chat_messages WHERE message_id=?", (message_id,)
).fetchone()
if existing:
if existing["conversation_id"] != conversation_id:
raise ApiError(409, "MESSAGE_ID_CONFLICT", "message id belongs to another conversation")
return
sequence = conn.execute(
"SELECT COALESCE(MAX(sequence), -1) + 1 FROM chat_messages WHERE conversation_id=?",
(conversation_id,),
).fetchone()[0]
conn.execute(
"""INSERT INTO chat_messages(message_id,conversation_id,sequence,role,content,thinking,citations_json,tool_calls_json,usage_json,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?)""",
(message_id, conversation_id, sequence, role, content, thinking,
json.dumps(citations or [], ensure_ascii=False), json.dumps(tool_calls or [], ensure_ascii=False),
json.dumps(usage, ensure_ascii=False) if usage is not None else None, now),
)
conn.execute(
"UPDATE chat_conversations SET updated_at=? WHERE conversation_id=?",
(now, conversation_id),
)
+121
View File
@@ -0,0 +1,121 @@
import asyncio
from datetime import datetime, timezone
from types import SimpleNamespace
from fastapi.testclient import TestClient
import pytest
from app.contracts import ChatRequest, ModelEvent, ModelEventType
from app.main import app
from app.services import chat_history
def test_chat_history_survives_new_connections_and_deletes_messages() -> None:
conversation = chat_history.create("Persistent chat", "conversation-1")
chat_history.append_message(
conversation.conversation_id,
message_id="user-1",
role="user",
content="question",
)
chat_history.append_message(
conversation.conversation_id,
message_id="assistant-1",
role="assistant",
content="answer",
citations=[{"note_id": "note-1", "heading_path": ["Heading"]}],
usage={"input_tokens": 2, "output_tokens": 1, "total_tokens": 3},
)
listed, total = chat_history.list_conversations(50, 0)
messages, message_total = chat_history.list_messages("conversation-1", 50, 0)
assert total == 1
assert listed[0].message_count == 2
assert message_total == 2
assert messages[1].citations[0]["note_id"] == "note-1"
assert messages[1].usage["total_tokens"] == 3
assert chat_history.delete("conversation-1") is True
assert chat_history.list_conversations(50, 0)[1] == 0
def test_chat_stream_persists_user_and_assistant_messages(monkeypatch) -> None:
from app import routes
class Adapter:
async def stream(self, _request):
now = datetime.now(timezone.utc)
yield ModelEvent(event=ModelEventType.text_delta, data={"text": "persisted answer"}, timestamp=now)
yield ModelEvent(event=ModelEventType.usage, data={"input_tokens": 4, "output_tokens": 2}, timestamp=now)
yield ModelEvent(event=ModelEventType.done, timestamp=now)
monkeypatch.setattr(routes, "provider_or_404", lambda _provider_id: SimpleNamespace(adapter=Adapter()))
payload = {
"provider_id": "configured",
"model": "model",
"conversation_id": "conversation-stream",
"user_message_id": "user-stream",
"assistant_message_id": "assistant-stream",
"conversation_title": "Persist this",
"use_rag": False,
"messages": [{"role": "user", "content": "question"}],
}
with TestClient(app) as client:
with client.stream("POST", "/api/chat", json=payload) as response:
assert response.status_code == 200
assert "persisted answer" in "".join(response.iter_text())
messages = client.get("/api/chat/conversations/conversation-stream/messages").json()["items"]
conversations = client.get("/api/chat/conversations").json()["items"]
assert [message["content"] for message in messages] == ["question", "persisted answer"]
assert messages[1]["usage"]["total_tokens"] == 6
assert conversations[0]["title"] == "Persist this"
assert conversations[0]["message_count"] == 2
def test_chat_conversation_crud_api() -> None:
with TestClient(app) as client:
created = client.post("/api/chat/conversations", json={"conversation_id": "crud", "title": "CRUD"})
assert created.status_code == 201
assert client.get("/api/chat/conversations").json()["page"]["total"] == 1
assert client.get("/api/chat/conversations/crud/messages").json()["items"] == []
assert client.delete("/api/chat/conversations/crud").status_code == 200
missing = client.get("/api/chat/conversations/crud/messages")
assert missing.status_code == 404
assert missing.json()["error"]["code"] == "CONVERSATION_NOT_FOUND"
@pytest.mark.parametrize("close_early", [True, False])
@pytest.mark.parametrize("deleted", [True, False])
def test_stream_finalization_respects_conversation_deletion(monkeypatch, close_early, deleted) -> None:
from app import routes
class Adapter:
async def stream(self, _request):
now = datetime.now(timezone.utc)
yield ModelEvent(event=ModelEventType.text_delta, data={"text": "partial answer"}, timestamp=now)
yield ModelEvent(event=ModelEventType.done, timestamp=now)
monkeypatch.setattr(routes, "provider_or_404", lambda _: SimpleNamespace(adapter=Adapter()))
async def scenario():
response = await routes.chat(ChatRequest(
provider_id="configured", model="model", conversation_id="stream",
use_rag=False, messages=[{"role": "user", "content": "question"}],
))
await anext(response.body_iterator)
if deleted:
assert chat_history.delete("stream")
if close_early:
await response.body_iterator.aclose()
else:
async for _ in response.body_iterator:
pass
if deleted:
assert chat_history.get("stream") is None
assert chat_history.list_conversations(50, 0)[1] == 0
else:
messages, total = chat_history.list_messages("stream", 50, 0)
assert total == 2
assert [message.content for message in messages] == ["question", "partial answer"]
asyncio.run(scenario())
+1 -1
View File
@@ -37,7 +37,7 @@
| Extension | 扩展安装状态尚未持久化;MCP Host、进程隔离、签名与来源校验属于第二阶段 | | Extension | 扩展安装状态尚未持久化;MCP Host、进程隔离、签名与来源校验属于第二阶段 |
| AI Core | 音频转写当前只读取文本或 Host 预生成旁路文本,后续接入本地 ASR 队列 | | AI Core | 音频转写当前只读取文本或 Host 预生成旁路文本,后续接入本地 ASR 队列 |
| Desktop | Web Workspace 已连接 FastAPI 单 Vault;后续由 Tauri IPC 增加原生目录选择、多 Vault 和文件监听 | | Desktop | Web Workspace 已连接 FastAPI 单 Vault;后续由 Tauri IPC 增加原生目录选择、多 Vault 和文件监听 |
| Editor / Chat | 待补文件冲突合并受控链接对话框会话持久化 | | Editor / Chat | 待补文件冲突合并受控链接对话框会话持久化已接入后端 SQLite |
| Performance | Shiki 已复用单例,后续按首屏指标评估延迟加载或 Web Worker | | Performance | Shiki 已复用单例,后续按首屏指标评估延迟加载或 Web Worker |
TODO 完成后应删除对应代码注释并同步更新本索引;若工作超过一个提交,应建立 Issue,并在 Issue 中引用代码位置,而不是在源码中记录长篇设计讨论。 TODO 完成后应删除对应代码注释并同步更新本索引;若工作超过一个提交,应建立 Issue,并在 Issue 中引用代码位置,而不是在源码中记录长篇设计讨论。
+6 -3
View File
@@ -25,7 +25,7 @@ pnpm dev
| `/extensions/mcp` | stdio、Streamable HTTP、旧 SSE Server 配置与工具发现 | | `/extensions/mcp` | stdio、Streamable HTTP、旧 SSE Server 配置与工具发现 |
| `/extensions/plugins` | Plugin Host、Command、Settings、Secret 与 MCP 状态 | | `/extensions/plugins` | Plugin Host、Command、Settings、Secret 与 MCP 状态 |
| `/themes` | 内置 Design Token 主题和编辑器显示偏好 | | `/themes` | 内置 Design Token 主题和编辑器显示偏好 |
| `/settings` | Provider、模型路由、本地模型、CPU/CUDA 组件、请求 JSON、用量与诊断 | | `/settings` | Provider、模型路由、本地模型、CPU/CUDA 组件、请求 JSON、用量与诊断;全局中英文和拼写检查设置 |
## 技术结构 ## 技术结构
@@ -41,9 +41,12 @@ pnpm dev
编辑器使用 Milkdown/Crepe 与 CodeMirror 6Markdown 展示使用 marked、DOMPurify 和 Shiki。Provider logo 位于 `src/assets/providers`,授权与来源说明随目录保存。 编辑器使用 Milkdown/Crepe 与 CodeMirror 6Markdown 展示使用 marked、DOMPurify 和 Shiki。Provider logo 位于 `src/assets/providers`,授权与来源说明随目录保存。
语言设置会即时更新主导航、页面标题和各功能页面,并同步更新文档与编辑器的 `lang`。拼写检查使用浏览器或桌面 WebView 提供的本地词典,开关会即时作用于可视化 Markdown、源码编辑器以及普通文本输入;JSON、密码等结构化或敏感输入保持关闭。
## 数据边界 ## 数据边界
- 笔记、附件、搜索历史、会话、任务、Trace、模型配置和多模态结果都通过 FastAPI 读写。 - 笔记、附件、搜索历史、任务、Trace、模型配置和多模态结果都通过 FastAPI 读写。
- AI 对话生成和知识库检索通过 FastAPI;会话列表、用户消息、流式助手结果、引用和 Token 用量保存在后端 SQLite,刷新页面后可恢复。
- API Key 只存在于密码输入和提交请求期间,不进入 Pinia 或 `localStorage` - API Key 只存在于密码输入和提交请求期间,不进入 Pinia 或 `localStorage`
- 页面内存可以保存尚未提交的临时状态;后端已经接收的任务和结果由 SQLite/Vault 持久化。 - 页面内存可以保存尚未提交的临时状态;后端已经接收的任务和结果由 SQLite/Vault 持久化。
- 主题、编辑器偏好、侧栏状态和最近 Vault 路径目前保存在浏览器 `localStorage`;它们是设备界面偏好,不作为笔记或模型业务数据。Tauri 集成时由桌面配置存储接管。 - 主题、编辑器偏好、侧栏状态和最近 Vault 路径目前保存在浏览器 `localStorage`;它们是设备界面偏好,不作为笔记或模型业务数据。Tauri 集成时由桌面配置存储接管。
@@ -66,7 +69,7 @@ pnpm type-check
pnpm build pnpm build
``` ```
阶段 F 合并基线为 29 个测试文件、103 项测试通过,TypeScript 类型检查与 Vite 生产构建通过;构建仍有既有大 bundle 提示。产物位于 `dist`,不提交 Git。 当前基线为 30 个测试文件、106 项测试通过,TypeScript 类型检查与 Vite 生产构建通过;构建仍有既有大 bundle 提示。产物位于 `dist`,不提交 Git。
## 开发约定 ## 开发约定
+3 -1
View File
@@ -1,3 +1,5 @@
import { t } from '@/i18n'
export interface ServiceStatus { export interface ServiceStatus {
name: string name: string
version: string version: string
@@ -10,7 +12,7 @@ const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? ''
export async function getServiceStatus(): Promise<ServiceStatus> { export async function getServiceStatus(): Promise<ServiceStatus> {
const response = await fetch(`${apiBaseUrl}/api/status`) const response = await fetch(`${apiBaseUrl}/api/status`)
if (!response.ok) { if (!response.ok) {
throw new Error(`后端请求失败:HTTP ${response.status}`) throw new Error(`${t('后端请求失败:', 'Backend request failed: ')}HTTP ${response.status}`)
} }
return response.json() as Promise<ServiceStatus> return response.json() as Promise<ServiceStatus>
} }
@@ -8,6 +8,7 @@ import * as workspaceService from '@/services/workspaceService'
import * as pluginService from '@/services/pluginService' import * as pluginService from '@/services/pluginService'
import type { PluginCommand, PluginCommandEffect } from '@/contracts' import type { PluginCommand, PluginCommandEffect } from '@/contracts'
import { usePluginStore } from '@/stores/plugin' import { usePluginStore } from '@/stores/plugin'
import { t } from '@/i18n'
const router = useRouter() const router = useRouter()
const editorStore = useEditorStore() const editorStore = useEditorStore()
@@ -25,15 +26,15 @@ const selectionSnapshot = ref<string | null>(null)
interface Command { id: string; label: string; hint: string; run: () => void | Promise<void> } interface Command { id: string; label: string; hint: string; run: () => void | Promise<void> }
const builtinCommands = computed<Command[]>(() => [ const builtinCommands = computed<Command[]>(() => [
{ id: 'workspace', label: '打开工作区', hint: '导航', run: () => router.push('/workspace') }, { id: 'workspace', label: t('打开工作区', 'Open workspace'), hint: t('导航', 'Navigation'), run: () => router.push('/workspace') },
{ id: 'search', label: '全局搜索', hint: '导航', run: () => router.push('/search') }, { id: 'search', label: t('全局搜索', 'Global search'), hint: t('导航', 'Navigation'), run: () => router.push('/search') },
{ id: 'chat', label: '打开 AI 对话', hint: '导航', run: () => router.push('/chat') }, { id: 'chat', label: t('打开 AI 对话', 'Open AI chat'), hint: t('导航', 'Navigation'), run: () => router.push('/chat') },
{ id: 'agent', label: '创建智能体运行', hint: '导航', run: () => router.push('/agent/runs') }, { id: 'agent', label: t('创建智能体运行', 'Create agent run'), hint: t('导航', 'Navigation'), run: () => router.push('/agent/runs') },
{ id: 'settings', label: '打开设置', hint: '导航', run: () => router.push('/settings') }, { id: 'settings', label: t('打开设置', 'Open settings'), hint: t('导航', 'Navigation'), run: () => router.push('/settings') },
{ id: 'mode', label: `切换为${editorStore.mode === 'source' ? '写作' : '源码'}模式`, hint: '编辑器', run: () => editorStore.toggleMode() }, { id: 'mode', label: editorStore.mode === 'source' ? t('切换为写作模式', 'Switch to writing mode') : t('切换为源码模式', 'Switch to source mode'), hint: t('编辑器', 'Editor'), run: () => editorStore.toggleMode() },
{ id: 'save', label: '保存当前笔记', hint: '编辑器', run: () => editorStore.save() }, { id: 'save', label: t('保存当前笔记', 'Save current note'), hint: t('编辑器', 'Editor'), run: () => editorStore.save() },
{ id: 'theme', label: `切换为${themeStore.isDark ? '浅色' : '深色'}主题`, hint: '外观', run: () => themeStore.toggleTheme() }, { id: 'theme', label: themeStore.isDark ? t('切换为浅色主题', 'Switch to light theme') : t('切换为深色主题', 'Switch to dark theme'), hint: t('外观', 'Appearance'), run: () => themeStore.toggleTheme() },
{ id: 'new-note', label: '创建笔记', hint: '工作区', run: createNote }, { id: 'new-note', label: t('创建笔记', 'Create note'), hint: t('工作区', 'Workspace'), run: createNote },
]) ])
const commands = computed<Command[]>(() => [ const commands = computed<Command[]>(() => [
@@ -78,12 +79,12 @@ async function execute(command: Command | undefined) {
try { try {
await command.run() await command.run()
} catch (error) { } catch (error) {
commandNotice.value = error instanceof Error ? error.message : '命令执行失败' commandNotice.value = error instanceof Error ? error.message : t('命令执行失败', 'Command failed')
} }
} }
async function createNote() { async function createNote() {
const rawName = window.prompt('笔记名称')?.trim() const rawName = window.prompt(t('笔记名称', 'Note name'))?.trim()
if (!rawName) return if (!rawName) return
const name = rawName.endsWith('.md') ? rawName : `${rawName}.md` const name = rawName.endsWith('.md') ? rawName : `${rawName}.md`
const file = await workspaceService.createFile('/', name, `# ${rawName}\n\n`) const file = await workspaceService.createFile('/', name, `# ${rawName}\n\n`)
@@ -97,7 +98,7 @@ async function loadPluginCommands() {
try { try {
pluginCommands.value = await pluginService.listPluginCommands('command_palette') pluginCommands.value = await pluginService.listPluginCommands('command_palette')
} catch (error) { } catch (error) {
commandError.value = error instanceof Error ? error.message : 'Plugin 命令加载失败' commandError.value = error instanceof Error ? error.message : t('Plugin 命令加载失败', 'Failed to load plugin commands')
} }
} }
@@ -109,7 +110,7 @@ async function executePluginCommand(command: PluginCommand) {
if (hasRequiredArguments(command)) { if (hasRequiredArguments(command)) {
pluginStore.selectPlugin(command.plugin_id) pluginStore.selectPlugin(command.plugin_id)
await router.push('/extensions/plugins') await router.push('/extensions/plugins')
commandNotice.value = '请在 Plugin 详情页填写参数后执行' + command.title + '”。' commandNotice.value = `${t('请在 Plugin 详情页填写参数后执行', 'Enter parameters on the Plugin details page, then run')}${command.title}”.`
return return
} }
const result = await pluginService.executePluginCommand(command.command_id, {}, { const result = await pluginService.executePluginCommand(command.command_id, {}, {
@@ -135,11 +136,11 @@ async function applyPluginEffect(effect: PluginCommandEffect) {
if (effect.type === 'refresh') { if (effect.type === 'refresh') {
if (effect.payload.scope === 'plugins') await pluginStore.loadPlugins() if (effect.payload.scope === 'plugins') await pluginStore.loadPlugins()
if (effect.payload.scope === 'commands') await loadPluginCommands() if (effect.payload.scope === 'commands') await loadPluginCommands()
commandNotice.value = '相关数据已刷新。' commandNotice.value = t('相关数据已刷新。', 'Related data refreshed.')
return return
} }
if (effect.type === 'job') { commandNotice.value = '后台任务已创建:' + effect.payload.job_id; return } if (effect.type === 'job') { commandNotice.value = t('后台任务已创建:', 'Background job created: ') + effect.payload.job_id; return }
commandNotice.value = 'Plugin 命令执行完成。' commandNotice.value = t('Plugin 命令执行完成。', 'Plugin command completed.')
} }
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
@@ -157,20 +158,20 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
<template> <template>
<div v-if="commandNotice" class="command-toast" role="status"> <div v-if="commandNotice" class="command-toast" role="status">
<span>{{ commandNotice }}</span><button aria-label="关闭通知" @click="commandNotice = ''">×</button> <span>{{ commandNotice }}</span><button :aria-label="t('关闭通知', 'Close notification')" @click="commandNotice = ''">×</button>
</div> </div>
<Teleport to="body"> <Teleport to="body">
<div v-if="open" class="command-backdrop" @click.self="hide"> <div v-if="open" class="command-backdrop" @click.self="hide">
<section class="command-palette" role="dialog" aria-modal="true" aria-label="命令面板"> <section class="command-palette" role="dialog" aria-modal="true" :aria-label="t('命令面板', 'Command palette')">
<input ref="input" v-model="query" class="command-input" placeholder="输入命令…" @keydown.enter.prevent="execute(filteredCommands[0])" /> <input ref="input" v-model="query" class="command-input" :placeholder="t('输入命令…', 'Enter a command…')" @keydown.enter.prevent="execute(filteredCommands[0])" />
<p v-if="commandError" class="command-error">{{ commandError }}</p> <p v-if="commandError" class="command-error">{{ commandError }}</p>
<div class="command-list"> <div class="command-list">
<button v-for="command in filteredCommands" :key="command.id" type="button" @click="execute(command)"> <button v-for="command in filteredCommands" :key="command.id" type="button" @click="execute(command)">
<span>{{ command.label }}</span><small>{{ command.hint }}</small> <span>{{ command.label }}</span><small>{{ command.hint }}</small>
</button> </button>
<p v-if="!filteredCommands.length">没有匹配的命令</p> <p v-if="!filteredCommands.length">{{ t('没有匹配的命令', 'No matching commands') }}</p>
</div> </div>
<footer><span>Enter 执行</span><span>Esc 关闭</span></footer> <footer><span>Enter · {{ t('执行', 'Run') }}</span><span>Esc · {{ t('关闭', 'Close') }}</span></footer>
</section> </section>
</div> </div>
</Teleport> </Teleport>
@@ -0,0 +1,36 @@
// @vitest-environment happy-dom
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import FilePicker from './FilePicker.vue'
describe('FilePicker', () => {
it('keeps the native file input accessible and reports the selected file', async () => {
const wrapper = mount(FilePicker, {
props: { file: null, label: '选择文件', emptyLabel: '尚未选择文件', accept: '.json' },
})
const input = wrapper.get('input[type="file"]')
const file = new File(['{}'], 'rules.json', { type: 'application/json' })
Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
await input.trigger('change')
expect(wrapper.emitted('select')).toEqual([[file]])
expect(wrapper.get('label').attributes('for')).toBe(input.attributes('id'))
expect(wrapper.text()).toContain('尚未选择文件')
await wrapper.setProps({ file })
expect(wrapper.text()).toContain('rules.json')
})
it('emits null when the native selection is cleared', async () => {
const wrapper = mount(FilePicker, {
props: { file: null, label: '选择文件', emptyLabel: '尚未选择文件' },
})
const input = wrapper.get('input[type="file"]')
Object.defineProperty(input.element, 'files', { value: [], configurable: true })
await input.trigger('change')
expect(wrapper.emitted('select')).toEqual([[null]])
})
})
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { useId } from 'vue'
import { Upload } from '@element-plus/icons-vue'
defineProps<{
file: File | null
label: string
emptyLabel: string
accept?: string
disabled?: boolean
}>()
const emit = defineEmits<{ select: [file: File | null] }>()
const inputId = useId()
function selectFile(event: Event) {
emit('select', (event.target as HTMLInputElement).files?.[0] ?? null)
}
function allowReselect(event: MouseEvent) {
;(event.currentTarget as HTMLInputElement).value = ''
}
</script>
<template>
<div class="file-picker" :class="{ disabled }">
<input
:id="inputId"
class="file-picker-input"
type="file"
:accept="accept"
:disabled="disabled"
@click="allowReselect"
@change="selectFile"
/>
<label class="file-picker-trigger" :for="inputId">
<Upload aria-hidden="true" />
<span>{{ label }}</span>
</label>
<span class="file-picker-name" :class="{ empty: !file }" :title="file?.name || emptyLabel">
{{ file?.name || emptyLabel }}
</span>
</div>
</template>
<style scoped>
.file-picker { display: flex; min-width: 0; align-items: center; gap: var(--space-sm); }
.file-picker-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
.file-picker-trigger { display: inline-flex; min-height: 36px; flex: 0 0 auto; align-items: center; gap: var(--space-sm); padding: 0 var(--space-md); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-primary); font-weight: 600; cursor: pointer; transition: border-color var(--motion-fast), background-color var(--motion-fast), color var(--motion-fast), box-shadow var(--motion-fast), transform var(--motion-fast); }
.file-picker-trigger svg { width: 16px; height: 16px; }
.file-picker-trigger:hover { border-color: var(--color-accent-secondary); background: var(--color-background-hover); color: var(--color-accent-primary); transform: translateY(-1px); }
.file-picker-input:focus-visible + .file-picker-trigger { outline: 2px solid var(--color-border-focus); outline-offset: 2px; }
.file-picker-name { min-width: 0; overflow: hidden; color: var(--color-text-secondary); text-overflow: ellipsis; white-space: nowrap; user-select: text; }
.file-picker-name.empty { color: var(--color-text-tertiary); }
.disabled { opacity: .55; }
.disabled .file-picker-trigger { cursor: not-allowed; transform: none; }
@media (max-width: 560px) { .file-picker { align-items: stretch; flex-direction: column; } .file-picker-trigger { justify-content: center; } }
</style>
@@ -3,24 +3,25 @@ import { useRoute, useRouter } from 'vue-router'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { ArrowLeftBold, ArrowRightBold, Brush, ChatDotRound, CircleCheck, Connection, Cpu, FolderOpened, Lightning, Monitor, Search, Setting } from '@element-plus/icons-vue' import { ArrowLeftBold, ArrowRightBold, Brush, ChatDotRound, CircleCheck, Connection, Cpu, FolderOpened, Lightning, Monitor, Search, Setting } from '@element-plus/icons-vue'
import AppIcon from './AppIcon.vue' import AppIcon from './AppIcon.vue'
import { t } from '@/i18n'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const expanded = ref(localStorage.getItem('primary-sidebar-expanded') === 'true') const expanded = ref(localStorage.getItem('primary-sidebar-expanded') === 'true')
const navItems = [ const navItems = computed(() => [
{ name: 'workspace', icon: FolderOpened, label: '工作区' }, { name: 'workspace', icon: FolderOpened, label: t('工作区', 'Workspace') },
{ name: 'search', icon: Search, label: '搜索' }, { name: 'search', icon: Search, label: t('搜索', 'Search') },
{ name: 'chat', icon: ChatDotRound, label: 'AI 对话' }, { name: 'chat', icon: ChatDotRound, label: t('AI 对话', 'AI Chat') },
{ name: 'agent', icon: Cpu, label: '智能体' }, { name: 'agent', icon: Cpu, label: t('智能体', 'Agent') },
{ name: 'tasks', icon: CircleCheck, label: '任务' }, { name: 'tasks', icon: CircleCheck, label: t('任务', 'Tasks') },
{ name: 'media', icon: Monitor, label: '音视频' }, { name: 'media', icon: Monitor, label: t('音视频', 'Media') },
{ name: 'skills', icon: Lightning, label: 'Skill' }, { name: 'skills', icon: Lightning, label: 'Skill' },
{ name: 'plugins', icon: Connection, label: 'Plugin' }, { name: 'plugins', icon: Connection, label: 'Plugin' },
{ name: 'mcp-servers', icon: Monitor, label: 'MCP' }, { name: 'mcp-servers', icon: Monitor, label: 'MCP' },
{ name: 'themes', icon: Brush, label: '主题' }, { name: 'themes', icon: Brush, label: t('主题', 'Themes') },
{ name: 'settings', icon: Setting, label: '设置' }, { name: 'settings', icon: Setting, label: t('设置', 'Settings') },
] ])
const currentName = computed(() => { const currentName = computed(() => {
return route.name as string return route.name as string
@@ -52,9 +53,9 @@ function toggleExpanded() {
</div> </div>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<button class="nav-item collapse-button" type="button" :title="expanded ? '收起导航' : '展开导航'" @click="toggleExpanded"> <button class="nav-item collapse-button" type="button" :title="expanded ? t('收起导航', 'Collapse navigation') : t('展开导航', 'Expand navigation')" @click="toggleExpanded">
<AppIcon class="nav-icon" :icon="expanded ? ArrowLeftBold : ArrowRightBold" /> <AppIcon class="nav-icon" :icon="expanded ? ArrowLeftBold : ArrowRightBold" />
<span class="nav-label">{{ expanded ? '收起' : '展开' }}</span> <span class="nav-label">{{ expanded ? t('收起', 'Collapse') : t('展开', 'Expand') }}</span>
</button> </button>
</div> </div>
</aside> </aside>
@@ -7,6 +7,7 @@ import SearchFiltersPanel from '@/features/search/SearchFiltersPanel.vue'
import TaskFiltersPanel from '@/features/tasks/TaskFiltersPanel.vue' import TaskFiltersPanel from '@/features/tasks/TaskFiltersPanel.vue'
import ExtensionListPanel from '@/components/common/ExtensionListPanel.vue' import ExtensionListPanel from '@/components/common/ExtensionListPanel.vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { t } from '@/i18n'
const props = defineProps<{ const props = defineProps<{
component: string | null component: string | null
@@ -17,12 +18,12 @@ const routeName = computed(() => route.name as string)
const sidebarTitle = computed(() => { const sidebarTitle = computed(() => {
const titles: Record<string, string> = { const titles: Record<string, string> = {
'file-tree': '文件', 'file-tree': t('文件', 'Files'),
'conversation-list': '对话', 'conversation-list': t('对话', 'Conversations'),
'run-list': '智能体运行', 'run-list': t('智能体运行', 'Agent Runs'),
'search-filters': '搜索筛选', 'search-filters': t('搜索筛选', 'Search Filters'),
'task-filters': '任务筛选', 'task-filters': t('任务筛选', 'Task Filters'),
'extension-list': '扩展', 'extension-list': t('扩展', 'Extensions'),
} }
return titles[props.component || ''] || '' return titles[props.component || ''] || ''
}) })
+16 -15
View File
@@ -5,6 +5,7 @@ import { useSettingsStore } from '@/stores/settings'
import { useProviderStore } from '@/stores/provider' import { useProviderStore } from '@/stores/provider'
import { useAgentStore } from '@/stores/agent' import { useAgentStore } from '@/stores/agent'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { t } from '@/i18n'
const editorStore = useEditorStore() const editorStore = useEditorStore()
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
@@ -15,12 +16,12 @@ const route = useRoute()
const saveStatusText = computed(() => { const saveStatusText = computed(() => {
const map: Record<string, string> = { const map: Record<string, string> = {
idle: '', idle: '',
dirty: '未保存', dirty: t('未保存', 'Unsaved'),
saving: '保存中...', saving: t('保存中...', 'Saving...'),
saved: '已保存', saved: t('已保存', 'Saved'),
save_failed: '保存失败', save_failed: t('保存失败', 'Save failed'),
external_changed: '外部已更新', external_changed: t('外部已更新', 'Changed externally'),
conflict: '存在冲突', conflict: t('存在冲突', 'Conflict'),
} }
return map[editorStore.saveStatus] || '' return map[editorStore.saveStatus] || ''
}) })
@@ -39,16 +40,16 @@ const saveStatusColor = computed(() => {
const indexStatusText = computed(() => { const indexStatusText = computed(() => {
const s = settingsStore.indexStatus.status const s = settingsStore.indexStatus.status
return s === 'unknown' ? '索引状态未获取' : s === 'idle' ? '索引就绪' : s === 'indexing' ? `索引中 (${settingsStore.indexStatus.pending_jobs})` : '索引错误' return s === 'unknown' ? t('索引状态未获取', 'Index status unavailable') : s === 'idle' ? t('索引就绪', 'Index ready') : s === 'indexing' ? `${t('索引中', 'Indexing')} (${settingsStore.indexStatus.pending_jobs})` : t('索引错误', 'Index error')
}) })
const aiCoreStatusText = computed(() => { const aiCoreStatusText = computed(() => {
const map: Record<string, string> = { const map: Record<string, string> = {
unknown: 'AI Core 状态未获取', unknown: t('AI Core 状态未获取', 'AI Core status unavailable'),
starting: 'AI Core 启动中', starting: t('AI Core 启动中', 'AI Core starting'),
running: 'AI Core 运行中', running: t('AI Core 运行中', 'AI Core running'),
stopped: 'AI Core 已停止', stopped: t('AI Core 已停止', 'AI Core stopped'),
error: 'AI Core 错误', error: t('AI Core 错误', 'AI Core error'),
} }
return map[settingsStore.aiCoreStatus] || '' return map[settingsStore.aiCoreStatus] || ''
}) })
@@ -85,7 +86,7 @@ const showEditorInfo = computed(() => route.name === 'workspace')
</span> </span>
<span v-if="agentStore.isRunning" class="status-item agent-status"> <span v-if="agentStore.isRunning" class="status-item agent-status">
<span class="spinner" /> <span class="spinner" />
智能体运行中 {{ t('智能体运行中', 'Agent running') }}
</span> </span>
</div> </div>
<div class="statusbar-right"> <div class="statusbar-right">
@@ -93,10 +94,10 @@ const showEditorInfo = computed(() => route.name === 'workspace')
{{ defaultProvider.name }} · {{ defaultProvider.default_model }} {{ defaultProvider.name }} · {{ defaultProvider.default_model }}
</span> </span>
<span v-if="showEditorInfo" class="status-item"> <span v-if="showEditorInfo" class="status-item">
{{ editorStore.lineCount }} {{ editorStore.lineCount }} {{ t('行', 'lines') }}
</span> </span>
<span v-if="showEditorInfo" class="status-item"> <span v-if="showEditorInfo" class="status-item">
{{ editorStore.wordCount }} {{ editorStore.wordCount }} {{ t('字', 'words') }}
</span> </span>
</div> </div>
</footer> </footer>
+11 -10
View File
@@ -6,6 +6,7 @@ import { useEditorStore } from '@/stores/editor'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { Moon, Sunny } from '@element-plus/icons-vue' import { Moon, Sunny } from '@element-plus/icons-vue'
import AppIcon from './AppIcon.vue' import AppIcon from './AppIcon.vue'
import { t } from '@/i18n'
const route = useRoute() const route = useRoute()
const workspaceStore = useWorkspaceStore() const workspaceStore = useWorkspaceStore()
@@ -15,15 +16,15 @@ const themeStore = useThemeStore()
const pageTitle = computed(() => { const pageTitle = computed(() => {
const name = route.name as string const name = route.name as string
const titles: Record<string, string> = { const titles: Record<string, string> = {
workspace: '工作区', workspace: t('工作区', 'Workspace'),
search: '搜索', search: t('搜索', 'Search'),
chat: 'AI 对话', chat: t('AI 对话', 'AI Chat'),
agent: '智能体执行轨迹', agent: t('智能体执行轨迹', 'Agent Trace'),
tasks: '任务', tasks: t('任务', 'Tasks'),
skills: 'Skill 管理', skills: t('Skill 管理', 'Skill Management'),
plugins: 'Plugin 与 MCP', plugins: t('Plugin 与 MCP', 'Plugins and MCP'),
themes: '主题管理', themes: t('主题管理', 'Theme Management'),
settings: '设置', settings: t('设置', 'Settings'),
} }
return titles[name] || 'NotesAgent' return titles[name] || 'NotesAgent'
}) })
@@ -53,7 +54,7 @@ const isDirty = computed(() => editorStore.saveStatus === 'dirty' || editorStore
<span class="app-name">NotesAgent</span> <span class="app-name">NotesAgent</span>
</div> </div>
<div class="titlebar-right"> <div class="titlebar-right">
<button class="icon-btn" @click="themeStore.toggleTheme()" :title="themeStore.isDark ? '切换浅色主题' : '切换深色主题'"> <button class="icon-btn" @click="themeStore.toggleTheme()" :title="themeStore.isDark ? t('切换浅色主题', 'Switch to light theme') : t('切换深色主题', 'Switch to dark theme')">
<AppIcon :icon="themeStore.isDark ? Sunny : Moon" :size="16" /> <AppIcon :icon="themeStore.isDark ? Sunny : Moon" :size="16" />
</button> </button>
<div class="window-controls"> <div class="window-controls">
+26 -25
View File
@@ -7,6 +7,7 @@ import { useSkillStore } from '@/stores/skill'
import type { AgentEvent } from '@/contracts' import type { AgentEvent } from '@/contracts'
import { eventLabel, localizeDetails, permissionLabel, runStatusLabel, toolLabel } from './labels' import { eventLabel, localizeDetails, permissionLabel, runStatusLabel, toolLabel } from './labels'
import ToolOption from './ToolOption.vue' import ToolOption from './ToolOption.vue'
import { localeTag, t } from '@/i18n'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -27,19 +28,19 @@ onMounted(async () => {
try { try {
await Promise.all([providerStore.loadProviders(), skillStore.loadSkills(), agentStore.loadTools()]) await Promise.all([providerStore.loadProviders(), skillStore.loadSkills(), agentStore.loadTools()])
form.provider_id = providerStore.defaultProviderId form.provider_id = providerStore.defaultProviderId
} catch (error) { pageError.value = error instanceof Error ? error.message : '智能体配置加载失败' } } catch (error) { pageError.value = error instanceof Error ? error.message : t('智能体配置加载失败', 'Failed to load agent configuration') }
}) })
watch(() => route.params.runId, async (runId) => { watch(() => route.params.runId, async (runId) => {
if (typeof runId !== 'string') return if (typeof runId !== 'string') return
try { await agentStore.loadRun(runId) } catch (error) { pageError.value = error instanceof Error ? error.message : '运行记录加载失败' } try { await agentStore.loadRun(runId) } catch (error) { pageError.value = error instanceof Error ? error.message : t('运行记录加载失败', 'Failed to load run') }
}, { immediate: true }) }, { immediate: true })
watch(() => form.provider_id, async (providerId) => { watch(() => form.provider_id, async (providerId) => {
form.model = providerStore.providers.find(p => p.provider_id === providerId)?.default_model ?? '' form.model = providerStore.providers.find(p => p.provider_id === providerId)?.default_model ?? ''
if (!providerId) return if (!providerId) return
try { await providerStore.loadModels(providerId) } try { await providerStore.loadModels(providerId) }
catch (error) { if (form.provider_id === providerId) pageError.value = error instanceof Error ? error.message : '模型列表加载失败,请手动填写模型 ID。' } catch (error) { if (form.provider_id === providerId) pageError.value = error instanceof Error ? error.message : t('模型列表加载失败,请手动填写模型 ID。', 'Unable to load models. Enter a model ID manually.') }
}) })
function toggleTool(name: string) { function toggleTool(name: string) {
@@ -51,7 +52,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。') if (!form.provider_id || !form.model.trim()) throw new Error(t('请选择提供商并填写模型 ID。', 'Select a provider and enter a model 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,
@@ -60,12 +61,12 @@ async function createRun() {
allow_network: form.allow_network, max_concurrent_tools: form.max_concurrent_tools, allow_network: form.allow_network, max_concurrent_tools: form.max_concurrent_tools,
}) })
await router.replace({ name: 'agent', params: { runId: run.run_id } }) await router.replace({ name: 'agent', params: { runId: run.run_id } })
} catch (error) { pageError.value = error instanceof Error ? error.message : '运行创建失败' } } catch (error) { pageError.value = error instanceof Error ? error.message : t('运行创建失败', 'Failed to create run') }
} }
function eventText(event: AgentEvent) { function eventText(event: AgentEvent) {
if (event.event === 'RunCompleted') return '任务已成功完成。' if (event.event === 'RunCompleted') return t('任务已成功完成。', 'The task completed successfully.')
if (event.event === 'RunCancelled') return '任务已取消。' if (event.event === 'RunCancelled') return t('任务已取消。', 'The task was cancelled.')
const text = event.data.text ?? event.data.message ?? event.data.code const text = event.data.text ?? event.data.message ?? event.data.code
if (text) return String(text) if (text) return String(text)
return '' return ''
@@ -74,40 +75,40 @@ function eventText(event: AgentEvent) {
<template> <template>
<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 ? t('创建智能体运行', 'Create Agent Run') : t('智能体执行轨迹', 'Agent Trace') }}</h1><p>{{ t('配置执行边界并实时查看模型工具和权限事件。', 'Configure execution limits and inspect model, tool, and permission events in real time.') }}</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' })">{{ t('新建运行', 'New run') }}</button></header>
<div v-if="pageError || agentStore.error || providerStore.error" class="error-banner">{{ pageError || agentStore.error || providerStore.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>{{ t('任务', 'Task') }}</label><textarea v-model="form.input" class="textarea" required :placeholder="t('描述希望智能体完成的任务', 'Describe the task for the agent')" /></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>{{ t('模型提供商', 'Model provider') }}</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><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>{{ t('模型', 'Model') }}</label><input v-model="form.model" class="input" list="agent-models" :placeholder="t('填写模型 ID', 'Enter model 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>{{ t('技能', 'Skill') }}</label><select v-model="form.skill_id" class="select"><option value="">{{ t('不使用技能', 'No skill') }}</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>{{ t('最大步骤', 'Maximum steps') }}</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>{{ t('工具超时(秒)', 'Tool timeout (seconds)') }}</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.run_timeout_seconds" class="input" type="number" min="1" /></div> <div class="field"><label>{{ t('运行超时(秒)', 'Run timeout (seconds)') }}</label><input v-model.number="form.run_timeout_seconds" 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>{{ t('令牌预算', 'Token budget') }}</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>{{ t('最大并发工具', 'Maximum concurrent tools') }}</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"><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> <div class="field"><label>{{ t('允许使用的工具', 'Allowed tools') }}</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" /> {{ t('允许本次运行调用网络工具', 'Allow network tools for this run') }}</label>
<div class="inline-actions"><button class="button-primary" :disabled="agentStore.isCreating || !form.input.trim() || !form.provider_id || !form.model.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 ? t('创建中…', 'Creating…') : t('创建并运行', 'Create and run') }}</button></div>
</form> </form>
<div v-else class="trace-layout"> <div v-else class="trace-layout">
<div class="panel run-summary"><div><span class="badge info">{{ runStatusLabel(agentStore.activeRun?.status) }}</span><h2>{{ agentStore.activeRunId }}</h2></div><div class="inline-actions"><span>步骤 {{ agentStore.currentStep }} / {{ agentStore.activeRun?.max_steps }}</span><button v-if="agentStore.isRunning" class="button-danger" @click="agentStore.cancelRun(agentStore.activeRunId!)">取消运行</button></div></div> <div class="panel run-summary"><div><span class="badge info">{{ runStatusLabel(agentStore.activeRun?.status) }}</span><h2>{{ agentStore.activeRunId }}</h2></div><div class="inline-actions"><span>{{ t('步骤', 'Step') }} {{ agentStore.currentStep }} / {{ agentStore.activeRun?.max_steps }}</span><button v-if="agentStore.isRunning" class="button-danger" @click="agentStore.cancelRun(agentStore.activeRunId!)">{{ t('取消运行', 'Cancel run') }}</button></div></div>
<div class="timeline"> <div class="timeline">
<article v-for="event in agentStore.events" :key="event.sequence" class="event-card item-card"> <article v-for="event in agentStore.events" :key="event.sequence" class="event-card item-card">
<div class="event-head"><span class="badge" :class="{ success: event.event === 'RunCompleted', error: event.event === 'RunFailed', warning: event.event === 'PermissionRequired' }">{{ eventLabel(event.event) }}</span><span> {{ event.sequence }} · {{ new Date(event.timestamp).toLocaleTimeString() }}</span></div> <div class="event-head"><span class="badge" :class="{ success: event.event === 'RunCompleted', error: event.event === 'RunFailed', warning: event.event === 'PermissionRequired' }">{{ eventLabel(event.event) }}</span><span>#{{ event.sequence }} · {{ new Date(event.timestamp).toLocaleTimeString(localeTag()) }}</span></div>
<p v-if="eventText(event)" class="event-text">{{ eventText(event) }}</p> <p v-if="eventText(event)" class="event-text">{{ eventText(event) }}</p>
<pre v-if="['ToolCall', 'ToolResult', 'Citation', 'Usage'].includes(event.event)">{{ JSON.stringify(localizeDetails(event.data), null, 2) }}</pre> <pre v-if="['ToolCall', 'ToolResult', 'Citation', 'Usage'].includes(event.event)">{{ JSON.stringify(localizeDetails(event.data), null, 2) }}</pre>
</article> </article>
<div v-if="!agentStore.events.length" class="empty-state"><div><strong>等待执行轨迹</strong><p>事件连接建立后将在这里实时显示</p></div></div> <div v-if="!agentStore.events.length" class="empty-state"><div><strong>{{ t('等待执行轨迹', 'Waiting for trace events') }}</strong><p>{{ t('事件连接建立后将在这里实时显示。', 'Events will appear here after the connection is established.') }}</p></div></div>
</div> </div>
</div> </div>
<div v-if="agentStore.permissionRequest" class="modal-backdrop"> <div v-if="agentStore.permissionRequest" class="modal-backdrop">
<div class="modal"><span class="badge warning">权限确认</span><h2>{{ toolLabel(agentStore.permissionRequest.tool_name) }}</h2><p>{{ agentStore.permissionRequest.impact }}</p><p class="subtle">所需权限{{ permissionLabel(agentStore.permissionRequest.permission) }}{{ agentStore.permissionRequest.permission }}</p><pre>{{ JSON.stringify(localizeDetails(agentStore.permissionRequest.parameters), null, 2) }}</pre><div class="inline-actions permission-actions"><button class="button-primary" @click="agentStore.respondPermission('allow', 'once')">仅本次允许</button><button class="button-secondary" @click="agentStore.respondPermission('allow', 'session')">本次会话允许</button><button class="button-danger" @click="agentStore.respondPermission('deny')">拒绝</button></div></div> <div class="modal"><span class="badge warning">{{ t('权限确认', 'Permission Confirmation') }}</span><h2>{{ toolLabel(agentStore.permissionRequest.tool_name) }}</h2><p>{{ agentStore.permissionRequest.impact }}</p><p class="subtle">{{ t('所需权限:', 'Required permission: ') }}{{ permissionLabel(agentStore.permissionRequest.permission) }} ({{ agentStore.permissionRequest.permission }})</p><pre>{{ JSON.stringify(localizeDetails(agentStore.permissionRequest.parameters), null, 2) }}</pre><div class="inline-actions permission-actions"><button class="button-primary" @click="agentStore.respondPermission('allow', 'once')">{{ t('仅本次允许', 'Allow once') }}</button><button class="button-secondary" @click="agentStore.respondPermission('allow', 'session')">{{ t('本次会话允许', 'Allow for session') }}</button><button class="button-danger" @click="agentStore.respondPermission('deny')">{{ t('拒绝', 'Deny') }}</button></div></div>
</div> </div>
</section> </section>
</template> </template>
+4 -3
View File
@@ -2,6 +2,7 @@
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAgentStore } from '@/stores/agent' import { useAgentStore } from '@/stores/agent'
import { localeTag, t } from '@/i18n'
import { runStatusLabel } from './labels' import { runStatusLabel } from './labels'
const agentStore = useAgentStore() const agentStore = useAgentStore()
@@ -9,7 +10,7 @@ const router = useRouter()
const error = ref('') const error = ref('')
onMounted(async () => { onMounted(async () => {
try { await agentStore.loadRuns() } catch (reason) { error.value = reason instanceof Error ? reason.message : '运行记录加载失败' } try { await agentStore.loadRuns() } catch (reason) { error.value = reason instanceof Error ? reason.message : t('运行记录加载失败', 'Failed to load runs') }
}) })
function selectRun(runId: string) { void router.push({ name: 'agent', params: { runId } }) } function selectRun(runId: string) { void router.push({ name: 'agent', params: { runId } }) }
@@ -17,13 +18,13 @@ function selectRun(runId: string) { void router.push({ name: 'agent', params: {
<template> <template>
<div class="sidebar-panel"> <div class="sidebar-panel">
<button class="button-primary new-button" @click="router.push({ name: 'agent' })"> 新建运行</button> <button class="button-primary new-button" @click="router.push({ name: 'agent' })"> {{ t('新建运行', 'New run') }}</button>
<p v-if="error" class="subtle error-text">{{ error }}</p> <p v-if="error" class="subtle error-text">{{ error }}</p>
<div class="sidebar-list"> <div class="sidebar-list">
<button v-for="run in agentStore.sortedRuns" :key="run.run_id" class="sidebar-list-item run-item" <button v-for="run in agentStore.sortedRuns" :key="run.run_id" class="sidebar-list-item run-item"
:class="{ active: agentStore.activeRunId === run.run_id }" @click="selectRun(run.run_id)"> :class="{ active: agentStore.activeRunId === run.run_id }" @click="selectRun(run.run_id)">
<span class="badge" :class="{ success: run.status === 'completed', error: run.status === 'failed', warning: run.status === 'waiting_permission' }">{{ runStatusLabel(run.status) }}</span> <span class="badge" :class="{ success: run.status === 'completed', error: run.status === 'failed', warning: run.status === 'waiting_permission' }">{{ runStatusLabel(run.status) }}</span>
<strong>{{ run.run_id.slice(0, 12) }}</strong><small>{{ run.started_at ? new Date(run.started_at).toLocaleString() : '等待开始' }}</small> <strong>{{ run.run_id.slice(0, 12) }}</strong><small>{{ run.started_at ? new Date(run.started_at).toLocaleString(localeTag()) : t('等待开始', 'Waiting to start') }}</small>
</button> </button>
</div> </div>
</div> </div>
+2 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { toolDescription, toolLabel } from './labels' import { toolDescription, toolLabel } from './labels'
import { t } from '@/i18n'
const props = defineProps<{ name: string; description: string; selected: boolean }>() const props = defineProps<{ name: string; description: string; selected: boolean }>()
const emit = defineEmits<{ toggle: [name: string] }>() const emit = defineEmits<{ toggle: [name: string] }>()
@@ -19,7 +20,7 @@ const showOriginal = computed(() => props.description.length > 0)
</span> </span>
</label> </label>
<details v-if="showOriginal" class="tool-original"> <details v-if="showOriginal" class="tool-original">
<summary>查看服务原文与参数</summary> <summary>{{ t('查看服务原文与参数', 'View original service description and parameters') }}</summary>
<p>{{ description }}</p> <p>{{ description }}</p>
</details> </details>
</article> </article>
+27 -7
View File
@@ -1,4 +1,5 @@
import type { AgentEventType, AgentRunStatus } from '@/contracts' import type { AgentEventType, AgentRunStatus } from '@/contracts'
import { appLocale, t } from '@/i18n'
const runStatusLabels: Record<AgentRunStatus, string> = { const runStatusLabels: Record<AgentRunStatus, string> = {
queued: '排队中', queued: '排队中',
@@ -27,6 +28,20 @@ const eventLabels: Record<AgentEventType, string> = {
RunCancelled: '运行取消', RunCancelled: '运行取消',
} }
const runStatusLabelsEn: Record<AgentRunStatus, string> = {
queued: 'Queued', running: 'Running', waiting_permission: 'Waiting for permission',
completed: 'Completed', failed: 'Failed', cancelled: 'Cancelled',
}
const eventLabelsEn: Record<AgentEventType, string> = {
RunStarted: 'Run started', TextDelta: 'Response', ThinkingDelta: 'Reasoning',
ToolCall: 'Tool call', ToolResult: 'Tool result', PermissionRequired: 'Permission required',
Usage: 'Usage', Citation: 'Citation', ModelCallStarted: 'Model call started',
ModelCallCompleted: 'Model call completed', ModelCallFailed: 'Model call failed',
PermissionResolved: 'Permission resolved', RunCompleted: 'Run completed',
RunFailed: 'Run failed', RunCancelled: 'Run cancelled',
}
const toolLabels: Record<string, string> = { const toolLabels: Record<string, string> = {
'system.echo': '回显测试', 'system.echo': '回显测试',
'math.add': '数值相加', 'math.add': '数值相加',
@@ -116,45 +131,50 @@ const detailLabels: Record<string, string> = {
} }
export function runStatusLabel(status?: AgentRunStatus): string { export function runStatusLabel(status?: AgentRunStatus): string {
return status ? runStatusLabels[status] : '未知状态' if (!status) return t('未知状态', 'Unknown status')
return appLocale.value === 'en' ? runStatusLabelsEn[status] : runStatusLabels[status]
} }
export function eventLabel(event: AgentEventType): string { export function eventLabel(event: AgentEventType): string {
return eventLabels[event] return appLocale.value === 'en' ? eventLabelsEn[event] : eventLabels[event]
} }
export function toolLabel(name: string): string { export function toolLabel(name: string): string {
const remote = mcpName(name) const remote = mcpName(name)
if (remote) return mcpTools[remote]?.label ?? `MCP 工具 · ${remote}` if (remote) return appLocale.value === 'en' ? `MCP Tool · ${remote}` : (mcpTools[remote]?.label ?? `MCP 工具 · ${remote}`)
if (appLocale.value === 'en') return name.split('.').map(part => part[0]?.toUpperCase() + part.slice(1)).join(' ')
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) const remote = mcpName(name)
if (remote) { if (remote) {
if (appLocale.value === 'en') return fallback && !/\p{Script=Han}/u.test(fallback) ? fallback : `MCP tool ${remote}. See the original service description for full parameters.`
if (/\p{Script=Han}/u.test(fallback)) return fallback if (/\p{Script=Han}/u.test(fallback)) return fallback
return mcpTools[remote]?.description ?? '暂无中文说明,请展开查看服务原文。' return mcpTools[remote]?.description ?? '暂无中文说明,请展开查看服务原文。'
} }
if (appLocale.value === 'en') return fallback && !/\p{Script=Han}/u.test(fallback) ? fallback : `Built-in tool: ${name}`
return toolDescriptions[name] ?? fallback return toolDescriptions[name] ?? fallback
} }
export function permissionLabel(permission: string): string { export function permissionLabel(permission: string): string {
if (appLocale.value === 'en') return permission.split('.').map(part => part[0]?.toUpperCase() + part.slice(1)).join(' ')
return permissionLabels[permission] ?? permission return permissionLabels[permission] ?? permission
} }
function localizeValue(value: unknown): unknown { function localizeValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(localizeValue) if (Array.isArray(value)) return value.map(localizeValue)
if (value && typeof value === 'object') return localizeDetails(value as Record<string, unknown>) if (value && typeof value === 'object') return localizeDetails(value as Record<string, unknown>)
if (value === true) return '是' if (value === true) return t('是', 'Yes')
if (value === false) return '否' if (value === false) return t('否', 'No')
if (typeof value === 'string' && value in runStatusLabels) { if (typeof value === 'string' && value in runStatusLabels) {
return runStatusLabels[value as AgentRunStatus] return runStatusLabel(value as AgentRunStatus)
} }
return value return value
} }
export function localizeDetails(data: Record<string, unknown>): Record<string, unknown> { export function localizeDetails(data: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries( return Object.fromEntries(
Object.entries(data).map(([key, value]) => [detailLabels[key] ?? key, localizeValue(value)]) Object.entries(data).map(([key, value]) => [appLocale.value === 'en' ? key.replaceAll('_', ' ') : (detailLabels[key] ?? key), localizeValue(value)])
) )
} }
@@ -11,6 +11,10 @@ vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/stores/editor', () => ({ useEditorStore: () => ({}) })) vi.mock('@/stores/editor', () => ({ useEditorStore: () => ({}) }))
vi.mock('@/stores/workspace', () => ({ useWorkspaceStore: () => ({}) })) vi.mock('@/stores/workspace', () => ({ useWorkspaceStore: () => ({}) }))
vi.mock('@/components/common/MarkdownContent.vue', () => ({ default: { template: '<div />' } })) vi.mock('@/components/common/MarkdownContent.vue', () => ({ default: { template: '<div />' } }))
vi.mock('@/services/chatService', () => ({
listConversations: vi.fn().mockResolvedValue({ items: [], page: { total: 0, limit: 100, offset: 0 } }),
listConversationMessages: vi.fn(), createConversation: vi.fn(), removeConversation: vi.fn(), streamChat: vi.fn(),
}))
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()) setActivePinia(createPinia())
+17 -16
View File
@@ -8,6 +8,7 @@ import { useProviderStore } from '@/stores/provider'
import { useSkillStore } from '@/stores/skill' import { useSkillStore } from '@/stores/skill'
import { useWorkspaceStore } from '@/stores/workspace' import { useWorkspaceStore } from '@/stores/workspace'
import MarkdownContent from '@/components/common/MarkdownContent.vue' import MarkdownContent from '@/components/common/MarkdownContent.vue'
import { t } from '@/i18n'
const chatStore = useChatStore() const chatStore = useChatStore()
const providerStore = useProviderStore() const providerStore = useProviderStore()
@@ -23,7 +24,7 @@ const availableModels = computed(() => providerStore.modelsByProvider[chatStore.
onMounted(async () => { onMounted(async () => {
try { try {
await Promise.all([providerStore.loadProviders(), skillStore.loadSkills()]) await Promise.all([providerStore.loadProviders(), skillStore.loadSkills(), chatStore.loadConversations()])
if (disposed || providerStore.error) return if (disposed || providerStore.error) return
const selected = providerStore.enabledProviders.find(p => p.provider_id === chatStore.selectedProviderId) const selected = providerStore.enabledProviders.find(p => p.provider_id === chatStore.selectedProviderId)
if (!selected) { if (!selected) {
@@ -33,7 +34,7 @@ onMounted(async () => {
} }
} catch (error) { } catch (error) {
if (disposed) return if (disposed) return
loadError.value = error instanceof Error ? error.message : '无法加载 AI 配置,请检查后端连接。' loadError.value = error instanceof Error ? error.message : t('无法加载 AI 配置,请检查后端连接。', 'Unable to load AI configuration. Check the backend connection.')
} }
}) })
@@ -41,7 +42,7 @@ async function refreshModels(providerId: string) {
loadError.value = '' loadError.value = ''
if (!providerId) return if (!providerId) return
try { await providerStore.loadModels(providerId) } try { await providerStore.loadModels(providerId) }
catch (error) { if (!disposed && chatStore.selectedProviderId === providerId) loadError.value = error instanceof Error ? error.message : '模型列表加载失败,请手动填写模型 ID。' } catch (error) { if (!disposed && chatStore.selectedProviderId === providerId) loadError.value = error instanceof Error ? error.message : t('模型列表加载失败,请手动填写模型 ID。', 'Unable to load models. Enter a model ID manually.') }
} }
watch(() => chatStore.selectedProviderId, async (providerId) => { watch(() => chatStore.selectedProviderId, async (providerId) => {
@@ -65,19 +66,19 @@ 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>模型 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> <div class="field compact"><label>{{ t('模型 ID', 'Model ID') }}</label><input v-model="chatStore.selectedModel" class="input" list="chat-models" :placeholder="t('填写模型 ID', 'Enter model ID')" /><datalist id="chat-models"><option v-for="model in availableModels" :key="model.model_id" :value="model.model_id">{{ model.name }}</option></datalist></div>
<label class="rag-toggle"><input v-model="chatStore.useRag" type="checkbox" :disabled="chatStore.isStreaming" />检索知识库</label> <label class="rag-toggle"><input v-model="chatStore.useRag" type="checkbox" :disabled="chatStore.isStreaming" />{{ t('检索知识库', 'Search knowledge base') }}</label>
<span class="subtle">开启后将相关笔记片段发送给所选模型并显示来源技能调用请使用智能体</span> <span class="subtle">{{ t('开启后,将相关笔记片段发送给所选模型,并显示来源。技能调用请使用智能体。', 'When enabled, relevant note excerpts are sent to the selected model and citations are shown. Use Agent for skills.') }}</span>
</header> </header>
<div v-if="loadError || providerStore.error" class="error-banner chat-error">{{ loadError || providerStore.error }}</div> <div v-if="loadError || providerStore.error || chatStore.historyError" class="error-banner chat-error">{{ loadError || providerStore.error || chatStore.historyError }}</div>
<main class="message-timeline"> <main class="message-timeline">
<div v-if="!chatStore.messages.length" class="empty-state"><div><strong>开始一段知识对话</strong><p>请先配置模型提供商聊天记录仅保留在本次页面会话中</p></div></div> <div v-if="!chatStore.messages.length" class="empty-state"><div><strong>{{ t('开始一段知识对话', 'Start a knowledge conversation') }}</strong><p>{{ t('请先配置模型提供商。聊天记录保存在本地数据库中。', 'Configure a model provider first. Messages are saved in the local database.') }}</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' ? t('你', 'You') : 'AI' }}</div>
<div class="message-body"> <div class="message-body">
<details v-if="message.thinking" class="thinking"><summary>思考过程</summary><p>{{ message.thinking }}</p></details> <details v-if="message.thinking" class="thinking"><summary>{{ t('思考过程', 'Reasoning') }}</summary><p>{{ message.thinking }}</p></details>
<MarkdownContent v-if="message.content" class="message-content" :source="message.content" /> <MarkdownContent v-if="message.content" class="message-content" :source="message.content" />
<div v-else-if="chatStore.isStreaming" class="message-content">正在思考</div> <div v-else-if="chatStore.isStreaming" class="message-content">{{ t('正在思考', 'Thinking') }}</div>
<div v-if="message.tool_calls?.length" class="tool-calls"><div v-for="call in message.tool_calls" :key="call.tool_call_id" class="item-card"><span class="badge info">{{ call.status }}</span><strong>{{ call.name }}</strong><pre>{{ JSON.stringify(call.parameters, null, 2) }}</pre></div></div> <div v-if="message.tool_calls?.length" class="tool-calls"><div v-for="call in message.tool_calls" :key="call.tool_call_id" class="item-card"><span class="badge info">{{ call.status }}</span><strong>{{ call.name }}</strong><pre>{{ JSON.stringify(call.parameters, null, 2) }}</pre></div></div>
<div v-if="message.citations?.length" class="citations"> <div v-if="message.citations?.length" class="citations">
<button v-for="(citation, index) in message.citations" :key="citation.block_id" class="citation-card" @click="openCitation(citation)"> <button v-for="(citation, index) in message.citations" :key="citation.block_id" class="citation-card" @click="openCitation(citation)">
@@ -85,16 +86,16 @@ 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 }}<span v-if="message.usage.input_tokens !== undefined && message.usage.output_tokens !== undefined">输入 {{ message.usage.input_tokens }} / 输出 {{ message.usage.output_tokens }}</span></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"> ({{ t('输入', 'input') }} {{ message.usage.input_tokens }} / {{ t('输出', 'output') }} {{ message.usage.output_tokens }})</span></small>
</div> </div>
</article> </article>
</main> </main>
<footer class="composer"> <footer class="composer">
<textarea v-model="chatStore.inputText" class="textarea" placeholder="输入问题,Ctrl + Enter 发送" <textarea v-model="chatStore.inputText" class="textarea" :placeholder="t('输入问题,Ctrl + Enter 发送', 'Enter a question; press Ctrl + Enter to send')"
@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">{{ t('回答可能包含错误,请核对 Citation。', 'Answers may contain errors. Verify the citations.') }}</span>
<button v-if="chatStore.isStreaming" class="button-danger" @click="chatStore.stopGeneration">停止</button> <button v-if="chatStore.isStreaming || chatStore.isPreparing" class="button-danger" @click="chatStore.stopGeneration">{{ t('停止', 'Stop') }}</button>
<button v-else class="button-primary" :disabled="!chatStore.inputText.trim() || !chatStore.selectedProviderId || !chatStore.selectedModel.trim()" @click="send">发送</button> <button v-else class="button-primary" :disabled="!chatStore.canSend || !chatStore.inputText.trim() || !chatStore.selectedProviderId || !chatStore.selectedModel.trim()" @click="send">{{ t('发送', 'Send') }}</button>
</div> </div>
</footer> </footer>
</section> </section>
@@ -1,18 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue'
import { useChatStore } from '@/stores/chat' import { useChatStore } from '@/stores/chat'
import { t } from '@/i18n'
const chatStore = useChatStore() const chatStore = useChatStore()
onMounted(() => { void chatStore.loadConversations() })
</script> </script>
<template> <template>
<div class="sidebar-panel"> <div class="sidebar-panel">
<button class="button-primary new-button" @click="chatStore.createNewConversation"> 新对话</button> <button class="button-primary new-button" @click="chatStore.createNewConversation"> {{ t('新对话', 'New conversation') }}</button>
<div class="sidebar-list conversation-list"> <div class="sidebar-list conversation-list">
<div v-for="conversation in chatStore.sortedConversations" :key="conversation.conversation_id" <div v-for="conversation in chatStore.sortedConversations" :key="conversation.conversation_id"
class="sidebar-list-item conversation" :class="{ active: chatStore.activeConversationId === conversation.conversation_id }" class="sidebar-list-item conversation" :class="{ active: chatStore.activeConversationId === conversation.conversation_id }"
@click="chatStore.setActiveConversation(conversation.conversation_id)"> @click="chatStore.setActiveConversation(conversation.conversation_id)">
<div><strong>{{ conversation.title }}</strong><p>{{ conversation.message_count }} 条消息</p></div> <div><strong>{{ conversation.title }}</strong><p>{{ conversation.message_count }} {{ t('条消息', 'messages') }}</p></div>
<button class="delete" title="删除会话" @click.stop="chatStore.deleteConversation(conversation.conversation_id)">×</button> <button class="delete" :title="t('删除会话', 'Delete conversation')" @click.stop="chatStore.deleteConversation(conversation.conversation_id)">×</button>
</div> </div>
</div> </div>
</div> </div>
+11 -9
View File
@@ -1,26 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
import { useWorkspaceStore } from '@/stores/workspace' import { useWorkspaceStore } from '@/stores/workspace'
import { computed } from 'vue'
import { t } from '@/i18n'
const editorStore = useEditorStore() const editorStore = useEditorStore()
const workspaceStore = useWorkspaceStore() const workspaceStore = useWorkspaceStore()
const statusText: Record<string, string> = { const statusText = computed<Record<string, string>>(() => ({
idle: '空闲', dirty: '未保存', saving: '保存中…', saved: '已保存', save_failed: '保存失败', idle: t('空闲', 'Idle'), dirty: t('未保存', 'Unsaved'), saving: t('保存中…', 'Saving…'), saved: t('已保存', 'Saved'), save_failed: t('保存失败', 'Save failed'),
external_changed: '外部文件已变化', conflict: '存在编辑冲突', external_changed: t('外部文件已变化', 'File changed externally'), conflict: t('存在编辑冲突', 'Edit conflict'),
} }))
</script> </script>
<template> <template>
<header class="editor-header"> <header class="editor-header">
<div class="file-identity"><strong>{{ workspaceStore.activeFile?.name ?? '未命名笔记' }}</strong><small>{{ workspaceStore.activeFilePath }}</small></div> <div class="file-identity"><strong>{{ workspaceStore.activeFile?.name ?? t('未命名笔记', 'Untitled note') }}</strong><small>{{ workspaceStore.activeFilePath }}</small></div>
<div class="editor-actions"> <div class="editor-actions">
<span class="save-status" :class="editorStore.saveStatus">{{ statusText[editorStore.saveStatus] }}</span> <span class="save-status" :class="editorStore.saveStatus">{{ statusText[editorStore.saveStatus] }}</span>
<div class="mode-switch" aria-label="编辑模式"> <div class="mode-switch" :aria-label="t('编辑模式', 'Editor mode')">
<button type="button" :class="{ active: editorStore.mode === 'wysiwyg' }" @click="editorStore.setMode('wysiwyg')">写作</button> <button type="button" :class="{ active: editorStore.mode === 'wysiwyg' }" @click="editorStore.setMode('wysiwyg')">{{ t('写作', 'Writing') }}</button>
<button type="button" :class="{ active: editorStore.mode === 'source' }" @click="editorStore.setMode('source')">源码</button> <button type="button" :class="{ active: editorStore.mode === 'source' }" @click="editorStore.setMode('source')">{{ t('源码', 'Source') }}</button>
</div> </div>
<button type="button" class="save-button" :disabled="editorStore.saveStatus === 'saving'" @click="editorStore.save">保存</button> <button type="button" class="save-button" :disabled="editorStore.saveStatus === 'saving'" @click="editorStore.save">{{ t('保存', 'Save') }}</button>
</div> </div>
</header> </header>
</template> </template>
@@ -53,4 +53,19 @@ describe('EditorPane file switching', () => {
expect(store.currentFilePath).toBe('/数据结构/红黑树.md') expect(store.currentFilePath).toBe('/数据结构/红黑树.md')
expect(wrapper.text()).not.toContain('祝你写作愉快') expect(wrapper.text()).not.toContain('祝你写作愉快')
}) })
it('applies the saved spell-check and language settings to source mode', async () => {
const editor = useEditorStore()
const settings = (await import('@/stores/settings')).useSettingsStore()
editor.setMode('source')
settings.spellCheck = true
settings.language = 'en'
wrapper = mount(EditorPane, { attachTo: document.body })
await nextTick()
const textarea = wrapper.get('textarea')
expect(textarea.attributes('spellcheck')).toBe('true')
expect(textarea.attributes('lang')).toBe('en')
expect(textarea.attributes('aria-label')).toBe('Markdown source editor')
})
}) })
+3 -3
View File
@@ -14,10 +14,10 @@ function updateContent(event: Event) {
</script> </script>
<template> <template>
<VisualMarkdownEditor v-if="editorStore.mode === 'wysiwyg'" :key="`${editorStore.currentFilePath ?? 'empty'}:${themeStore.resolvedCodeBlockTheme}`" <VisualMarkdownEditor v-if="editorStore.mode === 'wysiwyg'" :key="`${editorStore.currentFilePath ?? 'empty'}:${themeStore.resolvedCodeBlockTheme}:${settingsStore.language}`"
:initial-content="editorStore.content" /> :initial-content="editorStore.content" />
<textarea v-else class="editor-pane source" :value="editorStore.content" :spellcheck="false" <textarea v-else class="editor-pane source" :value="editorStore.content" :spellcheck="settingsStore.spellCheck"
aria-label="Markdown 源码编辑器" @input="updateContent" /> :lang="settingsStore.language" :aria-label="settingsStore.language === 'en' ? 'Markdown source editor' : 'Markdown 源码编辑器'" @input="updateContent" />
</template> </template>
<style scoped> <style scoped>
@@ -6,6 +6,7 @@ import { editorViewCtx, type Editor } from '@milkdown/kit/core'
import { TextSelection } from '@milkdown/kit/prose/state' import { TextSelection } from '@milkdown/kit/prose/state'
import { getMarkdown } from '@milkdown/kit/utils' import { getMarkdown } from '@milkdown/kit/utils'
import VisualMarkdownEditor from './VisualMarkdownEditor.vue' import VisualMarkdownEditor from './VisualMarkdownEditor.vue'
import { useSettingsStore } from '@/stores/settings'
type EditorComponent = { getEditor: () => Editor | undefined } type EditorComponent = { getEditor: () => Editor | undefined }
@@ -90,4 +91,19 @@ describe('VisualMarkdownEditor formatting toolbars', () => {
expect(editor.action(getMarkdown()).trim()).toBe('alpha') expect(editor.action(getMarkdown()).trim()).toBe('alpha')
}) })
it('updates native spell checking on the ProseMirror editor', async () => {
const settings = useSettingsStore()
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'mispelled word' }, attachTo: document.body })
mounted.push(wrapper)
await waitForEditor(wrapper)
settings.spellCheck = true
settings.language = 'en'
await wrapper.vm.$nextTick()
const editable = wrapper.get('.ProseMirror')
expect(editable.attributes('spellcheck')).toBe('true')
expect(editable.attributes('lang')).toBe('en')
})
}) })
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue' import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Link } from '@element-plus/icons-vue' import { Link } from '@element-plus/icons-vue'
import { Crepe } from '@milkdown/crepe' import { Crepe } from '@milkdown/crepe'
import { oneDark } from '@codemirror/theme-one-dark' import { oneDark } from '@codemirror/theme-one-dark'
@@ -22,6 +22,7 @@ import { useEditorStore } from '@/stores/editor'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { applyMarkdownFontSize, fontSizeMarkdownPlugin } from './fontSizeMarkdown' import { applyMarkdownFontSize, fontSizeMarkdownPlugin } from './fontSizeMarkdown'
import { t } from '@/i18n'
import '@milkdown/crepe/theme/common/style.css' import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css' import '@milkdown/crepe/theme/frame.css'
@@ -34,6 +35,14 @@ const loading = ref(true)
const fontSizeInput = ref(16) const fontSizeInput = ref(16)
let crepe: Crepe | null = null let crepe: Crepe | null = null
function applyProofingPreferences() {
const editable = editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')
if (!editable) return
editable.spellcheck = settingsStore.spellCheck
editable.setAttribute('spellcheck', String(settingsStore.spellCheck))
editable.lang = settingsStore.language
}
type ToolbarCommand = 'bold' | 'italic' | 'ordered-list' | 'bullet-list' | 'inline-code' | 'code-block' | 'inline-math' | 'math-block' type ToolbarCommand = 'bold' | 'italic' | 'ordered-list' | 'bullet-list' | 'inline-code' | 'code-block' | 'inline-math' | 'math-block'
function runCommand(command: ToolbarCommand) { function runCommand(command: ToolbarCommand) {
@@ -57,14 +66,14 @@ function runCommand(command: ToolbarCommand) {
function applyLink() { function applyLink() {
if (!crepe) return if (!crepe) return
// TODO(editor): Element Plus prompt URL // TODO(editor): Element Plus prompt URL
const href = window.prompt('请输入链接地址', 'https://')?.trim() const href = window.prompt(t('请输入链接地址', 'Enter link address'), 'https://')?.trim()
if (!href) return if (!href) return
crepe.editor.action((ctx) => { crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx) const view = ctx.get(editorViewCtx)
const commands = ctx.get(commandsCtx) const commands = ctx.get(commandsCtx)
if (view.state.selection.empty) { if (view.state.selection.empty) {
const label = window.prompt('请输入链接文字', href)?.trim() || href const label = window.prompt(t('请输入链接文字', 'Enter link text'), href)?.trim() || href
const from = view.state.selection.from const from = view.state.selection.from
const transaction = view.state.tr.insertText(label, from) const transaction = view.state.tr.insertText(label, from)
transaction.setSelection(TextSelection.create(transaction.doc, from, from + label.length)) transaction.setSelection(TextSelection.create(transaction.doc, from, from + label.length))
@@ -107,56 +116,56 @@ onMounted(async () => {
defaultValue: props.initialContent, defaultValue: props.initialContent,
features: { [Crepe.Feature.TopBar]: false }, features: { [Crepe.Feature.TopBar]: false },
featureConfigs: { featureConfigs: {
[Crepe.Feature.Placeholder]: { text: '开始记录你的想法…' }, [Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') },
[Crepe.Feature.CodeMirror]: { [Crepe.Feature.CodeMirror]: {
theme: themeStore.resolvedCodeBlockTheme === 'github-dark' ? oneDark : [], theme: themeStore.resolvedCodeBlockTheme === 'github-dark' ? oneDark : [],
previewOnlyByDefault: false, previewOnlyByDefault: false,
searchPlaceholder: '搜索语言', searchPlaceholder: t('搜索语言', 'Search languages'),
noResultText: '没有匹配的语言', noResultText: t('没有匹配的语言', 'No matching language'),
copyText: '复制', copyText: t('复制', 'Copy'),
}, },
[Crepe.Feature.Latex]: { [Crepe.Feature.Latex]: {
inlineEditConfirm: '确认', inlineEditConfirm: t('确认', 'Confirm'),
}, },
[Crepe.Feature.LinkTooltip]: { [Crepe.Feature.LinkTooltip]: {
editButton: '编辑', editButton: t('编辑', 'Edit'),
removeButton: '移除', removeButton: t('移除', 'Remove'),
confirmButton: '确认', confirmButton: t('确认', 'Confirm'),
inputPlaceholder: '粘贴链接地址…', inputPlaceholder: t('粘贴链接地址…', 'Paste link address…'),
}, },
[Crepe.Feature.Toolbar]: { [Crepe.Feature.Toolbar]: {
boldLabel: '加粗', boldLabel: t('加粗', 'Bold'),
italicLabel: '斜体', italicLabel: t('斜体', 'Italic'),
strikethroughLabel: '删除线', strikethroughLabel: t('删除线', 'Strikethrough'),
codeLabel: '行内代码', codeLabel: t('行内代码', 'Inline code'),
latexLabel: '行内公式', latexLabel: t('行内公式', 'Inline formula'),
linkLabel: '链接', linkLabel: t('链接', 'Link'),
}, },
[Crepe.Feature.BlockEdit]: { [Crepe.Feature.BlockEdit]: {
textGroup: { textGroup: {
label: '文本', label: t('文本', 'Text'),
text: { label: '正文' }, text: { label: t('正文', 'Paragraph') },
h1: { label: '一级标题' }, h1: { label: t('一级标题', 'Heading 1') },
h2: { label: '二级标题' }, h2: { label: t('二级标题', 'Heading 2') },
h3: { label: '三级标题' }, h3: { label: t('三级标题', 'Heading 3') },
h4: { label: '四级标题' }, h4: { label: t('四级标题', 'Heading 4') },
h5: { label: '五级标题' }, h5: { label: t('五级标题', 'Heading 5') },
h6: { label: '六级标题' }, h6: { label: t('六级标题', 'Heading 6') },
quote: { label: '引用' }, quote: { label: t('引用', 'Quote') },
divider: { label: '分割线' }, divider: { label: t('分割线', 'Divider') },
}, },
listGroup: { listGroup: {
label: '列表', label: t('列表', 'Lists'),
bulletList: { label: '无序列表' }, bulletList: { label: t('无序列表', 'Bullet list') },
orderedList: { label: '有序列表' }, orderedList: { label: t('有序列表', 'Ordered list') },
taskList: { label: '任务列表' }, taskList: { label: t('任务列表', 'Task list') },
}, },
advancedGroup: { advancedGroup: {
label: '插入', label: t('插入', 'Insert'),
image: { label: '图片' }, image: { label: t('图片', 'Image') },
codeBlock: { label: '代码块' }, codeBlock: { label: t('代码块', 'Code block') },
table: { label: '表格' }, table: { label: t('表格', 'Table') },
math: { label: '公式块' }, math: { label: t('公式块', 'Formula block') },
}, },
}, },
}, },
@@ -171,9 +180,12 @@ onMounted(async () => {
}) })
}) })
await crepe.create() await crepe.create()
applyProofingPreferences()
loading.value = false loading.value = false
}) })
watch([() => settingsStore.spellCheck, () => settingsStore.language], applyProofingPreferences)
onBeforeUnmount(() => { void crepe?.destroy() }) onBeforeUnmount(() => { void crepe?.destroy() })
defineExpose({ getEditor: () => crepe?.editor }) defineExpose({ getEditor: () => crepe?.editor })
@@ -181,42 +193,42 @@ defineExpose({ getEditor: () => crepe?.editor })
<template> <template>
<div class="visual-editor"> <div class="visual-editor">
<div class="markdown-toolbar" role="toolbar" aria-label="Markdown 格式工具栏"> <div class="markdown-toolbar" role="toolbar" :aria-label="t('Markdown 格式工具栏', 'Markdown formatting toolbar')">
<label class="toolbar-select heading-select" title="设置标题级别"> <label class="toolbar-select heading-select" :title="t('设置标题级别', 'Set heading level')">
<span class="format-glyph heading-glyph">H</span> <span class="format-glyph heading-glyph">H</span>
<select aria-label="标题级别" @change="applyHeading"> <select :aria-label="t('标题级别', 'Heading level')" @change="applyHeading">
<option value="" selected>标题</option> <option value="" selected>{{ t('标题', 'Heading') }}</option>
<option value="paragraph">正文</option> <option value="paragraph">{{ t('正文', 'Paragraph') }}</option>
<option v-for="level in 6" :key="level" :value="level">H{{ level }}</option> <option v-for="level in 6" :key="level" :value="level">H{{ level }}</option>
</select> </select>
</label> </label>
<button type="button" title="加粗 (Ctrl+B)" aria-label="加粗" @pointerdown.prevent="runCommand('bold')"><strong class="format-glyph">B</strong></button> <button type="button" :title="t('加粗 (Ctrl+B)', 'Bold (Ctrl+B)')" :aria-label="t('加粗', 'Bold')" @pointerdown.prevent="runCommand('bold')"><strong class="format-glyph">B</strong></button>
<button type="button" title="斜体 (Ctrl+I)" aria-label="斜体" @pointerdown.prevent="runCommand('italic')"><em class="format-glyph">I</em></button> <button type="button" :title="t('斜体 (Ctrl+I)', 'Italic (Ctrl+I)')" :aria-label="t('斜体', 'Italic')" @pointerdown.prevent="runCommand('italic')"><em class="format-glyph">I</em></button>
<span class="toolbar-divider" /> <span class="toolbar-divider" />
<button type="button" class="list-glyph" title="有序列表" aria-label="有序列表" @pointerdown.prevent="runCommand('ordered-list')"><span class="list-marker">1</span><span class="list-lines"></span></button> <button type="button" class="list-glyph" :title="t('有序列表', 'Ordered list')" :aria-label="t('有序列表', 'Ordered list')" @pointerdown.prevent="runCommand('ordered-list')"><span class="list-marker">1</span><span class="list-lines"></span></button>
<button type="button" class="list-glyph" title="无序列表" aria-label="无序列表" @pointerdown.prevent="runCommand('bullet-list')"><span class="list-marker"></span><span class="list-lines"></span></button> <button type="button" class="list-glyph" :title="t('无序列表', 'Bullet list')" :aria-label="t('无序列表', 'Bullet list')" @pointerdown.prevent="runCommand('bullet-list')"><span class="list-marker"></span><span class="list-lines"></span></button>
<span class="toolbar-divider" /> <span class="toolbar-divider" />
<label class="toolbar-select font-size-select" title="选择预设字号"> <label class="toolbar-select font-size-select" :title="t('选择预设字号', 'Choose a preset font size')">
<span class="format-glyph font-size-glyph">A</span> <span class="format-glyph font-size-glyph">A</span>
<select aria-label="文字字号" @change="applyFontSize"> <select :aria-label="t('文字字号', 'Font size')" @change="applyFontSize">
<option value="" selected>字号</option> <option value="" selected>{{ t('字号', 'Size') }}</option>
<option v-for="size in [12, 14, 16, 18, 20, 24, 28, 32]" :key="size" :value="size">{{ size }} px</option> <option v-for="size in [12, 14, 16, 18, 20, 24, 28, 32]" :key="size" :value="size">{{ size }} px</option>
</select> </select>
</label> </label>
<div class="font-size-input" title="输入字号后按 Enter 或点击应用"> <div class="font-size-input" :title="t('输入字号后按 Enter 或点击应用', 'Enter a font size, then press Enter or Apply')">
<input v-model.number="fontSizeInput" type="number" min="8" max="96" step="1" aria-label="自定义字号" <input v-model.number="fontSizeInput" type="number" min="8" max="96" step="1" :aria-label="t('自定义字号', 'Custom font size')"
@keydown.enter.prevent="applyFontSizeValue" /> @keydown.enter.prevent="applyFontSizeValue" />
<span>px</span> <span>px</span>
<button type="button" aria-label="应用自定义字号" @pointerdown.prevent="applyFontSizeValue">应用</button> <button type="button" :aria-label="t('应用自定义字号', 'Apply custom font size')" @pointerdown.prevent="applyFontSizeValue">{{ t('应用', 'Apply') }}</button>
</div> </div>
<span class="toolbar-divider" /> <span class="toolbar-divider" />
<button type="button" title="行内代码" aria-label="行内代码" @pointerdown.prevent="runCommand('inline-code')"><code class="code-glyph">&lt;/&gt;</code></button> <button type="button" :title="t('行内代码', 'Inline code')" :aria-label="t('行内代码', 'Inline code')" @pointerdown.prevent="runCommand('inline-code')"><code class="code-glyph">&lt;/&gt;</code></button>
<button type="button" title="代码块" aria-label="代码块" @pointerdown.prevent="runCommand('code-block')"><span class="block-glyph">{ }</span></button> <button type="button" :title="t('代码块', 'Code block')" :aria-label="t('代码块', 'Code block')" @pointerdown.prevent="runCommand('code-block')"><span class="block-glyph">{ }</span></button>
<button type="button" title="行内公式" aria-label="行内公式" @pointerdown.prevent="runCommand('inline-math')"><span class="math-glyph">ƒx</span></button> <button type="button" :title="t('行内公式', 'Inline formula')" :aria-label="t('行内公式', 'Inline formula')" @pointerdown.prevent="runCommand('inline-math')"><span class="math-glyph">ƒx</span></button>
<button type="button" title="公式块" aria-label="公式块" @pointerdown.prevent="runCommand('math-block')"><span class="math-glyph"></span></button> <button type="button" :title="t('公式块', 'Formula block')" :aria-label="t('公式块', 'Formula block')" @pointerdown.prevent="runCommand('math-block')"><span class="math-glyph"></span></button>
<button type="button" title="插入链接" aria-label="插入链接" @pointerdown.prevent="applyLink"><AppIcon :icon="Link" :size="17" /></button> <button type="button" :title="t('插入链接', 'Insert link')" :aria-label="t('插入链接', 'Insert link')" @pointerdown.prevent="applyLink"><AppIcon :icon="Link" :size="17" /></button>
</div> </div>
<div v-if="loading" class="editor-loading">正在加载编辑器</div> <div v-if="loading" class="editor-loading">{{ t('正在加载编辑器', 'Loading editor') }}</div>
<div ref="editorRoot" class="milkdown-host" :class="{ loading }" /> <div ref="editorRoot" class="milkdown-host" :class="{ loading }" />
</div> </div>
</template> </template>
+34 -33
View File
@@ -5,6 +5,7 @@ import AppIcon from '@/components/common/AppIcon.vue'
import type { McpServer, McpServerInput, McpServerTransport } from '@/contracts' import type { McpServer, McpServerInput, McpServerTransport } from '@/contracts'
import * as service from '@/services/mcpServerService' import * as service from '@/services/mcpServerService'
import { emptyMcpConfig, mergeImportedSecrets, normalizeMcpConfig, parseMcpJson, type ImportedSecret, type SecretKind } from './configuration' import { emptyMcpConfig, mergeImportedSecrets, normalizeMcpConfig, parseMcpJson, type ImportedSecret, type SecretKind } from './configuration'
import { t } from '@/i18n'
const servers = ref<McpServer[]>([]) const servers = ref<McpServer[]>([])
const busy = ref('') const busy = ref('')
@@ -24,12 +25,12 @@ const secretDrafts = reactive<Record<string, string>>({})
const form = reactive<McpServerInput>(emptyMcpConfig()) const form = reactive<McpServerInput>(emptyMcpConfig())
const importedSecrets = ref<ImportedSecret[]>([]) const importedSecrets = ref<ImportedSecret[]>([])
const dialogTitle = computed(() => editingId.value ? '编辑 MCP 服务器' : '新增 MCP 服务器') const dialogTitle = computed(() => editingId.value ? t('编辑 MCP 服务器', 'Edit MCP Server') : t('新增 MCP 服务器', 'Add MCP Server'))
async function load() { async function load() {
error.value = '' error.value = ''
try { servers.value = await service.listMcpServers() } try { servers.value = await service.listMcpServers() }
catch (cause) { error.value = message(cause, '读取 MCP 服务器失败') } catch (cause) { error.value = message(cause, t('读取 MCP 服务器失败', 'Failed to load MCP servers')) }
} }
function resetEditor(input: McpServerInput) { function resetEditor(input: McpServerInput) {
@@ -84,8 +85,8 @@ function applyTemplate(transport: McpServerTransport) {
function parseObject(value: string, label: string): Record<string, string> { function parseObject(value: string, label: string): Record<string, string> {
let parsed: unknown let parsed: unknown
try { parsed = JSON.parse(value || '{}') } catch { throw new Error(`${label}必须是 JSON 对象`) } try { parsed = JSON.parse(value || '{}') } catch { throw new Error(`${label}${t('必须是 JSON 对象', ' must be a JSON object')}`) }
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object' || Object.values(parsed).some(item => typeof item !== 'string')) throw new Error(`${label}必须是字符串键值 JSON 对象`) if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object' || Object.values(parsed).some(item => typeof item !== 'string')) throw new Error(`${label}${t('必须是字符串键值 JSON 对象', ' must be a JSON object with string keys and values')}`)
return parsed as Record<string, string> return parsed as Record<string, string>
} }
@@ -97,8 +98,8 @@ function formPayload(): McpServerInput {
command: stdio ? form.command?.trim() : null, command: stdio ? form.command?.trim() : null,
args: stdio ? argsText.value.split('\n').map(value => value.trim()).filter(Boolean) : [], args: stdio ? argsText.value.split('\n').map(value => value.trim()).filter(Boolean) : [],
url: stdio ? null : form.url?.trim(), url: stdio ? null : form.url?.trim(),
headers: stdio ? {} : parseObject(headersText.value, '普通 Header'), headers: stdio ? {} : parseObject(headersText.value, t('普通 Header', 'Headers')),
environment: stdio ? parseObject(environmentText.value, '普通环境变量') : {}, environment: stdio ? parseObject(environmentText.value, t('普通环境变量', 'Environment variables')) : {},
secret_environment_keys: stdio ? splitKeys(secretKeysText.value) : [], secret_environment_keys: stdio ? splitKeys(secretKeysText.value) : [],
secret_header_keys: stdio ? [] : splitKeys(secretHeaderKeysText.value), secret_header_keys: stdio ? [] : splitKeys(secretHeaderKeysText.value),
permissions: permissionsText.value.split(',').map(value => value.trim()).filter(Boolean), permissions: permissionsText.value.split(',').map(value => value.trim()).filter(Boolean),
@@ -131,7 +132,7 @@ function switchMode(mode: 'form' | 'json') {
if (mode === 'json') rawConfig.value = JSON.stringify(payload(false), null, 2) if (mode === 'json') rawConfig.value = JSON.stringify(payload(false), null, 2)
else resetEditor(payload(false)) else resetEditor(payload(false))
editorMode.value = mode editorMode.value = mode
} catch (cause) { error.value = message(cause, '配置转换失败') } } catch (cause) { error.value = message(cause, t('配置转换失败', 'Configuration conversion failed')) }
} }
async function save() { async function save() {
@@ -140,8 +141,8 @@ async function save() {
try { try {
error.value = '' error.value = ''
const input = payload() const input = payload()
if (!input.name || (input.transport === 'stdio' ? !input.command : !input.url)) throw new Error('请填写服务器名称和连接地址') if (!input.name || (input.transport === 'stdio' ? !input.command : !input.url)) throw new Error(t('请填写服务器名称和连接地址', 'Enter a server name and connection address'))
if (editingOriginal.value && executionChanged(editingOriginal.value, input) && !confirm('连接命令、地址或认证配置已变化,保存后旧测试与授权会失效。是否保存?')) return if (editingOriginal.value && executionChanged(editingOriginal.value, input) && !confirm(t('连接命令、地址或认证配置已变化,保存后旧测试与授权会失效。是否保存?', 'The command, address, or authentication settings changed. Previous tests and authorization will be invalidated. Save?'))) return
busy.value = 'save' busy.value = 'save'
saved = editingId.value ? await service.updateMcpServer(editingId.value, input) : await service.createMcpServer(input) saved = editingId.value ? await service.updateMcpServer(editingId.value, input) : await service.createMcpServer(input)
// Commit the returned ID/version before saving secrets so a partial failure can // Commit the returned ID/version before saving secrets so a partial failure can
@@ -157,7 +158,7 @@ async function save() {
await load() await load()
} catch (cause) { } catch (cause) {
if (saved) await load() if (saved) await load()
error.value = `${saved ? '服务器配置已保存,但密钥保存失败;可点击保存重试。' : ''}${message(cause, '保存失败')}` error.value = `${saved ? t('服务器配置已保存,但密钥保存失败;可点击保存重试。', 'Server settings were saved, but saving secrets failed. Save again to retry.') : ''}${message(cause, t('保存失败', 'Save failed'))}`
} }
finally { busy.value = '' } finally { busy.value = '' }
} }
@@ -189,8 +190,8 @@ function executionChanged(server: McpServer, input: McpServerInput) {
async function approve(server: McpServer): Promise<McpServer | null> { async function approve(server: McpServer): Promise<McpServer | null> {
if (server.trusted) return server if (server.trusted) return server
const localWarning = server.transport === 'stdio' ? '\n\n本机进程尚无系统级沙箱,仅应运行可信服务器。' : '\n\n连接可能向该地址发送配置的 Header。' const localWarning = server.transport === 'stdio' ? t('\n\n本机进程尚无系统级沙箱,仅应运行可信服务器。', '\n\nLocal processes have no system-level sandbox. Run trusted servers only.') : t('\n\n连接可能向该地址发送配置的 Header。', '\n\nThe connection may send configured headers to this address.')
if (!confirm(`请确认 MCP 连接:\n\n${server.command_summary}${localWarning}\n\n是否继续?`)) return null if (!confirm(`${t('请确认 MCP 连接:', 'Confirm MCP connection:')}\n\n${server.command_summary}${localWarning}\n\n${t('是否继续?', 'Continue?')}`)) return null
return service.trustMcpServer(server) return service.trustMcpServer(server)
} }
@@ -199,14 +200,14 @@ async function toggle(server: McpServer) { await act(server, 'toggle', current =
async function act(server: McpServer, action: string, operation: (server: McpServer) => Promise<McpServer>) { async function act(server: McpServer, action: string, operation: (server: McpServer) => Promise<McpServer>) {
busy.value = `${action}:${server.server_id}`; error.value = '' busy.value = `${action}:${server.server_id}`; error.value = ''
try { const current = action === 'toggle' && server.enabled ? server : await approve(server); if (!current) return; await operation(current); await load() } try { const current = action === 'toggle' && server.enabled ? server : await approve(server); if (!current) return; await operation(current); await load() }
catch (cause) { error.value = message(cause, '操作失败') } catch (cause) { error.value = message(cause, t('操作失败', 'Operation failed')) }
finally { busy.value = '' } finally { busy.value = '' }
} }
async function remove(server: McpServer) { async function remove(server: McpServer) {
if (!confirm(`删除“${server.name}”及其加密凭据?`)) return if (!confirm(t(`删除“${server.name}”及其加密凭据?`, `Delete “${server.name}” and its encrypted credentials?`))) return
try { busy.value = `delete:${server.server_id}`; await service.deleteMcpServer(server.server_id); await load() } try { busy.value = `delete:${server.server_id}`; await service.deleteMcpServer(server.server_id); await load() }
catch (cause) { error.value = message(cause, '删除失败') } finally { busy.value = '' } catch (cause) { error.value = message(cause, t('删除失败', 'Delete failed')) } finally { busy.value = '' }
} }
async function saveSecret(server: McpServer, key: string, kind: SecretKind) { async function saveSecret(server: McpServer, key: string, kind: SecretKind) {
@@ -214,7 +215,7 @@ async function saveSecret(server: McpServer, key: string, kind: SecretKind) {
const value = secretDrafts[draftKey]?.trim() const value = secretDrafts[draftKey]?.trim()
if (!value) return if (!value) return
try { busy.value = `secret:${draftKey}`; await service.putMcpServerSecret(server.server_id, key, value, kind); secretDrafts[draftKey] = ''; await load() } try { busy.value = `secret:${draftKey}`; await service.putMcpServerSecret(server.server_id, key, value, kind); secretDrafts[draftKey] = ''; await load() }
catch (cause) { error.value = message(cause, '保存密钥失败') } finally { busy.value = '' } catch (cause) { error.value = message(cause, t('保存密钥失败', 'Failed to save secret')) } finally { busy.value = '' }
} }
function splitKeys(value: string) { return value.split(/[\n,]/).map(item => item.trim()).filter(Boolean) } function splitKeys(value: string) { return value.split(/[\n,]/).map(item => item.trim()).filter(Boolean) }
@@ -224,20 +225,20 @@ onMounted(load)
<template> <template>
<section class="feature-page mcp-page"> <section class="feature-page mcp-page">
<header class="feature-header"><div><h1>MCP 服务器</h1><p>管理独立 MCP Server 的连接凭据与工具生命周期</p></div><div class="inline-actions"><button class="button-secondary" :disabled="!!busy" @click="load"><AppIcon :icon="Refresh" /> 刷新</button><button class="button-primary" @click="openCreate"><AppIcon :icon="Plus" /> 新增服务器</button></div></header> <header class="feature-header"><div><h1>{{ t('MCP 服务器', 'MCP Servers') }}</h1><p>{{ t('管理独立 MCP Server 的连接、凭据与工具生命周期。', 'Manage standalone MCP server connections, credentials, and tool lifecycles.') }}</p></div><div class="inline-actions"><button class="button-secondary" :disabled="!!busy" @click="load"><AppIcon :icon="Refresh" /> {{ t('刷新', 'Refresh') }}</button><button class="button-primary" @click="openCreate"><AppIcon :icon="Plus" /> {{ t('新增服务器', 'Add server') }}</button></div></header>
<div class="notice-banner">stdio 本机进程仅在开发环境开放Streamable HTTP 为首选远程传输SSE 仅用于兼容旧服务器uvx 隔离依赖但不是安全沙箱</div> <div class="notice-banner">{{ t('stdio 本机进程仅在开发环境开放;Streamable HTTP 为首选远程传输,SSE 仅用于兼容旧服务器。uvx 隔离依赖但不是安全沙箱。', 'Local stdio processes are available only in development. Streamable HTTP is the preferred remote transport; SSE supports legacy servers. uvx isolates dependencies but is not a security sandbox.') }}</div>
<div v-if="error" class="error-banner">{{ error }}</div> <div v-if="error" class="error-banner">{{ error }}</div>
<div v-if="!servers.length" class="panel empty"><AppIcon :icon="Connection" :size="34" /><h2>尚未配置 MCP 服务器</h2><p>添加 Server,测试连接成功后才能启用工具。</p><button class="button-primary" @click="openCreate">新增服务器</button></div> <div v-if="!servers.length" class="panel empty"><AppIcon :icon="Connection" :size="34" /><h2>{{ t('尚未配置 MCP 服务器', 'No MCP servers configured') }}</h2><p>{{ t('添加 Server,测试连接成功后才能启用工具。', 'Add a server and test its connection before enabling its tools.') }}</p><button class="button-primary" @click="openCreate">{{ t('新增服务器', 'Add server') }}</button></div>
<div v-else class="server-list"> <div v-else class="server-list">
<article v-for="server in servers" :key="server.server_id" class="panel server-card"> <article v-for="server in servers" :key="server.server_id" class="panel server-card">
<div class="server-main"><div class="server-title"><AppIcon :icon="Connection" :size="24" /><div><h2>{{ server.name }}</h2><code>{{ server.command_summary }}</code></div></div><span class="badge" :class="{ success: server.status === 'ready', error: ['error','unhealthy'].includes(server.status) }">{{ server.status }}</span></div> <div class="server-main"><div class="server-title"><AppIcon :icon="Connection" :size="24" /><div><h2>{{ server.name }}</h2><code>{{ server.command_summary }}</code></div></div><span class="badge" :class="{ success: server.status === 'ready', error: ['error','unhealthy'].includes(server.status) }">{{ server.status }}</span></div>
<div class="metadata"><span>{{ server.transport }}</span><span>v{{ server.version }}</span><span>{{ server.tools_count }} 个工具</span><span>{{ server.trusted ? '连接已确认' : '等待确认连接' }}</span><span v-if="server.last_test_succeeded">当前配置测试成功</span><span v-if="server.remote_server_name">{{ server.remote_server_name }} {{ server.remote_server_version }}</span></div> <div class="metadata"><span>{{ server.transport }}</span><span>v{{ server.version }}</span><span>{{ server.tools_count }} {{ t('个工具', 'tools') }}</span><span>{{ server.trusted ? t('连接已确认', 'Connection confirmed') : t('等待确认连接', 'Awaiting confirmation') }}</span><span v-if="server.last_test_succeeded">{{ t('当前配置测试成功', 'Current configuration passed') }}</span><span v-if="server.remote_server_name">{{ server.remote_server_name }} {{ server.remote_server_version }}</span></div>
<div v-if="server.error" class="error-banner compact">{{ server.error }}</div> <div v-if="server.error" class="error-banner compact">{{ server.error }}</div>
<div v-if="Object.keys(server.secret_environment).length || Object.keys(server.secret_headers).length" class="secrets"> <div v-if="Object.keys(server.secret_environment).length || Object.keys(server.secret_headers).length" class="secrets">
<label v-for="(configured, key) in server.secret_environment" :key="`env:${key}`"><span>环境变量 · {{ key }} <small>{{ configured ? '已加密保存' : '未配置' }}</small></span><span class="secret-input"><input v-model="secretDrafts[`${server.server_id}:environment:${key}`]" type="password" autocomplete="new-password" placeholder="输入后保存(不会回显)"><button class="button-secondary" @click="saveSecret(server, key, 'environment')">保存</button></span></label> <label v-for="(configured, key) in server.secret_environment" :key="`env:${key}`"><span>{{ t('环境变量', 'Environment variable') }} · {{ key }} <small>{{ configured ? t('已加密保存', 'Encrypted and saved') : t('未配置', 'Not configured') }}</small></span><span class="secret-input"><input v-model="secretDrafts[`${server.server_id}:environment:${key}`]" type="password" autocomplete="new-password" :placeholder="t('输入后保存(不会回显)', 'Enter and save (never displayed)')"><button class="button-secondary" @click="saveSecret(server, key, 'environment')">{{ t('保存', 'Save') }}</button></span></label>
<label v-for="(configured, key) in server.secret_headers" :key="`header:${key}`"><span>HTTP Header · {{ key }} <small>{{ configured ? '已加密保存' : '未配置' }}</small></span><span class="secret-input"><input v-model="secretDrafts[`${server.server_id}:header:${key}`]" type="password" autocomplete="new-password" placeholder="输入后保存(不会回显)"><button class="button-secondary" @click="saveSecret(server, key, 'header')">保存</button></span></label> <label v-for="(configured, key) in server.secret_headers" :key="`header:${key}`"><span>HTTP Header · {{ key }} <small>{{ configured ? t('已加密保存', 'Encrypted and saved') : t('未配置', 'Not configured') }}</small></span><span class="secret-input"><input v-model="secretDrafts[`${server.server_id}:header:${key}`]" type="password" autocomplete="new-password" :placeholder="t('输入后保存(不会回显)', 'Enter and save (never displayed)')"><button class="button-secondary" @click="saveSecret(server, key, 'header')">{{ t('保存', 'Save') }}</button></span></label>
</div> </div>
<footer class="card-actions"><button class="button-secondary" :disabled="!!busy || server.enabled" @click="test(server)"><AppIcon :icon="VideoPlay" /> 测试连接</button><button class="button-secondary" :disabled="!!busy" @click="openEdit(server)"><AppIcon :icon="EditPen" /> 编辑</button><button class="button-danger" :disabled="!!busy" @click="remove(server)"><AppIcon :icon="Delete" /> 删除</button><button class="button-primary" :disabled="!!busy || (!server.enabled && !server.last_test_succeeded)" :title="!server.enabled && !server.last_test_succeeded ? '请先测试当前配置' : ''" @click="toggle(server)">{{ server.enabled ? '停用' : '启用' }}</button></footer> <footer class="card-actions"><button class="button-secondary" :disabled="!!busy || server.enabled" @click="test(server)"><AppIcon :icon="VideoPlay" /> {{ t('测试连接', 'Test connection') }}</button><button class="button-secondary" :disabled="!!busy" @click="openEdit(server)"><AppIcon :icon="EditPen" /> {{ t('编辑', 'Edit') }}</button><button class="button-danger" :disabled="!!busy" @click="remove(server)"><AppIcon :icon="Delete" /> {{ t('删除', 'Delete') }}</button><button class="button-primary" :disabled="!!busy || (!server.enabled && !server.last_test_succeeded)" :title="!server.enabled && !server.last_test_succeeded ? t('请先测试当前配置', 'Test the current configuration first') : ''" @click="toggle(server)">{{ server.enabled ? t('停用', 'Disable') : t('启用', 'Enable') }}</button></footer>
</article> </article>
</div> </div>
@@ -246,18 +247,18 @@ onMounted(load)
<fieldset :disabled="!!busy" class="editor-fields"> <fieldset :disabled="!!busy" class="editor-fields">
<header><h2><AppIcon :icon="Plus" /> {{ dialogTitle }}</h2><button type="button" class="close" @click="closeEditor">×</button></header> <header><h2><AppIcon :icon="Plus" /> {{ dialogTitle }}</h2><button type="button" class="close" @click="closeEditor">×</button></header>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div> <div v-if="error" class="error-banner" role="alert">{{ error }}</div>
<div v-if="importedSecrets.length" class="notice-banner">已识别 {{ importedSecrets.length }} 项密钥保存时将单独加密不会写入普通服务器配置取消将清除未保存密钥</div> <div v-if="importedSecrets.length" class="notice-banner">{{ t('已识别', 'Detected') }} {{ importedSecrets.length }} {{ t('项密钥保存时将单独加密不会写入普通服务器配置取消将清除未保存密钥', 'secrets. They will be encrypted separately and excluded from regular server settings. Canceling clears unsaved secrets.') }}</div>
<div class="mode-tabs"><button type="button" :class="{ active: editorMode === 'form' }" @click="switchMode('form')">表单配置</button><button type="button" :class="{ active: editorMode === 'json' }" @click="switchMode('json')">JSON 配置</button></div> <div class="mode-tabs"><button type="button" :class="{ active: editorMode === 'form' }" @click="switchMode('form')">{{ t('表单配置', 'Form') }}</button><button type="button" :class="{ active: editorMode === 'json' }" @click="switchMode('json')">{{ t('JSON 配置', 'JSON') }}</button></div>
<template v-if="editorMode === 'form'"> <template v-if="editorMode === 'form'">
<label>服务器名称<input v-model="form.name" maxlength="80" placeholder="例如:文件系统工具"></label> <label>{{ t('服务器名称', 'Server name') }}<input v-model="form.name" maxlength="80" :placeholder="t('例如:文件系统工具', 'For example: Filesystem tools')"></label>
<div class="template-row"><span>服务器配置</span><button type="button" class="template" :class="{ active: form.transport === 'stdio' }" @click="applyTemplate('stdio')">stdio 模板</button><button type="button" class="template" :class="{ active: form.transport === 'streamable_http' }" @click="applyTemplate('streamable_http')">Streamable HTTP</button><button type="button" class="template" :class="{ active: form.transport === 'sse' }" @click="applyTemplate('sse')">SSE兼容</button></div> <div class="template-row"><span>{{ t('服务器配置', 'Server configuration') }}</span><button type="button" class="template" :class="{ active: form.transport === 'stdio' }" @click="applyTemplate('stdio')">stdio {{ t('模板', 'template') }}</button><button type="button" class="template" :class="{ active: form.transport === 'streamable_http' }" @click="applyTemplate('streamable_http')">Streamable HTTP</button><button type="button" class="template" :class="{ active: form.transport === 'sse' }" @click="applyTemplate('sse')">SSE {{ t('兼容', '(legacy)') }}</button></div>
<template v-if="form.transport === 'stdio'"><label>可执行命令<input v-model="form.command" placeholder="uvx、npx 或可信可执行文件路径"></label><label>参数(每行一项)<textarea v-model="argsText" rows="5"></textarea></label><div class="two-columns"><label>普通环境变量(JSON<textarea v-model="environmentText" rows="5"></textarea></label><label>敏感环境变量名(每行一项)<textarea v-model="secretKeysText" rows="5" placeholder="API_KEY"></textarea></label></div></template> <template v-if="form.transport === 'stdio'"><label>{{ t('可执行命令', 'Executable command') }}<input v-model="form.command" :placeholder="t('uvx、npx 或可信可执行文件路径', 'uvx, npx, or a trusted executable path')"></label><label>{{ t('参数(每行一项)', 'Arguments (one per line)') }}<textarea v-model="argsText" rows="5"></textarea></label><div class="two-columns"><label>{{ t('普通环境变量(JSON', 'Environment variables (JSON)') }}<textarea v-model="environmentText" rows="5"></textarea></label><label>{{ t('敏感环境变量名(每行一项)', 'Secret environment names (one per line)') }}<textarea v-model="secretKeysText" rows="5" placeholder="API_KEY"></textarea></label></div></template>
<template v-else><label>MCP URL<input v-model="form.url" placeholder="https://example.com/mcp"></label><div class="two-columns"><label>普通 HeaderJSON<textarea v-model="headersText" rows="5" placeholder='{"X-Client":"NotesAgent"}'></textarea></label><label>敏感 Header 名(每行一项)<textarea v-model="secretHeaderKeysText" rows="5" placeholder="Authorization"></textarea></label></div></template> <template v-else><label>MCP URL<input v-model="form.url" placeholder="https://example.com/mcp"></label><div class="two-columns"><label>{{ t('普通 HeaderJSON', 'Headers (JSON)') }}<textarea v-model="headersText" rows="5" placeholder='{"X-Client":"NotesAgent"}'></textarea></label><label>{{ t('敏感 Header 名(每行一项)', 'Secret header names (one per line)') }}<textarea v-model="secretHeaderKeysText" rows="5" placeholder="Authorization"></textarea></label></div></template>
<label>声明权限逗号分隔可选<input v-model="permissionsText" placeholder="network.request, notes.read"></label> <label>{{ t('声明权限(逗号分隔,可选)', 'Declared permissions (comma-separated, optional)') }}<input v-model="permissionsText" placeholder="network.request, notes.read"></label>
<div class="two-columns"><label>启动超时<input v-model.number="form.startup_timeout_seconds" type="number" min="1" max="120"></label><label>工具超时<input v-model.number="form.tool_timeout_seconds" type="number" min="1" max="300"></label></div> <div class="two-columns"><label>{{ t('启动超时(秒)', 'Startup timeout (seconds)') }}<input v-model.number="form.startup_timeout_seconds" type="number" min="1" max="120"></label><label>{{ t('工具超时(秒)', 'Tool timeout (seconds)') }}<input v-model.number="form.tool_timeout_seconds" type="number" min="1" max="300"></label></div>
</template> </template>
<label v-else>服务器 JSON 配置<textarea v-model="rawConfig" class="json-editor" rows="22" spellcheck="false"></textarea><small>支持 NotesAgent 配置command/args/env 和单服务器 mcpServers 配置已声明的 Secret 及常见 API KeyTokenAuthorization 会拆分后加密保存其他敏感值请显式声明不要把密钥放入命令或参数</small><small>兼容导入 timeout 为启动超时sse_read_timeout 为工具等待上限不保留 SSE 读取超时语义</small></label> <label v-else>{{ t('服务器 JSON 配置', 'Server JSON configuration') }}<textarea v-model="rawConfig" class="json-editor" rows="22" spellcheck="false"></textarea><small>{{ t('支持 NotesAgent 配置、command/args/env 和单服务器 mcpServers 配置。已声明的 Secret 及常见 API Key、Token、Authorization 会拆分后加密保存其他敏感值请显式声明不要把密钥放入命令或参数。', 'Supports NotesAgent, command/args/env, and single-server mcpServers configurations. Declared secrets and common API key, token, and authorization values are separated and encrypted. Declare other sensitive values explicitly; never place secrets in commands or arguments.') }}</small><small>{{ t('兼容导入 timeout 为启动超时,sse_read_timeout 为工具等待上限(不保留 SSE 读取超时语义)。', 'For compatible imports, timeout maps to startup timeout and sse_read_timeout maps to the tool wait limit.') }}</small></label>
<footer><button type="button" class="button-secondary" @click="closeEditor">取消</button><button class="button-primary" :disabled="busy === 'save'">保存</button></footer> <footer><button type="button" class="button-secondary" @click="closeEditor">{{ t('取消', 'Cancel') }}</button><button class="button-primary" :disabled="busy === 'save'">{{ t('保存', 'Save') }}</button></footer>
</fieldset> </fieldset>
</form> </form>
</div> </div>
+30 -29
View File
@@ -1,4 +1,5 @@
import type { McpServerInput } from '@/contracts' import type { McpServerInput } from '@/contracts'
import { t } from '@/i18n'
export type SecretKind = 'environment' | 'header' export type SecretKind = 'environment' | 'header'
export interface ImportedSecret { kind: SecretKind; key: string; value: string } export interface ImportedSecret { kind: SecretKind; key: string; value: string }
@@ -26,38 +27,38 @@ export function emptyMcpConfig(): McpServerInput {
} }
function object(value: unknown, label: string): Record<string, unknown> { function object(value: unknown, label: string): Record<string, unknown> {
if (!value || Array.isArray(value) || typeof value !== 'object') throw new Error(`${label}必须是 JSON 对象`) if (!value || Array.isArray(value) || typeof value !== 'object') throw new Error(`${label}${t('必须是 JSON 对象', ' must be a JSON object')}`)
return value as Record<string, unknown> return value as Record<string, unknown>
} }
function strings(value: unknown, label: string): string[] { function strings(value: unknown, label: string): string[] {
if (value === undefined) return [] if (value === undefined) return []
if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) throw new Error(`${label}必须是字符串数组`) if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) throw new Error(`${label}${t('必须是字符串数组', ' must be a string array')}`)
return [...value] return [...value]
} }
function entries(value: unknown, label: string): Record<string, string> { function entries(value: unknown, label: string): Record<string, string> {
if (value === undefined) return {} if (value === undefined) return {}
const result = object(value, label) const result = object(value, label)
if (Object.values(result).some(item => typeof item !== 'string')) throw new Error(`${label}必须是字符串键值 JSON 对象`) if (Object.values(result).some(item => typeof item !== 'string')) throw new Error(`${label}${t('必须是字符串键值 JSON 对象', ' must be a JSON object with string keys and values')}`)
return { ...result } as Record<string, string> return { ...result } as Record<string, string>
} }
function timeout(value: unknown, fallback: number, max: number, label: string): number { function timeout(value: unknown, fallback: number, max: number, label: string): number {
if (value === undefined) return fallback if (value === undefined) return fallback
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1 || value > max) throw new Error(`${label}必须是 1${max} 秒之间的数字`) if (typeof value !== 'number' || !Number.isFinite(value) || value < 1 || value > max) throw new Error(`${label}${t(`必须是 1${max} 秒之间的数字`, ` must be a number from 1 to ${max} seconds`)}`)
return value return value
} }
// Do not silently rewrite executable arguments or secret values copied from chat. // Do not silently rewrite executable arguments or secret values copied from chat.
function checkUrl(value: string, label: string) { function checkUrl(value: string, label: string) {
if (/^\[https?:\/\//i.test(value)) throw new Error(`${label}请填写纯 URL,不要粘贴 Markdown 链接`) if (/^\[https?:\/\//i.test(value)) throw new Error(`${label}${t('请填写纯 URL,不要粘贴 Markdown 链接', ': enter a plain URL instead of a Markdown link')}`)
} }
export function parseMcpJson(raw: string, fallbackName = '', requireConnection = true) { export function parseMcpJson(raw: string, fallbackName = '', requireConnection = true) {
let parsed: unknown let parsed: unknown
try { parsed = JSON.parse(raw) } try { parsed = JSON.parse(raw) }
catch { throw new Error('服务器配置不是有效 JSON;请检查逗号、引号和无效的 \\_ 转义') } catch { throw new Error(t('服务器配置不是有效 JSON;请检查逗号、引号和无效的 \\_ 转义', 'The server configuration is not valid JSON. Check commas, quotes, and invalid \\_ escapes.')) }
return normalizeMcpConfig(parsed, fallbackName, requireConnection) return normalizeMcpConfig(parsed, fallbackName, requireConnection)
} }
@@ -65,54 +66,54 @@ export function parseMcpJson(raw: string, fallbackName = '', requireConnection =
* Inline secrets leave the public config here and are sent only to the Secret API. * Inline secrets leave the public config here and are sent only to the Secret API.
*/ */
export function normalizeMcpConfig(parsed: unknown, fallbackName = '', requireConnection = true) { export function normalizeMcpConfig(parsed: unknown, fallbackName = '', requireConnection = true) {
let raw = object(parsed, '服务器配置') let raw = object(parsed, t('服务器配置', 'Server configuration'))
if ('mcpServers' in raw) { if ('mcpServers' in raw) {
const servers = Object.entries(object(raw.mcpServers, 'mcpServers')) const servers = Object.entries(object(raw.mcpServers, 'mcpServers'))
if (servers.length !== 1) throw new Error('请一次导入一个 MCP 服务器') if (servers.length !== 1) throw new Error(t('请一次导入一个 MCP 服务器', 'Import one MCP server at a time'))
fallbackName = servers[0]![0] fallbackName = servers[0]![0]
raw = object(servers[0]![1], '服务器配置') raw = object(servers[0]![1], t('服务器配置', 'Server configuration'))
} }
const allowed = new Set([...Object.keys(emptyMcpConfig()), 'version', 'env', 'type', 'timeout', 'sse_read_timeout']) const allowed = new Set([...Object.keys(emptyMcpConfig()), 'version', 'env', 'type', 'timeout', 'sse_read_timeout'])
if (Object.keys(raw).some(key => !allowed.has(key))) { if (Object.keys(raw).some(key => !allowed.has(key))) {
// Never echo arbitrary unknown keys: pasted secrets sometimes become JSON keys. // Never echo arbitrary unknown keys: pasted secrets sometimes become JSON keys.
throw new Error('服务器配置含不支持的字段;API Key 请放在 env/environment 的对应变量中,不要放在顶层') throw new Error(t('服务器配置含不支持的字段;API Key 请放在 env/environment 的对应变量中,不要放在顶层', 'The server configuration contains unsupported fields. Put API keys in the corresponding env/environment variables, not at the top level.'))
} }
if (raw.env !== undefined && raw.environment !== undefined) throw new Error('env 与 environment 请只保留一个,避免覆盖配置') if (raw.env !== undefined && raw.environment !== undefined) throw new Error(t('env 与 environment 请只保留一个,避免覆盖配置', 'Keep either env or environment, not both'))
const transport = raw.transport ?? raw.type ?? (raw.url ? 'streamable_http' : 'stdio') const transport = raw.transport ?? raw.type ?? (raw.url ? 'streamable_http' : 'stdio')
if (!['stdio', 'streamable_http', 'sse'].includes(transport as string)) throw new Error('transport 必须是 stdio、streamable_http 或 sse') if (!['stdio', 'streamable_http', 'sse'].includes(transport as string)) throw new Error(t('transport 必须是 stdio、streamable_http 或 sse', 'transport must be stdio, streamable_http, or sse'))
const config = emptyMcpConfig() const config = emptyMcpConfig()
config.transport = transport as McpServerInput['transport'] config.transport = transport as McpServerInput['transport']
const name = raw.name ?? (fallbackName || (typeof raw.command === 'string' ? raw.command : 'MCP 服务器')) const name = raw.name ?? (fallbackName || (typeof raw.command === 'string' ? raw.command : t('MCP 服务器', 'MCP Server')))
if (typeof name !== 'string' || (requireConnection && !name.trim()) || name.trim().length > 80) throw new Error('服务器名称必须为 180 个字符') if (typeof name !== 'string' || (requireConnection && !name.trim()) || name.trim().length > 80) throw new Error(t('服务器名称必须为 180 个字符', 'The server name must contain 180 characters'))
config.name = name.trim() config.name = name.trim()
for (const key of ['command', 'url'] as const) { for (const key of ['command', 'url'] as const) {
const value = raw[key] const value = raw[key]
if (value !== undefined && value !== null && typeof value !== 'string') throw new Error(`${key}必须是字符串`) if (value !== undefined && value !== null && typeof value !== 'string') throw new Error(`${key}${t('必须是字符串', ' must be a string')}`)
config[key] = typeof value === 'string' ? value.trim() : null config[key] = typeof value === 'string' ? value.trim() : null
} }
config.args = strings(raw.args, 'args') config.args = strings(raw.args, 'args')
if (config.args.length > 64) throw new Error('args 最多允许 64 项') if (config.args.length > 64) throw new Error(t('args 最多允许 64 项', 'args allows at most 64 items'))
for (const value of config.args) checkUrl(value, 'args 中的地址') for (const value of config.args) checkUrl(value, t('args 中的地址', 'URL in args'))
config.environment = entries(raw.environment ?? raw.env, 'environment/env') config.environment = entries(raw.environment ?? raw.env, 'environment/env')
config.headers = entries(raw.headers, 'headers') config.headers = entries(raw.headers, 'headers')
config.secret_environment_keys = [...new Set(strings(raw.secret_environment_keys, 'secret_environment_keys'))] config.secret_environment_keys = [...new Set(strings(raw.secret_environment_keys, 'secret_environment_keys'))]
config.secret_header_keys = [...new Set(strings(raw.secret_header_keys, 'secret_header_keys'))] config.secret_header_keys = [...new Set(strings(raw.secret_header_keys, 'secret_header_keys'))]
config.permissions = strings(raw.permissions, 'permissions') config.permissions = strings(raw.permissions, 'permissions')
config.startup_timeout_seconds = timeout(raw.startup_timeout_seconds ?? raw.timeout, 15, 120, '启动超时') config.startup_timeout_seconds = timeout(raw.startup_timeout_seconds ?? raw.timeout, 15, 120, t('启动超时', 'Startup timeout'))
// Compatibility policy: legacy read timeout becomes the tool wait budget, not an SSE transport setting. // Compatibility policy: legacy read timeout becomes the tool wait budget, not an SSE transport setting.
config.tool_timeout_seconds = timeout(raw.tool_timeout_seconds ?? raw.sse_read_timeout, 30, 300, '工具超时') config.tool_timeout_seconds = timeout(raw.tool_timeout_seconds ?? raw.sse_read_timeout, 30, 300, t('工具超时', 'Tool timeout'))
if (config.transport === 'stdio') { if (config.transport === 'stdio') {
if (requireConnection && !config.command) throw new Error('stdio 配置必须填写 command') if (requireConnection && !config.command) throw new Error(t('stdio 配置必须填写 command', 'stdio configuration requires command'))
if (config.url || Object.keys(config.headers).length || config.secret_header_keys.length) throw new Error('stdio 配置不能包含 URL 或 HTTP Header') if (config.url || Object.keys(config.headers).length || config.secret_header_keys.length) throw new Error(t('stdio 配置不能包含 URL 或 HTTP Header', 'stdio configuration cannot contain a URL or HTTP headers'))
} else { } else {
if (requireConnection && !config.url) throw new Error('HTTP/SSE 配置必须填写 url') if (requireConnection && !config.url) throw new Error(t('HTTP/SSE 配置必须填写 url', 'HTTP/SSE configuration requires a URL'))
if (config.url) { if (config.url) {
checkUrl(config.url, 'url') checkUrl(config.url, 'url')
let url: URL let url: URL
try { url = new URL(config.url) } catch { throw new Error('url 必须是有效的 HTTP(S) 地址') } try { url = new URL(config.url) } catch { throw new Error(t('url 必须是有效的 HTTP(S) 地址', 'url must be a valid HTTP(S) address')) }
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) throw new Error('url 必须为不含账号密码或片段的 HTTP(S) 地址') if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) throw new Error(t('url 必须为不含账号密码或片段的 HTTP(S) 地址', 'url must be an HTTP(S) address without credentials or a fragment'))
} }
if (config.command || config.args.length || Object.keys(config.environment).length || config.secret_environment_keys.length) throw new Error('HTTP/SSE 配置不能包含 command、args 或环境变量') if (config.command || config.args.length || Object.keys(config.environment).length || config.secret_environment_keys.length) throw new Error(t('HTTP/SSE 配置不能包含 command、args 或环境变量', 'HTTP/SSE configuration cannot contain command, args, or environment variables'))
} }
const secrets: ImportedSecret[] = [] const secrets: ImportedSecret[] = []
for (const kind of ['environment', 'header'] as const) { for (const kind of ['environment', 'header'] as const) {
@@ -120,19 +121,19 @@ export function normalizeMcpConfig(parsed: unknown, fallbackName = '', requireCo
const keys = kind === 'environment' ? config.secret_environment_keys : config.secret_header_keys const keys = kind === 'environment' ? config.secret_environment_keys : config.secret_header_keys
const identity = (key: string) => kind === 'header' ? key.toLowerCase() : key const identity = (key: string) => kind === 'header' ? key.toLowerCase() : key
const allKeys = [...Object.keys(values), ...keys] const allKeys = [...Object.keys(values), ...keys]
if (kind === 'header' && (new Set(keys.map(identity)).size !== keys.length || new Set(Object.keys(values).map(identity)).size !== Object.keys(values).length)) throw new Error('HTTP Header 名称不能仅大小写不同而重复声明') if (kind === 'header' && (new Set(keys.map(identity)).size !== keys.length || new Set(Object.keys(values).map(identity)).size !== Object.keys(values).length)) throw new Error(t('HTTP Header 名称不能仅大小写不同而重复声明', 'HTTP header names cannot be duplicated with case-only differences'))
const validKey = kind === 'environment' ? /^[A-Za-z_][A-Za-z0-9_]{0,127}$/ : /^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$/ const validKey = kind === 'environment' ? /^[A-Za-z_][A-Za-z0-9_]{0,127}$/ : /^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$/
if (allKeys.some(key => !validKey.test(key))) throw new Error(`${kind === 'environment' ? '环境变量' : 'Header'}名称无效;敏感变量名只能填名称,不能填密钥值`) if (allKeys.some(key => !validKey.test(key))) throw new Error(`${kind === 'environment' ? t('环境变量', 'Environment variable') : 'Header'}${t('名称无效;敏感变量名只能填名称,不能填密钥值', ' name is invalid; secret variable declarations accept names only, not secret values')}`)
for (const [key, value] of Object.entries(values)) { for (const [key, value] of Object.entries(values)) {
const declared = keys.find(item => identity(item) === identity(key)) const declared = keys.find(item => identity(item) === identity(key))
const sensitive = /api[_-]?key|token|secret|password|authorization|cookie|credential/i.test(key) const sensitive = /api[_-]?key|token|secret|password|authorization|cookie|credential/i.test(key)
if (declared || sensitive) { if (declared || sensitive) {
if (!value || value.length > 32768) throw new Error('密钥值必须为 132768 个字符') if (!value || value.length > 32768) throw new Error(t('密钥值必须为 132768 个字符', 'Secret values must contain 132768 characters'))
const secretKey = declared ?? key const secretKey = declared ?? key
if (!declared) keys.push(key) if (!declared) keys.push(key)
secrets.push({ kind, key: secretKey, value }) secrets.push({ kind, key: secretKey, value })
delete values[key] delete values[key]
} else if (/host|url|endpoint/i.test(key)) checkUrl(value, '环境变量或 Header 地址') } else if (/host|url|endpoint/i.test(key)) checkUrl(value, t('环境变量或 Header 地址', 'Environment variable or Header URL'))
} }
} }
return { config, secrets } return { config, secrets }
+45 -37
View File
@@ -2,6 +2,8 @@
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { mediaService, createMediaSubmission, type MediaJob } from '@/services/mediaService' import { mediaService, createMediaSubmission, type MediaJob } from '@/services/mediaService'
import { localeTag, t } from '@/i18n'
import FilePicker from '@/components/common/FilePicker.vue'
const route = useRoute() const route = useRoute()
const submission = createMediaSubmission() const submission = createMediaSubmission()
@@ -11,6 +13,7 @@ const selected = ref<MediaJob | null>(null)
const file = ref<File | null>(null) const file = ref<File | null>(null)
const reference = ref<File | null>(null) const reference = ref<File | null>(null)
const matchResult = ref('') const matchResult = ref('')
const terminologyPlaceholder = computed(() => t('{"错误术语": "正确术语"}', '{"incorrect term": "correct term"}'))
const localOnly = ref(false) const localOnly = ref(false)
const diarization = ref(true) const diarization = ref(true)
const terminology = ref('') const terminology = ref('')
@@ -18,17 +21,22 @@ const busy = ref(false)
const error = ref('') const error = ref('')
const notice = ref('') const notice = ref('')
const dirty = ref(false) const dirty = ref(false)
const title = ref('课堂转写') const title = ref(t('课堂转写', 'Class transcript'))
const player = ref<HTMLAudioElement | null>(null) const player = ref<HTMLAudioElement | null>(null)
const position = ref(0) const position = ref(0)
const speed = ref(1) const speed = ref(1)
const history = ref<MediaJob[]>([]) const history = ref<MediaJob[]>([])
let timer: ReturnType<typeof setTimeout> | undefined let timer: ReturnType<typeof setTimeout> | undefined
let stopped = false let stopped = false
const labels = {queued: '排队中', running: '转写中', processing: '处理中', completed: '已完成', failed: '失败', cancelled: '已取消'} const labels = computed(() => ({queued: t('排队中', 'Queued'), running: t('转写中', 'Transcribing'), processing: t('处理中', 'Processing'), completed: t('已完成', 'Completed'), failed: t('失败', 'Failed'), cancelled: t('已取消', 'Cancelled')}))
const speakers = computed(() => [...new Set(selected.value?.segments.map(s => s.speaker).filter((s): s is string => !!s) || [])]) const speakers = computed(() => [...new Set(selected.value?.segments.map(s => s.speaker).filter((s): s is string => !!s) || [])])
const active = (job: MediaJob) => ['queued', 'running', 'processing'].includes(job.status) const active = (job: MediaJob) => ['queued', 'running', 'processing'].includes(job.status)
const stamp = (seconds: number) => `${Math.floor(seconds / 60).toString().padStart(2, '0')}:${Math.floor(seconds % 60).toString().padStart(2, '0')}` const stamp = (seconds: number) => `${Math.floor(seconds / 60).toString().padStart(2, '0')}:${Math.floor(seconds % 60).toString().padStart(2, '0')}`
const warningLabel = (warning: string) => ({
DIARIZATION_UNAVAILABLE: t('当前无法分离说话人', 'Speaker identification is unavailable'),
WORD_TIMESTAMPS_UNAVAILABLE: t('未提供逐字时间戳', 'Word-level timestamps are unavailable'),
DIARIZATION_SEGMENT_LEVEL: t('说话人按音频段估计,同段多人或重叠发言需人工校对', 'Speakers are estimated per segment; multiple or overlapping speakers require manual correction'),
} as Record<string, string>)[warning] || warning
async function refresh() { async function refresh() {
try { try {
@@ -38,7 +46,7 @@ async function refresh() {
if (!stopped) timer = setTimeout(refresh, 2000) if (!stopped) timer = setTimeout(refresh, 2000)
} }
async function choose(job: MediaJob) { async function choose(job: MediaJob) {
if (dirty.value && !window.confirm('当前校对尚未保存,切换后放弃修改?')) return if (dirty.value && !window.confirm(t('当前校对尚未保存,切换后放弃修改?', 'The current corrections are unsaved. Discard them and switch?'))) return
selected.value = JSON.parse(JSON.stringify(job)); dirty.value = false; history.value = [] selected.value = JSON.parse(JSON.stringify(job)); dirty.value = false; history.value = []
} }
async function action(work: () => Promise<void>) { async function action(work: () => Promise<void>) {
@@ -52,7 +60,7 @@ async function submit() {
let terms = {} let terms = {}
if (terminology.value.trim()) { if (terminology.value.trim()) {
terms = JSON.parse(terminology.value) terms = JSON.parse(terminology.value)
if (!terms || typeof terms !== 'object' || Array.isArray(terms) || Object.values(terms).some(v => typeof v !== 'string')) throw new Error('术语表需要 JSON 对象,值为替换后的文本。') if (!terms || typeof terms !== 'object' || Array.isArray(terms) || Object.values(terms).some(v => typeof v !== 'string')) throw new Error(t('术语表需要 JSON 对象,值为替换后的文本。', 'The terminology map must be a JSON object whose values are replacement text.'))
} }
selected.value = await submission.submit(file.value!, {local_only: localOnly.value, selected.value = await submission.submit(file.value!, {local_only: localOnly.value,
diarization: diarization.value, terminology: terms}) diarization: diarization.value, terminology: terms})
@@ -65,10 +73,10 @@ async function purge() {
if (!selected.value) return if (!selected.value) return
await action(async () => { await action(async () => {
const impact = await mediaService.impact(selected.value!.attachment_id) const impact = await mediaService.impact(selected.value!.attachment_id)
if (!window.confirm(`${impact.message}\n将保留 ${impact.retained_note_ids.length} 篇已保存笔记。确定清理?`)) return if (!window.confirm(`${impact.message}\n${t('将保留', 'Will retain')} ${impact.retained_note_ids.length} ${t('篇已保存笔记。确定清理?', 'saved notes. Continue cleanup?')}`)) return
await mediaService.purge(selected.value!.attachment_id) await mediaService.purge(selected.value!.attachment_id)
selected.value = await mediaService.get(selected.value!.job_id) selected.value = await mediaService.get(selected.value!.job_id)
dirty.value = false; history.value = []; notice.value = '附件与转写内容已清理' dirty.value = false; history.value = []; notice.value = t('附件与转写内容已清理', 'Attachment and transcript content were removed')
}) })
} }
async function compareSpeaker() { async function compareSpeaker() {
@@ -79,10 +87,10 @@ async function compareSpeaker() {
const sample = await mediaService.upload(file.value!); temporary.push(sample.attachment_id) const sample = await mediaService.upload(file.value!); temporary.push(sample.attachment_id)
const known = await mediaService.upload(reference.value!); temporary.push(known.attachment_id) const known = await mediaService.upload(reference.value!); temporary.push(known.attachment_id)
const result = await mediaService.match(sample.attachment_id, known.attachment_id, localOnly.value) const result = await mediaService.match(sample.attachment_id, known.attachment_id, localOnly.value)
matchResult.value = `相似度 ${result.score.toFixed(3)} · ${result.source === 'local' ? '本地模型' : 'API'}${result.fallback_reason ? ` · 回退:${result.fallback_reason}` : ''}` matchResult.value = `${t('相似度', 'Similarity')} ${result.score.toFixed(3)} · ${result.source === 'local' ? t('本地模型', 'Local model') : 'API'}${result.fallback_reason ? ` · ${t('回退:', 'Fallback: ')}${result.fallback_reason}` : ''}`
} finally { } finally {
const cleanup = await Promise.allSettled(temporary.map(id => mediaService.purge(id))) const cleanup = await Promise.allSettled(temporary.map(id => mediaService.purge(id)))
if (cleanup.some(result => result.status === 'rejected')) notice.value = '部分临时参考附件清理失败,请检查后端连接。' if (cleanup.some(result => result.status === 'rejected')) notice.value = t('部分临时参考附件清理失败,请检查后端连接。', 'Some temporary reference files could not be removed. Check the backend connection.')
} }
}) })
} }
@@ -98,56 +106,56 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
<template> <template>
<section class="media-page"> <section class="media-page">
<header><h1>音视频转写</h1><p class="subtle">上传音频或视频音轨转写校对后保存到知识库单个文件最多 25 MiB</p></header> <header><h1>{{ t('音视频转写', 'Media Transcription') }}</h1><p class="subtle">{{ t('上传音频或视频音轨,转写、校对后保存到知识库单个文件最多 25 MiB。', 'Upload audio or a video soundtrack, transcribe and correct it, then save it to the knowledge base. Maximum file size: 25 MiB.') }}</p></header>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div><p v-if="notice" role="status">{{ notice }}</p> <div v-if="error" class="error-banner" role="alert">{{ error }}</div><p v-if="notice" role="status">{{ notice }}</p>
<form class="panel upload" @submit.prevent="submit"> <form class="panel upload" @submit.prevent="submit">
<label>选择附件<input type="file" accept=".wav,.mp3,.flac,.ogg,.m4a,.mp4,.webm,.txt,.md" @change="file = ($event.target as HTMLInputElement).files?.[0] || null" /></label> <FilePicker :file="file" :label="t('选择附件', 'Choose attachment')" :empty-label="t('尚未选择文件', 'No file selected')" accept=".wav,.mp3,.flac,.ogg,.m4a,.mp4,.webm,.txt,.md" @select="file = $event" />
<label><input v-model="localOnly" type="checkbox" />仅本地处理</label> <div class="upload-options"><label><input v-model="localOnly" type="checkbox" />{{ t('仅本地处理', 'Process locally only') }}</label>
<label><input v-model="diarization" type="checkbox" />识别不同说话人</label> <label><input v-model="diarization" type="checkbox" />{{ t('识别不同说话人', 'Identify different speakers') }}</label></div>
<p class="subtle">{{ localOnly ? '本次任务不调用远程模型 API,模型需预先下载。' : '若配置了转写 API,将上传所选附件;API 失败后回退到本地模型。' }}</p> <p class="subtle">{{ localOnly ? t('本次任务不调用远程模型 API,模型需预先下载。', 'This job will not call a remote model API; models must already be downloaded.') : t('若配置了转写 API,将上传所选附件;API 失败后回退到本地模型。', 'When a transcription API is configured, the selected file is uploaded; failures fall back to the local model.') }}</p>
<details><summary>术语校对</summary><p class="subtle">在识别完成后替换文本原始识别结果会保留</p><textarea v-model="terminology" class="input" rows="3" placeholder='{"错误术语": "正确术语"}' /></details> <details class="ui-disclosure"><summary>{{ t('术语校对', 'Terminology corrections') }}</summary><p class="subtle">{{ t('在识别完成后替换文本原始识别结果会保留。', 'Replace text after recognition while retaining the original result.') }}</p><textarea v-model="terminology" class="input" rows="3" :placeholder="terminologyPlaceholder" /></details>
<button type="button" class="button-secondary" :disabled="busy" @click="submission.reset(); notice = '下一次提交将作为新任务处理'">重新处理为新任务</button><button class="button-primary" :disabled="busy || !file">{{ busy ? '处理中…' : '上传并转写' }}</button> <div class="inline-actions upload-actions"><button type="button" class="button-secondary" :disabled="busy" @click="submission.reset(); notice = t('下一次提交将作为新任务处理', 'The next submission will be processed as a new job')">{{ t('重新处理为新任务', 'Process as new job') }}</button><button class="button-primary" :disabled="busy || !file">{{ busy ? t('处理中…', 'Processing…') : t('上传并转写', 'Upload and transcribe') }}</button></div>
<details><summary>声纹参考比对</summary><p class="subtle">将所选附件与参考音频比对至少各含 1 秒语音分数是相似度不是身份认证概率临时参考文件在比对后清理</p> <details class="ui-disclosure"><summary>{{ t('声纹参考比对', 'Speaker reference comparison') }}</summary><p class="subtle">{{ t('将所选附件与参考音频比对。至少各含 1 秒语音;分数是相似度不是身份认证概率临时参考文件在比对后清理。', 'Compare the selected file with reference audio. Each must contain at least one second of speech. The score is similarity, not an identity probability. Temporary files are removed afterward.') }}</p>
<input type="file" accept=".wav,.mp3,.flac,.ogg,.m4a" aria-label="声纹参考音频" @change="reference = ($event.target as HTMLInputElement).files?.[0] || null" /> <FilePicker :file="reference" :label="t('选择参考音频', 'Choose reference audio')" :empty-label="t('尚未选择参考音频', 'No reference audio selected')" accept=".wav,.mp3,.flac,.ogg,.m4a" @select="reference = $event" />
<button type="button" class="button-secondary" :disabled="busy || !file || !reference" @click="compareSpeaker">比对声纹</button><p v-if="matchResult">{{ matchResult }}</p></details> <button type="button" class="button-secondary" :disabled="busy || !file || !reference" @click="compareSpeaker">{{ t('比对声纹', 'Compare speakers') }}</button><p v-if="matchResult">{{ matchResult }}</p></details>
</form> </form>
<div class="media-columns"> <div class="media-columns">
<aside class="panel"><h2>转写任务</h2><p v-if="!jobs.length" class="subtle">暂无转写任务</p> <aside class="panel"><h2>{{ t('转写任务', 'Transcription Jobs') }}</h2><p v-if="!jobs.length" class="subtle">{{ t('暂无转写任务', 'No transcription jobs') }}</p>
<button v-for="job in jobs" :key="job.job_id" class="job-row" :class="{ selected: selected?.job_id === job.job_id }" @click="choose(job)"> <button v-for="job in jobs" :key="job.job_id" class="job-row" :class="{ selected: selected?.job_id === job.job_id }" @click="choose(job)">
<strong>{{ labels[job.status] }}</strong><span>{{ new Date(job.created_at).toLocaleString() }}</span><small>{{ job.attachment_id }}</small> <strong>{{ labels[job.status] }}</strong><span>{{ new Date(job.created_at).toLocaleString(localeTag()) }}</span><small>{{ job.attachment_id }}</small>
</button> </button>
</aside> </aside>
<article v-if="selected" class="panel transcript"> <article v-if="selected" class="panel transcript">
<header><h2>{{ labels[selected.status] }}</h2><span class="badge">修订 {{ selected.revision }}</span></header> <header><h2>{{ labels[selected.status] }}</h2><span class="badge">{{ t('修订', 'Revision') }} {{ selected.revision }}</span></header>
<progress v-if="active(selected) && selected.progress !== null" :value="selected.progress" :max="1" aria-label="转写进度" /> <progress v-if="active(selected) && selected.progress !== null" :value="selected.progress" :max="1" :aria-label="t('转写进度', 'Transcription progress')" />
<audio ref="player" controls :src="mediaService.audio(selected.attachment_id)" @loadedmetadata="loaded" @timeupdate="position = player?.currentTime || 0" /> <audio ref="player" controls :src="mediaService.audio(selected.attachment_id)" @loadedmetadata="loaded" @timeupdate="position = player?.currentTime || 0" />
<label>播放速度<select v-model.number="speed" class="select" @change="player && (player.playbackRate = speed)"><option v-for="value in [0.5, 0.75, 1, 1.25, 1.5, 2]" :key="value" :value="value">{{ value }}×</option></select></label> <label>{{ t('播放速度', 'Playback speed') }}<select v-model.number="speed" class="select" @change="player && (player.playbackRate = speed)"><option v-for="value in [0.5, 0.75, 1, 1.25, 1.5, 2]" :key="value" :value="value">{{ value }}×</option></select></label>
<p v-if="selected.error_message" class="error-banner">{{ selected.error_message }} · {{ selected.error_code }}</p> <p v-if="selected.error_message" class="error-banner">{{ selected.error_message }} · {{ selected.error_code }}</p>
<p v-if="selected.fallback_reason" class="subtle">已回退{{ selected.fallback_reason }}</p> <p v-if="selected.fallback_reason" class="subtle">{{ t('已回退', 'Fallback: ') }}{{ selected.fallback_reason }}</p>
<p v-for="warning in selected.warnings" :key="warning" class="subtle">{{ ({DIARIZATION_UNAVAILABLE: '当前无法分离说话人', WORD_TIMESTAMPS_UNAVAILABLE: '未提供逐字时间戳', DIARIZATION_SEGMENT_LEVEL: '说话人按音频段估计同段多人或重叠发言需人工校对'} as Record<string,string>)[warning] || warning }}</p> <p v-for="warning in selected.warnings" :key="warning" class="subtle">{{ warningLabel(warning) }}</p>
<button v-if="active(selected)" class="button-secondary" :disabled="busy" @click="action(async () => { selected = await mediaService.cancel(selected!.job_id) })">取消任务</button> <button v-if="active(selected)" class="button-secondary" :disabled="busy" @click="action(async () => { selected = await mediaService.cancel(selected!.job_id) })">{{ t('取消任务', 'Cancel job') }}</button>
<button v-if="['failed', 'cancelled'].includes(selected.status) && selected.error_code !== 'MEDIA_PURGED'" class="button-secondary" :disabled="busy" @click="action(async () => { selected = await mediaService.retry(selected!.job_id) })">重新处理</button> <button v-if="['failed', 'cancelled'].includes(selected.status) && selected.error_code !== 'MEDIA_PURGED'" class="button-secondary" :disabled="busy" @click="action(async () => { selected = await mediaService.retry(selected!.job_id) })">{{ t('重新处理', 'Process again') }}</button>
<button v-if="!active(selected) && selected.error_code !== 'MEDIA_PURGED'" class="button-danger" :disabled="busy" @click="purge">清理原附件与转写</button> <button v-if="!active(selected) && selected.error_code !== 'MEDIA_PURGED'" class="button-danger" :disabled="busy" @click="purge">{{ t('清理原附件与转写', 'Remove attachment and transcript') }}</button>
<template v-if="selected.status === 'completed'"> <template v-if="selected.status === 'completed'">
<div class="speaker-names"><label v-for="speaker in speakers" :key="speaker">{{ speaker }}<input v-model="selected.speaker_names[speaker]" class="input" placeholder="说话人显示名" @input="dirty = true" /></label></div> <div class="speaker-names"><label v-for="speaker in speakers" :key="speaker">{{ speaker }}<input v-model="selected.speaker_names[speaker]" class="input" :placeholder="t('说话人显示名', 'Speaker display name')" @input="dirty = true" /></label></div>
<p v-if="selected.segments.length" class="subtle">时间戳对应音频分段边界可点击定位播放</p> <p v-if="selected.segments.length" class="subtle">{{ t('时间戳对应音频分段边界可点击定位播放', 'Timestamps mark segment boundaries; click one to seek playback.') }}</p>
<div v-for="segment in selected.segments" :key="segment.segment_id" class="segment" :class="{ current: position >= segment.start_time && position < segment.end_time }"> <div v-for="segment in selected.segments" :key="segment.segment_id" class="segment" :class="{ current: position >= segment.start_time && position < segment.end_time }">
<button class="button-secondary" @click="seek(segment.start_time)">{{ stamp(segment.start_time) }}</button><small>{{ selected.speaker_names[segment.speaker || ''] || segment.speaker }}</small> <button class="button-secondary" @click="seek(segment.start_time)">{{ stamp(segment.start_time) }}</button><small>{{ selected.speaker_names[segment.speaker || ''] || segment.speaker }}</small>
<textarea v-model="segment.text" class="input" rows="2" @input="dirty = true; selected.text = selected.segments.map(s => s.text).join('\n')" /> <textarea v-model="segment.text" class="input" rows="2" @input="dirty = true; selected.text = selected.segments.map(s => s.text).join('\n')" />
</div> </div>
<textarea v-if="!selected.segments.length" v-model="selected.text" class="input" rows="12" @input="dirty = true" /> <textarea v-if="!selected.segments.length" v-model="selected.text" class="input" rows="12" @input="dirty = true" />
<div class="inline-actions"><button class="button-primary" :disabled="busy || !dirty" @click="action(async () => { selected = await mediaService.save(selected!); dirty = false; notice = '校对已保存' })">保存校对</button> <div class="inline-actions"><button class="button-primary" :disabled="busy || !dirty" @click="action(async () => { selected = await mediaService.save(selected!); dirty = false; notice = t('校对已保存', 'Corrections saved') })">{{ t('保存校对', 'Save corrections') }}</button>
<button class="button-secondary" @click="action(async () => { history = (await mediaService.revisions(selected!.job_id)).items })">修订历史</button></div> <button class="button-secondary" @click="action(async () => { history = (await mediaService.revisions(selected!.job_id)).items })">{{ t('修订历史', 'Revision history') }}</button></div>
<details><summary>原始识别文本</summary><pre>{{ selected.original_text }}</pre></details> <details class="ui-disclosure"><summary>{{ t('原始识别文本', 'Original recognition text') }}</summary><pre>{{ selected.original_text }}</pre></details>
<details v-for="revision in history" :key="revision.revision"><summary>修订 {{ revision.revision }}</summary><pre>{{ revision.text }}</pre></details> <details v-for="revision in history" :key="revision.revision" class="ui-disclosure"><summary>{{ t('修订', 'Revision') }} {{ revision.revision }}</summary><pre>{{ revision.text }}</pre></details>
<div class="inline-actions"><label><input v-model="updateExisting" type="checkbox" />更新上次导出的笔记(已手动修改则拒绝)</label><input v-model="title" class="input" aria-label="笔记标题" /><button class="button-primary" :disabled="busy || dirty || !title.trim()" @click="action(async () => { const note = await mediaService.note(selected!.job_id, title, updateExisting); notice = `已保存笔记:${note.title}` })">保存为笔记</button></div> <div class="inline-actions"><label><input v-model="updateExisting" type="checkbox" />{{ t('更新上次导出的笔记(已手动修改则拒绝)', 'Update the previously exported note (refuse if manually edited)') }}</label><input v-model="title" class="input" :aria-label="t('笔记标题', 'Note title')" /><button class="button-primary" :disabled="busy || dirty || !title.trim()" @click="action(async () => { const note = await mediaService.note(selected!.job_id, title, updateExisting); notice = `${t('已保存笔记:', 'Saved note: ')}${note.title}` })">{{ t('保存为笔记', 'Save as note') }}</button></div>
</template> </template>
</article> </article>
<div v-else class="panel subtle">选择任务查看转写结果</div> <div v-else class="panel subtle">{{ t('选择任务查看转写结果。', 'Select a job to view its transcript.') }}</div>
</div> </div>
</section> </section>
</template> </template>
<style scoped> <style scoped>
.media-page{padding:28px;overflow:auto;height:100%;display:flex;flex-direction:column;gap:20px}.upload{display:grid;gap:12px;padding:20px}.media-columns{display:grid;grid-template-columns:260px minmax(0,1fr);gap:20px}.panel{padding:20px}.job-row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;padding:12px;background:transparent;border:1px solid var(--color-border-default);border-radius:10px;margin-bottom:8px;cursor:pointer;color:inherit}.job-row small{overflow:hidden;text-overflow:ellipsis;max-width:100%}.selected,.current{background:var(--color-background-hover);outline:1px solid var(--color-accent-primary)}.transcript{display:flex;flex-direction:column;gap:16px}.transcript header,.segment{display:flex;gap:12px;align-items:center}.transcript>.button-danger{align-self:flex-start}.transcript>label{white-space:nowrap}.transcript>label select{width:160px}.segment textarea{flex:1}.speaker-names{display:flex;flex-wrap:wrap;gap:10px}audio{width:100%}pre{white-space:pre-wrap;word-break:break-word}label{display:flex;gap:8px;align-items:center}@media(max-width:850px){.media-columns{grid-template-columns:1fr}.segment{flex-wrap:wrap}} .media-page{padding:28px;overflow:auto;height:100%;display:flex;flex-direction:column;gap:20px}.upload{display:grid;gap:12px;padding:20px}.upload-options{display:flex;flex-wrap:wrap;gap:16px}.upload-actions{justify-content:flex-end}.media-columns{display:grid;grid-template-columns:260px minmax(0,1fr);gap:20px}.panel{padding:20px}.job-row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;padding:12px;background:transparent;border:1px solid var(--color-border-default);border-radius:10px;margin-bottom:8px;cursor:pointer;color:inherit}.job-row small{overflow:hidden;text-overflow:ellipsis;max-width:100%}.selected,.current{background:var(--color-background-hover);outline:1px solid var(--color-accent-primary)}.transcript{display:flex;flex-direction:column;gap:16px}.transcript header,.segment{display:flex;gap:12px;align-items:center}.transcript>.button-danger{align-self:flex-start}.transcript>label{white-space:nowrap}.transcript>label select{width:160px}.segment textarea{flex:1}.speaker-names{display:flex;flex-wrap:wrap;gap:10px}audio{width:100%;border-radius:var(--radius-md);accent-color:var(--color-accent-primary)}pre{white-space:pre-wrap;word-break:break-word}label{display:flex;gap:8px;align-items:center}@media(max-width:850px){.media-columns{grid-template-columns:1fr}.segment{flex-wrap:wrap}}@media(max-width:560px){.upload-actions>*{flex:1}.upload-options{flex-direction:column}}
</style> </style>
@@ -8,6 +8,7 @@ import * as pluginService from '@/services/pluginService'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
import { usePluginStore } from '@/stores/plugin' import { usePluginStore } from '@/stores/plugin'
import { useWorkspaceStore } from '@/stores/workspace' import { useWorkspaceStore } from '@/stores/workspace'
import { t, localeTag } from '@/i18n'
const props = defineProps<{ plugin: Plugin }>() const props = defineProps<{ plugin: Plugin }>()
const pluginStore = usePluginStore() const pluginStore = usePluginStore()
@@ -31,8 +32,8 @@ let loadVersion = 0
const hasSettings = computed(() => props.plugin.contributions.some((item) => item.type === 'settings_section')) const hasSettings = computed(() => props.plugin.contributions.some((item) => item.type === 'settings_section'))
const tabs = computed(() => [ const tabs = computed(() => [
...(props.plugin.backend_type === 'mcp' ? [{ id: 'host' as const, label: 'MCP Host' }] : []), ...(props.plugin.backend_type === 'mcp' ? [{ id: 'host' as const, label: 'MCP Host' }] : []),
...(hasSettings.value ? [{ id: 'settings' as const, label: '设置与密钥' }] : []), ...(hasSettings.value ? [{ id: 'settings' as const, label: t('设置与密钥', 'Settings and secrets') }] : []),
{ id: 'commands' as const, label: '插件命令' }, { id: 'commands' as const, label: t('插件命令', 'Plugin commands') },
]) ])
watch(() => props.plugin.plugin_id, () => { watch(() => props.plugin.plugin_id, () => {
@@ -48,7 +49,7 @@ watch(() => props.plugin.plugin_id, () => {
function feedback(message = '') { error.value = message; notice.value = '' } function feedback(message = '') { error.value = message; notice.value = '' }
function message(reason: unknown, fallback: string) { return reason instanceof Error ? reason.message : fallback } function message(reason: unknown, fallback: string) { return reason instanceof Error ? reason.message : fallback }
function formatTime(value?: string | null) { return value ? new Date(value).toLocaleString() : '—' } function formatTime(value?: string | null) { return value ? new Date(value).toLocaleString(localeTag()) : '—' }
async function selectTab(tab: typeof activeTab.value) { async function selectTab(tab: typeof activeTab.value) {
activeTab.value = tab activeTab.value = tab
@@ -80,7 +81,7 @@ async function loadActive() {
} }
} }
} catch (reason) { } catch (reason) {
if (version === loadVersion) feedback(message(reason, 'MCP 数据加载失败')) if (version === loadVersion) feedback(message(reason, t('MCP 数据加载失败', 'Failed to load MCP data')))
} finally { } finally {
if (version === loadVersion) loading.value = false if (version === loadVersion) loading.value = false
} }
@@ -92,8 +93,8 @@ async function restartHost() {
await pluginService.restartPluginHost(props.plugin.plugin_id) await pluginService.restartPluginHost(props.plugin.plugin_id)
host.value = await pluginService.getPluginHostStatus(props.plugin.plugin_id) host.value = await pluginService.getPluginHostStatus(props.plugin.plugin_id)
await pluginStore.loadPlugins() await pluginStore.loadPlugins()
notice.value = 'MCP Host 已重启。' notice.value = t('MCP Host 已重启。', 'MCP Host restarted.')
} catch (reason) { feedback(message(reason, 'MCP Host 重启失败')) } finally { busy.value = '' } } catch (reason) { feedback(message(reason, t('MCP Host 重启失败', 'Failed to restart MCP Host'))) } finally { busy.value = '' }
} }
function updateValue(field: PluginSettingField, raw: string | boolean) { function updateValue(field: PluginSettingField, raw: string | boolean) {
values.value[field.key] = field.type === 'number' && typeof raw === 'string' ? (raw === '' ? null : Number(raw)) : raw values.value[field.key] = field.type === 'number' && typeof raw === 'string' ? (raw === '' ? null : Number(raw)) : raw
@@ -105,31 +106,31 @@ async function saveSettings() {
try { try {
schema.value = await pluginService.updatePluginSettings(props.plugin.plugin_id, schema.value.schema_version, values.value) schema.value = await pluginService.updatePluginSettings(props.plugin.plugin_id, schema.value.schema_version, values.value)
values.value = { ...schema.value.values } values.value = { ...schema.value.values }
notice.value = '普通设置已保存。' notice.value = t('普通设置已保存。', 'Settings saved.')
} catch (reason) { feedback(message(reason, '设置保存失败')) } finally { busy.value = '' } } catch (reason) { feedback(message(reason, t('设置保存失败', 'Failed to save settings'))) } finally { busy.value = '' }
} }
async function saveSecret(field: PluginSettingField) { async function saveSecret(field: PluginSettingField) {
const secret = secrets.value[field.key]?.trim() const secret = secrets.value[field.key]?.trim()
if (!secret) { feedback('请输入' + field.label); return } if (!secret) { feedback(t('请输入', 'Enter ') + field.label); return }
busy.value = 'secret:' + field.key busy.value = 'secret:' + field.key
feedback() feedback()
try { try {
const state = await pluginService.putPluginSecret(props.plugin.plugin_id, field.key, secret) const state = await pluginService.putPluginSecret(props.plugin.plugin_id, field.key, secret)
if (schema.value) schema.value.secrets[field.key] = { configured: state.configured } if (schema.value) schema.value.secrets[field.key] = { configured: state.configured }
secrets.value[field.key] = '' secrets.value[field.key] = ''
notice.value = field.label + '已加密保存。' notice.value = field.label + t('已加密保存。', ' encrypted and saved.')
} catch (reason) { feedback(message(reason, '密钥保存失败')) } finally { busy.value = '' } } catch (reason) { feedback(message(reason, t('密钥保存失败', 'Failed to save secret'))) } finally { busy.value = '' }
} }
async function deleteSecret(field: PluginSettingField) { async function deleteSecret(field: PluginSettingField) {
if (!confirm('删除已保存的' + field.label + '')) return if (!confirm(t('删除已保存的', 'Delete saved ') + field.label + '')) return
busy.value = 'secret:' + field.key busy.value = 'secret:' + field.key
feedback() feedback()
try { try {
const state = await pluginService.deletePluginSecret(props.plugin.plugin_id, field.key) const state = await pluginService.deletePluginSecret(props.plugin.plugin_id, field.key)
if (schema.value) schema.value.secrets[field.key] = { configured: state.configured } if (schema.value) schema.value.secrets[field.key] = { configured: state.configured }
secrets.value[field.key] = '' secrets.value[field.key] = ''
notice.value = field.label + '已删除。' notice.value = field.label + t('已删除。', ' deleted.')
} catch (reason) { feedback(message(reason, '密钥删除失败')) } finally { busy.value = '' } } catch (reason) { feedback(message(reason, t('密钥删除失败', 'Failed to delete secret'))) } finally { busy.value = '' }
} }
function properties(command: PluginCommand): Record<string, Record<string, unknown>> { function properties(command: PluginCommand): Record<string, Record<string, unknown>> {
const result = command.parameters.properties const result = command.parameters.properties
@@ -165,7 +166,7 @@ async function execute(command: PluginCommand) {
selection: null, selection: null,
}) })
if (result.effect.type === 'notification') notice.value = result.effect.payload.message if (result.effect.type === 'notification') notice.value = result.effect.payload.message
else if (result.effect.type === 'job') notice.value = '后台任务已创建:' + result.effect.payload.job_id else if (result.effect.type === 'job') notice.value = t('后台任务已创建:', 'Background job created: ') + result.effect.payload.job_id
else if (result.effect.type === 'navigate') { else if (result.effect.type === 'navigate') {
const routes: Record<string, string> = { const routes: Record<string, string> = {
'vault-entry': '/', workspace: '/workspace', search: '/search', chat: '/chat', 'vault-entry': '/', workspace: '/workspace', search: '/search', chat: '/chat',
@@ -175,64 +176,64 @@ async function execute(command: PluginCommand) {
await router.push(routes[result.effect.payload.route]) await router.push(routes[result.effect.payload.route])
} else if (result.effect.type === 'refresh') { } else if (result.effect.type === 'refresh') {
await loadActive() await loadActive()
notice.value = '相关数据已刷新。' notice.value = t('相关数据已刷新。', 'Related data refreshed.')
} else notice.value = '命令执行完成。' } else notice.value = t('命令执行完成。', 'Command completed.')
} catch (reason) { feedback(message(reason, '命令执行失败')) } finally { busy.value = '' } } catch (reason) { feedback(message(reason, t('命令执行失败', 'Command failed'))) } finally { busy.value = '' }
} }
</script> </script>
<template> <template>
<section class="mcp-panel"> <section class="mcp-panel">
<nav class="mcp-tabs" aria-label="MCP Plugin 配置"> <nav class="mcp-tabs" :aria-label="t('MCP Plugin 配置', 'MCP and Plugin settings')">
<button v-for="tab in tabs" :key="tab.id" :class="{ active: activeTab === tab.id }" @click="selectTab(tab.id)">{{ tab.label }}</button> <button v-for="tab in tabs" :key="tab.id" :class="{ active: activeTab === tab.id }" @click="selectTab(tab.id)">{{ tab.label }}</button>
</nav> </nav>
<div v-if="error" class="error-banner">{{ error }}</div> <div v-if="error" class="error-banner">{{ error }}</div>
<div v-if="notice" class="notice-banner">{{ notice }}</div> <div v-if="notice" class="notice-banner">{{ notice }}</div>
<div v-if="activeTab === 'host'" class="mcp-section"> <div v-if="activeTab === 'host'" class="mcp-section">
<div class="section-head"><div><h3>MCP Host 状态</h3><p>查看协议协商运行状态与 Host 错误</p></div><div class="inline-actions"><button class="button-secondary" :disabled="loading" @click="loadActive"><AppIcon :icon="Refresh" :size="15" />刷新</button><button class="button-primary" :disabled="busy === 'host' || !plugin.enabled" @click="restartHost">{{ busy === 'host' ? '重启中' : '重启 Host' }}</button></div></div> <div class="section-head"><div><h3>{{ t('MCP Host 状态', 'MCP Host status') }}</h3><p>{{ t('查看协议协商、运行状态与 Host 错误。', 'Inspect protocol negotiation, runtime status, and Host errors.') }}</p></div><div class="inline-actions"><button class="button-secondary" :disabled="loading" @click="loadActive"><AppIcon :icon="Refresh" :size="15" />{{ t('刷新', 'Refresh') }}</button><button class="button-primary" :disabled="busy === 'host' || !plugin.enabled" @click="restartHost">{{ busy === 'host' ? t('重启中', 'Restarting') : t('重启 Host', 'Restart Host') }}</button></div></div>
<div v-if="host" class="status-grid"> <div v-if="host" class="status-grid">
<div><span>状态</span><strong><i class="status-dot" :class="host.status"></i>{{ host.status }}</strong></div> <div><span>{{ t('状态', 'Status') }}</span><strong><i class="status-dot" :class="host.status"></i>{{ host.status }}</strong></div>
<div><span>服务</span><strong>{{ host.server_name || '—' }} {{ host.server_version || '' }}</strong></div> <div><span>{{ t('服务', 'Server') }}</span><strong>{{ host.server_name || '—' }} {{ host.server_version || '' }}</strong></div>
<div><span>协议版本</span><strong>{{ host.protocol_version || '—' }}</strong></div> <div><span>{{ t('协议版本', 'Protocol version') }}</span><strong>{{ host.protocol_version || '—' }}</strong></div>
<div><span>工具数量</span><strong>{{ host.tools_count }}</strong></div> <div><span>{{ t('工具数量', 'Tools') }}</span><strong>{{ host.tools_count }}</strong></div>
<div><span>启动时间</span><strong>{{ formatTime(host.started_at) }}</strong></div> <div><span>{{ t('启动时间', 'Started') }}</span><strong>{{ formatTime(host.started_at) }}</strong></div>
<div><span>最后心跳</span><strong>{{ formatTime(host.last_seen_at) }}</strong></div> <div><span>{{ t('最后心跳', 'Last heartbeat') }}</span><strong>{{ formatTime(host.last_seen_at) }}</strong></div>
</div> </div>
<div v-else-if="loading" class="empty-state">正在读取 Host 状态</div> <div v-else-if="loading" class="empty-state">{{ t('正在读取 Host 状态', 'Loading Host status') }}</div>
<div v-if="host?.error" class="error-banner host-error">{{ host.error }}</div> <div v-if="host?.error" class="error-banner host-error">{{ host.error }}</div>
<p class="security-hint">当前仅运行插件清单声明的 stdio MCP Server不开放任意 Shell 命令和环境变量编辑</p> <p class="security-hint">{{ t('当前仅运行插件清单声明的 stdio MCP Server,不开放任意 Shell 命令和环境变量编辑。', 'Only stdio MCP servers declared by the plugin manifest can run. Arbitrary shell commands and environment variable editing are unavailable.') }}</p>
</div> </div>
<div v-else-if="activeTab === 'settings'" class="mcp-section"> <div v-else-if="activeTab === 'settings'" class="mcp-section">
<div class="section-head"><div><h3>设置与密钥</h3><p>表单由后端 Schema 生成密钥不会被读取或回显</p></div><button class="button-primary" :disabled="!schema || busy === 'settings'" @click="saveSettings">{{ busy === 'settings' ? '保存中' : '保存普通设置' }}</button></div> <div class="section-head"><div><h3>{{ t('设置与密钥', 'Settings and secrets') }}</h3><p>{{ t('表单由后端 Schema 生成;密钥不会被读取或回显。', 'The backend schema generates this form. Secrets are never read back or displayed.') }}</p></div><button class="button-primary" :disabled="!schema || busy === 'settings'" @click="saveSettings">{{ busy === 'settings' ? t('保存中', 'Saving') : t('保存普通设置', 'Save settings') }}</button></div>
<div v-if="schema" class="settings-list"> <div v-if="schema" class="settings-list">
<div v-for="field in schema.fields" :key="field.key" class="setting-row"> <div v-for="field in schema.fields" :key="field.key" class="setting-row">
<div class="field-copy"><label :for="'plugin-setting-' + field.key"><AppIcon v-if="field.type === 'secret'" :icon="Key" :size="15" />{{ field.label }}<em v-if="field.required">必填</em></label><p>{{ field.description || (field.type === 'secret' ? '加密保存,不在页面回显。' : '') }}</p></div> <div class="field-copy"><label :for="'plugin-setting-' + field.key"><AppIcon v-if="field.type === 'secret'" :icon="Key" :size="15" />{{ field.label }}<em v-if="field.required">{{ t('必填', 'Required') }}</em></label><p>{{ field.description || (field.type === 'secret' ? t('加密保存,不在页面回显。', 'Encrypted and never displayed.') : '') }}</p></div>
<template v-if="field.type === 'secret'"> <template v-if="field.type === 'secret'">
<div class="secret-control"><input :id="'plugin-setting-' + field.key" :value="secrets[field.key] || ''" class="input" type="password" autocomplete="new-password" :placeholder="schema.secrets[field.key]?.configured ? '已配置;输入新值可替换' : '输入密钥'" @input="secrets[field.key] = ($event.target as HTMLInputElement).value"><button class="button-secondary" :disabled="!secrets[field.key]?.trim() || busy === 'secret:' + field.key" @click="saveSecret(field)">安全保存</button><button v-if="schema.secrets[field.key]?.configured" class="button-danger" @click="deleteSecret(field)">删除</button></div> <div class="secret-control"><input :id="'plugin-setting-' + field.key" :value="secrets[field.key] || ''" class="input" type="password" autocomplete="new-password" :placeholder="schema.secrets[field.key]?.configured ? t('已配置;输入新值可替换', 'Configured; enter a new value to replace') : t('输入密钥', 'Enter secret')" @input="secrets[field.key] = ($event.target as HTMLInputElement).value"><button class="button-secondary" :disabled="!secrets[field.key]?.trim() || busy === 'secret:' + field.key" @click="saveSecret(field)">{{ t('安全保存', 'Save securely') }}</button><button v-if="schema.secrets[field.key]?.configured" class="button-danger" @click="deleteSecret(field)">{{ t('删除', 'Delete') }}</button></div>
<span class="secret-state" :class="{ configured: schema.secrets[field.key]?.configured }">{{ schema.secrets[field.key]?.configured ? '已配置' : '未配置' }}</span> <span class="secret-state" :class="{ configured: schema.secrets[field.key]?.configured }">{{ schema.secrets[field.key]?.configured ? t('已配置', 'Configured') : t('未配置', 'Not configured') }}</span>
</template> </template>
<template v-else-if="field.type === 'boolean'"><label class="check-control"><input :id="'plugin-setting-' + field.key" type="checkbox" :checked="Boolean(values[field.key])" @change="updateValue(field, ($event.target as HTMLInputElement).checked)">{{ values[field.key] ? '开启' : '关闭' }}</label></template> <template v-else-if="field.type === 'boolean'"><label class="check-control"><input :id="'plugin-setting-' + field.key" type="checkbox" :checked="Boolean(values[field.key])" @change="updateValue(field, ($event.target as HTMLInputElement).checked)">{{ values[field.key] ? t('开启', 'On') : t('关闭', 'Off') }}</label></template>
<template v-else-if="field.type === 'select'"><select :id="'plugin-setting-' + field.key" class="select" :value="values[field.key]" @change="updateValue(field, ($event.target as HTMLSelectElement).value)"><option v-for="option in field.options" :key="option" :value="option">{{ option }}</option></select></template> <template v-else-if="field.type === 'select'"><select :id="'plugin-setting-' + field.key" class="select" :value="values[field.key]" @change="updateValue(field, ($event.target as HTMLSelectElement).value)"><option v-for="option in field.options" :key="option" :value="option">{{ option }}</option></select></template>
<template v-else><input :id="'plugin-setting-' + field.key" class="input" :type="field.type === 'number' ? 'number' : 'text'" :min="field.minimum ?? undefined" :max="field.maximum ?? undefined" :required="field.required" :value="values[field.key] ?? ''" @input="updateValue(field, ($event.target as HTMLInputElement).value)"></template> <template v-else><input :id="'plugin-setting-' + field.key" class="input" :type="field.type === 'number' ? 'number' : 'text'" :min="field.minimum ?? undefined" :max="field.maximum ?? undefined" :required="field.required" :value="values[field.key] ?? ''" @input="updateValue(field, ($event.target as HTMLInputElement).value)"></template>
</div> </div>
</div> </div>
<div v-else-if="loading" class="empty-state">正在读取 Plugin 设置</div> <div v-else-if="loading" class="empty-state">{{ t('正在读取 Plugin 设置', 'Loading Plugin settings') }}</div>
</div> </div>
<div v-else class="mcp-section"> <div v-else class="mcp-section">
<div class="section-head"><div><h3>Plugin 命令</h3><p>执行该 Plugin 注册的受控 Command Contribution</p></div><button class="button-secondary" :disabled="loading" @click="loadActive"><AppIcon :icon="Refresh" :size="15" />刷新</button></div> <div class="section-head"><div><h3>{{ t('Plugin 命令', 'Plugin commands') }}</h3><p>{{ t('执行该 Plugin 注册的受控 Command Contribution。', 'Run controlled command contributions registered by this Plugin.') }}</p></div><button class="button-secondary" :disabled="loading" @click="loadActive"><AppIcon :icon="Refresh" :size="15" />{{ t('刷新', 'Refresh') }}</button></div>
<div v-if="commands.length" class="command-list"> <div v-if="commands.length" class="command-list">
<article v-for="command in commands" :key="command.command_id" class="item-card command-card"> <article v-for="command in commands" :key="command.command_id" class="item-card command-card">
<div class="command-head"><div><strong>{{ command.title }}</strong><p>{{ command.description || command.command_id }}</p></div><span class="badge" :class="{ success: commandAvailable(command), warning: command.enabled && !commandAvailable(command) }">{{ commandAvailable(command) ? '可执行' : command.enabled ? '缺少上下文' : '不可用' }}</span></div> <div class="command-head"><div><strong>{{ command.title }}</strong><p>{{ command.description || command.command_id }}</p></div><span class="badge" :class="{ success: commandAvailable(command), warning: command.enabled && !commandAvailable(command) }">{{ commandAvailable(command) ? t('可执行', 'Available') : command.enabled ? t('缺少上下文', 'Missing context') : t('不可用', 'Unavailable') }}</span></div>
<div v-if="Object.keys(properties(command)).length" class="command-fields"> <div v-if="Object.keys(properties(command)).length" class="command-fields">
<label v-for="(definition, key) in properties(command)" :key="key" class="field"><span>{{ String(definition.title || key) }}<em v-if="required(command, key)">必填</em></span><select v-if="Array.isArray(definition.enum)" class="select" @change="updateArgument(command.command_id, key, ($event.target as HTMLSelectElement).value, definition)"><option value="">请选择</option><option v-for="option in definition.enum" :key="String(option)" :value="String(option)">{{ option }}</option></select><select v-else-if="definition.type === 'boolean'" class="select" @change="updateArgument(command.command_id, key, ($event.target as HTMLSelectElement).value, definition)"><option value="false"></option><option value="true"></option></select><input v-else class="input" :type="definition.type === 'number' || definition.type === 'integer' ? 'number' : 'text'" @input="updateArgument(command.command_id, key, ($event.target as HTMLInputElement).value, definition)"></label> <label v-for="(definition, key) in properties(command)" :key="key" class="field"><span>{{ String(definition.title || key) }}<em v-if="required(command, key)">{{ t('必填', 'Required') }}</em></span><select v-if="Array.isArray(definition.enum)" class="select" @change="updateArgument(command.command_id, key, ($event.target as HTMLSelectElement).value, definition)"><option value="">{{ t('请选择', 'Select') }}</option><option v-for="option in definition.enum" :key="String(option)" :value="String(option)">{{ option }}</option></select><select v-else-if="definition.type === 'boolean'" class="select" @change="updateArgument(command.command_id, key, ($event.target as HTMLSelectElement).value, definition)"><option value="false">{{ t('否', 'No') }}</option><option value="true">{{ t('是', 'Yes') }}</option></select><input v-else class="input" :type="definition.type === 'number' || definition.type === 'integer' ? 'number' : 'text'" @input="updateArgument(command.command_id, key, ($event.target as HTMLInputElement).value, definition)"></label>
</div> </div>
<button class="button-primary command-run" :disabled="!commandAvailable(command) || busy === command.command_id" @click="execute(command)"><AppIcon :icon="VideoPlay" :size="15" />{{ busy === command.command_id ? '执行中…' : '执行命令' }}</button> <button class="button-primary command-run" :disabled="!commandAvailable(command) || busy === command.command_id" @click="execute(command)"><AppIcon :icon="VideoPlay" :size="15" />{{ busy === command.command_id ? t('执行中…', 'Running…') : t('执行命令', 'Run command') }}</button>
</article> </article>
</div> </div>
<div v-else-if="!loading" class="empty-state"><div><strong>没有可用命令</strong><p>启用 Plugin 已注册的命令会出现在这里</p></div></div> <div v-else-if="!loading" class="empty-state"><div><strong>{{ t('没有可用命令', 'No available commands') }}</strong><p>{{ t('启用 Plugin 后,已注册的命令会出现在这里。', 'Registered commands appear here after the Plugin is enabled.') }}</p></div></div>
</div> </div>
</section> </section>
</template> </template>
+11 -10
View File
@@ -4,31 +4,32 @@ import AppIcon from '@/components/common/AppIcon.vue'
import PluginMcpPanel from './PluginMcpPanel.vue' import PluginMcpPanel from './PluginMcpPanel.vue'
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { usePluginStore } from '@/stores/plugin' import { usePluginStore } from '@/stores/plugin'
import { t } from '@/i18n'
const pluginStore = usePluginStore() const pluginStore = usePluginStore()
const actionError = ref('') const actionError = ref('')
onMounted(() => { void pluginStore.loadPlugins() }) onMounted(() => { void pluginStore.loadPlugins() })
async function install() { const path = prompt('请输入 Plugin Package 路径')?.trim(); if (!path) return; try { await pluginStore.installPlugin(path) } catch (error) { actionError.value = error instanceof Error ? error.message : '安装失败' } } async function install() { const path = prompt(t('请输入 Plugin Package 路径', 'Enter the Plugin Package path'))?.trim(); if (!path) return; try { await pluginStore.installPlugin(path) } catch (error) { actionError.value = error instanceof Error ? error.message : t('安装失败', 'Installation failed') } }
async function toggle(id: string, enabled: boolean) { try { enabled ? await pluginStore.disablePlugin(id) : await pluginStore.enablePlugin(id) } catch (error) { actionError.value = error instanceof Error ? error.message : '状态更新失败' } } async function toggle(id: string, enabled: boolean) { try { enabled ? await pluginStore.disablePlugin(id) : await pluginStore.enablePlugin(id) } catch (error) { actionError.value = error instanceof Error ? error.message : t('状态更新失败', 'Status update failed') } }
async function grant(id: string, permissions: string[]) { if (!confirm(`将授权:${permissions.join('')}是否继续?`)) return; try { await pluginStore.grantPermissions(id, permissions) } catch (error) { actionError.value = error instanceof Error ? error.message : '授权失败' } } async function grant(id: string, permissions: string[]) { if (!confirm(`${t('将授权:', 'Grant permissions: ')}${permissions.join(', ')}${t('是否继续?', 'Continue?')}`)) return; try { await pluginStore.grantPermissions(id, permissions) } catch (error) { actionError.value = error instanceof Error ? error.message : t('授权失败', 'Authorization failed') } }
async function uninstall(id: string, name: string) { if (!confirm(`卸载“${name}”将移除其全部 Contribution,是否继续?`)) return; try { await pluginStore.uninstallPlugin(id) } catch (error) { actionError.value = error instanceof Error ? error.message : '卸载失败' } } async function uninstall(id: string, name: string) { if (!confirm(t(`卸载“${name}”将移除其全部 Contribution,是否继续?`, `Uninstalling “${name}” removes all its contributions. Continue?`))) return; try { await pluginStore.uninstallPlugin(id) } catch (error) { actionError.value = error instanceof Error ? error.message : t('卸载失败', 'Uninstall failed') } }
</script> </script>
<template> <template>
<section class="feature-page"> <section class="feature-page">
<header class="feature-header"><div><h1>Plugin MCP</h1><p>管理插件生命周期MCP Host权限和受控 Contribution</p></div><button class="button-primary" @click="install">安装 Plugin</button></header> <header class="feature-header"><div><h1>{{ t('Plugin 与 MCP', 'Plugins and MCP') }}</h1><p>{{ t('管理插件生命周期、MCP Host、权限和受控 Contribution。', 'Manage plugin lifecycles, MCP hosts, permissions, and controlled contributions.') }}</p></div><button class="button-primary" @click="install">{{ t('安装 Plugin', 'Install Plugin') }}</button></header>
<div v-if="pluginStore.error || actionError" class="error-banner">{{ pluginStore.error || actionError }}</div> <div v-if="pluginStore.error || actionError" class="error-banner">{{ pluginStore.error || actionError }}</div>
<div v-if="pluginStore.selectedPlugin" class="panel"> <div v-if="pluginStore.selectedPlugin" class="panel">
<div class="detail-head"><div><span class="badge" :class="{ success: pluginStore.selectedPlugin.status === 'ready', error: pluginStore.selectedPlugin.status === 'error', warning: pluginStore.selectedPlugin.status === 'permission_required' }">{{ pluginStore.selectedPlugin.status }}</span><h2>{{ pluginStore.selectedPlugin.icon }} {{ pluginStore.selectedPlugin.name }}</h2><p class="muted">v{{ pluginStore.selectedPlugin.version }} · {{ pluginStore.selectedPlugin.backend_type || 'none' }}/{{ pluginStore.selectedPlugin.transport || 'none' }}</p></div><div class="inline-actions"><button v-if="pluginStore.selectedPlugin.status === 'permission_required'" class="button-primary" @click="grant(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.permissions)">授权权限</button><button class="button-secondary" @click="toggle(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.enabled)">{{ pluginStore.selectedPlugin.enabled ? '停用' : '启用' }}</button><button class="button-danger" @click="uninstall(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.name)">卸载</button></div></div> <div class="detail-head"><div><span class="badge" :class="{ success: pluginStore.selectedPlugin.status === 'ready', error: pluginStore.selectedPlugin.status === 'error', warning: pluginStore.selectedPlugin.status === 'permission_required' }">{{ pluginStore.selectedPlugin.status }}</span><h2>{{ pluginStore.selectedPlugin.icon }} {{ pluginStore.selectedPlugin.name }}</h2><p class="muted">v{{ pluginStore.selectedPlugin.version }} · {{ pluginStore.selectedPlugin.backend_type || 'none' }}/{{ pluginStore.selectedPlugin.transport || 'none' }}</p></div><div class="inline-actions"><button v-if="pluginStore.selectedPlugin.status === 'permission_required'" class="button-primary" @click="grant(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.permissions)">{{ t('授权权限', 'Grant permissions') }}</button><button class="button-secondary" @click="toggle(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.enabled)">{{ pluginStore.selectedPlugin.enabled ? t('停用', 'Disable') : t('启用', 'Enable') }}</button><button class="button-danger" @click="uninstall(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.name)">{{ t('卸载', 'Uninstall') }}</button></div></div>
<p class="description">{{ pluginStore.selectedPlugin.description }}</p> <p class="description">{{ pluginStore.selectedPlugin.description }}</p>
<div class="detail-grid"><div><h3>权限</h3><div class="tag-list"><span v-for="permission in pluginStore.selectedPlugin.permissions" :key="permission" class="badge warning">{{ permission }}</span></div></div><div><h3>Contribution</h3><div class="contribution-list"><div v-for="item in pluginStore.selectedPlugin.contributions" :key="item.id" class="item-card"><span class="badge info">{{ item.type }}</span><strong>{{ item.name }}</strong><p class="subtle">{{ item.description || item.id }}</p></div></div></div></div> <div class="detail-grid"><div><h3>{{ t('权限', 'Permissions') }}</h3><div class="tag-list"><span v-for="permission in pluginStore.selectedPlugin.permissions" :key="permission" class="badge warning">{{ permission }}</span></div></div><div><h3>Contribution</h3><div class="contribution-list"><div v-for="item in pluginStore.selectedPlugin.contributions" :key="item.id" class="item-card"><span class="badge info">{{ item.type }}</span><strong>{{ item.name }}</strong><p class="subtle">{{ item.description || item.id }}</p></div></div></div></div>
<div v-if="pluginStore.selectedPlugin.last_error" class="error-banner last-error">{{ pluginStore.selectedPlugin.last_error }}</div> <div v-if="pluginStore.selectedPlugin.last_error" class="error-banner last-error">{{ pluginStore.selectedPlugin.last_error }}</div>
<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">{{ t('依赖此插件的 Skill', 'Skills that depend on this plugin: ') }}{{ 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-if="!pluginStore.plugins.length" class="empty-state"><div><strong>{{ pluginStore.isLoading ? t('正在加载…', 'Loading…') : pluginStore.error ? t('加载失败', 'Load failed') : t('尚未安装', 'No plugins installed') }}</strong><button class="button-secondary" @click="pluginStore.loadPlugins">{{ t('重新加载', 'Reload') }}</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 }} {{ t('项权限', 'permissions') }} · {{ plugin.contributions.length }} {{ t(' Contribution', 'contributions') }}</p></article></div>
</section> </section>
</template> </template>
@@ -1,24 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { useSearchStore } from '@/stores/search' import { useSearchStore } from '@/stores/search'
import { computed } from 'vue'
import { t } from '@/i18n'
const searchStore = useSearchStore() const searchStore = useSearchStore()
const modes = [ const modes = computed(() => [
{ value: 'hybrid', label: '混合检索' }, { value: 'hybrid' as const, label: t('混合检索', 'Hybrid search') },
{ value: 'fts', label: '全文检索' }, { value: 'fts' as const, label: t('全文检索', 'Full-text search') },
{ value: 'vector', label: '向量检索' }, { value: 'vector' as const, label: t('向量检索', 'Vector search') },
] as const ])
</script> </script>
<template> <template>
<div class="sidebar-panel"> <div class="sidebar-panel">
<p class="subtle">检索模式</p> <p class="subtle">{{ t('检索模式', 'Search mode') }}</p>
<div class="sidebar-list mode-list"> <div class="sidebar-list mode-list">
<button v-for="item in modes" :key="item.value" class="sidebar-list-item" <button v-for="item in modes" :key="item.value" class="sidebar-list-item"
:class="{ active: searchStore.mode === item.value }" @click="searchStore.setMode(item.value)"> :class="{ active: searchStore.mode === item.value }" @click="searchStore.setMode(item.value)">
{{ item.label }} {{ item.label }}
</button> </button>
</div> </div>
<p class="subtle section-title">最近搜索</p> <p class="subtle section-title">{{ t('最近搜索', 'Recent searches') }}</p>
<div class="sidebar-list"> <div class="sidebar-list">
<button v-for="query in searchStore.recentQueries" :key="query" class="sidebar-list-item recent" <button v-for="query in searchStore.recentQueries" :key="query" class="sidebar-list-item recent"
@click="searchStore.doSearch({ query, mode: searchStore.mode })">{{ query }}</button> @click="searchStore.doSearch({ query, mode: searchStore.mode })">{{ query }}</button>
+12 -11
View File
@@ -5,6 +5,7 @@ import type { SearchResult } from '@/contracts'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
import { useSearchStore } from '@/stores/search' import { useSearchStore } from '@/stores/search'
import { useWorkspaceStore } from '@/stores/workspace' import { useWorkspaceStore } from '@/stores/workspace'
import { t } from '@/i18n'
const searchStore = useSearchStore() const searchStore = useSearchStore()
onMounted(() => { void searchStore.loadHistory() }) onMounted(() => { void searchStore.loadHistory() })
@@ -34,28 +35,28 @@ async function openResult(result: SearchResult) {
<template> <template>
<section class="feature-page search-page"> <section class="feature-page search-page">
<header class="feature-header"> <header class="feature-header">
<div><h1>搜索知识库</h1><p>在当前 Vault 中进行全文向量或混合检索</p></div> <div><h1>{{ t('搜索知识库', 'Search Knowledge Base') }}</h1><p>{{ t('在当前 Vault 中进行全文、向量或混合检索。', 'Run full-text, vector, or hybrid search in the current Vault.') }}</p></div>
</header> </header>
<form class="search-form panel" @submit.prevent="submitSearch"> <form class="search-form panel" @submit.prevent="submitSearch">
<input v-model="searchStore.query" class="input search-input" placeholder="搜索笔记内容、标题或标签" autofocus /> <input v-model="searchStore.query" class="input search-input" :placeholder="t('搜索笔记内容、标题或标签', 'Search note content, titles, or tags')" autofocus />
<button class="button-primary" :disabled="!searchStore.query.trim() || searchStore.isSearching"> <button class="button-primary" :disabled="!searchStore.query.trim() || searchStore.isSearching">
{{ searchStore.isSearching ? '搜索中…' : '搜索' }} {{ searchStore.isSearching ? t('搜索中…', 'Searching…') : t('搜索', 'Search') }}
</button> </button>
<div class="form-grid advanced"> <div class="form-grid advanced">
<div class="field"><label>文件夹范围</label><input v-model="folder" class="input" placeholder="例如 /数据结构" /></div> <div class="field"><label>{{ t('文件夹范围', 'Folder scope') }}</label><input v-model="folder" class="input" :placeholder="t('例如 /数据结构', 'For example /Data Structures')" /></div>
<div class="field"><label>标签</label><input v-model="tag" class="input" placeholder="例如 算法" /></div> <div class="field"><label>{{ t('标签', 'Tag') }}</label><input v-model="tag" class="input" :placeholder="t('例如 算法', 'For example algorithms')" /></div>
</div> </div>
</form> </form>
<div v-if="searchStore.error" class="error-banner">{{ searchStore.error }}</div> <div v-if="searchStore.error" class="error-banner">{{ searchStore.error }}</div>
<div v-if="searchStore.historyError" class="notice-banner">{{ searchStore.historyError }}</div> <div v-if="searchStore.historyError" class="notice-banner">{{ searchStore.historyError }}</div>
<div v-if="searchStore.recentQueries.length" class="search-history"> <div v-if="searchStore.recentQueries.length" class="search-history">
<span class="subtle">最近搜索保存在应用数据中</span> <span class="subtle">{{ t('最近搜索(保存在应用数据中)', 'Recent searches (stored in application data)') }}</span>
<button v-for="item in searchStore.recentQueries" :key="item" class="button-secondary" @click="searchStore.query = item; submitSearch()">{{ item }}</button> <button v-for="item in searchStore.recentQueries" :key="item" class="button-secondary" @click="searchStore.query = item; submitSearch()">{{ item }}</button>
<button class="button-secondary" @click="searchStore.clearHistory">清空记录</button> <button class="button-secondary" @click="searchStore.clearHistory">{{ t('清空记录', 'Clear history') }}</button>
</div> </div>
<div v-if="searchStore.vectorUnavailable" class="notice-banner">向量索引不可用已保留全文检索能力</div> <div v-if="searchStore.vectorUnavailable" class="notice-banner">{{ t('向量索引不可用已保留全文检索能力', 'Vector search is unavailable; full-text search remains active.') }}</div>
<div v-if="searchStore.results.length" class="results-header"> <div v-if="searchStore.results.length" class="results-header">
<span>找到 {{ searchStore.total }} 条结果</span><span class="badge info">{{ searchStore.mode }}</span> <span>{{ t('找到', 'Found') }} {{ searchStore.total }} {{ t('条结果', 'results') }}</span><span class="badge info">{{ searchStore.mode }}</span>
</div> </div>
<div v-if="searchStore.results.length" class="result-list"> <div v-if="searchStore.results.length" class="result-list">
<article v-for="result in searchStore.results" :key="`${result.note_id}:${result.block_id}`" <article v-for="result in searchStore.results" :key="`${result.note_id}:${result.block_id}`"
@@ -63,11 +64,11 @@ async function openResult(result: SearchResult) {
<div class="result-title"><strong>{{ result.note_title }}</strong><span class="badge">{{ result.match_type }}</span></div> <div class="result-title"><strong>{{ result.note_title }}</strong><span class="badge">{{ result.match_type }}</span></div>
<p class="subtle">{{ result.file_path }} · {{ result.heading_path }}</p> <p class="subtle">{{ result.file_path }} · {{ result.heading_path }}</p>
<p class="snippet">{{ result.snippet }}</p> <p class="snippet">{{ result.snippet }}</p>
<div class="result-meta"><span>相关度 {{ Math.round(result.score * 100) }}%</span><span>点击定位原文 </span></div> <div class="result-meta"><span>{{ t('相关度', 'Relevance') }} {{ Math.round(result.score * 100) }}%</span><span>{{ t('点击定位原文 →', 'Open source →') }}</span></div>
</article> </article>
</div> </div>
<div v-else-if="!searchStore.isSearching" class="empty-state"> <div v-else-if="!searchStore.isSearching" class="empty-state">
<div><strong>{{ searchStore.query ? '没有找到匹配内容' : '从你的知识库开始搜索' }}</strong><p>可切换检索模式或缩小文件夹标签范围</p></div> <div><strong>{{ searchStore.query ? t('没有找到匹配内容', 'No matching content') : t('从你的知识库开始搜索', 'Start searching your knowledge base') }}</strong><p>{{ t('可切换检索模式或缩小文件夹、标签范围。', 'Try another search mode or narrow the folder and tag scope.') }}</p></div>
</div> </div>
</section> </section>
</template> </template>
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { apiClient } from '@/services/apiClient' import { apiClient } from '@/services/apiClient'
import { t } from '@/i18n'
interface Config {device: 'cpu'|'cuda'; cpu_threads: number; memory_limit_mb: number; gpu_memory_limit_mb: number; timeout_seconds: number; embedding_model: string; version: number} interface Config {device: 'cpu'|'cuda'; cpu_threads: number; memory_limit_mb: number; gpu_memory_limit_mb: number; timeout_seconds: number; embedding_model: string; version: number}
interface Model {key: string; name: string; capability: string; revision: string; license: string; status: string; disk_bytes: number|null; downloaded_bytes: number; total_bytes: number|null; error_code?: string} interface Model {key: string; name: string; capability: string; revision: string; license: string; status: string; disk_bytes: number|null; downloaded_bytes: number; total_bytes: number|null; error_code?: string}
const items = ref<Model[]>([]) const items = ref<Model[]>([])
@@ -22,8 +23,8 @@ const dirty = ref(false)
const busy = ref(false) const busy = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined let timer: ReturnType<typeof setTimeout> | undefined
let stopped = false let stopped = false
const size = (bytes: number | null) => bytes === null ? '未知' : `${(bytes / 1024 / 1024).toFixed(1)} MiB` const size = (bytes: number | null) => bytes === null ? t('未知', 'Unknown') : `${(bytes / 1024 / 1024).toFixed(1)} MiB`
const labels: Record<string,string> = {not_installed:'未下载',downloading:'下载中',installed:'已下载并校验',failed:'下载失败',interrupted:'已中断,可续传'} const labels = computed<Record<string,string>>(() => ({not_installed:t('未下载','Not downloaded'),downloading:t('下载中','Downloading'),installed:t('已下载并校验','Downloaded and verified'),failed:t('下载失败','Download failed'),interrupted:t('已中断,可续传','Interrupted; resumable')}))
async function load() { async function load() {
await loadCuda() await loadCuda()
try { try {
@@ -52,39 +53,39 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
</script> </script>
<template> <template>
<section class="local-models"> <section class="local-models">
<h3>本地模型</h3><p class="subtle">默认 CPU下载需要联网推理只读取本地权重文件校验通过不代表当前设备已完成推理验证</p> <h3>{{ t('本地模型', 'Local Models') }}</h3><p class="subtle">{{ t('默认 CPU。下载需要联网推理只读取本地权重文件校验通过不代表当前设备已完成推理验证。', 'CPU is the default. Downloads require network access; inference reads local weights only. File verification does not mean the current device passed inference validation.') }}</p>
<p v-if="error" class="error-banner" role="alert">{{ error }}</p> <p v-if="error" class="error-banner" role="alert">{{ error }}</p>
<p v-if="lastInference" class="subtle">最近实际运行{{ lastInference.actual_device || '未开始推理' }} · 请求设备 {{ lastInference.requested_device }} · 推理 {{ (lastInference.inference_seconds ?? lastInference.elapsed_seconds ?? 0).toFixed(2) }} · {{ lastInference.status }} {{ lastInference.error_code || '' }}</p> <p v-if="lastInference" class="subtle">{{ t('最近实际运行', 'Last actual run: ') }}{{ lastInference.actual_device || t('未开始推理', 'No inference yet') }} · {{ t('请求设备', 'requested device') }} {{ lastInference.requested_device }} · {{ t('推理', 'inference') }} {{ (lastInference.inference_seconds ?? lastInference.elapsed_seconds ?? 0).toFixed(2) }} {{ t('', 'sec') }} · {{ lastInference.status }} {{ lastInference.error_code || '' }}</p>
<p v-if="!installed" class="subtle">尚未安装模型运行环境在项目根目录执行 <code>./backend/scripts/install-model-runtime.ps1</code>CUDA 选装追加 <code>-Device cuda</code></p> <p v-if="!installed" class="subtle">{{ t('尚未安装模型运行环境在项目根目录执行', 'The model runtime is not installed. Run this from the project root:') }} <code>./backend/scripts/install-model-runtime.ps1</code>; {{ t('CUDA 选装追加', 'for optional CUDA, append') }} <code>-Device cuda</code>.</p>
<article class="item-card cuda-components" aria-label="CUDA 运行组件"> <article class="item-card cuda-components" :aria-label="t('CUDA 运行组件', 'CUDA runtime components')">
<h4>CUDA 运行组件可选</h4> <h4>{{ t('CUDA 运行组件(可选)', 'CUDA Runtime Components (Optional)') }}</h4>
<p class="subtle">默认使用 CPU需要 NVIDIA GPU 加速时下载此组件 3 GB安装时还需要额外磁盘空间不包含显卡驱动和模型权重</p> <p class="subtle">{{ t('默认使用 CPU。需要 NVIDIA GPU 加速时下载此组件,约 3 GB,安装时还需要额外磁盘空间不包含显卡驱动和模型权重。', 'CPU is used by default. Download this component for NVIDIA GPU acceleration. It is about 3 GB and needs extra installation space; drivers and model weights are not included.') }}</p>
<p v-if="cudaError" class="error-text" role="alert">{{ cudaError }} <button class="button-secondary" @click="loadCuda">重新检查</button></p> <p v-if="cudaError" class="error-text" role="alert">{{ cudaError }} <button class="button-secondary" @click="loadCuda">{{ t('重新检查', 'Check again') }}</button></p>
<template v-if="cuda"> <template v-if="cuda">
<p role="status">{{ cuda.stage }} {{ cuda.torch || '' }}</p> <p role="status">{{ cuda.stage }} {{ cuda.torch || '' }}</p>
<progress v-if="['checking','installing'].includes(cuda.status)" aria-label="CUDA 组件安装进度" /> <progress v-if="['checking','installing'].includes(cuda.status)" :aria-label="t('CUDA 组件安装进度', 'CUDA component installation progress')" />
<p v-if="cuda.error" class="error-text">{{ cuda.error }}</p> <p v-if="cuda.error" class="error-text">{{ cuda.error }}</p>
<p v-if="!cuda.supported" class="subtle">当前平台暂不支持页面安装请使用对应平台的模型运行环境</p> <p v-if="!cuda.supported" class="subtle">{{ t('当前平台暂不支持页面安装请使用对应平台的模型运行环境', 'This platform does not support in-app installation. Use the model runtime for your platform.') }}</p>
<button v-else-if="cuda.status !== 'installed'" class="button-primary" :disabled="busy || ['checking','installing'].includes(cuda.status)" @click="installCuda">{{ cuda.status === 'installing' ? '正在下载并安装' : ['failed','interrupted'].includes(cuda.status) ? '重试安装 CUDA 组件' : '下载并安装 CUDA 组件' }}</button> <button v-else-if="cuda.status !== 'installed'" class="button-primary" :disabled="busy || ['checking','installing'].includes(cuda.status)" @click="installCuda">{{ cuda.status === 'installing' ? t('正在下载并安装', 'Downloading and installing') : ['failed','interrupted'].includes(cuda.status) ? t('重试安装 CUDA 组件', 'Retry CUDA installation') : t('下载并安装 CUDA 组件', 'Download and install CUDA components') }}</button>
<p v-if="cuda.status === 'installed'" class="subtle">{{ cuda.cuda_available ? '组件已就绪在下方选择 CUDA 并保存即可启用' : '组件已安装但当前未检测到可用 CUDA 设备将回退 CPU' }}</p> <p v-if="cuda.status === 'installed'" class="subtle">{{ cuda.cuda_available ? t('组件已就绪在下方选择 CUDA 并保存即可启用', 'Components are ready. Select CUDA below and save to enable it.') : t('组件已安装但当前未检测到可用 CUDA 设备将回退 CPU', 'Components are installed, but no CUDA device is available; CPU fallback will be used.') }}</p>
<p v-if="cuda.custom_interpreter" class="subtle">当前后端设置了 APP_MODEL_PYTHON优先使用指定环境要使用页面安装的组件请移除该覆盖并重启后端</p> <p v-if="cuda.custom_interpreter" class="subtle">{{ t('当前后端设置了 APP_MODEL_PYTHON优先使用指定环境要使用页面安装的组件请移除该覆盖并重启后端', 'APP_MODEL_PYTHON is set and takes priority. Remove the override and restart the backend to use components installed from this page.') }}</p>
</template> </template>
</article> </article>
<form v-if="config" @submit.prevent="save" @input="dirty = true" @change="dirty = true"> <form v-if="config" @submit.prevent="save" @input="dirty = true" @change="dirty = true">
<div class="runtime-grid"><label>请求设备<select v-model="config.device" class="select"><option value="cpu">CPU(默认)</option><option value="cuda">CUDA不可用则 CPU</option></select></label> <div class="runtime-grid"><label>{{ t('请求设备', 'Requested device') }}<select v-model="config.device" class="select"><option value="cpu">{{ t('CPU(默认)', 'CPU (default)') }}</option><option value="cuda">{{ t('CUDA不可用则 CPU', 'CUDA (CPU fallback)') }}</option></select></label>
<label>Embedding<select v-model="config.embedding_model" class="select"><option value="bekko">Bekko A8M</option><option value="granite">Granite 97M 多语言</option></select></label> <label>Embedding<select v-model="config.embedding_model" class="select"><option value="bekko">Bekko A8M</option><option value="granite">Granite 97M {{ t('多语言', 'Multilingual') }}</option></select></label>
<label>CPU 线程<input v-model.number="config.cpu_threads" class="input" type="number" min="1" max="32" /></label> <label>{{ t('CPU 线程', 'CPU threads') }}<input v-model.number="config.cpu_threads" class="input" type="number" min="1" max="32" /></label>
<label>内存预算 MiB<input v-model.number="config.memory_limit_mb" class="input" type="number" min="1024" max="131072" /></label> <label>{{ t('内存预算 MiB', 'Memory budget MiB') }}<input v-model.number="config.memory_limit_mb" class="input" type="number" min="1024" max="131072" /></label>
<label>显存预算 MiB<input v-model.number="config.gpu_memory_limit_mb" class="input" type="number" min="512" max="65536" /></label></div> <label>{{ t('显存预算 MiB', 'GPU memory budget MiB') }}<input v-model.number="config.gpu_memory_limit_mb" class="input" type="number" min="512" max="65536" /></label></div>
<p class="subtle">修改 Embedding 后需要重建索引任务按预算串行运行模型在任务结束后释放</p><button class="button-primary" :disabled="busy || !dirty">保存运行设置</button> <p class="subtle">{{ t('修改 Embedding 后需要重建索引任务按预算串行运行模型在任务结束后释放。', 'Changing the embedding model requires rebuilding the index. Jobs run serially within the resource budget, and models are released when each job finishes.') }}</p><button class="button-primary" :disabled="busy || !dirty">{{ t('保存运行设置', 'Save runtime settings') }}</button>
</form> </form>
<div class="model-grid"><article v-for="model in items" :key="model.key" class="item-card"><h4>{{ model.name }}</h4><p>{{ model.license }} · {{ labels[model.status] || model.status }}</p><small :title="model.revision">版本 {{ model.revision.slice(0,12) }}</small> <div class="model-grid"><article v-for="model in items" :key="model.key" class="item-card"><h4>{{ model.name }}</h4><p>{{ model.license }} · {{ labels[model.status] || model.status }}</p><small :title="model.revision">{{ t('版本', 'Revision') }} {{ model.revision.slice(0,12) }}</small>
<p>实际磁盘占用 {{ size(model.disk_bytes) }}</p><p>{{ size(model.downloaded_bytes) }} / {{ size(model.total_bytes) }}</p><progress v-if="model.status === 'downloading' && model.total_bytes" :value="model.downloaded_bytes" :max="model.total_bytes" /> <p>{{ t('实际磁盘占用', 'Disk usage') }} {{ size(model.disk_bytes) }}</p><p>{{ size(model.downloaded_bytes) }} / {{ size(model.total_bytes) }}</p><progress v-if="model.status === 'downloading' && model.total_bytes" :value="model.downloaded_bytes" :max="model.total_bytes" />
<p v-if="model.error_code" class="error-text">{{ model.error_code }}</p><div class="inline-actions"> <p v-if="model.error_code" class="error-text">{{ model.error_code }}</p><div class="inline-actions">
<button v-if="model.status !== 'installed' && model.status !== 'downloading'" class="button-secondary" :disabled="busy" @click="act(() => apiClient.post(`/api/local-models/${model.key}/download`))">{{ model.status === 'not_installed' ? '下载模型' : '重试 / 续传' }}</button> <button v-if="model.status !== 'installed' && model.status !== 'downloading'" class="button-secondary" :disabled="busy" @click="act(() => apiClient.post(`/api/local-models/${model.key}/download`))">{{ model.status === 'not_installed' ? t('下载模型', 'Download model') : t('重试 / 续传', 'Retry / Resume') }}</button>
<button v-if="model.status === 'downloading'" class="button-secondary" :disabled="busy" @click="act(() => apiClient.post(`/api/local-models/${model.key}/cancel`))">暂停</button> <button v-if="model.status === 'downloading'" class="button-secondary" :disabled="busy" @click="act(() => apiClient.post(`/api/local-models/${model.key}/cancel`))">{{ t('暂停', 'Pause') }}</button>
<button v-if="model.status !== 'not_installed'" class="button-danger" :disabled="busy" @click="act(() => apiClient.delete(`/api/local-models/${model.key}`))">删除权重</button></div> <button v-if="model.status !== 'not_installed'" class="button-danger" :disabled="busy" @click="act(() => apiClient.delete(`/api/local-models/${model.key}`))">{{ t('删除权重', 'Delete weights') }}</button></div>
</article></div><button class="button-secondary" @click="diagnostics">导出最近运行诊断</button><p class="subtle">诊断仅包含模型设备耗时和资源信息不包含正文音频和密钥</p> </article></div><button class="button-secondary" @click="diagnostics">{{ t('导出最近运行诊断', 'Export recent runtime diagnostics') }}</button><p class="subtle">{{ t('诊断仅包含模型、设备、耗时和资源信息,不包含正文、音频和密钥。', 'Diagnostics include only model, device, timing, and resource data. Note content, audio, and secrets are excluded.') }}</p>
</section> </section>
</template> </template>
<style scoped>.local-models{display:grid;gap:16px}.runtime-grid,.model-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}label{display:grid;gap:6px}.item-card{padding:16px}progress{width:100%}</style> <style scoped>.local-models{display:grid;gap:16px}.runtime-grid,.model-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}label{display:grid;gap:6px}.item-card{padding:16px}progress{width:100%}</style>
@@ -4,14 +4,15 @@ import type { ModelBinding, ModelRoutingConfig, ModelRoutingResponse, ProviderCo
import { getModelRouting, saveModelRouting } from '@/services/modelRoutingService' import { getModelRouting, saveModelRouting } from '@/services/modelRoutingService'
import { listProviders } from '@/services/providerService' import { listProviders } from '@/services/providerService'
import { ApiErrorClass } from '@/services/apiClient' import { ApiErrorClass } from '@/services/apiClient'
import { t } from '@/i18n'
const capabilities: Array<{ id: RoutingCapability; name: string; endpoint: string; placeholder: string; local: string }> = [ const capabilities = computed<Array<{ id: RoutingCapability; name: string; endpoint: string; placeholder: string; local: string }>>(() => [
{ id: 'embedding', name: '向量嵌入 · Embedding', endpoint: '/embeddings', placeholder: '例如 text-embedding-3-small', local: '本地支持 Bekko / Granite,安装权重后可离线运行。' }, { id: 'embedding', name: t('向量嵌入 · Embedding', 'Embedding'), endpoint: '/embeddings', placeholder: t('例如 text-embedding-3-small', 'For example, text-embedding-3-small'), local: t('本地支持 Bekko / Granite,安装权重后可离线运行。', 'Local Bekko / Granite can run offline after weights are installed.') },
{ id: 'transcription', name: '语音转写 · Transcription', endpoint: '/audio/transcriptions', placeholder: '输入转写模型 ID', local: '本地采用 Qwen3-ASR 0.6B,默认 CPU。' }, { id: 'transcription', name: t('语音转写 · Transcription', 'Transcription'), endpoint: '/audio/transcriptions', placeholder: t('输入转写模型 ID', 'Enter a transcription model ID'), local: t('本地采用 Qwen3-ASR 0.6B,默认 CPU。', 'Local Qwen3-ASR 0.6B uses CPU by default.') },
{ id: 'speaker_matching', name: '说话人匹配 · Speaker matching', endpoint: '/audio/speaker-matches', placeholder: '输入说话人匹配模型 ID', local: '本地采用 ERes2NetV2,比对结果是相似度。' }, { id: 'speaker_matching', name: t('说话人匹配 · Speaker matching', 'Speaker matching'), endpoint: '/audio/speaker-matches', placeholder: t('输入说话人匹配模型 ID', 'Enter a speaker matching model ID'), local: t('本地采用 ERes2NetV2,比对结果是相似度。', 'Local ERes2NetV2 returns a similarity score.') },
] ])
type Draft = { provider_id: string; model: string; endpoint: string; dimensions: string | number } type Draft = { provider_id: string; model: string; endpoint: string; dimensions: string | number }
const drafts = reactive(Object.fromEntries(capabilities.map(item => [item.id, { provider_id: '', model: '', endpoint: item.endpoint, dimensions: '' }])) as Record<RoutingCapability, Draft>) const drafts = reactive(Object.fromEntries(capabilities.value.map(item => [item.id, { provider_id: '', model: '', endpoint: item.endpoint, dimensions: '' }])) as Record<RoutingCapability, Draft>)
const providers = ref<ProviderConfig[]>([]) const providers = ref<ProviderConfig[]>([])
const response = ref<ModelRoutingResponse | null>(null) const response = ref<ModelRoutingResponse | null>(null)
const loading = ref(false) const loading = ref(false)
@@ -26,7 +27,7 @@ const unavailable = computed(() => providers.value.filter(provider => !eligible(
const localBackend = (capability: RoutingCapability) => response.value?.local_backends.find(item => item.capability === capability) const localBackend = (capability: RoutingCapability) => response.value?.local_backends.find(item => item.capability === capability)
const localLabel = (capability: RoutingCapability) => { const localLabel = (capability: RoutingCapability) => {
const status = localBackend(capability)?.status const status = localBackend(capability)?.status
return status === 'ready' ? '已安装' : status === 'placeholder' ? '测试占位实现' : '未安装' return status === 'ready' ? t('已安装', 'Installed') : status === 'placeholder' ? t('测试占位实现', 'Test placeholder') : t('未安装', 'Not installed')
} }
const protocols = [ const protocols = [
{ id: 'openai_chat', label: 'OpenAI Chat' }, { id: 'openai_compatible', label: 'OpenAI Compatible' }, { id: 'openai_chat', label: 'OpenAI Chat' }, { id: 'openai_compatible', label: 'OpenAI Compatible' },
@@ -35,7 +36,7 @@ const protocols = [
function applyResponse(result: ModelRoutingResponse) { function applyResponse(result: ModelRoutingResponse) {
response.value = result response.value = result
for (const item of capabilities) { for (const item of capabilities.value) {
const binding = result.config[item.id] const binding = result.config[item.id]
Object.assign(drafts[item.id], { provider_id: binding?.provider_id ?? '', model: binding?.model ?? '', endpoint: binding?.endpoint ?? item.endpoint, dimensions: binding?.dimensions?.toString() ?? '' }) Object.assign(drafts[item.id], { provider_id: binding?.provider_id ?? '', model: binding?.model ?? '', endpoint: binding?.endpoint ?? item.endpoint, dimensions: binding?.dimensions?.toString() ?? '' })
} }
@@ -53,7 +54,7 @@ async function load() {
applyResponse(routing) applyResponse(routing)
conflict.value = false conflict.value = false
} catch (reason) { } catch (reason) {
if (active) error.value = `加载失败:${reason instanceof Error ? reason.message : '无法读取模型路由或提供商'}` if (active) error.value = `${t('加载失败:', 'Load failed: ')}${reason instanceof Error ? reason.message : t('无法读取模型路由或提供商', 'Could not read model routes or providers')}`
} finally { loading.value = false } } finally { loading.value = false }
} }
@@ -64,20 +65,20 @@ function changeProvider(capability: RoutingCapability) {
const draft = drafts[capability] const draft = drafts[capability]
draft.model = '' draft.model = ''
draft.dimensions = '' draft.dimensions = ''
draft.endpoint = capabilities.find(item => item.id === capability)!.endpoint draft.endpoint = capabilities.value.find(item => item.id === capability)!.endpoint
saved.value = false saved.value = false
} }
function bindingFor(capability: RoutingCapability): ModelBinding | null { function bindingFor(capability: RoutingCapability): ModelBinding | null {
const draft = drafts[capability] const draft = drafts[capability]
if (!draft.provider_id) return null if (!draft.provider_id) return null
if (!available.value.some(provider => provider.provider_id === draft.provider_id)) throw new Error('请选择已启用且协议可用的提供商,或切换到本地。') if (!available.value.some(provider => provider.provider_id === draft.provider_id)) throw new Error(t('请选择已启用且协议可用的提供商,或切换到本地。', 'Select an enabled provider with a supported protocol, or switch to local.'))
if (!draft.model.trim()) throw new Error('请填写所选 API 的模型 ID。') if (!draft.model.trim()) throw new Error(t('请填写所选 API 的模型 ID。', 'Enter the model ID for the selected API.'))
if (!/^\/[A-Za-z0-9_/-]+$/.test(draft.endpoint) || draft.endpoint.startsWith('//')) throw new Error('Endpoint 必须是以 / 开头的相对路径,只能包含字母、数字、下划线、连字符和 /。') if (!/^\/[A-Za-z0-9_/-]+$/.test(draft.endpoint) || draft.endpoint.startsWith('//')) throw new Error(t('Endpoint 必须是以 / 开头的相对路径,只能包含字母、数字、下划线、连字符和 /。', 'Endpoint must be a relative path beginning with / and containing only letters, numbers, underscores, hyphens, and /.'))
const binding: ModelBinding = { provider_id: draft.provider_id, model: draft.model.trim(), endpoint: draft.endpoint } const binding: ModelBinding = { provider_id: draft.provider_id, model: draft.model.trim(), endpoint: draft.endpoint }
if (capability === 'embedding') { if (capability === 'embedding') {
const dimension = String(draft.dimensions).trim() const dimension = String(draft.dimensions).trim()
if (dimension && (!/^\d+$/.test(dimension) || !Number.isSafeInteger(Number(dimension)) || Number(dimension) < 1 || Number(dimension) > 16384)) throw new Error('嵌入维度必须为 1–16384 的整数,或留空使用 API 默认值。') if (dimension && (!/^\d+$/.test(dimension) || !Number.isSafeInteger(Number(dimension)) || Number(dimension) < 1 || Number(dimension) > 16384)) throw new Error(t('嵌入维度必须为 1–16384 的整数,或留空使用 API 默认值。', 'Embedding dimensions must be an integer from 1 to 16384, or blank to use the API default.'))
binding.dimensions = dimension ? Number(dimension) : null binding.dimensions = dimension ? Number(dimension) : null
} }
return binding return binding
@@ -99,47 +100,47 @@ async function save() {
if (!active) return if (!active) return
conflict.value = reason instanceof ApiErrorClass && /CONFLICT|VERSION|HTTP_409/i.test(reason.code) conflict.value = reason instanceof ApiErrorClass && /CONFLICT|VERSION|HTTP_409/i.test(reason.code)
error.value = conflict.value error.value = conflict.value
? '配置版本冲突:其他窗口已修改路由。当前输入尚未保存,请重新加载最新配置后再编辑。' ? t('配置版本冲突:其他窗口已修改路由。当前输入尚未保存,请重新加载最新配置后再编辑。', 'Configuration conflict: another window changed these routes. Your input is unsaved; reload the latest settings before editing.')
: `保存失败:${reason instanceof Error ? reason.message : '请重试'}` : `${t('保存失败:', 'Save failed: ')}${reason instanceof Error ? reason.message : t('请重试', 'please retry')}`
} finally { saving.value = false } } finally { saving.value = false }
} }
</script> </script>
<template> <template>
<section class="routing-settings" aria-labelledby="routing-title" :aria-busy="loading || saving"> <section class="routing-settings" aria-labelledby="routing-title" :aria-busy="loading || saving">
<div><h2 id="routing-title">能力模型路由</h2><p class="subtle">向量嵌入语音转写和说话人匹配分别选择提供商与模型独立于默认聊天模型API Key 模型提供商中管理</p></div> <div><h2 id="routing-title">{{ t('能力模型路由', 'Capability model routing') }}</h2><p class="subtle">{{ t('向量嵌入、语音转写和说话人匹配分别选择提供商与模型独立于默认聊天模型。API Key 在「模型提供商」中管理。', 'Choose providers and models separately for embeddings, transcription, and speaker matching. API keys are managed under Model Providers.') }}</p></div>
<p class="subtle">未选择提供商即使用本地模型API 请求失败配置不可用或响应无效时回退到本地使用前请下载对应权重并安装运行环境</p> <p class="subtle">{{ t('未选择提供商即使用本地模型。API 请求失败、配置不可用或响应无效时回退到本地使用前请下载对应权重并安装运行环境。', 'With no provider selected, the local model is used. Failed API requests, invalid settings, or invalid responses fall back to local. Download the required weights and runtime first.') }}</p>
<p v-if="loading" role="status">正在加载模型路由</p> <p v-if="loading" role="status">{{ t('正在加载模型路由', 'Loading model routes') }}</p>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div> <div v-if="error" class="error-banner" role="alert">{{ error }}</div>
<div class="inline-actions"><button type="button" class="button-secondary" :disabled="loading || saving" @click="load">{{ conflict ? '放弃当前输入并加载最新配置' : response ? '重新加载放弃未保存更改' : '重试加载' }}</button><span v-if="response" class="subtle">配置版本 {{ response.config.version }}</span></div> <div class="inline-actions"><button type="button" class="button-secondary" :disabled="loading || saving" @click="load">{{ conflict ? t('放弃当前输入并加载最新配置', 'Discard input and load latest settings') : response ? t('重新加载放弃未保存更改', 'Reload (discard unsaved changes)') : t('重试加载', 'Retry loading') }}</button><span v-if="response" class="subtle">{{ t('配置版本', 'Configuration version') }} {{ response.config.version }}</span></div>
<form v-if="response" @submit.prevent="save" @input="saved = false" @change="saved = false"> <form v-if="response" @submit.prevent="save" @input="saved = false" @change="saved = false">
<fieldset :disabled="loading || saving || conflict"> <fieldset :disabled="loading || saving || conflict">
<article v-for="capability in capabilities" :key="capability.id" class="routing-card" :data-capability="capability.id"> <article v-for="capability in capabilities" :key="capability.id" class="routing-card" :data-capability="capability.id">
<h3>{{ capability.name }}</h3> <h3>{{ capability.name }}</h3>
<p v-if="capability.id === 'embedding'" class="embedding-notice">保存配置或更换模型接口后请重建全部索引配置成功不代表已有笔记的向量索引已更新重建完成前可使用全文检索混合检索会回退到全文检索</p> <p v-if="capability.id === 'embedding'" class="embedding-notice">{{ t('保存配置或更换模型接口后请重建全部索引配置成功不代表已有笔记的向量索引已更新重建完成前可使用全文检索混合检索会回退到全文检索', 'Rebuild all indexes after saving or changing the model or endpoint. Saving settings does not update existing note vectors. Full-text search remains available, and hybrid search falls back to it until rebuilding completes.') }}</p>
<div class="protocols" aria-label="协议可用性"> <div class="protocols" :aria-label="t('协议可用性', 'Protocol availability')">
<span v-for="protocol in protocols" :key="protocol.id" class="badge" :class="{ 'protocol-unavailable': !['openai_chat', 'openai_compatible'].includes(protocol.id) }">{{ protocol.label }}{{ ['openai_chat', 'openai_compatible'].includes(protocol.id) ? ' · 可用' : ' · 不可用' }}</span> <span v-for="protocol in protocols" :key="protocol.id" class="badge" :class="{ 'protocol-unavailable': !['openai_chat', 'openai_compatible'].includes(protocol.id) }">{{ protocol.label }}{{ ['openai_chat', 'openai_compatible'].includes(protocol.id) ? t(' · 可用', ' · Available') : t(' · 不可用', ' · Unavailable') }}</span>
</div> </div>
<label class="field"><span>处理方式 / 提供商</span><select v-model="drafts[capability.id].provider_id" class="select" data-field="provider" @change="changeProvider(capability.id)"> <label class="field"><span>{{ t('处理方式 / 提供商', 'Processing / Provider') }}</span><select v-model="drafts[capability.id].provider_id" class="select" data-field="provider" @change="changeProvider(capability.id)">
<option value="">本地 · {{ localLabel(capability.id) }}</option> <option value="">{{ t('本地', 'Local') }} · {{ localLabel(capability.id) }}</option>
<option v-for="provider in available" :key="provider.provider_id" :value="provider.provider_id">{{ provider.name }} · {{ provider.provider_type }}</option> <option v-for="provider in available" :key="provider.provider_id" :value="provider.provider_id">{{ provider.name }} · {{ provider.provider_type }}</option>
<option v-for="provider in unavailable" :key="provider.provider_id" :value="provider.provider_id" disabled>{{ provider.name }} · {{ provider.enabled ? '协议不可用' : '未启用' }}</option> <option v-for="provider in unavailable" :key="provider.provider_id" :value="provider.provider_id" disabled>{{ provider.name }} · {{ provider.enabled ? t('协议不可用', 'Protocol unavailable') : t('未启用', 'Disabled') }}</option>
<option v-if="drafts[capability.id].provider_id && !providers.some(provider => provider.provider_id === drafts[capability.id].provider_id)" :value="drafts[capability.id].provider_id" disabled>原提供商已不可用 · {{ drafts[capability.id].provider_id }}</option> <option v-if="drafts[capability.id].provider_id && !providers.some(provider => provider.provider_id === drafts[capability.id].provider_id)" :value="drafts[capability.id].provider_id" disabled>{{ t('原提供商已不可用', 'Previous provider is unavailable') }} · {{ drafts[capability.id].provider_id }}</option>
</select></label> </select></label>
<div v-if="drafts[capability.id].provider_id" class="routing-fields"> <div v-if="drafts[capability.id].provider_id" class="routing-fields">
<label class="field"><span>模型 ID</span><input v-model="drafts[capability.id].model" class="input" data-field="model" :placeholder="capability.placeholder" maxlength="256" required /></label> <label class="field"><span>{{ t('模型 ID', 'Model ID') }}</span><input v-model="drafts[capability.id].model" class="input" data-field="model" :placeholder="capability.placeholder" maxlength="256" required /></label>
<label class="field"><span>Endpoint相对 Base URL</span><input v-model="drafts[capability.id].endpoint" class="input" data-field="endpoint" :placeholder="capability.endpoint" maxlength="256" required /></label> <label class="field"><span>{{ t('Endpoint(相对 Base URL', 'Endpoint (relative to Base URL)') }}</span><input v-model="drafts[capability.id].endpoint" class="input" data-field="endpoint" :placeholder="capability.endpoint" maxlength="256" required /></label>
<label v-if="capability.id === 'embedding'" class="field"><span>向量维度(可选)</span><input v-model="drafts.embedding.dimensions" class="input" data-field="dimensions" type="number" min="1" max="16384" step="1" placeholder="留空使用 API 默认维度" /><small class="subtle">填写模型支持的 116384 整数维度或留空使用 API 默认值</small></label> <label v-if="capability.id === 'embedding'" class="field"><span>{{ t('向量维度(可选)', 'Vector dimensions (optional)') }}</span><input v-model="drafts.embedding.dimensions" class="input" data-field="dimensions" type="number" min="1" max="16384" step="1" :placeholder="t('留空使用 API 默认维度', 'Blank uses the API default')" /><small class="subtle">{{ t('填写模型支持的 116384 整数维度或留空使用 API 默认值', 'Enter an integer from 1 to 16384 supported by the model, or leave blank for the API default.') }}</small></label>
</div> </div>
<p v-if="capability.id === 'speaker_matching'" class="subtle">说话人匹配使用本应用自定义 HTTP multipart 契约该端点不是 OpenAI 标准接口服务需实现对应的说话人匹配请求和响应</p> <p v-if="capability.id === 'speaker_matching'" class="subtle">{{ t('说话人匹配使用本应用自定义 HTTP multipart 契约该端点不是 OpenAI 标准接口服务需实现对应的说话人匹配请求和响应', 'Speaker matching uses this apps custom HTTP multipart contract. It is not an OpenAI-standard endpoint; the service must implement the corresponding request and response.') }}</p>
<div class="local-status" :class="{ selected: !drafts[capability.id].provider_id }"> <div class="local-status" :class="{ selected: !drafts[capability.id].provider_id }">
<strong>{{ drafts[capability.id].provider_id ? '本地回退状态' : '当前本地状态' }}</strong> <strong>{{ drafts[capability.id].provider_id ? t('本地回退状态', 'Local fallback status') : t('当前本地状态', 'Current local status') }}</strong>
<p>{{ localBackend(capability.id)?.status === 'ready' ? '本地后端已就绪。' : capability.local }}</p> <p>{{ localBackend(capability.id)?.status === 'ready' ? t('本地后端已就绪。', 'The local backend is ready.') : capability.local }}</p>
<p v-for="backend in response.local_backends.filter(item => item.capability === capability.id)" :key="backend.capability" class="subtle"><span class="badge">{{ backend.status === 'ready' ? '已就绪' : backend.status === 'placeholder' ? '占位实现' : '未安装 / 未接入' }}</span> {{ backend.message }}</p> <p v-for="backend in response.local_backends.filter(item => item.capability === capability.id)" :key="backend.capability" class="subtle"><span class="badge">{{ backend.status === 'ready' ? t('已就绪', 'Ready') : backend.status === 'placeholder' ? t('占位实现', 'Placeholder') : t('未安装 / 未接入', 'Not installed / connected') }}</span> {{ backend.message }}</p>
</div> </div>
</article> </article>
</fieldset> </fieldset>
<div class="inline-actions"><button type="submit" class="button-primary" :disabled="loading || saving || conflict">{{ saving ? '保存中…' : '保存模型路由' }}</button><span v-if="saved" role="status">模型路由已保存</span></div> <div class="inline-actions"><button type="submit" class="button-primary" :disabled="loading || saving || conflict">{{ saving ? t('保存中…', 'Saving…') : t('保存模型路由', 'Save model routes') }}</button><span v-if="saved" role="status">{{ t('模型路由已保存', 'Model routes saved.') }}</span></div>
</form> </form>
</section> </section>
</template> </template>
+26 -25
View File
@@ -5,6 +5,7 @@ import * as service from '@/services/providerService'
import ProviderPresetSelector from './ProviderPresetSelector.vue' import ProviderPresetSelector from './ProviderPresetSelector.vue'
import RequestJsonEditor from './RequestJsonEditor.vue' import RequestJsonEditor from './RequestJsonEditor.vue'
import { apiClient } from '@/services/apiClient' import { apiClient } from '@/services/apiClient'
import { t } from '@/i18n'
const props = defineProps<{ provider?: ProviderConfig; models?: ModelInfo[] }>() const props = defineProps<{ provider?: ProviderConfig; models?: ModelInfo[] }>()
const emit = defineEmits<{ close: []; saved: [provider: ProviderConfig] }>() const emit = defineEmits<{ close: []; saved: [provider: ProviderConfig] }>()
@@ -37,9 +38,9 @@ async function previewRequest() {
const generation = draftGeneration const generation = draftGeneration
error.value = '' error.value = ''
try { try {
if (!requestJsonValid.value) throw new Error('请先修正 JSON。') if (!requestJsonValid.value) throw new Error(t('请先修正 JSON。', 'Fix the JSON first.'))
const response = await apiClient.post<{body:Record<string,unknown>}>('/api/providers/request-preview', { const response = await apiClient.post<{body:Record<string,unknown>}>('/api/providers/request-preview', {
provider: {provider_type:form.provider_type,name:form.name || '预览',base_url:form.base_url || null, provider: {provider_type:form.provider_type,name:form.name || t('预览', 'Preview'),base_url:form.base_url || null,
default_model:form.default_model || null,request_overrides:requestOverrides.value}, stream:previewStream.value, capability:previewCapability.value, default_model:form.default_model || null,request_overrides:requestOverrides.value}, stream:previewStream.value, capability:previewCapability.value,
}) })
if (active && generation === draftGeneration) requestPreview.value = JSON.stringify(response.body, null, 2) if (active && generation === draftGeneration) requestPreview.value = JSON.stringify(response.body, null, 2)
@@ -50,10 +51,10 @@ async function probeRequest() {
error.value = ''; probeResult.value = ''; probing.value = true error.value = ''; probeResult.value = ''; probing.value = true
const generation = draftGeneration const generation = draftGeneration
try { try {
if (!requestJsonValid.value) throw new Error('请先修正 JSON。') if (!requestJsonValid.value) throw new Error(t('请先修正 JSON。', 'Fix the JSON first.'))
if (apiKey.value.trim()) throw new Error('请先保存新的 API Key,再进行推理验证。') if (apiKey.value.trim()) throw new Error(t('请先保存新的 API Key,再进行推理验证。', 'Save the new API key before testing inference.'))
const result = await apiClient.post<{message:string}>('/api/providers/request-probe', { const result = await apiClient.post<{message:string}>('/api/providers/request-probe', {
provider: {provider_type:form.provider_type,name:form.name || '推理验证',base_url:form.base_url || null, provider: {provider_type:form.provider_type,name:form.name || t('推理验证', 'Inference test'),base_url:form.base_url || null,
default_model:form.default_model || null,request_overrides:JSON.parse(JSON.stringify(requestOverrides.value)), default_model:form.default_model || null,request_overrides:JSON.parse(JSON.stringify(requestOverrides.value)),
credential_id:configured.value ? credentialId.value : null}, stream:previewStream.value, credential_id:configured.value ? credentialId.value : null}, stream:previewStream.value,
}) })
@@ -75,7 +76,7 @@ async function loadPresets() {
try { try {
presets.value = await service.listProviderPresets() presets.value = await service.listProviderPresets()
if (!contextChanged.value) form.preset_id = presets.value.find(preset => preset.provider_type === props.provider?.provider_type && preset.base_url === props.provider?.base_url)?.preset_id ?? '' if (!contextChanged.value) form.preset_id = presets.value.find(preset => preset.provider_type === props.provider?.provider_type && preset.base_url === props.provider?.base_url)?.preset_id ?? ''
} catch { presetsError.value = '预设加载失败,请重试,或填写自定义服务。' } } catch { presetsError.value = t('预设加载失败,请重试,或填写自定义服务。', 'Preset loading failed. Retry or enter a custom service.') }
finally { presetsLoading.value = false } finally { presetsLoading.value = false }
} }
@@ -88,7 +89,7 @@ onMounted(async () => {
const result = await service.getCredentialStatus(credentialId.value) const result = await service.getCredentialStatus(credentialId.value)
if (active && generation === credentialGeneration) configured.value = result if (active && generation === credentialGeneration) configured.value = result
} catch { } catch {
if (active && generation === credentialGeneration) credentialError.value = '无法检查已保存的凭据。可输入新密钥,或关闭后重试。' if (active && generation === credentialGeneration) credentialError.value = t('无法检查已保存的凭据。可输入新密钥,或关闭后重试。', 'Could not check the saved credential. Enter a new key or close and retry.')
} finally { } finally {
if (generation === credentialGeneration) credentialLoading.value = false if (generation === credentialGeneration) credentialLoading.value = false
} }
@@ -148,9 +149,9 @@ async function save() {
error.value = '' error.value = ''
saving.value = true saving.value = true
try { try {
if (!form.name.trim() || !form.base_url.trim()) throw new Error('请填写名称和 Base URL。') if (!form.name.trim() || !form.base_url.trim()) throw new Error(t('请填写名称和 Base URL。', 'Enter a name and Base URL.'))
if (!requestJsonValid.value) throw new Error('请先修正自定义请求 JSON。') if (!requestJsonValid.value) throw new Error(t('请先修正自定义请求 JSON。', 'Fix the custom request JSON first.'))
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(t('请输入 API Key。密钥将由后端加密保存。', 'Enter an API key. It will be encrypted by the backend.'))
// 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, request_overrides: requestOverrides.value } 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, request_overrides: requestOverrides.value }
if (apiKey.value.trim()) { if (apiKey.value.trim()) {
@@ -171,7 +172,7 @@ async function save() {
: await service.createProvider({ ...data, credential_id: reference }) : await service.createProvider({ ...data, credential_id: reference })
if (active) { emit('saved', saved); close() } if (active) { emit('saved', saved); close() }
} catch (reason) { } catch (reason) {
if (active) error.value = reason instanceof Error ? reason.message : 'Provider 保存失败,请重试。' if (active) error.value = reason instanceof Error ? reason.message : t('Provider 保存失败,请重试。', 'Provider save failed. Please retry.')
} finally { apiKey.value = ''; saving.value = false } } finally { apiKey.value = ''; saving.value = false }
} }
</script> </script>
@@ -179,32 +180,32 @@ async function save() {
<template> <template>
<div class="modal-backdrop provider-backdrop" @click.self="close" @keydown="handleKeydown"> <div class="modal-backdrop provider-backdrop" @click.self="close" @keydown="handleKeydown">
<div ref="dialog" class="modal provider-modal" role="dialog" aria-modal="true" aria-labelledby="provider-form-title" :aria-busy="saving"> <div ref="dialog" class="modal provider-modal" role="dialog" aria-modal="true" aria-labelledby="provider-form-title" :aria-busy="saving">
<div class="form-heading"><h2 id="provider-form-title">{{ provider ? '编辑 Provider' : '新增 Provider' }}</h2><button type="button" class="button-secondary" aria-label="关闭提供商表单" @click="close">关闭</button></div> <div class="form-heading"><h2 id="provider-form-title">{{ provider ? t('编辑 Provider', 'Edit Provider') : t('新增 Provider', 'Add Provider') }}</h2><button type="button" class="button-secondary" :aria-label="t('关闭提供商表单', 'Close provider form')" @click="close">{{ t('关闭', 'Close') }}</button></div>
<p v-if="presetsLoading" class="subtle" role="status">正在加载提供商预设</p> <p v-if="presetsLoading" class="subtle" role="status">{{ t('正在加载提供商预设', 'Loading provider presets') }}</p>
<div v-if="presetsError" class="error-banner" role="alert">{{ presetsError }} <button type="button" class="button-secondary" :disabled="presetsLoading || saving" @click="loadPresets">重试</button></div> <div v-if="presetsError" class="error-banner" role="alert">{{ presetsError }} <button type="button" class="button-secondary" :disabled="presetsLoading || saving" @click="loadPresets">{{ t('重试', 'Retry') }}</button></div>
<form @submit.prevent="save" @input="requestPreview = ''" @change="requestPreview = ''"> <form @submit.prevent="save" @input="requestPreview = ''" @change="requestPreview = ''">
<fieldset :disabled="saving"> <fieldset :disabled="saving">
<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></select></label> <label class="field"><span>{{ t('接入协议', 'Protocol') }}</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>{{ t('名称', 'Name') }}</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 @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 ? t('已配置,留空表示不修改', 'Configured; leave blank to keep it') : t('请输入 API Key(无鉴权服务可留空)', 'Enter an API key (optional for unauthenticated services)')" /><small class="subtle">{{ t('密钥由本地 AI Core 加密保存提供商配置仅保存独立的凭据引用', 'The local AI Core encrypts the key; provider settings store only its credential reference.') }}</small></label>
<p v-if="credentialLoading" class="subtle wide" role="status">正在检查凭据状态</p> <p v-if="credentialLoading" class="subtle wide" role="status">{{ t('正在检查凭据状态', 'Checking credential 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>
<label class="field wide"><span>默认聊天模型</span><input v-model="form.default_model" class="input" data-field="model" list="provider-model-options" placeholder="输入模型 ID,或保存后获取模型列表" /><datalist id="provider-model-options"><option v-for="model in modelOptions" :key="model.model_id" :value="model.model_id">{{ model.name }}</option></datalist></label> <label class="field wide"><span>{{ t('默认聊天模型', 'Default chat model') }}</span><input v-model="form.default_model" class="input" data-field="model" list="provider-model-options" :placeholder="t('输入模型 ID,或保存后获取模型列表', 'Enter a model ID, or save to fetch the model list')" /><datalist id="provider-model-options"><option v-for="model in modelOptions" :key="model.model_id" :value="model.model_id">{{ model.name }}</option></datalist></label>
</div> </div>
<label class="inline-actions"><input v-model="form.enabled" type="checkbox" /> 启用</label> <label class="inline-actions"><input v-model="form.enabled" type="checkbox" /> {{ t('启用', 'Enabled') }}</label>
<RequestJsonEditor v-model="requestOverrides" @valid="requestJsonValid = $event" /> <RequestJsonEditor v-model="requestOverrides" @valid="requestJsonValid = $event" />
<div class="inline-actions"><label>预览能力<select v-model="previewCapability" class="select"><option value="chat">聊天</option><option value="embedding">Embedding</option><option value="transcription">转写</option><option value="speaker_matching">声纹</option></select></label><label><input v-model="previewStream" type="checkbox" />流式聊天</label></div> <div class="inline-actions"><label>{{ t('预览能力', 'Preview capability') }}<select v-model="previewCapability" class="select"><option value="chat">{{ t('聊天', 'Chat') }}</option><option value="embedding">Embedding</option><option value="transcription">{{ t('转写', 'Transcription') }}</option><option value="speaker_matching">{{ t('声纹', 'Speaker') }}</option></select></label><label><input v-model="previewStream" type="checkbox" />{{ t('流式聊天', 'Streaming chat') }}</label></div>
<button type="button" class="button-secondary" @click="previewRequest">预览最终请求隐藏正文</button> <button type="button" class="button-secondary" @click="previewRequest">{{ t('预览最终请求隐藏正文', 'Preview final request (content hidden)') }}</button>
<button v-if="previewCapability === 'chat'" type="button" class="button-secondary" :disabled="probing || credentialLoading || !requestJsonValid" @click="probeRequest">{{ probing ? '推理验证中' : '发送测试推理请求' }}</button> <button v-if="previewCapability === 'chat'" type="button" class="button-secondary" :disabled="probing || credentialLoading || !requestJsonValid" @click="probeRequest">{{ probing ? t('推理验证中', 'Testing inference') : t('发送测试推理请求', 'Send test inference request') }}</button>
<p class="subtle">推理验证会向当前模型发送固定短消息并计入实际用量媒体参数请通过真实转写或声纹操作验证</p><p v-if="probeResult" role="status">{{ probeResult }}</p> <p class="subtle">{{ t('推理验证会向当前模型发送固定短消息并计入实际用量媒体参数请通过真实转写或声纹操作验证。', 'The inference test sends a fixed short message to the current model and counts toward usage. Validate media parameters through an actual transcription or speaker operation.') }}</p><p v-if="probeResult" role="status">{{ probeResult }}</p>
<pre v-if="requestPreview" class="request-preview">{{ requestPreview }}</pre> <pre v-if="requestPreview" class="request-preview">{{ requestPreview }}</pre>
</fieldset> </fieldset>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div> <div v-if="error" class="error-banner" role="alert">{{ error }}</div>
<div class="inline-actions form-footer"><button class="button-primary" type="submit" :disabled="saving || credentialLoading">{{ saving ? '保存中…' : '保存提供商' }}</button><button type="button" class="button-secondary" @click="close">取消</button></div> <div class="inline-actions form-footer"><button class="button-primary" type="submit" :disabled="saving || credentialLoading">{{ saving ? t('保存中…', 'Saving…') : t('保存提供商', 'Save provider') }}</button><button type="button" class="button-secondary" @click="close">{{ t('取消', 'Cancel') }}</button></div>
</form> </form>
</div> </div>
</div> </div>
@@ -2,6 +2,7 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import type { ProviderPreset } from '@/contracts' import type { ProviderPreset } from '@/contracts'
import ProviderLogo from './ProviderLogo.vue' import ProviderLogo from './ProviderLogo.vue'
import { t } from '@/i18n'
const props = defineProps<{ presets: ProviderPreset[]; modelValue: string }>() const props = defineProps<{ presets: ProviderPreset[]; modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>() const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
@@ -15,14 +16,14 @@ const filtered = computed(() => {
<template> <template>
<div class="preset-selector"> <div class="preset-selector">
<label class="field" for="provider-search"><span>提供商预设</span><input id="provider-search" v-model="search" class="input" type="search" placeholder="搜索提供商,例如 通义千问 / DeepSeek" /></label> <label class="field" for="provider-search"><span>{{ t('提供商预设', 'Provider presets') }}</span><input id="provider-search" v-model="search" class="input" type="search" :placeholder="t('搜索提供商,例如 通义千问 / DeepSeek', 'Search providers, such as Qwen / DeepSeek')" /></label>
<div class="preset-grid" role="group" aria-label="提供商预设"> <div class="preset-grid" role="group" :aria-label="t('提供商预设', 'Provider presets')">
<button type="button" class="preset-chip" :class="{ selected: !modelValue }" :aria-pressed="!modelValue" @click="emit('update:modelValue', '')"><ProviderLogo /><span>自定义</span></button> <button type="button" class="preset-chip" :class="{ selected: !modelValue }" :aria-pressed="!modelValue" @click="emit('update:modelValue', '')"><ProviderLogo /><span>{{ t('自定义', 'Custom') }}</span></button>
<button v-for="preset in filtered" :key="preset.preset_id" type="button" class="preset-chip" :class="{ selected: modelValue === preset.preset_id }" :aria-pressed="modelValue === preset.preset_id" :title="preset.description || preset.name" :data-preset="preset.preset_id" @click="emit('update:modelValue', preset.preset_id)"> <button v-for="preset in filtered" :key="preset.preset_id" type="button" class="preset-chip" :class="{ selected: modelValue === preset.preset_id }" :aria-pressed="modelValue === preset.preset_id" :title="preset.description || preset.name" :data-preset="preset.preset_id" @click="emit('update:modelValue', preset.preset_id)">
<ProviderLogo :logo-id="preset.logo_id || preset.preset_id" /><span>{{ preset.name }}</span> <ProviderLogo :logo-id="preset.logo_id || preset.preset_id" /><span>{{ preset.name }}</span>
</button> </button>
</div> </div>
<p v-if="search && !filtered.length" class="subtle" role="status">没有匹配的预设可以使用自定义服务</p> <p v-if="search && !filtered.length" class="subtle" role="status">{{ t('没有匹配的预设可以使用自定义服务', 'No matching preset. You can use a custom service.') }}</p>
</div> </div>
</template> </template>
@@ -2,6 +2,8 @@
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { apiClient } from '@/services/apiClient' import { apiClient } from '@/services/apiClient'
import type { RequestOverride } from '@/contracts' import type { RequestOverride } from '@/contracts'
import { t } from '@/i18n'
import FilePicker from '@/components/common/FilePicker.vue'
const props = defineProps<{modelValue: RequestOverride[]}>() const props = defineProps<{modelValue: RequestOverride[]}>()
const emit = defineEmits<{ 'update:modelValue': [value:RequestOverride[]]; valid:[value:boolean] }>() const emit = defineEmits<{ 'update:modelValue': [value:RequestOverride[]]; valid:[value:boolean] }>()
const transferError = ref('') const transferError = ref('')
@@ -16,9 +18,9 @@ function publish() {
for (const rule of rules.value) { for (const rule of rules.value) {
try { try {
const body = JSON.parse(rule.draft) const body = JSON.parse(rule.draft)
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('顶层必须为 JSON 对象') if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error(t('顶层必须为 JSON 对象', 'The top level must be a JSON object'))
const conflicts = Object.keys(body).filter(key => protectedFields.has(key)) const conflicts = Object.keys(body).filter(key => protectedFields.has(key))
if (conflicts.length) throw new Error(`运行请求管理字段不可覆盖:${conflicts.join(', ')}`) if (conflicts.length) throw new Error(`${t('运行请求管理字段不可覆盖:', 'Runtime-managed fields cannot be overridden: ')}${conflicts.join(', ')}`)
rule.error = '' rule.error = ''
result.push({capability:rule.capability,model:rule.model || null,stream:rule.stream ?? null,body}) result.push({capability:rule.capability,model:rule.model || null,stream:rule.stream ?? null,body})
} catch(e) { rule.error = (e as Error).message; valid = false } } catch(e) { rule.error = (e as Error).message; valid = false }
@@ -37,15 +39,12 @@ watch(() => props.modelValue, value => {
} }
}, {deep: true}) }, {deep: true})
function reset() { rules.value = []; transferError.value = ''; publish() } function reset() { rules.value = []; transferError.value = ''; publish() }
async function importRules(event: Event) { async function importRules(file: File | null) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return if (!file) return
const current = ++generation const current = ++generation
transferError.value = '' transferError.value = ''
try { try {
if (file.size > 1024 * 1024) throw new Error('配置文件不得超过 1 MiB') if (file.size > 1024 * 1024) throw new Error(t('配置文件不得超过 1 MiB', 'The configuration file must not exceed 1 MiB'))
const parsed = JSON.parse(await file.text()) const parsed = JSON.parse(await file.text())
const validated = await apiClient.post<{request_overrides: RequestOverride[]}>('/api/providers/request-rules/validate', parsed) const validated = await apiClient.post<{request_overrides: RequestOverride[]}>('/api/providers/request-rules/validate', parsed)
if (current !== generation) return if (current !== generation) return
@@ -57,7 +56,7 @@ async function exportRules() {
transferError.value = '' transferError.value = ''
try { try {
publish() publish()
if (rules.value.some(rule => rule.error)) throw new Error('请先修正 JSON') if (rules.value.some(rule => rule.error)) throw new Error(t('请先修正 JSON', 'Fix the JSON first'))
const validated = await apiClient.post('/api/providers/request-rules/validate', {version:1, request_overrides:JSON.parse(published)}) const validated = await apiClient.post('/api/providers/request-rules/validate', {version:1, request_overrides:JSON.parse(published)})
const url = URL.createObjectURL(new Blob([JSON.stringify(validated, null, 2)], {type:'application/json'})) const url = URL.createObjectURL(new Blob([JSON.stringify(validated, null, 2)], {type:'application/json'}))
const link = document.createElement('a'); link.href = url; link.download = 'model-request-rules.json'; link.click() const link = document.createElement('a'); link.href = url; link.download = 'model-request-rules.json'; link.click()
@@ -67,20 +66,20 @@ async function exportRules() {
</script> </script>
<template> <template>
<details class="request-json"><summary>高级自定义请求 JSON</summary> <details class="request-json ui-disclosure"><summary>{{ t('高级:自定义请求 JSON', 'Advanced: Custom request JSON') }}</summary>
<p class="subtle">提供商通用规则先应用再应用模型规则对象递归合并数组整体替换null 作为实际值删除键后恢复继承密钥继续使用独立 API Key 配置</p> <p class="subtle">{{ t('提供商通用规则先应用再应用模型规则对象递归合并数组整体替换null 作为实际值;删除键后恢复继承密钥继续使用独立 API Key 配置。', 'Provider-wide rules are applied before model rules. Objects merge recursively, arrays replace whole values, and null is kept as a value. Delete a key to inherit it again. API keys remain in the separate credential setting.') }}</p>
<div v-for="(rule,index) in rules" :key="index" class="rule"> <div v-for="(rule,index) in rules" :key="index" class="rule">
<div class="rule-selectors"><label>能力<select v-model="rule.capability" class="select" @change="publish"><option value="chat">聊天</option><option value="embedding">Embedding</option><option value="transcription">音频转写</option><option value="speaker_matching">声纹比对</option></select></label> <div class="rule-selectors"><label>{{ t('能力', 'Capability') }}<select v-model="rule.capability" class="select" @change="publish"><option value="chat">{{ t('聊天', 'Chat') }}</option><option value="embedding">Embedding</option><option value="transcription">{{ t('音频转写', 'Transcription') }}</option><option value="speaker_matching">{{ t('声纹比对', 'Speaker matching') }}</option></select></label>
<label>模型<input v-model="rule.model" class="input" placeholder="留空:全部模型" @input="publish" /></label> <label>{{ t('模型', 'Model') }}<input v-model="rule.model" class="input" :placeholder="t('留空:全部模型', 'Blank: all models')" @input="publish" /></label>
<label>请求模式<select v-model="rule.stream" class="select" @change="publish"><option :value="null">全部</option><option :value="true">仅流式</option><option :value="false">仅非流式</option></select></label></div> <label>{{ t('请求模式', 'Request mode') }}<select v-model="rule.stream" class="select" @change="publish"><option :value="null">{{ t('全部', 'All') }}</option><option :value="true">{{ t('仅流式', 'Streaming only') }}</option><option :value="false">{{ t('仅非流式', 'Non-streaming only') }}</option></select></label></div>
<textarea v-model="rule.draft" class="input json-body" rows="6" aria-label="自定义请求 JSON" spellcheck="false" placeholder='{"stream_options":{"include_usage":true}}' @input="publish" /> <textarea v-model="rule.draft" class="input json-body" rows="6" :aria-label="t('自定义请求 JSON', 'Custom request JSON')" spellcheck="false" placeholder='{"stream_options":{"include_usage":true}}' @input="publish" />
<p v-if="rule.error" class="error-text" role="alert">{{ rule.error }}</p> <p v-if="rule.error" class="error-text" role="alert">{{ rule.error }}</p>
<div class="inline-actions"><button type="button" class="button-secondary" @click="format(index)">格式化</button><button type="button" class="button-danger" @click="rules.splice(index,1); publish()">删除规则</button></div> <div class="inline-actions"><button type="button" class="button-secondary" @click="format(index)">{{ t('格式化', 'Format') }}</button><button type="button" class="button-danger" @click="rules.splice(index,1); publish()">{{ t('删除规则', 'Delete rule') }}</button></div>
</div> </div>
<button type="button" class="button-secondary" @click="add">添加请求规则</button> <button type="button" class="button-secondary" @click="add">{{ t('添加请求规则', 'Add request rule') }}</button>
<div class="inline-actions"><button type="button" class="button-secondary" @click="reset">恢复默认请求</button><button type="button" class="button-secondary" @click="exportRules">导出请求配置</button><label>导入请求配置<input type="file" accept=".json" @change="importRules" /></label></div> <div class="transfer-actions"><div class="inline-actions"><button type="button" class="button-secondary" @click="reset">{{ t('恢复默认请求', 'Restore default request') }}</button><button type="button" class="button-secondary" @click="exportRules">{{ t('导出请求配置', 'Export request settings') }}</button></div><FilePicker :file="null" :label="t('导入请求配置', 'Import request settings')" :empty-label="t('选择 JSON 文件', 'Choose a JSON file')" accept=".json,application/json" @select="importRules" /></div>
<p v-if="transferError" class="error-text" role="alert">{{ transferError }}</p> <p v-if="transferError" class="error-text" role="alert">{{ transferError }}</p>
<p class="subtle">导入替换当前请求规则保存提供商后生效导出仅包含请求规则不包含凭据引用和 API Key</p> <p class="subtle">{{ t('导入替换当前请求规则保存提供商后生效导出仅包含请求规则不包含凭据引用和 API Key。', 'Importing replaces the current request rules and takes effect after saving the provider. Exports contain rules only, without credential references or API keys.') }}</p>
</details> </details>
</template> </template>
<style scoped>.request-json{display:grid;gap:12px}.rule{padding:12px;border:1px solid var(--border-color);border-radius:8px;margin:12px 0}.rule-selectors{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}.rule-selectors label{display:grid;gap:5px}.json-body{font-family:monospace;width:100%}</style> <style scoped>.request-json{display:grid;gap:12px}.rule{padding:12px;border:1px solid var(--color-border-default);border-radius:8px;margin:12px 0}.rule-selectors{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}.rule-selectors label{display:grid;gap:5px}.json-body{font-family:monospace;width:100%}.transfer-actions{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-sm);justify-content:space-between}</style>
+27 -26
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import type { ProviderConfig } from '@/contracts' import type { ProviderConfig } from '@/contracts'
import ProviderForm from './ProviderForm.vue' import ProviderForm from './ProviderForm.vue'
import ProviderLogo from './ProviderLogo.vue' import ProviderLogo from './ProviderLogo.vue'
@@ -9,12 +9,13 @@ import UsageCard from './UsageCard.vue'
import { useProviderStore } from '@/stores/provider' import { useProviderStore } from '@/stores/provider'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { t } from '@/i18n'
type Section = 'general' | 'editor' | 'providers' | 'index' | 'permissions' | 'ai-core' type Section = 'general' | 'editor' | 'providers' | 'index' | 'permissions' | 'ai-core'
const sections: Array<{ id: Section; label: string }> = [ const sections = computed<Array<{ id: Section; label: string }>>(() => [
{ id: 'general', label: '通用' }, { id: 'editor', label: '编辑器' }, { id: 'providers', label: '模型提供商' }, { id: 'general', label: t('通用', 'General') }, { id: 'editor', label: t('编辑器', 'Editor') }, { id: 'providers', label: t('模型提供商', 'Model Providers') },
{ id: 'index', label: '索引与模型' }, { id: 'permissions', label: '权限' }, { id: 'ai-core', label: 'AI Core 诊断' }, { id: 'index', label: t('索引与模型', 'Index and Models') }, { id: 'permissions', label: t('权限', 'Permissions') }, { id: 'ai-core', label: t('AI Core 诊断', 'AI Core Diagnostics') },
] ])
const activeSection = ref<Section>('general') const activeSection = ref<Section>('general')
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
const providerStore = useProviderStore() const providerStore = useProviderStore()
@@ -47,66 +48,66 @@ async function providerSaved(provider: ProviderConfig) {
if (provider.enabled) void providerStore.loadModels(provider.provider_id).catch(() => undefined) if (provider.enabled) void providerStore.loadModels(provider.provider_id).catch(() => undefined)
} }
async function removeProvider(provider: ProviderConfig) { if (!confirm(`确定删除 Provider“${provider.name}吗?`)) return; try { await providerStore.deleteProvider(provider.provider_id) } catch (error) { providerAction.value = error instanceof Error ? error.message : '删除失败' } } async function removeProvider(provider: ProviderConfig) { if (!confirm(`${t('确定删除 Provider', 'Delete Provider')} ${provider.name}?`)) return; try { await providerStore.deleteProvider(provider.provider_id) } catch (error) { providerAction.value = error instanceof Error ? error.message : t('删除失败', 'Delete failed') } }
async function testProvider(provider: ProviderConfig) { testResults.value[provider.provider_id] = '测试中…'; const result = await providerStore.testProvider(provider.provider_id); testResults.value[provider.provider_id] = result.success ? `连接成功${result.latency_ms ? ` · ${result.latency_ms}ms` : ''}` : `连接失败:${result.error}` } async function testProvider(provider: ProviderConfig) { testResults.value[provider.provider_id] = t('测试中…', 'Testing…'); const result = await providerStore.testProvider(provider.provider_id); testResults.value[provider.provider_id] = result.success ? `${t('连接成功', 'Connection succeeded')}${result.latency_ms ? ` · ${result.latency_ms}ms` : ''}` : `${t('连接失败:', 'Connection failed: ')}${result.error}` }
async function refreshModels(provider: ProviderConfig) { await providerStore.loadModels(provider.provider_id).catch(() => undefined) } async function refreshModels(provider: ProviderConfig) { await providerStore.loadModels(provider.provider_id).catch(() => undefined) }
async function chooseDefaultModel(provider: ProviderConfig, event: Event) { async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
const defaultModel = (event.target as HTMLSelectElement).value const defaultModel = (event.target as HTMLSelectElement).value
try { await providerStore.updateProvider(provider.provider_id, { default_model: defaultModel }) } try { await providerStore.updateProvider(provider.provider_id, { default_model: defaultModel }) }
catch (error) { providerAction.value = error instanceof Error ? error.message : '默认模型更新失败' } catch (error) { providerAction.value = error instanceof Error ? error.message : t('默认模型更新失败', 'Failed to update the default model') }
} }
</script> </script>
<template> <template>
<section class="feature-page settings-page"> <section class="feature-page settings-page">
<header class="feature-header"><div><h1>设置</h1><p>管理应用偏好模型索引权限和本地 AI Core</p></div></header> <header class="feature-header"><div><h1>{{ t('设置', 'Settings') }}</h1><p>{{ t('管理应用偏好、模型、索引、权限和本地 AI Core。', 'Manage application preferences, models, indexing, permissions, and the local AI Core.') }}</p></div></header>
<nav class="settings-nav"><button v-for="section in sections" :key="section.id" :class="{ active: activeSection === section.id }" @click="activeSection = section.id">{{ section.label }}</button></nav> <nav class="settings-nav"><button v-for="section in sections" :key="section.id" :class="{ active: activeSection === section.id }" @click="activeSection = section.id">{{ section.label }}</button></nav>
<div v-if="activeSection === 'general'" class="panel settings-section"><h2>通用</h2><label class="setting-row"><span><strong>恢复上次 Vault</strong><small>启动后自动打开最近使用的知识库</small></span><input v-model="settingsStore.restoreLastVault" type="checkbox" /></label><div class="setting-row"><span><strong>自动保存间隔</strong><small>编辑停止后等待多久写入文件</small></span><select v-model.number="settingsStore.autoSaveInterval" class="select short"><option :value="500">0.5 </option><option :value="1500">1.5 </option><option :value="3000">3 </option></select></div><div class="setting-row"><span><strong>界面语言</strong><small>当前阶段支持中文和英文入口</small></span><select v-model="settingsStore.language" class="select short"><option value="zh-CN">简体中文</option><option value="en">English</option></select></div><div class="setting-row"><span><strong>版本</strong><small>Desktop / AI Core</small></span><span>{{ settingsStore.appVersion }} / {{ settingsStore.aiCoreVersion }}</span></div></div> <div v-if="activeSection === 'general'" class="panel settings-section"><h2>{{ t('通用', 'General') }}</h2><label class="setting-row"><span><strong>{{ t('恢复上次 Vault', 'Restore last Vault') }}</strong><small>{{ t('启动后自动打开最近使用的知识库', 'Open the most recently used knowledge base at startup') }}</small></span><input v-model="settingsStore.restoreLastVault" type="checkbox" /></label><div class="setting-row"><span><strong>{{ t('自动保存间隔', 'Autosave interval') }}</strong><small>{{ t('编辑停止后等待多久写入文件', 'How long to wait after editing before saving') }}</small></span><select v-model.number="settingsStore.autoSaveInterval" class="select short"><option :value="500">0.5 {{ t('秒', 'sec') }}</option><option :value="1500">1.5 {{ t('秒', 'sec') }}</option><option :value="3000">3 {{ t('秒', 'sec') }}</option></select></div><div class="setting-row"><span><strong>{{ t('界面语言', 'Interface language') }}</strong><small>{{ t('切换后立即应用到界面', 'Applied to the interface immediately') }}</small></span><select v-model="settingsStore.language" class="select short"><option value="zh-CN">简体中文</option><option value="en">English</option></select></div><div class="setting-row"><span><strong>{{ t('版本', 'Version') }}</strong><small>Desktop / AI Core</small></span><span>{{ settingsStore.appVersion }} / {{ settingsStore.aiCoreVersion }}</span></div></div>
<div v-else-if="activeSection === 'editor'" class="panel settings-section"><h2>编辑器</h2><div class="setting-row"><span><strong>默认模式</strong><small>新打开文件使用的编辑器模式</small></span><select v-model="settingsStore.defaultEditorMode" class="select short"><option value="wysiwyg">写作与预览</option><option value="source">Markdown 源码</option></select></div><div class="setting-row"><span><strong>字号</strong></span><input v-model.number="themeStore.fontEditorSize" class="input short" type="number" min="12" max="32" /></div><div class="setting-row"><span><strong>行高</strong></span><input v-model.number="themeStore.lineHeight" class="input short" type="number" min="1.2" max="2.4" step="0.1" /></div><div class="setting-row"><span><strong>行宽</strong><small>Markdown 预览最大字符宽度</small></span><input v-model.number="settingsStore.editorLineWidth" class="input short" type="number" min="40" max="140" /></div><label class="setting-row"><span><strong>拼写检查</strong></span><input v-model="settingsStore.spellCheck" type="checkbox" /></label></div> <div v-else-if="activeSection === 'editor'" class="panel settings-section"><h2>{{ t('编辑器', 'Editor') }}</h2><div class="setting-row"><span><strong>{{ t('默认模式', 'Default mode') }}</strong><small>{{ t('新打开文件使用的编辑器模式', 'Editor mode used for newly opened files') }}</small></span><select v-model="settingsStore.defaultEditorMode" class="select short"><option value="wysiwyg">{{ t('写作与预览', 'Writing and preview') }}</option><option value="source">{{ t('Markdown 源码', 'Markdown source') }}</option></select></div><div class="setting-row"><span><strong>{{ t('字号', 'Font size') }}</strong></span><input v-model.number="themeStore.fontEditorSize" class="input short" type="number" min="12" max="32" /></div><div class="setting-row"><span><strong>{{ t('行高', 'Line height') }}</strong></span><input v-model.number="themeStore.lineHeight" class="input short" type="number" min="1.2" max="2.4" step="0.1" /></div><div class="setting-row"><span><strong>{{ t('行宽', 'Line width') }}</strong><small>{{ t('Markdown 预览最大字符宽度', 'Maximum character width for Markdown preview') }}</small></span><input v-model.number="settingsStore.editorLineWidth" class="input short" type="number" min="40" max="140" /></div><label class="setting-row"><span><strong>{{ t('拼写检查', 'Spell check') }}</strong><small>{{ t('在写作与源码编辑器中使用系统拼写检查', 'Use system spell checking in visual and source editors') }}</small></span><input v-model="settingsStore.spellCheck" type="checkbox" /></label></div>
<div v-else-if="activeSection === 'providers'" class="settings-section"> <div v-else-if="activeSection === 'providers'" class="settings-section">
<div class="section-head"> <div class="section-head">
<div><h2>模型提供商</h2><p class="subtle">选择国内外提供商预设或配置自定义 API 与独立密钥</p></div> <div><h2>{{ t('模型提供商', 'Model Providers') }}</h2><p class="subtle">{{ t('选择国内外提供商预设或配置自定义 API 与独立密钥。', 'Choose a provider preset or configure a custom API with separate credentials.') }}</p></div>
<button class="button-primary" @click="openProvider()">新增 Provider</button> <button class="button-primary" @click="openProvider()">{{ t('新增 Provider', 'Add 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>
<LocalModelSettings /> <LocalModelSettings />
<UsageCard /> <UsageCard />
<p v-if="!providerStore.providers.length" class="subtle">{{ providerStore.isLoading ? '正在加载提供商' : '尚无可用提供商请添加真实 API 或本地 Ollama 配置' }}</p> <p v-if="!providerStore.providers.length" class="subtle">{{ providerStore.isLoading ? t('正在加载提供商', 'Loading providers') : t('尚无可用提供商请添加真实 API 或本地 Ollama 配置', 'No providers are available. Add a real API or local Ollama configuration.') }}</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">
<div class="inline-actions"><ProviderLogo :logo-id="providerStore.presets.find(preset => preset.preset_id === presetIdFor(provider))?.logo_id || presetIdFor(provider)" /><strong>{{ provider.name }}</strong><span class="badge" :class="{ success: provider.enabled }">{{ provider.provider_type }}</span></div> <div class="inline-actions"><ProviderLogo :logo-id="providerStore.presets.find(preset => preset.preset_id === presetIdFor(provider))?.logo_id || presetIdFor(provider)" /><strong>{{ provider.name }}</strong><span class="badge" :class="{ success: provider.enabled }">{{ provider.provider_type }}</span></div>
<p class="subtle">{{ provider.base_url || '本地内置' }} · 默认模型 {{ provider.default_model || '未设置' }}</p> <p class="subtle">{{ provider.base_url || t('本地内置', 'Built in locally') }} · {{ t('默认模型', 'Default model') }} {{ provider.default_model || t('未设置', 'Not set') }}</p>
<div class="tag-list"><span v-for="(_, capability) in provider.capabilities" :key="capability" class="badge">{{ capability }}</span></div> <div class="tag-list"><span v-for="(_, capability) in provider.capabilities" :key="capability" class="badge">{{ capability }}</span></div>
<div v-if="providerStore.modelsByProvider[provider.provider_id]?.length" class="model-picker"> <div v-if="providerStore.modelsByProvider[provider.provider_id]?.length" class="model-picker">
<label :for="`default-model-${provider.provider_id}`">默认模型</label> <label :for="`default-model-${provider.provider_id}`">{{ t('默认模型', 'Default model') }}</label>
<select :id="`default-model-${provider.provider_id}`" class="select" :value="provider.default_model" @change="chooseDefaultModel(provider, $event)"> <select :id="`default-model-${provider.provider_id}`" class="select" :value="provider.default_model" @change="chooseDefaultModel(provider, $event)">
<option value="">未设置</option> <option value="">{{ t('未设置', 'Not set') }}</option>
<option v-for="model in providerStore.modelsByProvider[provider.provider_id]" :key="model.model_id" :value="model.model_id">{{ model.name }}</option> <option v-for="model in providerStore.modelsByProvider[provider.provider_id]" :key="model.model_id" :value="model.model_id">{{ model.name }}</option>
</select> </select>
<span class="subtle">已获取 {{ providerStore.modelsByProvider[provider.provider_id].length }} 个模型</span> <span class="subtle">{{ t('已获取', 'Loaded') }} {{ providerStore.modelsByProvider[provider.provider_id].length }} {{ t('个模型', 'models') }}</span>
</div> </div>
<p v-if="providerStore.modelErrorsByProvider[provider.provider_id]" class="error-text">模型获取失败{{ providerStore.modelErrorsByProvider[provider.provider_id] }}</p> <p v-if="providerStore.modelErrorsByProvider[provider.provider_id]" class="error-text">{{ t('模型获取失败', 'Failed to load models: ') }}{{ providerStore.modelErrorsByProvider[provider.provider_id] }}</p>
<p v-if="testResults[provider.provider_id]" class="test-result">{{ testResults[provider.provider_id] }}</p> <p v-if="testResults[provider.provider_id]" class="test-result">{{ testResults[provider.provider_id] }}</p>
</div> </div>
<div class="inline-actions provider-actions"> <div class="inline-actions provider-actions">
<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] ? t('获取中', 'Loading') : t('刷新模型', 'Refresh models') }}</button>
<button class="button-secondary" @click="testProvider(provider)">测试</button> <button class="button-secondary" @click="testProvider(provider)">{{ t('测试', 'Test') }}</button>
<button class="button-secondary" @click="openProvider(provider)">编辑</button> <button class="button-secondary" @click="openProvider(provider)">{{ t('编辑', 'Edit') }}</button>
<button class="button-danger" @click="removeProvider(provider)">删除</button> <button class="button-danger" @click="removeProvider(provider)">{{ t('删除', 'Delete') }}</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><span class="subtle">当前后端支持全量重建</span></div><ModelRoutingSettings /></div> <div v-else-if="activeSection === 'index'" class="panel settings-section"><h2>{{ t('索引与模型', 'Index and Models') }}</h2><div class="index-summary"><div><span class="badge" :class="{ success: settingsStore.indexStatus.status === 'idle', error: settingsStore.indexStatus.status === 'error' }">{{ settingsStore.indexStatus.status }}</span><p>{{ t('待处理任务', 'Pending jobs') }} {{ settingsStore.indexStatus.pending_jobs }}</p></div><div><strong>{{ settingsStore.indexStatus.total_notes ?? t('未获取', 'Unavailable') }}</strong><small>{{ t('笔记', 'Notes') }}</small></div><div><strong>{{ settingsStore.indexStatus.total_blocks ?? t('未获取', 'Unavailable') }}</strong><small>Block</small></div></div><div v-if="settingsStore.indexStatus.error" class="error-banner">{{ settingsStore.indexStatus.error }}</div><div class="inline-actions"><button class="button-primary" @click="settingsStore.rebuildIndex('full')">{{ t('重建全部', 'Rebuild all') }}</button><span class="subtle">{{ t('当前后端支持全量重建', 'The current backend supports a full rebuild.') }}</span></div><ModelRoutingSettings /></div>
<div v-else-if="activeSection === '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-if="activeSection === 'permissions'" class="panel settings-section"><h2>{{ t('权限策略', 'Permission Policy') }}</h2><p class="muted section-description">{{ t('以下为后端当前生效的权限策略;全局策略编辑尚未开放,运行时按实际权限请求确认。', 'These policies are active in the backend. Global policy editing is not yet available; runtime requests are confirmed as needed.') }}</p><p v-if="!Object.keys(settingsStore.permissionPolicy).length" class="subtle">{{ t('尚未获取权限策略,请检查后端连接并重新检测。', 'Permission policy is unavailable. Check the backend connection and try again.') }}</p><div class="permission-list"><div v-for="(policy, permission) in settingsStore.permissionPolicy" :key="permission" class="setting-row"><span><strong>{{ permission }}</strong></span><span>{{ policy === 'allow' ? t('允许', 'Allow') : policy === 'confirm' ? t('每次确认', 'Confirm each time') : t('拒绝', 'Deny') }}</span></div></div></div>
<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> <div v-else class="panel settings-section"><h2>{{ t('AI Core 诊断', 'AI Core Diagnostics') }}</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>{{ t('AI Core 连接状态', 'AI Core connection') }}</h3><p class="subtle">{{ t('AI Core 不可用时,Markdown 编辑仍可继续使用。', 'Markdown editing remains available when AI Core is offline.') }}</p></div><div class="item-card"><strong>{{ settingsStore.aiCoreAddress }}</strong><h3>{{ t('开发 API 地址', 'Development API address') }}</h3><p class="subtle">{{ t('正式桌面环境由 Sidecar Manager 动态提供。', 'The desktop build will provide this through Sidecar Manager.') }}</p></div></div><div class="inline-actions diagnostic-actions"><button class="button-primary" @click="settingsStore.loadDiagnostics">{{ t('重新检测', 'Check again') }}</button><span class="subtle">{{ t('当前 Web 端不支持重启后端进程请在运行后端的终端中操作', 'The web build cannot restart the backend. Use the terminal running it.') }}</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>
+16 -15
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { apiClient } from '@/services/apiClient' import { apiClient } from '@/services/apiClient'
import { t } from '@/i18n'
interface Usage {audio_request_count:number;audio_seconds:number|null;audio_covered_requests:number;totals: Record<string,number|null>;coverage:Record<string,number>;request_count:number;complete_requests:number;cache_hit_rate:number|null;cache_covered_requests:number;options:{provider_id:string;model:string;source:string}[]} interface Usage {audio_request_count:number;audio_seconds:number|null;audio_covered_requests:number;totals: Record<string,number|null>;coverage:Record<string,number>;request_count:number;complete_requests:number;cache_hit_rate:number|null;cache_covered_requests:number;options:{provider_id:string;model:string;source:string}[]}
const data = ref<Usage | null>(null) const data = ref<Usage | null>(null)
const period = ref('7') const period = ref('7')
@@ -11,7 +12,7 @@ const start = ref('')
const end = ref('') const end = ref('')
const busy = ref(false) const busy = ref(false)
const error = ref('') const error = ref('')
const metrics: Record<string,string> = {input_tokens:'输入 Token',output_tokens:'输出 Token',total_tokens:'总 Token',cache_hit_tokens:'缓存命中',cache_miss_tokens:'缓存未命中',cache_write_tokens:'缓存写入',reasoning_tokens:'推理 Token'} const metrics = computed<Record<string,string>>(() => ({input_tokens:t('输入 Token','Input tokens'),output_tokens:t('输出 Token','Output tokens'),total_tokens:t('总 Token','Total tokens'),cache_hit_tokens:t('缓存命中','Cache hits'),cache_miss_tokens:t('缓存未命中','Cache misses'),cache_write_tokens:t('缓存写入','Cache writes'),reasoning_tokens:t('推理 Token','Reasoning tokens')}))
async function load() { async function load() {
busy.value = true; error.value = '' busy.value = true; error.value = ''
try { try {
@@ -19,27 +20,27 @@ async function load() {
const from = period.value === 'custom' ? new Date(start.value) : new Date(until) const from = period.value === 'custom' ? new Date(start.value) : new Date(until)
if (period.value === 'today') from.setHours(0,0,0,0) if (period.value === 'today') from.setHours(0,0,0,0)
else if (period.value !== 'custom') from.setDate(from.getDate() - Number(period.value)) else if (period.value !== 'custom') from.setDate(from.getDate() - Number(period.value))
if (!Number.isFinite(from.getTime()) || !Number.isFinite(until.getTime()) || until <= from) throw new Error('请选择有效的开始与结束时间。') if (!Number.isFinite(from.getTime()) || !Number.isFinite(until.getTime()) || until <= from) throw new Error(t('请选择有效的开始与结束时间。', 'Choose a valid start and end time.'))
data.value = await apiClient.get<Usage>('/api/usage', {params: {start:from.toISOString(),end:until.toISOString(),provider_id:provider.value || undefined,model:model.value || undefined,source:source.value || undefined}}) data.value = await apiClient.get<Usage>('/api/usage', {params: {start:from.toISOString(),end:until.toISOString(),provider_id:provider.value || undefined,model:model.value || undefined,source:source.value || undefined}})
} catch(e) { error.value = (e as Error).message } finally { busy.value = false } } catch(e) { error.value = (e as Error).message } finally { busy.value = false }
} }
onMounted(load) onMounted(load)
</script> </script>
<template> <template>
<section class="panel usage-card"><header><h3>Token 消耗情况</h3><button class="button-secondary" :disabled="busy" @click="load">{{ busy ? '加载中' : '刷新统计' }}</button></header> <section class="panel usage-card"><header><h3>{{ t('Token 消耗情况', 'Token Usage') }}</h3><button class="button-secondary" :disabled="busy" @click="load">{{ busy ? t('加载中', 'Loading') : t('刷新统计', 'Refresh') }}</button></header>
<div class="filters"><label>时间<select v-model="period" class="select" @change="period !== 'custom' && load()"><option value="today">今日</option><option value="7">近 7 天</option><option value="30">近 30 天</option><option value="custom">自定义</option></select></label> <div class="filters"><label>{{ t('时间', 'Period') }}<select v-model="period" class="select" @change="period !== 'custom' && load()"><option value="today">{{ t('今日', 'Today') }}</option><option value="7">{{ t('近 7 天', 'Last 7 days') }}</option><option value="30">{{ t('近 30 天', 'Last 30 days') }}</option><option value="custom">{{ t('自定义', 'Custom') }}</option></select></label>
<label>提供商<select v-model="provider" class="select" @change="model = ''; load()"><option value="">全部</option><option v-for="id in [...new Set(data?.options.map(o => o.provider_id) || [])]" :key="id">{{ id }}</option></select></label> <label>{{ t('提供商', 'Provider') }}<select v-model="provider" class="select" @change="model = ''; load()"><option value="">{{ t('全部', 'All') }}</option><option v-for="id in [...new Set(data?.options.map(o => o.provider_id) || [])]" :key="id">{{ id }}</option></select></label>
<label>模型<select v-model="model" class="select" @change="load"><option value="">全部</option><option v-for="id in [...new Set(data?.options.filter(o => !provider || o.provider_id === provider).map(o => o.model) || [])]" :key="id">{{ id }}</option></select></label> <label>{{ t('模型', 'Model') }}<select v-model="model" class="select" @change="load"><option value="">{{ t('全部', 'All') }}</option><option v-for="id in [...new Set(data?.options.filter(o => !provider || o.provider_id === provider).map(o => o.model) || [])]" :key="id">{{ id }}</option></select></label>
<label>来源<select v-model="source" class="select" @change="load"><option value="">全部</option><option value="api">远程 API</option><option value="local">本地服务</option></select></label> <label>{{ t('来源', 'Source') }}<select v-model="source" class="select" @change="load"><option value="">{{ t('全部', 'All') }}</option><option value="api">{{ t('远程 API', 'Remote API') }}</option><option value="local">{{ t('本地服务', 'Local service') }}</option></select></label>
</div> </div>
<div v-if="period === 'custom'" class="filters"><label>开始<input v-model="start" class="input" type="datetime-local" /></label><label>结束<input v-model="end" class="input" type="datetime-local" /></label><button class="button-secondary" @click="load">应用时间段</button></div> <div v-if="period === 'custom'" class="filters"><label>{{ t('开始', 'Start') }}<input v-model="start" class="input" type="datetime-local" /></label><label>{{ t('结束', 'End') }}<input v-model="end" class="input" type="datetime-local" /></label><button class="button-secondary" @click="load">{{ t('应用时间段', 'Apply period') }}</button></div>
<p v-if="error" class="error-banner" role="alert">{{ error }}</p> <p v-if="error" class="error-banner" role="alert">{{ error }}</p>
<template v-if="data"><p v-if="!data.request_count" class="subtle">该时间段没有已记录的模型请求</p> <template v-if="data"><p v-if="!data.request_count" class="subtle">{{ t('该时间段没有已记录的模型请求', 'No model requests were recorded during this period.') }}</p>
<div class="usage-grid"><div v-for="(label,key) in metrics" :key="key"><small>{{ label }}</small><strong>{{ data.totals[key] === null ? '未提供' : data.totals[key]?.toLocaleString() }}</strong><small>覆盖 {{ data.coverage[key] }} / {{ data.request_count }} </small></div> <div class="usage-grid"><div v-for="(label,key) in metrics" :key="key"><small>{{ label }}</small><strong>{{ data.totals[key] === null ? t('未提供', 'Unavailable') : data.totals[key]?.toLocaleString() }}</strong><small>{{ t('覆盖', 'Coverage') }} {{ data.coverage[key] }} / {{ data.request_count }} {{ t('次', 'requests') }}</small></div>
<div><small>缓存命中率</small><strong>{{ data.cache_hit_rate === null ? '未提供' : `${(data.cache_hit_rate * 100).toFixed(1)}%` }}</strong><small>覆盖 {{ data.cache_covered_requests }} </small></div></div> <div><small>{{ t('缓存命中率', 'Cache hit rate') }}</small><strong>{{ data.cache_hit_rate === null ? t('未提供', 'Unavailable') : `${(data.cache_hit_rate * 100).toFixed(1)}%` }}</strong><small>{{ t('覆盖', 'Coverage') }} {{ data.cache_covered_requests }}</small></div></div>
<p class="subtle">音频调用 {{ data.audio_request_count ?? 0 }} · 时长 {{ data.audio_seconds == null ? '未提供' : `${data.audio_seconds.toFixed(2)} ` }}覆盖 {{ data.audio_covered_requests ?? 0 }} 重试分别计数</p> <p class="subtle">{{ t('音频调用', 'Audio calls') }} {{ data.audio_request_count ?? 0 }} · {{ t('时长', 'Duration') }} {{ data.audio_seconds == null ? t('未提供', 'Unavailable') : `${data.audio_seconds.toFixed(2)} ${t('秒', 'sec')}` }} ({{ t('覆盖', 'coverage') }} {{ data.audio_covered_requests ?? 0 }}; {{ t('重试分别计数', 'retries counted separately') }})</p>
<p class="subtle">请求 {{ data.request_count }} 其中完整结束 {{ data.complete_requests }} 输入总量包含厂商已报告的缓存推理 Token 不重复加入输出</p> <p class="subtle">{{ t('请求', 'Requests') }} {{ data.request_count }}, {{ t('其中完整结束', 'completed') }} {{ data.complete_requests }}. {{ t('输入总量包含厂商已报告的缓存,推理 Token 不重复加入输出。', 'Input totals include provider-reported cache tokens; reasoning tokens are not added to output twice.') }}</p>
</template><p class="subtle">统计为本应用观测值不是厂商账户账单缺失指标显示未提供历史未记录的数据不补估</p> </template><p class="subtle">{{ t('统计为本应用观测值不是厂商账户账单缺失指标显示“未提供”,历史未记录的数据不补估。', 'Statistics are application observations, not provider billing. Missing metrics stay unavailable and historical gaps are not estimated.') }}</p>
</section> </section>
</template> </template>
<style scoped>.usage-card{display:grid;gap:16px;padding:20px}.usage-card header,.filters{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.usage-card header{justify-content:space-between}.filters label{display:grid;gap:5px}.usage-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:16px}.usage-grid>div{display:grid;gap:8px}.usage-grid strong{font-size:22px}</style> <style scoped>.usage-card{display:grid;gap:16px;padding:20px}.usage-card header,.filters{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.usage-card header{justify-content:space-between}.filters label{display:grid;gap:5px}.usage-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:16px}.usage-grid>div{display:grid;gap:8px}.usage-grid strong{font-size:22px}</style>
+11 -10
View File
@@ -3,36 +3,37 @@ import { Lightning } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue' import AppIcon from '@/components/common/AppIcon.vue'
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useSkillStore } from '@/stores/skill' import { useSkillStore } from '@/stores/skill'
import { t } from '@/i18n'
const skillStore = useSkillStore() const skillStore = useSkillStore()
const actionError = ref('') const actionError = ref('')
onMounted(() => { void skillStore.loadSkills() }) onMounted(() => { void skillStore.loadSkills() })
async function install() { async function install() {
const path = prompt('请输入 Skill Package 路径')?.trim() const path = prompt(t('请输入 Skill Package 路径', 'Enter the Skill package path'))?.trim()
if (!path) return if (!path) return
try { await skillStore.installSkill(path) } catch (error) { actionError.value = error instanceof Error ? error.message : '安装失败' } try { await skillStore.installSkill(path) } catch (error) { actionError.value = error instanceof Error ? error.message : t('安装失败', 'Installation failed') }
} }
async function toggle(skillId: string, enabled: boolean) { async function toggle(skillId: string, enabled: boolean) {
try { enabled ? await skillStore.disableSkill(skillId) : await skillStore.enableSkill(skillId) } catch (error) { actionError.value = error instanceof Error ? error.message : '状态更新失败' } try { enabled ? await skillStore.disableSkill(skillId) : await skillStore.enableSkill(skillId) } catch (error) { actionError.value = error instanceof Error ? error.message : t('状态更新失败', 'Status update failed') }
} }
async function uninstall(skillId: string, name: string) { async function uninstall(skillId: string, name: string) {
if (!confirm(`确定卸载 Skill${name}吗?`)) return if (!confirm(`${t('确定卸载 Skill', 'Uninstall Skill')} ${name}?`)) return
try { await skillStore.uninstallSkill(skillId) } catch (error) { actionError.value = error instanceof Error ? error.message : '卸载失败' } try { await skillStore.uninstallSkill(skillId) } catch (error) { actionError.value = error instanceof Error ? error.message : t('卸载失败', 'Uninstall failed') }
} }
</script> </script>
<template> <template>
<section class="feature-page"> <section class="feature-page">
<header class="feature-header"><div><h1>Skill 管理</h1><p>查看工作流使用的 Tool权限检索配置和模型要求</p></div><button class="button-primary" @click="install">安装 Skill</button></header> <header class="feature-header"><div><h1>{{ t('Skill 管理', 'Skill Management') }}</h1><p>{{ t('查看工作流使用的 Tool、权限、检索配置和模型要求。', 'Review the tools, permissions, retrieval settings, and model requirements used by workflows.') }}</p></div><button class="button-primary" @click="install">{{ t('安装 Skill', 'Install Skill') }}</button></header>
<div v-if="skillStore.error || actionError" class="error-banner">{{ skillStore.error || actionError }}</div> <div v-if="skillStore.error || actionError" class="error-banner">{{ skillStore.error || actionError }}</div>
<div v-if="skillStore.selectedSkill" class="panel detail-panel"> <div v-if="skillStore.selectedSkill" class="panel detail-panel">
<div class="detail-head"><div><span class="badge" :class="{ success: skillStore.selectedSkill.status === 'ready', error: skillStore.selectedSkill.status === 'error', warning: skillStore.selectedSkill.status.includes('missing') }">{{ skillStore.selectedSkill.status }}</span><h2>{{ skillStore.selectedSkill.icon }} {{ skillStore.selectedSkill.name }}</h2><p class="muted">v{{ skillStore.selectedSkill.version }} · {{ skillStore.selectedSkill.author || '未知作者' }}</p></div><div class="inline-actions"><button class="button-secondary" @click="toggle(skillStore.selectedSkill.skill_id, skillStore.selectedSkill.enabled)">{{ skillStore.selectedSkill.enabled ? '停用' : '启用' }}</button><button class="button-danger" @click="uninstall(skillStore.selectedSkill.skill_id, skillStore.selectedSkill.name)">卸载</button></div></div> <div class="detail-head"><div><span class="badge" :class="{ success: skillStore.selectedSkill.status === 'ready', error: skillStore.selectedSkill.status === 'error', warning: skillStore.selectedSkill.status.includes('missing') }">{{ skillStore.selectedSkill.status }}</span><h2>{{ skillStore.selectedSkill.icon }} {{ skillStore.selectedSkill.name }}</h2><p class="muted">v{{ skillStore.selectedSkill.version }} · {{ skillStore.selectedSkill.author || t('未知作者', 'Unknown author') }}</p></div><div class="inline-actions"><button class="button-secondary" @click="toggle(skillStore.selectedSkill.skill_id, skillStore.selectedSkill.enabled)">{{ skillStore.selectedSkill.enabled ? t('停用', 'Disable') : t('启用', 'Enable') }}</button><button class="button-danger" @click="uninstall(skillStore.selectedSkill.skill_id, skillStore.selectedSkill.name)">{{ t('卸载', 'Uninstall') }}</button></div></div>
<p class="description">{{ skillStore.selectedSkill.description }}</p> <p class="description">{{ skillStore.selectedSkill.description }}</p>
<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>{{ t('工具', 'Tools') }}</h3><div class="tag-list"><span v-for="tool in skillStore.selectedSkill.tools" :key="tool" class="badge info">{{ tool }}</span></div></div><div><h3>{{ t('权限', 'Permissions') }}</h3><div class="tag-list"><span v-for="permission in skillStore.selectedSkill.permissions" :key="permission" class="badge warning">{{ permission }}</span></div></div><div><h3>{{ t('检索配置', 'Retrieval Settings') }}</h3><pre>{{ JSON.stringify(skillStore.selectedSkill.retrieval_config, null, 2) }}</pre></div><div><h3>{{ t('模型能力', 'Model Capabilities') }}</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">{{ t('缺失依赖', 'Missing 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-if="!skillStore.skills.length" class="empty-state"><div><strong>{{ skillStore.isLoading ? t('正在加载…', 'Loading…') : skillStore.error ? t('加载失败', 'Load failed') : t('尚未安装', 'Not installed') }}</strong><button class="button-secondary" @click="skillStore.loadSkills">{{ t('重新加载', 'Reload') }}</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>
@@ -1,12 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTaskStore } from '@/stores/task' import { useTaskStore } from '@/stores/task'
import { t } from '@/i18n'
const taskStore = useTaskStore() const taskStore = useTaskStore()
</script> </script>
<template> <template>
<div class="sidebar-panel filters"> <div class="sidebar-panel filters">
<div class="field"><label>状态</label><select v-model="taskStore.filterStatus" class="select"><option value="all">全部</option><option value="todo">待办</option><option value="in_progress">进行中</option><option value="done">已完成</option><option value="cancelled">已取消</option></select></div> <div class="field"><label>{{ t('状态', 'Status') }}</label><select v-model="taskStore.filterStatus" class="select"><option value="all">{{ t('全部', 'All') }}</option><option value="todo">{{ t('待办', 'To do') }}</option><option value="in_progress">{{ t('进行中', 'In progress') }}</option><option value="done">{{ t('已完成', 'Completed') }}</option><option value="cancelled">{{ t('已取消', 'Cancelled') }}</option></select></div>
<div class="task-counts"><p><span>待办</span><strong>{{ taskStore.todoTasks.length }}</strong></p><p><span>进行中</span><strong>{{ taskStore.inProgressTasks.length }}</strong></p><p><span>已完成</span><strong>{{ taskStore.doneTasks.length }}</strong></p></div> <div class="task-counts"><p><span>{{ t('待办', 'To do') }}</span><strong>{{ taskStore.todoTasks.length }}</strong></p><p><span>{{ t('进行中', 'In progress') }}</span><strong>{{ taskStore.inProgressTasks.length }}</strong></p><p><span>{{ t('已完成', 'Completed') }}</span><strong>{{ taskStore.doneTasks.length }}</strong></p></div>
</div> </div>
</template> </template>
+11 -10
View File
@@ -2,6 +2,7 @@
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import type { TaskItem, TaskStatus } from '@/contracts' import type { TaskItem, TaskStatus } from '@/contracts'
import { useTaskStore } from '@/stores/task' import { useTaskStore } from '@/stores/task'
import { localeTag, t } from '@/i18n'
const taskStore = useTaskStore() const taskStore = useTaskStore()
const showForm = ref(false) const showForm = ref(false)
@@ -20,32 +21,32 @@ async function saveTask() {
if (editingId.value) await taskStore.updateTask(editingId.value, { ...form, due_date: form.due_date || undefined, note_id: form.note_id || null }) if (editingId.value) await taskStore.updateTask(editingId.value, { ...form, due_date: form.due_date || undefined, note_id: form.note_id || null })
else await taskStore.createTask({ ...form, due_date: form.due_date || undefined, note_id: form.note_id || undefined }) else await taskStore.createTask({ ...form, due_date: form.due_date || undefined, note_id: form.note_id || undefined })
showForm.value = false; resetForm() showForm.value = false; resetForm()
} catch (error) { actionError.value = error instanceof Error ? error.message : '任务保存失败' } } catch (error) { actionError.value = error instanceof Error ? error.message : t('任务保存失败', 'Failed to save task') }
} }
async function setStatus(task: TaskItem, status: TaskStatus) { async function setStatus(task: TaskItem, status: TaskStatus) {
try { await taskStore.updateTask(task.task_id, { status }) } catch (error) { actionError.value = error instanceof Error ? error.message : '状态更新失败' } try { await taskStore.updateTask(task.task_id, { status }) } catch (error) { actionError.value = error instanceof Error ? error.message : t('状态更新失败', 'Failed to update status') }
} }
async function remove(task: TaskItem) { async function remove(task: TaskItem) {
if (!confirm(`确定删除任务${task.title}吗?`)) return if (!confirm(`${t('确定删除任务', 'Delete task')} ${task.title}?`)) return
try { await taskStore.deleteTask(task.task_id) } catch (error) { actionError.value = error instanceof Error ? error.message : '任务删除失败' } try { await taskStore.deleteTask(task.task_id) } catch (error) { actionError.value = error instanceof Error ? error.message : t('任务删除失败', 'Failed to delete task') }
} }
</script> </script>
<template> <template>
<section class="feature-page"> <section class="feature-page">
<header class="feature-header"><div><h1>任务</h1><p>管理用户笔记和 Agent 产生的行动项</p></div><button class="button-primary" @click="resetForm(); showForm = true"> 新建任务</button></header> <header class="feature-header"><div><h1>{{ t('任务', 'Tasks') }}</h1><p>{{ t('管理用户、笔记和 Agent 产生的行动项。', 'Manage action items created by users, notes, and agents.') }}</p></div><button class="button-primary" @click="resetForm(); showForm = true"> {{ t('新建任务', 'New task') }}</button></header>
<div v-if="taskStore.error || actionError" class="error-banner">{{ taskStore.error || actionError }}</div> <div v-if="taskStore.error || actionError" class="error-banner">{{ taskStore.error || actionError }}</div>
<div v-if="taskStore.filteredTasks.length" class="task-list"> <div v-if="taskStore.filteredTasks.length" class="task-list">
<article v-for="task in taskStore.filteredTasks" :key="task.task_id" class="item-card task-card"> <article v-for="task in taskStore.filteredTasks" :key="task.task_id" class="item-card task-card">
<button class="status-check" :class="{ done: task.status === 'done' }" title="切换完成状态" @click="setStatus(task, task.status === 'done' ? 'todo' : 'done')">{{ task.status === 'done' ? '' : '' }}</button> <button class="status-check" :class="{ done: task.status === 'done' }" :title="t('切换完成状态', 'Toggle completion')" @click="setStatus(task, task.status === 'done' ? 'todo' : 'done')">{{ task.status === 'done' ? '' : '' }}</button>
<div class="task-content"><div class="task-title"><strong :class="{ completed: task.status === 'done' }">{{ task.title }}</strong></div><p v-if="task.description" class="muted">{{ task.description }}</p><div class="subtle"><span>{{ task.status }}</span><span v-if="task.due_date">截止 {{ new Date(task.due_date).toLocaleString() }}</span><span v-if="task.note_id">关联 Note{{ task.note_id }}</span></div></div> <div class="task-content"><div class="task-title"><strong :class="{ completed: task.status === 'done' }">{{ task.title }}</strong></div><p v-if="task.description" class="muted">{{ task.description }}</p><div class="subtle"><span>{{ task.status }}</span><span v-if="task.due_date">{{ t('截止', 'Due') }} {{ new Date(task.due_date).toLocaleString(localeTag()) }}</span><span v-if="task.note_id">{{ t('关联 Note', 'Linked Note') }}: {{ task.note_id }}</span></div></div>
<div class="inline-actions"><button class="icon-button" @click="editTask(task)">编辑</button><button class="button-danger" @click="remove(task)">删除</button></div> <div class="inline-actions"><button class="icon-button" @click="editTask(task)">{{ t('编辑', 'Edit') }}</button><button class="button-danger" @click="remove(task)">{{ t('删除', 'Delete') }}</button></div>
</article> </article>
</div> </div>
<div v-else class="empty-state"><div><strong>{{ taskStore.isLoading ? '正在加载任务…' : '没有符合条件的任务' }}</strong><p>创建一项任务或调整左侧筛选条件</p></div></div> <div v-else class="empty-state"><div><strong>{{ taskStore.isLoading ? t('正在加载任务…', 'Loading tasks…') : t('没有符合条件的任务', 'No matching tasks') }}</strong><p>{{ t('创建一项任务或调整左侧筛选条件。', 'Create a task or adjust the filters.') }}</p></div></div>
<div v-if="showForm" class="modal-backdrop" @click.self="showForm = false"><div class="modal"><h2>{{ editingId ? '编辑任务' : '新建任务' }}</h2><form @submit.prevent="saveTask"><div class="field"><label>标题</label><input v-model="form.title" class="input" required /></div><div class="field"><label>描述</label><textarea v-model="form.description" class="textarea" /></div><div class="field"><label>截止时间</label><input v-model="form.due_date" class="input" type="datetime-local" /></div><div class="field"><label>关联 Note ID</label><input v-model="form.note_id" class="input" /></div><div class="inline-actions"><button class="button-primary">保存</button><button type="button" class="button-secondary" @click="showForm = false">取消</button></div></form></div></div> <div v-if="showForm" class="modal-backdrop" @click.self="showForm = false"><div class="modal"><h2>{{ editingId ? t('编辑任务', 'Edit task') : t('新建任务', 'New task') }}</h2><form @submit.prevent="saveTask"><div class="field"><label>{{ t('标题', 'Title') }}</label><input v-model="form.title" class="input" required /></div><div class="field"><label>{{ t('描述', 'Description') }}</label><textarea v-model="form.description" class="textarea" /></div><div class="field"><label>{{ t('截止时间', 'Due date') }}</label><input v-model="form.due_date" class="input" type="datetime-local" /></div><div class="field"><label>{{ t('关联 Note ID', 'Linked Note ID') }}</label><input v-model="form.note_id" class="input" /></div><div class="inline-actions"><button class="button-primary">{{ t('保存', 'Save') }}</button><button type="button" class="button-secondary" @click="showForm = false">{{ t('取消', 'Cancel') }}</button></div></form></div></div>
</section> </section>
</template> </template>
+11 -10
View File
@@ -2,6 +2,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import MarkdownContent from '@/components/common/MarkdownContent.vue' import MarkdownContent from '@/components/common/MarkdownContent.vue'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { t } from '@/i18n'
const themeStore = useThemeStore() const themeStore = useThemeStore()
const shikiPreview = `\`\`\`typescript const shikiPreview = `\`\`\`typescript
@@ -14,25 +15,25 @@ const codeThemeLabel = computed(() => themeStore.resolvedCodeBlockTheme === 'git
<template> <template>
<section class="feature-page"> <section class="feature-page">
<header class="feature-header"><div><h1>主题</h1><p>预览并切换 Design Token编辑器偏好会即时生效</p></div><button class="button-secondary" @click="themeStore.resetToDefault">恢复默认</button></header> <header class="feature-header"><div><h1>{{ t('主题', 'Themes') }}</h1><p>{{ t('预览并切换 Design Token,编辑器偏好会即时生效。', 'Preview and switch design tokens. Editor preferences apply immediately.') }}</p></div><button class="button-secondary" @click="themeStore.resetToDefault">{{ t('恢复默认', 'Reset defaults') }}</button></header>
<div class="feature-grid themes"> <div class="feature-grid themes">
<button v-for="theme in themeStore.themes" :key="theme.theme_id" class="item-card theme-card" :class="{ selected: themeStore.currentThemeId === theme.theme_id }" @click="themeStore.applyTheme(theme.theme_id)"> <button v-for="theme in themeStore.themes" :key="theme.theme_id" class="item-card theme-card" :class="{ selected: themeStore.currentThemeId === theme.theme_id }" @click="themeStore.applyTheme(theme.theme_id)">
<div class="theme-preview" :class="`preview-${theme.theme_id}`"><span></span><span></span><span></span><div></div></div> <div class="theme-preview" :class="`preview-${theme.theme_id}`"><span></span><span></span><span></span><div></div></div>
<div class="theme-info"><div><strong>{{ theme.name }}</strong><p class="subtle">{{ theme.description }}</p></div><span v-if="themeStore.currentThemeId === theme.theme_id" class="badge success">使用中</span></div> <div class="theme-info"><div><strong>{{ theme.name }}</strong><p class="subtle">{{ theme.description }}</p></div><span v-if="themeStore.currentThemeId === theme.theme_id" class="badge success">{{ t('使用中', 'Active') }}</span></div>
<p class="subtle">v{{ theme.version }} · {{ theme.builtin ? '内置主题' : theme.author }}</p> <p class="subtle">v{{ theme.version }} · {{ theme.builtin ? t('内置主题', 'Built-in theme') : theme.author }}</p>
</button> </button>
</div> </div>
<div class="panel preference-panel"> <div class="panel preference-panel">
<h2 class="panel-title">编辑器外观</h2> <h2 class="panel-title">{{ t('编辑器外观', 'Editor Appearance') }}</h2>
<div class="form-grid"> <div class="form-grid">
<div class="field"><label>字号{{ themeStore.fontEditorSize }}px</label><input v-model.number="themeStore.fontEditorSize" type="range" min="12" max="24" /></div> <div class="field"><label>{{ t('字号', 'Font size') }}: {{ themeStore.fontEditorSize }}px</label><input v-model.number="themeStore.fontEditorSize" type="range" min="12" max="24" /></div>
<div class="field"><label>行高{{ themeStore.lineHeight }}</label><input v-model.number="themeStore.lineHeight" type="range" min="1.2" max="2.2" step="0.1" /></div> <div class="field"><label>{{ t('行高', 'Line height') }}: {{ themeStore.lineHeight }}</label><input v-model.number="themeStore.lineHeight" type="range" min="1.2" max="2.2" step="0.1" /></div>
<div class="field"><label>字体</label><select v-model="themeStore.fontEditorFamily" class="select"><option value="system-ui">系统字体</option><option value="serif">衬线字体</option><option value="var(--font-ui-mono)">等宽字体</option></select></div> <div class="field"><label>{{ t('字体', 'Font') }}</label><select v-model="themeStore.fontEditorFamily" class="select"><option value="system-ui">{{ t('系统字体', 'System font') }}</option><option value="serif">{{ t('衬线字体', 'Serif') }}</option><option value="var(--font-ui-mono)">{{ t('等宽字体', 'Monospace') }}</option></select></div>
<div class="field"><label>代码块样式</label><select v-model="themeStore.codeBlockTheme" class="select"><option value="auto">跟随主题</option><option value="github-light">GitHub Light</option><option value="github-dark">GitHub Dark</option></select><small>Markdown 渲染使用对应的 Shiki GitHub 主题</small></div> <div class="field"><label>{{ t('代码块样式', 'Code block style') }}</label><select v-model="themeStore.codeBlockTheme" class="select"><option value="auto">{{ t('跟随主题', 'Follow theme') }}</option><option value="github-light">GitHub Light</option><option value="github-dark">GitHub Dark</option></select><small>{{ t('Markdown 渲染使用对应的 Shiki GitHub 主题', 'Markdown rendering uses the matching Shiki GitHub theme') }}</small></div>
</div> </div>
<div class="editor-preview" :style="{ fontSize: `${themeStore.fontEditorSize}px`, lineHeight: themeStore.lineHeight, fontFamily: themeStore.fontEditorFamily }"> <div class="editor-preview" :style="{ fontSize: `${themeStore.fontEditorSize}px`, lineHeight: themeStore.lineHeight, fontFamily: themeStore.fontEditorFamily }">
<div class="preview-heading"><h3>主题预览</h3><span class="badge info">{{ codeThemeLabel }}</span></div> <div class="preview-heading"><h3>{{ t('主题预览', 'Theme Preview') }}</h3><span class="badge info">{{ codeThemeLabel }}</span></div>
<p>知识的价值不只在于保存更在于被重新发现和使用</p> <p>{{ t('知识的价值不只在于保存更在于被重新发现和使用。', 'Knowledge gains value when it can be rediscovered and used.') }}</p>
<MarkdownContent class="code-theme-preview" :source="shikiPreview" /> <MarkdownContent class="code-theme-preview" :source="shikiPreview" />
</div> </div>
</div> </div>
+10 -9
View File
@@ -6,6 +6,7 @@ import { useThemeStore } from '@/stores/theme'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { ArrowRight, Document, Folder, FolderOpened, Moon, Sunny } from '@element-plus/icons-vue' import { ArrowRight, Document, Folder, FolderOpened, Moon, Sunny } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue' import AppIcon from '@/components/common/AppIcon.vue'
import { t } from '@/i18n'
const router = useRouter() const router = useRouter()
const workspaceStore = useWorkspaceStore() const workspaceStore = useWorkspaceStore()
@@ -55,15 +56,15 @@ async function openFolderPicker() {
<div class="brand-section"> <div class="brand-section">
<div class="logo"><AppIcon :icon="Document" :size="56" /></div> <div class="logo"><AppIcon :icon="Document" :size="56" /></div>
<h1 class="app-title">NotesAgent</h1> <h1 class="app-title">NotesAgent</h1>
<p class="app-subtitle">本地优先的 AI 笔记软件</p> <p class="app-subtitle">{{ t('本地优先的 AI 笔记软件', 'A local-first AI note-taking app') }}</p>
</div> </div>
<div class="vault-card"> <div class="vault-card">
<h2 class="card-title">选择知识库</h2> <h2 class="card-title">{{ t('选择知识库', 'Select Knowledge Base') }}</h2>
<p class="card-desc">Web 联调模式连接 AI Core 当前配置的 Vault</p> <p class="card-desc">{{ t('Web 联调模式连接 AI Core 当前配置的 Vault', 'Web development mode connects to the Vault configured in AI Core') }}</p>
<div v-if="workspaceStore.recentVaults.length" class="recent-vaults"> <div v-if="workspaceStore.recentVaults.length" class="recent-vaults">
<div class="section-label">最近打开</div> <div class="section-label">{{ t('最近打开', 'Recently opened') }}</div>
<div class="vault-list"> <div class="vault-list">
<button <button
v-for="vault in workspaceStore.recentVaults" v-for="vault in workspaceStore.recentVaults"
@@ -84,15 +85,15 @@ async function openFolderPicker() {
<div class="actions"> <div class="actions">
<button class="btn btn-primary" @click="openFolderPicker" :disabled="isLoading || !workspaceStore.recentVaults.length"> <button class="btn btn-primary" @click="openFolderPicker" :disabled="isLoading || !workspaceStore.recentVaults.length">
<AppIcon :icon="FolderOpened" /> 打开后端 Vault <AppIcon :icon="FolderOpened" /> {{ t('打开后端 Vault', 'Open backend Vault') }}
</button> </button>
</div> </div>
<div class="ai-core-status"> <div class="ai-core-status">
<span class="status-dot" :class="aiCoreStatus" /> <span class="status-dot" :class="aiCoreStatus" />
<span v-if="aiCoreStatus === 'checking'">正在检查 AI Core 状态...</span> <span v-if="aiCoreStatus === 'checking'">{{ t('正在检查 AI Core 状态...', 'Checking AI Core status...') }}</span>
<span v-else-if="aiCoreStatus === 'running'" class="status-running">AI Core 运行正常</span> <span v-else-if="aiCoreStatus === 'running'" class="status-running">{{ t('AI Core 运行正常', 'AI Core is running') }}</span>
<span v-else class="status-stopped">AI Core 未启动编辑功能仍可用</span> <span v-else class="status-stopped">{{ t('AI Core 未启动(编辑功能仍可用)', 'AI Core is offline (editing remains available)') }}</span>
</div> </div>
</div> </div>
@@ -100,7 +101,7 @@ async function openFolderPicker() {
<span>v0.1.0</span> <span>v0.1.0</span>
<button class="theme-toggle" @click="themeStore.toggleTheme()"> <button class="theme-toggle" @click="themeStore.toggleTheme()">
<AppIcon :icon="themeStore.isDark ? Sunny : Moon" :size="15" /> <AppIcon :icon="themeStore.isDark ? Sunny : Moon" :size="15" />
{{ themeStore.isDark ? '浅色' : '深色' }} {{ themeStore.isDark ? t('浅色', 'Light') : t('深色', 'Dark') }}
</button> </button>
</div> </div>
</div> </div>
@@ -8,6 +8,7 @@ import { useWorkspaceStore } from '@/stores/workspace'
import FileTreeNode from './FileTreeNode.vue' import FileTreeNode from './FileTreeNode.vue'
import { DocumentAdd, FolderAdd } from '@element-plus/icons-vue' import { DocumentAdd, FolderAdd } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue' import AppIcon from '@/components/common/AppIcon.vue'
import { t } from '@/i18n'
const workspaceStore = useWorkspaceStore() const workspaceStore = useWorkspaceStore()
const editorStore = useEditorStore() const editorStore = useEditorStore()
@@ -91,7 +92,7 @@ function closeContextMenu() { contextTarget.value = null }
async function renameTarget() { async function renameTarget() {
const node = contextTarget.value const node = contextTarget.value
if (!node) return if (!node) return
const newName = window.prompt('新名称', node.name)?.trim() const newName = window.prompt(t('新名称', 'New name'), node.name)?.trim()
if (newName && newName !== node.name) { if (newName && newName !== node.name) {
const normalizedName = node.type === 'file' && !newName.toLowerCase().endsWith('.md') ? `${newName}.md` : newName const normalizedName = node.type === 'file' && !newName.toLowerCase().endsWith('.md') ? `${newName}.md` : newName
const oldPath = node.path const oldPath = node.path
@@ -113,7 +114,7 @@ async function renameTarget() {
async function deleteTarget() { async function deleteTarget() {
const node = contextTarget.value const node = contextTarget.value
if (!node) return if (!node) return
if (!window.confirm(`确定要删除${node.name}吗?`)) return closeContextMenu() if (!window.confirm(`${t('确定要删除', 'Delete')} ${node.name}?`)) return closeContextMenu()
await workspaceService.deleteFile(node.path) await workspaceService.deleteFile(node.path)
const activeWasRemoved = workspaceStore.closePath(node.path) const activeWasRemoved = workspaceStore.closePath(node.path)
workspaceStore.removeFromTree(node.path) workspaceStore.removeFromTree(node.path)
@@ -137,13 +138,13 @@ function containingFolder(path: string): string {
<template> <template>
<section class="file-tree-panel" @click="closeContextMenu"> <section class="file-tree-panel" @click="closeContextMenu">
<div class="toolbar"> <div class="toolbar">
<button type="button" title="新建笔记" aria-label="新建笔记" @click.stop="beginCreate('file', selectedFolderPath)"><AppIcon :icon="DocumentAdd" /></button> <button type="button" :title="t('新建笔记', 'New note')" :aria-label="t('新建笔记', 'New note')" @click.stop="beginCreate('file', selectedFolderPath)"><AppIcon :icon="DocumentAdd" /></button>
<button type="button" title="新建文件夹" aria-label="新建文件夹" @click.stop="beginCreate('folder', selectedFolderPath)"><AppIcon :icon="FolderAdd" /></button> <button type="button" :title="t('新建文件夹', 'New folder')" :aria-label="t('新建文件夹', 'New folder')" @click.stop="beginCreate('folder', selectedFolderPath)"><AppIcon :icon="FolderAdd" /></button>
</div> </div>
<form v-if="newItemType" class="new-item" @submit.prevent="createItem"> <form v-if="newItemType" class="new-item" @submit.prevent="createItem">
<input v-model="newItemName" :placeholder="newItemType === 'file' ? '笔记名称' : '文件夹名称'" autofocus /> <input v-model="newItemName" :placeholder="newItemType === 'file' ? t('笔记名称', 'Note name') : t('文件夹名称', 'Folder name')" autofocus />
<button type="submit">创建</button> <button type="submit">{{ t('创建', 'Create') }}</button>
<button type="button" @click="newItemType = null">取消</button> <button type="button" @click="newItemType = null">{{ t('取消', 'Cancel') }}</button>
</form> </form>
<div class="tree"> <div class="tree">
<FileTreeNode v-for="node in workspaceStore.fileTree" :key="node.id" :node="node" <FileTreeNode v-for="node in workspaceStore.fileTree" :key="node.id" :node="node"
@@ -152,8 +153,8 @@ function containingFolder(path: string): string {
<Teleport to="body"> <Teleport to="body">
<div v-if="contextTarget" class="context-menu" <div v-if="contextTarget" class="context-menu"
:style="{ left: `${contextMenuPosition.x}px`, top: `${contextMenuPosition.y}px` }" @click.stop> :style="{ left: `${contextMenuPosition.x}px`, top: `${contextMenuPosition.y}px` }" @click.stop>
<button @click="renameTarget">重命名</button> <button @click="renameTarget">{{ t('重命名', 'Rename') }}</button>
<button class="danger" @click="deleteTarget">删除</button> <button class="danger" @click="deleteTarget">{{ t('删除', 'Delete') }}</button>
</div> </div>
</Teleport> </Teleport>
</section> </section>
@@ -4,6 +4,7 @@ import EditorHeader from '@/features/editor/EditorHeader.vue'
import EditorPane from '@/features/editor/EditorPane.vue' import EditorPane from '@/features/editor/EditorPane.vue'
import { EditPen } from '@element-plus/icons-vue' import { EditPen } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue' import AppIcon from '@/components/common/AppIcon.vue'
import { t } from '@/i18n'
const workspaceStore = useWorkspaceStore() const workspaceStore = useWorkspaceStore()
</script> </script>
@@ -17,8 +18,8 @@ const workspaceStore = useWorkspaceStore()
<div v-else class="empty-workspace"> <div v-else class="empty-workspace">
<div class="empty-content"> <div class="empty-content">
<AppIcon class="empty-icon" :icon="EditPen" :size="48" /> <AppIcon class="empty-icon" :icon="EditPen" :size="48" />
<h2>开始写作</h2> <h2>{{ t('开始写作', 'Start writing') }}</h2>
<p>从左侧文件树选择笔记或创建新的笔记</p> <p>{{ t('从左侧文件树选择笔记或创建新的笔记', 'Select a note from the file tree or create a new one') }}</p>
</div> </div>
</div> </div>
</div> </div>
+41
View File
@@ -0,0 +1,41 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
import { nextTick } from 'vue'
import PrimarySidebar from '@/components/common/PrimarySidebar.vue'
import { appLocale, t } from '@/i18n'
import { useSettingsStore } from '@/stores/settings'
beforeEach(() => {
localStorage.clear()
appLocale.value = 'zh-CN'
setActivePinia(createPinia())
})
afterEach(() => {
appLocale.value = 'zh-CN'
localStorage.clear()
})
describe('interface locale', () => {
it('changes shared labels and the document language immediately', async () => {
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', name: 'workspace', component: { template: '<div />' } }],
})
await router.push('/')
await router.isReady()
const wrapper = mount(PrimarySidebar, { global: { plugins: [router] } })
const settings = useSettingsStore()
expect(wrapper.text()).toContain('工作区')
settings.language = 'en'
await nextTick()
expect(t('工作区', 'Workspace')).toBe('Workspace')
expect(wrapper.text()).toContain('Workspace')
expect(document.documentElement.lang).toBe('en')
})
})
+28
View File
@@ -0,0 +1,28 @@
import { ref, watch } from 'vue'
export type AppLocale = 'zh-CN' | 'en'
function storedLocale(): AppLocale {
if (typeof localStorage === 'undefined') return 'zh-CN'
try {
const saved = JSON.parse(localStorage.getItem('app-settings') ?? '{}') as { language?: unknown }
return saved.language === 'en' ? 'en' : 'zh-CN'
} catch {
return 'zh-CN'
}
}
export const appLocale = ref<AppLocale>(storedLocale())
watch(appLocale, (value) => {
if (typeof document !== 'undefined') document.documentElement.lang = value
}, { immediate: true })
/** Keep the Chinese source beside its English translation while the UI is migrated. */
export function t(zh: string, en: string): string {
return appLocale.value === 'en' ? en : zh
}
export function localeTag(): string {
return appLocale.value === 'en' ? 'en' : 'zh-CN'
}
+10
View File
@@ -5,6 +5,10 @@ import router from './router'
import './styles/tokens.css' import './styles/tokens.css'
import './styles/features.css' import './styles/features.css'
import { useThemeStore } from './stores/theme' import { useThemeStore } from './stores/theme'
import { useSettingsStore } from './stores/settings'
import { watch } from 'vue'
import { appLocale } from './i18n'
import { updateDocumentTitle } from './router'
const app = createApp(App) const app = createApp(App)
const pinia = createPinia() const pinia = createPinia()
@@ -13,6 +17,12 @@ app.use(pinia)
app.use(router) app.use(router)
const themeStore = useThemeStore() const themeStore = useThemeStore()
const settingsStore = useSettingsStore()
themeStore.initTheme() themeStore.initTheme()
watch(appLocale, () => updateDocumentTitle())
watch(() => settingsStore.spellCheck, (enabled) => {
document.body.spellcheck = enabled
document.body.setAttribute('spellcheck', String(enabled))
}, { immediate: true })
app.mount('#app') app.mount('#app')
+20 -3
View File
@@ -1,5 +1,6 @@
import { createRouter, createWebHashHistory } from 'vue-router' import { createRouter, createWebHashHistory } from 'vue-router'
import { useWorkspaceStore } from '@/stores/workspace' import { useWorkspaceStore } from '@/stores/workspace'
import { t } from '@/i18n'
const routes = [ const routes = [
{ path: '/media', name: 'media', component: () => import('@/features/media/MediaView.vue'), meta: { title: '音视频转写', requiresVault: true } }, { path: '/media', name: 'media', component: () => import('@/features/media/MediaView.vue'), meta: { title: '音视频转写', requiresVault: true } },
@@ -87,10 +88,26 @@ router.beforeEach((to) => {
return true return true
}) })
router.afterEach((to) => { export function updateDocumentTitle(to = router.currentRoute.value) {
const baseTitle = 'NotesAgent' const baseTitle = 'NotesAgent'
const title = to.meta.title as string | undefined const titles: Record<string, string> = {
media: t('音视频转写', 'Media Transcription'),
'vault-entry': t('选择知识库', 'Select Knowledge Base'),
workspace: t('工作区', 'Workspace'),
search: t('搜索', 'Search'),
chat: t('AI 对话', 'AI Chat'),
agent: 'Agent Trace',
tasks: t('任务', 'Tasks'),
skills: t('Skill 管理', 'Skill Management'),
'mcp-servers': t('MCP 服务器', 'MCP Servers'),
plugins: t('Plugin 与 MCP', 'Plugins and MCP'),
themes: t('主题管理', 'Theme Management'),
settings: t('设置', 'Settings'),
}
const title = titles[String(to.name ?? '')] ?? (to.meta.title as string | undefined)
document.title = title ? `${title} · ${baseTitle}` : baseTitle document.title = title ? `${title} · ${baseTitle}` : baseTitle
}) }
router.afterEach(updateDocumentTitle)
export default router export default router
+24 -1
View File
@@ -1,10 +1,14 @@
import { SseClient } from './sseClient' import { SseClient } from './sseClient'
import type { ModelEvent } from '@/contracts' import { apiClient } from './apiClient'
import type { ChatMessage, Conversation, ModelEvent, PageMeta } from '@/contracts'
export interface ChatRequest { export interface ChatRequest {
provider_id: string provider_id: string
model: string model: string
conversation_id?: string conversation_id?: string
user_message_id?: string
assistant_message_id?: string
conversation_title?: string
system?: string system?: string
messages: Array<{ messages: Array<{
role: 'system' | 'user' | 'assistant' | 'tool' role: 'system' | 'user' | 'assistant' | 'tool'
@@ -18,6 +22,25 @@ export interface ChatRequest {
max_tokens?: number max_tokens?: number
} }
export function listConversations(offset = 0, limit = 100) {
return apiClient.get<{ items: Conversation[]; page: PageMeta }>('/api/chat/conversations', { params: { limit, offset } })
}
export function createConversation(conversation: Pick<Conversation, 'conversation_id' | 'title'>) {
return apiClient.post<Conversation>('/api/chat/conversations', {
conversation_id: conversation.conversation_id,
title: conversation.title,
})
}
export function listConversationMessages(conversationId: string, offset = 0, limit = 500) {
return apiClient.get<{ items: ChatMessage[]; page: PageMeta }>(`/api/chat/conversations/${encodeURIComponent(conversationId)}/messages`, { params: { limit, offset } })
}
export function removeConversation(conversationId: string) {
return apiClient.delete(`/api/chat/conversations/${encodeURIComponent(conversationId)}`)
}
export function streamChat( export function streamChat(
request: ChatRequest, request: ChatRequest,
handlers: { handlers: {
+2 -1
View File
@@ -1,4 +1,5 @@
import { apiClient, resolveApiUrl } from './apiClient' import { apiClient, resolveApiUrl } from './apiClient'
import { t } from '@/i18n'
export interface Segment { segment_id: string; start_time: number; end_time: number; text: string; speaker: string | null; language?: string } export interface Segment { segment_id: string; start_time: number; end_time: number; text: string; speaker: string | null; language?: string }
export interface MediaJob { export interface MediaJob {
@@ -26,7 +27,7 @@ export const mediaService = {
const response = await fetch(resolveApiUrl(`/api/media/attachments?filename=${encodeURIComponent(file.name)}`), { const response = await fetch(resolveApiUrl(`/api/media/attachments?filename=${encodeURIComponent(file.name)}`), {
method: 'POST', headers: {'Content-Type': 'application/octet-stream', ...(idempotencyKey ? {'Idempotency-Key': idempotencyKey} : {})}, body: file, method: 'POST', headers: {'Content-Type': 'application/octet-stream', ...(idempotencyKey ? {'Idempotency-Key': idempotencyKey} : {})}, body: file,
}) })
if (!response.ok) throw new Error((await response.json())?.error?.message || '附件上传失败') if (!response.ok) throw new Error((await response.json())?.error?.message || t('附件上传失败', 'Attachment upload failed'))
return await response.json() as {attachment_id: string} return await response.json() as {attachment_id: string}
}, },
} }
+3 -2
View File
@@ -7,6 +7,7 @@ import type {
OperationResponse, OperationResponse,
} from '@/contracts' } from '@/contracts'
import apiClient from './apiClient' import apiClient from './apiClient'
import { t } from '@/i18n'
import * as noteService from './noteService' import * as noteService from './noteService'
/** Web 联调只连接 AI Core 配置的单一 Vault;多 Vault 选择由 Tauri Host 接管。 */ /** Web 联调只连接 AI Core 配置的单一 Vault;多 Vault 选择由 Tauri Host 接管。 */
@@ -72,7 +73,7 @@ async function requireNoteId(filePath: string): Promise<string> {
await refreshTree() await refreshTree()
noteId = noteIdByPath.get(path) noteId = noteIdByPath.get(path)
} }
if (!noteId) throw new Error(`笔记尚未建立后端索引:${path}`) if (!noteId) throw new Error(`${t('笔记尚未建立后端索引:', 'The note has not been indexed by the backend: ')}${path}`)
return noteId return noteId
} }
@@ -174,7 +175,7 @@ export async function deleteFile(pathValue: string): Promise<void> {
export async function moveFile(sourcePath: string, targetPath: string): Promise<void> { export async function moveFile(sourcePath: string, targetPath: string): Promise<void> {
const source = normalizePublicPath(sourcePath) const source = normalizePublicPath(sourcePath)
if (typeByPath.get(source) !== 'file') { if (typeByPath.get(source) !== 'file') {
throw new Error('当前阶段只支持移动笔记文件。') throw new Error(t('当前阶段只支持移动笔记文件。', 'Only note files can be moved at this stage.'))
} }
await noteService.moveNote(await requireNoteId(source), relativePath(targetPath)) await noteService.moveNote(await requireNoteId(source), relativePath(targetPath))
await refreshTree() await refreshTree()
+2 -1
View File
@@ -3,6 +3,7 @@ import { ref, computed } from 'vue'
import type { AgentRun, AgentEvent, ToolDefinition, PermissionRequest, ToolCall } from '@/contracts' import type { AgentRun, AgentEvent, ToolDefinition, PermissionRequest, ToolCall } from '@/contracts'
import * as agentService from '@/services/agentService' import * as agentService from '@/services/agentService'
import type { SseClient } from '@/services/sseClient' import type { SseClient } from '@/services/sseClient'
import { t } from '@/i18n'
export const useAgentStore = defineStore('agent', () => { export const useAgentStore = defineStore('agent', () => {
const runs = ref<AgentRun[]>([]) const runs = ref<AgentRun[]>([])
@@ -93,7 +94,7 @@ export const useAgentStore = defineStore('agent', () => {
tool_name: String(call.name ?? 'unknown'), tool_name: String(call.name ?? 'unknown'),
permission: String(data.permission ?? ''), permission: String(data.permission ?? ''),
parameters: (call.arguments ?? {}) as Record<string, unknown>, parameters: (call.arguments ?? {}) as Record<string, unknown>,
impact: '该工具需要获得权限后才能继续执行。', impact: t('该工具需要获得权限后才能继续执行。', 'This tool requires permission before it can continue.'),
} }
if (run) run.status = 'waiting_permission' if (run) run.status = 'waiting_permission'
} else if (['RunCompleted', 'RunFailed', 'RunCancelled'].includes(event.event)) { } else if (['RunCompleted', 'RunFailed', 'RunCancelled'].includes(event.event)) {
+248 -11
View File
@@ -1,36 +1,85 @@
import { beforeEach, expect, it, vi } from 'vitest' import { beforeEach, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import { useChatStore } from './chat' import { useChatStore } from './chat'
import { streamChat } from '@/services/chatService' import {
createConversation,
listConversationMessages,
listConversations,
removeConversation,
streamChat,
} from '@/services/chatService'
import type { ChatMessage, Conversation } from '@/contracts'
import type { SseClient } from '@/services/sseClient' import type { SseClient } from '@/services/sseClient'
vi.mock('@/services/chatService', () => ({ streamChat: vi.fn() })) vi.mock('@/services/chatService', () => ({
createConversation: vi.fn(),
listConversationMessages: vi.fn(),
listConversations: vi.fn(),
removeConversation: vi.fn(),
streamChat: vi.fn(),
}))
const page = { total: 0, limit: 100, offset: 0 }
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: Error) => void
const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail })
return { promise, resolve, reject }
}
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()) setActivePinia(createPinia())
vi.mocked(streamChat).mockReset().mockReturnValue({ cancel: vi.fn() } as unknown as SseClient) vi.mocked(streamChat).mockReset().mockReturnValue({ cancel: vi.fn() } as unknown as SseClient)
vi.mocked(listConversations).mockReset().mockResolvedValue({ items: [], page })
vi.mocked(listConversationMessages).mockReset().mockResolvedValue({ items: [], page: { ...page, limit: 1000 } })
vi.mocked(createConversation).mockReset().mockImplementation(async value => ({
...value, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), message_count: 0,
}))
vi.mocked(removeConversation).mockReset().mockResolvedValue(undefined)
}) })
it('sends real user history, applies streaming changes, and restores it when switching conversations', async () => { it('sends persistent message ids and restores messages from the backend', async () => {
const store = useChatStore() const store = useChatStore()
store.selectedProviderId = 'real' store.selectedProviderId = 'real'
store.selectedModel = 'configured-model' store.selectedModel = 'configured-model'
await store.sendMessage('user input') await store.sendMessage('user input')
const [request, handlers] = vi.mocked(streamChat).mock.calls[0]! const [request, handlers] = vi.mocked(streamChat).mock.calls[0]!
expect(request.use_rag).toBe(true) expect(request.use_rag).toBe(true)
handlers.onEvent?.({ event: 'Citation', sequence: 0, timestamp: '', data: { note_id: 'note', block_id: 'block', file_path: 'note.md', content: 'real evidence' } }) expect(request.user_message_id).toBe(store.messages[0]?.message_id)
expect(store.messages[1]?.citations?.[0]?.content).toBe('real evidence') expect(request.assistant_message_id).toBe(store.messages[1]?.message_id)
expect(request.messages).toEqual([{ role: 'user', content: 'user input' }]) expect(request.messages).toEqual([{ role: 'user', content: 'user input' }])
handlers.onEvent?.({ event: 'TextDelta', sequence: 0, timestamp: '', data: { text: 'real response' } }) handlers.onEvent?.({ event: 'Citation', sequence: 0, timestamp: '', data: { note_id: 'note', block_id: 'block', file_path: 'note.md', heading_path: ['Heading'], content: 'real evidence' } })
expect(store.messages[1]?.content).toBe('real response') handlers.onEvent?.({ event: 'TextDelta', sequence: 1, timestamp: '', data: { text: 'real response' } })
handlers.onDone?.() handlers.onDone?.()
const persisted = store.messages.map(message => ({ ...message })) as ChatMessage[]
vi.mocked(listConversationMessages).mockResolvedValueOnce({ items: persisted, page: { total: 2, limit: 1000, offset: 0 } })
const id = store.activeConversationId! const id = store.activeConversationId!
store.createNewConversation() await store.createNewConversation()
expect(store.messages).toEqual([]) expect(store.messages).toEqual([])
await store.setActiveConversation(id) await store.setActiveConversation(id)
expect(store.messages.map(m => m.content)).toEqual(['user input', 'real response']) expect(store.messages.map(message => message.content)).toEqual(['user input', 'real response'])
expect(store.messages[1]?.citations?.[0]?.heading_path).toBe('Heading')
}) })
it('does not send without a provider and ignores late callbacks from a cancelled conversation', async () => { it('loads the newest persisted conversation on initialization', async () => {
const conversation: Conversation = {
conversation_id: 'persisted', title: 'Saved', created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-02T00:00:00Z', message_count: 1,
}
vi.mocked(listConversations).mockResolvedValue({ items: [conversation], page: { ...page, total: 1 } })
vi.mocked(listConversationMessages).mockResolvedValue({
items: [{ message_id: 'm1', conversation_id: 'persisted', role: 'user', content: 'saved text', created_at: '2026-01-01T00:00:00Z' }],
page: { total: 1, limit: 1000, offset: 0 },
})
const store = useChatStore()
await store.loadConversations()
expect(store.activeConversationId).toBe('persisted')
expect(store.messages[0]?.content).toBe('saved text')
})
it('does not send without a provider and ignores callbacks from a cancelled conversation', async () => {
const store = useChatStore() const store = useChatStore()
await store.sendMessage('no provider') await store.sendMessage('no provider')
expect(streamChat).not.toHaveBeenCalled() expect(streamChat).not.toHaveBeenCalled()
@@ -38,9 +87,197 @@ it('does not send without a provider and ignores late callbacks from a cancelled
store.selectedModel = 'configured-model' store.selectedModel = 'configured-model'
await store.sendMessage('first') await store.sendMessage('first')
const old = vi.mocked(streamChat).mock.calls[0]![1] const old = vi.mocked(streamChat).mock.calls[0]![1]
store.createNewConversation() await store.createNewConversation()
await store.sendMessage('second') await store.sendMessage('second')
old.onDone?.() old.onDone?.()
expect(store.isStreaming).toBe(true) expect(store.isStreaming).toBe(true)
expect(store.messages[0]?.content).toBe('second') expect(store.messages[0]?.content).toBe('second')
}) })
it('keeps a conversation visible when backend deletion fails', async () => {
const store = useChatStore()
await store.createNewConversation()
const id = store.activeConversationId!
vi.mocked(removeConversation).mockRejectedValueOnce(new Error('offline'))
await store.deleteConversation(id)
expect(store.conversations.some(item => item.conversation_id === id)).toBe(true)
expect(store.historyError).toBe('offline')
})
it('blocks sends until history is loaded, then includes that history', async () => {
const store = useChatStore()
store.selectedProviderId = 'real'
store.selectedModel = 'model'
await store.createNewConversation()
const id = store.activeConversationId!
const history = deferred<Awaited<ReturnType<typeof listConversationMessages>>>()
vi.mocked(listConversationMessages).mockReturnValueOnce(history.promise)
const loading = store.setActiveConversation(id)
store.inputText = 'followup'
expect(store.canSend).toBe(false)
await store.sendMessage(store.inputText)
expect(streamChat).not.toHaveBeenCalled()
expect(store.inputText).toBe('followup')
history.resolve({ items: [{ message_id: 'old', conversation_id: id, role: 'user', content: 'previous context', created_at: '' }], page: { ...page, total: 1 } })
await loading
expect(store.canSend).toBe(true)
await store.sendMessage(store.inputText)
expect(vi.mocked(streamChat).mock.calls[0]![0].messages).toEqual([
{ role: 'user', content: 'previous context' }, { role: 'user', content: 'followup' },
])
expect(store.messages.map(m => m.content)).toEqual(['previous context', 'followup', ''])
})
it('keeps sending blocked after history failure until a successful retry', async () => {
const store = useChatStore()
store.selectedProviderId = 'real'
store.selectedModel = 'model'
await store.createNewConversation()
const id = store.activeConversationId!
vi.mocked(listConversationMessages).mockRejectedValueOnce(new Error('offline'))
await store.setActiveConversation(id)
await store.sendMessage('followup')
expect(streamChat).not.toHaveBeenCalled()
expect(store.historyError).toBe('offline')
expect(store.canSend).toBe(false)
await store.setActiveConversation(id)
expect(store.canSend).toBe(true)
})
it.each(['switch', 'stop', 'delete'] as const)('cancels a pending send on %s without touching another send', async action => {
const store = useChatStore()
store.selectedProviderId = 'real'
store.selectedModel = 'model'
await store.createNewConversation()
const b = store.activeConversationId!
const creation = deferred<Conversation>()
vi.mocked(createConversation).mockReturnValueOnce(creation.promise)
const creating = store.createNewConversation()
const a = store.activeConversationId!
const saved = { ...store.activeConversation! }
const sending = store.sendMessage('belongs to a')
expect(store.isPreparing).toBe(true)
const deleting = action === 'delete' ? store.deleteConversation(a) : undefined
if (action === 'stop') store.stopGeneration()
await store.setActiveConversation(b)
await store.sendMessage('belongs to b')
creation.resolve(saved)
await Promise.all([creating, sending, deleting])
expect(store.activeConversationId).toBe(b)
expect(store.messages.every(m => m.conversation_id === b)).toBe(true)
expect(store.messages[0]?.content).toBe('belongs to b')
expect(streamChat).toHaveBeenCalledTimes(1)
expect(store.isStreaming).toBe(true)
})
it('locks the initial send while creating its conversation and allows retry after failure', async () => {
const store = useChatStore()
store.selectedProviderId = 'real'
store.selectedModel = 'model'
const creation = deferred<Conversation>()
vi.mocked(createConversation).mockReturnValueOnce(creation.promise)
const sending = store.sendMessage('first')
await store.sendMessage('duplicate')
expect(createConversation).toHaveBeenCalledTimes(1)
expect(streamChat).not.toHaveBeenCalled()
creation.resolve({ ...store.activeConversation! })
await sending
expect(streamChat).toHaveBeenCalledTimes(1)
store.stopGeneration()
store.activeConversationId = null
vi.mocked(createConversation).mockRejectedValueOnce(new Error('offline'))
await store.sendMessage('retry')
expect(store.isPreparing).toBe(false)
expect(store.canSend).toBe(true)
await store.sendMessage('retry')
expect(streamChat).toHaveBeenCalledTimes(2)
})
it('stops the first send before creation completes without switching conversations', async () => {
const store = useChatStore()
store.selectedProviderId = 'real'
store.selectedModel = 'model'
const creation = deferred<Conversation>()
vi.mocked(createConversation).mockReturnValueOnce(creation.promise)
const sending = store.sendMessage('cancelled')
const saved = { ...store.activeConversation! }
store.stopGeneration()
creation.resolve(saved)
await sending
expect(streamChat).not.toHaveBeenCalled()
expect(store.messages).toEqual([])
expect(store.canSend).toBe(true)
})
it('ignores old history after switching to a new conversation and sending', async () => {
const store = useChatStore()
store.selectedProviderId = 'real'
store.selectedModel = 'model'
await store.createNewConversation()
const id = store.activeConversationId!
const history = deferred<Awaited<ReturnType<typeof listConversationMessages>>>()
vi.mocked(listConversationMessages).mockReturnValueOnce(history.promise)
const loading = store.setActiveConversation(id)
await store.createNewConversation()
await store.sendMessage('new question')
history.resolve({ items: [], page })
await loading
expect(store.messages.map(m => m.content)).toEqual(['new question', ''])
expect(store.isStreaming).toBe(true)
})
it.each(['success', 'failure'])('blocks sends and duplicate deletes until deletion ends with %s', async outcome => {
const store = useChatStore()
store.selectedProviderId = 'real'
store.selectedModel = 'model'
await store.createNewConversation()
const id = store.activeConversationId!
const removal = deferred<Awaited<ReturnType<typeof removeConversation>>>()
vi.mocked(removeConversation).mockReturnValueOnce(removal.promise)
const deleting = store.deleteConversation(id)
store.inputText = 'keep this draft'
expect(store.canSend).toBe(false)
await store.sendMessage(store.inputText)
await store.deleteConversation(id)
expect(streamChat).not.toHaveBeenCalled()
expect(removeConversation).toHaveBeenCalledTimes(1)
expect(store.inputText).toBe('keep this draft')
if (outcome === 'success') removal.resolve(undefined)
else removal.reject(new Error('offline'))
await deleting
expect(store.isStreaming).toBe(false)
expect(store.canSend).toBe(true)
expect(store.activeConversationId).toBe(outcome === 'success' ? null : id)
await store.sendMessage(store.inputText)
expect(streamChat).toHaveBeenCalledTimes(1)
const request = vi.mocked(streamChat).mock.calls[0]![0]
if (outcome === 'success') expect(request.conversation_id).not.toBe(id)
else expect(request.conversation_id).toBe(id)
})
it('keeps a deleting conversation blocked after reselecting it without blocking other conversations', async () => {
const store = useChatStore()
store.selectedProviderId = 'real'
store.selectedModel = 'model'
await store.createNewConversation()
const a = store.activeConversationId!
await store.createNewConversation()
const b = store.activeConversationId!
const removal = deferred<Awaited<ReturnType<typeof removeConversation>>>()
vi.mocked(removeConversation).mockReturnValueOnce(removal.promise)
const deleting = store.deleteConversation(a)
await store.setActiveConversation(a)
expect(store.canSend).toBe(false)
await store.sendMessage('blocked')
expect(streamChat).not.toHaveBeenCalled()
await store.setActiveConversation(b)
expect(store.canSend).toBe(true)
await store.sendMessage('belongs to b')
const client = vi.mocked(streamChat).mock.results[0]!.value as SseClient
removal.resolve(undefined)
await deleting
expect(store.activeConversationId).toBe(b)
expect(store.messages[0]?.content).toBe('belongs to b')
expect(store.isStreaming).toBe(true)
expect(client.cancel).not.toHaveBeenCalled()
})
+188 -103
View File
@@ -1,92 +1,206 @@
import { computed, reactive, ref } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed, reactive } from 'vue' import type { ChatMessage, Citation, Conversation } from '@/contracts'
import type { ChatMessage, Conversation } from '@/contracts' import {
import { streamChat } from '@/services/chatService' createConversation as createConversationApi,
listConversationMessages,
listConversations as listConversationsApi,
removeConversation,
streamChat,
} from '@/services/chatService'
import type { SseClient } from '@/services/sseClient' import type { SseClient } from '@/services/sseClient'
import { t } from '@/i18n'
export const useChatStore = defineStore('chat', () => { export const useChatStore = defineStore('chat', () => {
const conversations = ref<Conversation[]>([]) const conversations = ref<Conversation[]>([])
const activeConversationId = ref<string | null>(null) const activeConversationId = ref<string | null>(null)
const messages = ref<ChatMessage[]>([]) const messages = ref<ChatMessage[]>([])
const isStreaming = ref(false) const isStreaming = ref(false)
const isPreparing = ref(false)
const messagesReady = ref(true)
const deletingConversations = reactive(new Set<string>())
const canSend = computed(() => messagesReady.value && !isPreparing.value && !isStreaming.value
&& (!activeConversationId.value || !deletingConversations.has(activeConversationId.value)))
const inputText = ref('') const inputText = ref('')
const useRag = ref(true) const useRag = ref(true)
const selectedSkillId = ref<string | null>(null) const selectedSkillId = ref<string | null>(null)
const selectedProviderId = ref('') const selectedProviderId = ref('')
const selectedModel = ref('') const selectedModel = ref('')
const historyError = ref('')
let initialized = false
let loading: Promise<void> | null = null
let loadVersion = 0
let sseClient: SseClient | null = null let sseClient: SseClient | null = null
let streamVersion = 0 let streamVersion = 0
const pendingCreates = new Map<string, Promise<void>>()
// 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(item => item.conversation_id === activeConversationId.value) || null
) )
const sortedConversations = computed(() => const sortedConversations = computed(() =>
[...conversations.value].sort((a, b) => b.updated_at.localeCompare(a.updated_at)) [...conversations.value].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
) )
function normalizeMessage(message: ChatMessage): ChatMessage {
return {
...message,
citations: message.citations?.map(citation => ({
...citation,
heading_path: Array.isArray(citation.heading_path)
? citation.heading_path.join(' / ')
: citation.heading_path,
} as Citation)),
}
}
async function fetchAllConversations() {
const items: Conversation[] = []
while (true) {
const result = await listConversationsApi(items.length, 100)
items.push(...result.items)
if (!result.items.length || items.length >= result.page.total) return items
}
}
async function fetchAllMessages(conversationId: string) {
const items: ChatMessage[] = []
while (true) {
const result = await listConversationMessages(conversationId, items.length, 500)
items.push(...result.items)
if (!result.items.length || items.length >= result.page.total) return items
}
}
async function loadConversations(force = false) {
if (loading) return loading
if (initialized && !force) return
const version = ++loadVersion
messagesReady.value = false
loading = (async () => {
historyError.value = ''
try {
const items = await fetchAllConversations()
if (version !== loadVersion) return
conversations.value = items
initialized = true
const selected = activeConversationId.value && items.some(item => item.conversation_id === activeConversationId.value)
? activeConversationId.value
: items[0]?.conversation_id || null
if (selected) await setActiveConversation(selected)
else { activeConversationId.value = null; messages.value = []; messagesReady.value = true }
} catch (error) {
if (version === loadVersion) historyError.value = error instanceof Error ? error.message : t('聊天记录加载失败', 'Failed to load chat history')
} finally {
loading = null
}
})()
return loading
}
async function setActiveConversation(id: string) { async function setActiveConversation(id: string) {
stopGeneration() stopGeneration()
const version = ++loadVersion
activeConversationId.value = id activeConversationId.value = id
messages.value = history[id] ?? [] messagesReady.value = false
messages.value = []
historyError.value = ''
try {
const loadedMessages = await fetchAllMessages(id)
if (version === loadVersion && activeConversationId.value === id) {
messages.value = loadedMessages.map(normalizeMessage)
messagesReady.value = true
}
} catch (error) {
if (version === loadVersion) historyError.value = error instanceof Error ? error.message : t('消息加载失败', 'Failed to load messages')
}
}
function addLocalConversation(title: string) {
loadVersion++
const now = new Date().toISOString()
const conversation: Conversation = {
conversation_id: crypto.randomUUID(), title, created_at: now, updated_at: now, message_count: 0,
}
conversations.value.unshift(conversation)
activeConversationId.value = conversation.conversation_id
messages.value = []
messagesReady.value = true
return conversation
}
async function persistConversation(conversation: Conversation) {
const promise = createConversationApi(conversation).then(saved => {
const index = conversations.value.findIndex(item => item.conversation_id === saved.conversation_id)
if (index >= 0) Object.assign(conversations.value[index]!, saved)
}).catch(error => {
conversations.value = conversations.value.filter(item => item.conversation_id !== conversation.conversation_id)
if (activeConversationId.value === conversation.conversation_id) {
activeConversationId.value = null
messages.value = []
}
historyError.value = error instanceof Error ? error.message : t('会话创建失败', 'Failed to create conversation')
throw error
}).finally(() => pendingCreates.delete(conversation.conversation_id))
pendingCreates.set(conversation.conversation_id, promise)
return promise
}
async function createNewConversation() {
stopGeneration()
historyError.value = ''
const conversation = addLocalConversation(t('新对话', 'New conversation'))
try { await persistConversation(conversation) } catch { /* exposed through historyError */ }
} }
async function sendMessage(text: string) { async function sendMessage(text: string) {
if (!text.trim() || isStreaming.value || !selectedProviderId.value || !selectedModel.value) return const content = text.trim()
const conversationId = activeConversationId.value || crypto.randomUUID() if (!content || !canSend.value || !selectedProviderId.value || !selectedModel.value) return
const version = ++streamVersion
if (!activeConversationId.value) { isPreparing.value = true
const newConv: Conversation = { historyError.value = ''
conversation_id: conversationId, let conversation = activeConversation.value
title: text.slice(0, 30), try {
created_at: new Date().toISOString(), if (!conversation) {
updated_at: new Date().toISOString(), conversation = addLocalConversation(content.slice(0, 30))
message_count: 0, await persistConversation(conversation)
} else if (pendingCreates.has(conversation.conversation_id)) {
await pendingCreates.get(conversation.conversation_id)
} }
conversations.value.unshift(newConv) } catch { return }
activeConversationId.value = conversationId finally {
if (version === streamVersion) isPreparing.value = false
} }
// Switching, stopping or deleting cancels sends still waiting for creation.
if (version !== streamVersion || activeConversationId.value !== conversation.conversation_id) return
history[conversationId] = messages.value const conversationId = conversation.conversation_id
const conversationMessages = messages.value if (conversation.message_count === 0) conversation.title = content.slice(0, 30)
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
message_id: crypto.randomUUID(), message_id: crypto.randomUUID(), conversation_id: conversationId, role: 'user', content,
conversation_id: conversationId,
role: 'user',
content: text,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
} }
messages.value.push(userMsg) const aiMsg = reactive<ChatMessage>({
message_id: crypto.randomUUID(), conversation_id: conversationId, role: 'assistant', content: '',
created_at: new Date().toISOString(), citations: [], tool_calls: [],
})
messages.value.push(userMsg, aiMsg)
inputText.value = '' inputText.value = ''
isStreaming.value = true isStreaming.value = true
const conversation = conversations.value.find(c => c.conversation_id === conversationId) conversation.updated_at = new Date().toISOString()
if (conversation) { conversation.updated_at = new Date().toISOString(); conversation.message_count = messages.value.length } conversation.message_count = messages.value.length
// 先插入占位消息,随后将 SSE 增量原位合并,避免每个 token 重建消息列表。
const aiMsg = reactive<ChatMessage>({
message_id: crypto.randomUUID(),
conversation_id: conversationId,
role: 'assistant',
content: '',
created_at: new Date().toISOString(),
citations: [],
tool_calls: [],
})
messages.value.push(aiMsg)
const version = ++streamVersion
const argumentBuffers = new Map<string, string>() 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,
user_message_id: userMsg.message_id,
assistant_message_id: aiMsg.message_id,
conversation_title: conversation.title,
use_rag: useRag.value, use_rag: useRag.value,
messages: messages.value messages: messages.value
.filter((message) => message.message_id !== aiMsg.message_id) .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 (version !== streamVersion) return
@@ -94,25 +208,21 @@ export const useChatStore = defineStore('chat', () => {
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') {
aiMsg.tool_calls?.push({ aiMsg.tool_calls?.push({
tool_call_id: String(event.data.tool_call_id ?? ''), tool_call_id: String(event.data.tool_call_id ?? ''), name: String(event.data.name ?? 'unknown'),
name: String(event.data.name ?? 'unknown'), parameters: (event.data.arguments ?? {}) as Record<string, unknown>, status: 'running',
parameters: (event.data.arguments ?? {}) as Record<string, unknown>,
status: 'running',
}) })
} }
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') { if (call && typeof event.data.arguments_delta === 'string') {
const buffer = (argumentBuffers.get(call.tool_call_id) ?? '') + event.data.arguments_delta const buffer = (argumentBuffers.get(call.tool_call_id) ?? '') + event.data.arguments_delta
argumentBuffers.set(call.tool_call_id, buffer) argumentBuffers.set(call.tool_call_id, buffer)
try { call.parameters = JSON.parse(buffer) } catch { /* incomplete JSON fragment */ } 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)
}
} }
if (event.event === 'ToolCallEnd') { if (event.event === 'ToolCallEnd') {
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) call.status = 'completed' if (call) call.status = 'completed'
} }
if (event.event === 'Usage') { if (event.event === 'Usage') {
@@ -128,21 +238,18 @@ export const useChatStore = defineStore('chat', () => {
content: String(event.data.content ?? event.data.snippet ?? ''), content: String(event.data.content ?? event.data.snippet ?? ''),
}) })
} }
if (event.event === 'Error') aiMsg.content += `\n\n生成失败:${String(event.data.message ?? '未知错误')}` if (event.event === 'Error') aiMsg.content += `\n\n${t('生成失败:', 'Generation failed: ')}${String(event.data.message ?? t('未知错误', 'Unknown error'))}`
}, },
onError(error) { onError(error) {
if (version !== streamVersion) return if (version !== streamVersion) return
aiMsg.content += `\n\n连接失败:${error.message}` aiMsg.content += `\n\n${t('连接失败:', 'Connection failed: ')}${error.message}`
isStreaming.value = false isStreaming.value = false
sseClient = null sseClient = null
}, },
onDone() { onDone() {
if (version !== streamVersion) return if (version !== streamVersion) return
const conversation = conversations.value.find((item) => item.conversation_id === conversationId) conversation!.message_count = messages.value.length
if (conversation) { conversation!.updated_at = new Date().toISOString()
conversation.message_count = conversationMessages.length
conversation.updated_at = new Date().toISOString()
}
isStreaming.value = false isStreaming.value = false
sseClient = null sseClient = null
}, },
@@ -151,57 +258,35 @@ export const useChatStore = defineStore('chat', () => {
function stopGeneration() { function stopGeneration() {
streamVersion++ streamVersion++
if (sseClient) { isPreparing.value = false
sseClient.cancel() if (sseClient) { sseClient.cancel(); sseClient = null }
sseClient = null
}
isStreaming.value = false isStreaming.value = false
} }
function createNewConversation() { async function deleteConversation(id: string) {
stopGeneration() if (deletingConversations.has(id)) return
const newConv: Conversation = { deletingConversations.add(id)
conversation_id: crypto.randomUUID(),
title: '新对话',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
message_count: 0,
}
conversations.value.unshift(newConv)
activeConversationId.value = newConv.conversation_id
history[newConv.conversation_id] = []
messages.value = history[newConv.conversation_id]
}
function deleteConversation(id: string) {
if (activeConversationId.value === id) stopGeneration() if (activeConversationId.value === id) stopGeneration()
delete history[id] historyError.value = ''
const idx = conversations.value.findIndex((c) => c.conversation_id === id) try {
if (idx > -1) { if (pendingCreates.has(id)) await pendingCreates.get(id)
conversations.value.splice(idx, 1) await removeConversation(id)
conversations.value = conversations.value.filter(item => item.conversation_id !== id)
if (activeConversationId.value === id) { if (activeConversationId.value === id) {
activeConversationId.value = conversations.value[0]?.conversation_id || null const next = sortedConversations.value[0]
messages.value = conversations.value[0] ? history[conversations.value[0].conversation_id] || [] : [] if (next) await setActiveConversation(next.conversation_id)
else { loadVersion++; activeConversationId.value = null; messages.value = []; messagesReady.value = true }
} }
} catch (error) {
historyError.value = error instanceof Error ? error.message : t('会话删除失败', 'Failed to delete conversation')
} finally {
deletingConversations.delete(id)
} }
} }
return { return {
conversations, conversations, activeConversationId, activeConversation, sortedConversations, messages,
activeConversationId, isStreaming, isPreparing, canSend, inputText, useRag, selectedSkillId, selectedProviderId, selectedModel, historyError,
activeConversation, loadConversations, setActiveConversation, sendMessage, stopGeneration, createNewConversation, deleteConversation,
sortedConversations,
messages,
isStreaming,
inputText,
useRag,
selectedSkillId,
selectedProviderId,
selectedModel,
setActiveConversation,
sendMessage,
stopGeneration,
createNewConversation,
deleteConversation,
} }
}) })
+3 -2
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { SaveStatus } from '@/contracts' import type { SaveStatus } from '@/contracts'
import * as workspaceService from '@/services/workspaceService' import * as workspaceService from '@/services/workspaceService'
import { t } from '@/i18n'
export const useEditorStore = defineStore('editor', () => { export const useEditorStore = defineStore('editor', () => {
const mode = ref<'wysiwyg' | 'source'>('wysiwyg') const mode = ref<'wysiwyg' | 'source'>('wysiwyg')
@@ -76,12 +77,12 @@ export const useEditorStore = defineStore('editor', () => {
saveTimer = null saveTimer = null
} }
if (saveStatus.value === 'conflict') { if (saveStatus.value === 'conflict') {
throw new Error('当前文件存在编辑冲突,请处理后再切换文件。') throw new Error(t('当前文件存在编辑冲突,请处理后再切换文件。', 'The current file has an editing conflict. Resolve it before switching files.'))
} }
if (pendingSave) await pendingSave if (pendingSave) await pendingSave
if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') await save() if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') await save()
if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') { if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') {
throw new Error('当前文件保存失败,已阻止切换以避免内容丢失。') throw new Error(t('当前文件保存失败,已阻止切换以避免内容丢失。', 'The current file could not be saved. Switching was blocked to prevent data loss.'))
} }
// 版本号使较慢的旧读取不能覆盖用户后选择的新文件。 // 版本号使较慢的旧读取不能覆盖用户后选择的新文件。
const version = ++loadVersion const version = ++loadVersion
+2 -1
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { Plugin } from '@/contracts' import type { Plugin } from '@/contracts'
import * as pluginService from '@/services/pluginService' import * as pluginService from '@/services/pluginService'
import { t } from '@/i18n'
export const usePluginStore = defineStore('plugin', () => { export const usePluginStore = defineStore('plugin', () => {
const plugins = ref<Plugin[]>([]) const plugins = ref<Plugin[]>([])
@@ -23,7 +24,7 @@ export const usePluginStore = defineStore('plugin', () => {
plugins.value = await pluginService.listPlugins() plugins.value = await pluginService.listPlugins()
error.value = null error.value = null
} catch (reason) { } catch (reason) {
error.value = reason instanceof Error ? reason.message : 'Plugin 加载失败' error.value = reason instanceof Error ? reason.message : t('Plugin 加载失败', 'Failed to load Plugins')
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
+6 -5
View File
@@ -3,6 +3,7 @@ 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, 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'
import { t } from '@/i18n'
export const useProviderStore = defineStore('provider', () => { export const useProviderStore = defineStore('provider', () => {
const providers = ref<ProviderConfig[]>([]) const providers = ref<ProviderConfig[]>([])
@@ -29,7 +30,7 @@ export const useProviderStore = defineStore('provider', () => {
} }
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 : t('Provider 加载失败', 'Failed to load Providers')
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
@@ -39,7 +40,7 @@ export const useProviderStore = defineStore('provider', () => {
try { try {
presets.value = await listProviderPresets() presets.value = await listProviderPresets()
} catch (reason) { } catch (reason) {
error.value = reason instanceof Error ? reason.message : 'Provider 预设加载失败' error.value = reason instanceof Error ? reason.message : t('Provider 预设加载失败', 'Failed to load Provider presets')
} }
} }
@@ -55,11 +56,11 @@ export const useProviderStore = defineStore('provider', () => {
} catch (reason) { } catch (reason) {
const provider = providers.value.find((item) => item.provider_id === providerId) const provider = providers.value.find((item) => item.provider_id === providerId)
const credentialId = provider?.credential_id const credentialId = provider?.credential_id
let message = reason instanceof Error ? reason.message : '模型列表获取失败' let message = reason instanceof Error ? reason.message : t('模型列表获取失败', 'Failed to load the model list')
if (reason instanceof ApiErrorClass && reason.code === 'PROVIDER_CREDENTIAL_MISSING') { if (reason instanceof ApiErrorClass && reason.code === 'PROVIDER_CREDENTIAL_MISSING') {
message = '尚未配置 API Key,请编辑该 Provider 后填写并保存。' message = t('尚未配置 API Key,请编辑该 Provider 后填写并保存。', 'No API key is configured. Edit this Provider, enter a key, and save it.')
} else if (reason instanceof ApiErrorClass && reason.code === 'PROVIDER_AUTH_FAILED') { } else if (reason instanceof ApiErrorClass && reason.code === 'PROVIDER_AUTH_FAILED') {
message = `鉴权失败,请检查凭据“${credentialId || '未设置'}”对应的 API Key 是否有效。` message = t(`鉴权失败,请检查凭据“${credentialId || '未设置'}”对应的 API Key 是否有效。`, `Authentication failed. Check the API key for credential “${credentialId || 'not set'}”.`)
} }
modelErrorsByProvider.value[providerId] = message modelErrorsByProvider.value[providerId] = message
throw reason throw reason
+5 -4
View File
@@ -3,6 +3,7 @@ import { ref } from 'vue'
import type { SearchResult, SearchRequest } from '@/contracts' import type { SearchResult, SearchRequest } from '@/contracts'
import * as searchService from '@/services/searchService' import * as searchService from '@/services/searchService'
import { ApiErrorClass } from '@/services/apiClient' import { ApiErrorClass } from '@/services/apiClient'
import { t } from '@/i18n'
const VECTOR_ERROR_CODES = new Set([ const VECTOR_ERROR_CODES = new Set([
'SEMANTIC_INDEX_UNAVAILABLE', 'SEMANTIC_INDEX_UNAVAILABLE',
@@ -28,7 +29,7 @@ export const useSearchStore = defineStore('search', () => {
if (version !== historyVersion) return if (version !== historyVersion) return
recentQueries.value = response.queries recentQueries.value = response.queries
historyError.value = '' historyError.value = ''
} catch { if (version === historyVersion) historyError.value = '无法读取应用搜索记录,请检查后端连接。' } } catch { if (version === historyVersion) historyError.value = t('无法读取应用搜索记录,请检查后端连接。', 'Could not load search history. Check the backend connection.') }
} }
async function clearHistory() { async function clearHistory() {
const version = ++historyVersion const version = ++historyVersion
@@ -36,7 +37,7 @@ export const useSearchStore = defineStore('search', () => {
await searchService.clearHistory() await searchService.clearHistory()
if (version !== historyVersion) return if (version !== historyVersion) return
recentQueries.value = []; historyError.value = '' recentQueries.value = []; historyError.value = ''
} catch { if (version === historyVersion) historyError.value = '清空搜索记录失败,请重试。' } } catch { if (version === historyVersion) historyError.value = t('清空搜索记录失败,请重试。', 'Failed to clear search history. Please retry.') }
} }
const error = ref<string | null>(null) const error = ref<string | null>(null)
const vectorUnavailable = ref(false) const vectorUnavailable = ref(false)
@@ -71,12 +72,12 @@ export const useSearchStore = defineStore('search', () => {
selectedIndex.value = 0 selectedIndex.value = 0
} catch (fallbackError) { } catch (fallbackError) {
if (version !== searchVersion) return if (version !== searchVersion) return
error.value = fallbackError instanceof Error ? fallbackError.message : '全文检索降级失败' error.value = fallbackError instanceof Error ? fallbackError.message : t('全文检索降级失败', 'Full-text search fallback failed')
results.value = [] results.value = []
total.value = 0 total.value = 0
} }
} else { } else {
error.value = reason instanceof Error ? reason.message : '搜索失败' error.value = reason instanceof Error ? reason.message : t('搜索失败', 'Search failed')
results.value = [] results.value = []
total.value = 0 total.value = 0
} }
+6 -5
View File
@@ -5,6 +5,7 @@ import { resolveApiUrl } from '@/services/apiClient'
import packageInfo from '../../package.json' 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'
import { appLocale, t } from '@/i18n'
export const useSettingsStore = defineStore('settings', () => { export const useSettingsStore = defineStore('settings', () => {
const saved = (() => { const saved = (() => {
@@ -14,9 +15,9 @@ export const useSettingsStore = defineStore('settings', () => {
// General // General
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 = appLocale
const appVersion = ref(packageInfo.version) const appVersion = ref(packageInfo.version)
const aiCoreVersion = ref('未获取') 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')
@@ -47,10 +48,10 @@ export const useSettingsStore = defineStore('settings', () => {
]) ])
const [health, status, index, policy] = results const [health, status, index, policy] = results
aiCoreStatus.value = health.status === 'fulfilled' && health.value.status === 'ok' ? 'running' : 'error' aiCoreStatus.value = health.status === 'fulfilled' && health.value.status === 'ok' ? 'running' : 'error'
aiCoreVersion.value = status.status === 'fulfilled' ? status.value.version : '未获取' aiCoreVersion.value = status.status === 'fulfilled' ? status.value.version : ''
indexStatus.value = index.status === 'fulfilled' ? index.value : emptyIndex() indexStatus.value = index.status === 'fulfilled' ? index.value : emptyIndex()
permissionPolicy.value = policy.status === 'fulfilled' ? policy.value : {} permissionPolicy.value = policy.status === 'fulfilled' ? policy.value : {}
diagnosticsError.value = results.filter(item => item.status === 'rejected').map(item => item.reason instanceof Error ? item.reason.message : '后端请求失败').join('') || null diagnosticsError.value = results.filter(item => item.status === 'rejected').map(item => item.reason instanceof Error ? item.reason.message : t('后端请求失败', 'Backend request failed')).join(t('', '; ')) || null
} }
function setAutoSaveInterval(ms: number) { function setAutoSaveInterval(ms: number) {
@@ -68,7 +69,7 @@ export const useSettingsStore = defineStore('settings', () => {
indexStatus.value = await indexService.getIndexStatus() indexStatus.value = await indexService.getIndexStatus()
} catch (reason) { } catch (reason) {
indexStatus.value.status = 'error' indexStatus.value.status = 'error'
indexStatus.value.error = reason instanceof Error ? reason.message : '索引重建失败' indexStatus.value.error = reason instanceof Error ? reason.message : t('索引重建失败', 'Index rebuild failed')
} }
} }
+2 -1
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { Skill } from '@/contracts' import type { Skill } from '@/contracts'
import * as skillService from '@/services/skillService' import * as skillService from '@/services/skillService'
import { t } from '@/i18n'
export const useSkillStore = defineStore('skill', () => { export const useSkillStore = defineStore('skill', () => {
const skills = ref<Skill[]>([]) const skills = ref<Skill[]>([])
@@ -23,7 +24,7 @@ export const useSkillStore = defineStore('skill', () => {
skills.value = await skillService.listSkills() skills.value = await skillService.listSkills()
error.value = null error.value = null
} catch (reason) { } catch (reason) {
error.value = reason instanceof Error ? reason.message : 'Skill 加载失败' error.value = reason instanceof Error ? reason.message : t('Skill 加载失败', 'Failed to load Skills')
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
+2 -1
View File
@@ -2,6 +2,7 @@ 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, updateTask as updateTaskRequest } from '@/services/taskService' import { createTask as createTaskRequest, deleteTask as deleteTaskRequest, listTasks, updateTask as updateTaskRequest } from '@/services/taskService'
import { t } from '@/i18n'
export const useTaskStore = defineStore('task', () => { export const useTaskStore = defineStore('task', () => {
const tasks = ref<TaskItem[]>([]) const tasks = ref<TaskItem[]>([])
@@ -31,7 +32,7 @@ export const useTaskStore = defineStore('task', () => {
tasks.value = resp.items tasks.value = resp.items
error.value = null error.value = null
} catch (reason) { } catch (reason) {
error.value = reason instanceof Error ? reason.message : '任务加载失败' error.value = reason instanceof Error ? reason.message : t('任务加载失败', 'Failed to load tasks')
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
+6 -5
View File
@@ -1,11 +1,12 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import type { ThemeConfig } from '@/contracts' import type { ThemeConfig } from '@/contracts'
import { t } from '@/i18n'
const builtinThemes: ThemeConfig[] = [ const builtinThemes = (): ThemeConfig[] => [
{ theme_id: 'light', name: '浅色', version: '1.0.0', description: '默认浅色主题', is_dark: false, builtin: true, code_theme: 'github-light' }, { theme_id: 'light', name: t('浅色', 'Light'), version: '1.0.0', description: t('默认浅色主题', 'Default light theme'), is_dark: false, builtin: true, code_theme: 'github-light' },
{ theme_id: 'dark', name: '深色', version: '1.0.0', description: '默认深色主题', is_dark: true, builtin: true, code_theme: 'github-dark' }, { theme_id: 'dark', name: t('深色', 'Dark'), version: '1.0.0', description: t('默认深色主题', 'Default dark theme'), is_dark: true, builtin: true, code_theme: 'github-dark' },
{ theme_id: 'sepia', name: '护眼', version: '1.0.0', description: '护眼暖色调', is_dark: false, builtin: true, code_theme: 'github-light' }, { theme_id: 'sepia', name: t('护眼', 'Sepia'), version: '1.0.0', description: t('护眼暖色调', 'Warm, low-glare theme'), is_dark: false, builtin: true, code_theme: 'github-light' },
] ]
export type CodeBlockThemePreference = 'auto' | 'github-light' | 'github-dark' export type CodeBlockThemePreference = 'auto' | 'github-light' | 'github-dark'
@@ -15,7 +16,7 @@ function isCodeBlockThemePreference(value: unknown): value is CodeBlockThemePref
} }
export const useThemeStore = defineStore('theme', () => { export const useThemeStore = defineStore('theme', () => {
const themes = ref<ThemeConfig[]>(builtinThemes) const themes = computed<ThemeConfig[]>(builtinThemes)
const currentThemeId = ref<string>('light') const currentThemeId = ref<string>('light')
const fontEditorSize = ref(15) const fontEditorSize = ref(15)
const fontEditorFamily = ref('system-ui') const fontEditorFamily = ref('system-ui')
+40
View File
@@ -85,6 +85,44 @@ button:disabled { cursor: not-allowed; opacity: .55; box-shadow: none; transform
.input:focus, .select:focus, .textarea:focus { border-color: var(--color-border-focus); box-shadow: 0 0 0 3px var(--color-accent-soft); background: var(--color-surface-primary); } .input:focus, .select:focus, .textarea:focus { border-color: var(--color-border-focus); box-shadow: 0 0 0 3px var(--color-accent-soft); background: var(--color-surface-primary); }
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--space-md); } .form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--space-md); }
input[type='checkbox'], input[type='radio'] {
appearance: none;
display: inline-grid;
place-content: center;
width: 18px;
height: 18px;
flex: 0 0 18px;
border: 1px solid var(--color-border-default);
background: var(--color-background-primary);
transition: border-color var(--motion-fast), background-color var(--motion-fast), box-shadow var(--motion-fast);
}
input[type='checkbox'] { border-radius: 5px; }
input[type='radio'] { border-radius: 50%; }
input[type='checkbox']::before, input[type='radio']::before { content: ''; width: 10px; height: 10px; transform: scale(0); transition: transform var(--motion-fast); }
input[type='checkbox']::before { clip-path: polygon(14% 44%, 0 59%, 39% 96%, 100% 20%, 84% 7%, 37% 68%); background: var(--color-text-inverse); }
input[type='radio']::before { border-radius: 50%; background: var(--color-text-inverse); }
input[type='checkbox']:hover, input[type='radio']:hover { border-color: var(--color-accent-primary); }
input[type='checkbox']:checked, input[type='radio']:checked { border-color: var(--color-accent-primary); background: var(--color-accent-primary); }
input[type='checkbox']:checked::before, input[type='radio']:checked::before { transform: scale(1); }
input[type='checkbox']:disabled, input[type='radio']:disabled { cursor: not-allowed; opacity: .55; }
input[type='range'] { appearance: none; height: 20px; background: transparent; cursor: pointer; }
input[type='range']::-webkit-slider-runnable-track { height: 5px; border-radius: var(--radius-full); background: var(--color-background-tertiary); }
input[type='range']::-webkit-slider-thumb { appearance: none; width: 16px; height: 16px; margin-top: -5.5px; border: 2px solid var(--color-surface-primary); border-radius: 50%; background: var(--color-accent-primary); box-shadow: 0 1px 4px color-mix(in srgb, var(--color-text-primary) 24%, transparent); }
progress { appearance: none; width: 100%; height: 8px; overflow: hidden; border: 0; border-radius: var(--radius-full); background: var(--color-background-tertiary); }
progress::-webkit-progress-bar { border-radius: var(--radius-full); background: var(--color-background-tertiary); }
progress::-webkit-progress-value { border-radius: var(--radius-full); background: linear-gradient(90deg, var(--color-accent-primary), var(--color-accent-secondary)); }
progress:not([value]) { background: linear-gradient(90deg, var(--color-background-tertiary) 25%, var(--color-accent-secondary) 50%, var(--color-background-tertiary) 75%); background-size: 200% 100%; animation: progress-pulse 1.2s linear infinite; }
.ui-disclosure { padding: var(--space-md); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-background-secondary); }
.ui-disclosure > summary { display: flex; align-items: center; justify-content: space-between; gap: var(--space-sm); list-style: none; color: var(--color-text-secondary); font-weight: 600; cursor: pointer; }
.ui-disclosure > summary::-webkit-details-marker { display: none; }
.ui-disclosure > summary::after { content: ''; width: 8px; height: 8px; flex: 0 0 auto; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(45deg); transition: transform var(--motion-fast); }
.ui-disclosure[open] > summary::after { transform: rotate(225deg); }
.ui-disclosure[open] > summary { margin-bottom: var(--space-md); color: var(--color-text-primary); }
.ui-disclosure > :not(summary) + :not(summary) { margin-top: var(--space-sm); }
.badge { .badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -143,6 +181,8 @@ button:disabled { cursor: not-allowed; opacity: .55; box-shadow: none; transform
to { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 1; transform: translateY(0) scale(1); }
} }
@keyframes progress-pulse { from { background-position: 100% 0; } to { background-position: -100% 0; } }
@media (max-width: 900px) { @media (max-width: 900px) {
.feature-page { padding: var(--space-lg); } .feature-page { padding: var(--space-lg); }
.split-view { grid-template-columns: 1fr; } .split-view { grid-template-columns: 1fr; }