Fix/frontend review findings #4
@@ -13,6 +13,7 @@ backend/**/__pycache__/
|
||||
backend/.env
|
||||
# 运行期生成的 SQLite 索引(vault 下的 Markdown 测试数据需提交)
|
||||
backend/data/*.db*
|
||||
backend/data/credentials/
|
||||
|
||||
# Editors and operating systems
|
||||
.idea/
|
||||
|
||||
@@ -74,6 +74,14 @@ uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
|
||||
- API 文档:<http://127.0.0.1:8000/docs>
|
||||
- OpenAPI JSON:<http://127.0.0.1:8000/openapi.json>
|
||||
|
||||
#### 开发环境使用外部模型
|
||||
|
||||
在“设置 → 模型提供商”中选择 DeepSeek 或 OpenAI 预设后,直接在密码输入框填写 API Key。前端只在提交期间持有该值,不写入 Pinia 或 localStorage;AI Core 将其加密保存到本机 `backend/data/credentials/`,Provider 配置只保留内部 Credential ID。
|
||||
|
||||
该目录同时包含本地开发用主密钥和密文,并已加入 `.gitignore`。这提供本地静态加密和完整性校验,但不能替代操作系统凭据库。开始 Tauri 桌面集成后,应将存储实现迁移到 Stronghold,保留现有 Credential API 与 Provider 接口边界。
|
||||
|
||||
无界面或自动化环境仍可使用 `DEEPSEEK_API_KEY`、`OPENAI_API_KEY` 或 `AINOTE_CREDENTIAL_<ID>` 注入;设置页保存的本地密钥优先,环境变量仅在本地未保存对应 Credential ID 时作为回退。密钥不得写入仓库文件、README、Issue、提交信息或聊天记录。
|
||||
|
||||
### 终端二:启动前端
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -6,13 +6,18 @@ from app.contracts import ModelCapability, ProviderConfig, ProviderType
|
||||
from app.config import BACKEND_DIR
|
||||
from app.extensions import PluginRuntime, SkillRuntime
|
||||
from app.providers import MockProvider, ProviderFactory, ProviderRegistry
|
||||
from app.providers.credentials import EnvironmentCredentialResolver
|
||||
from app.providers.credentials import (
|
||||
ChainedCredentialResolver,
|
||||
EncryptedCredentialStore,
|
||||
EnvironmentCredentialResolver,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApplicationContainer:
|
||||
providers: ProviderRegistry
|
||||
provider_factory: ProviderFactory
|
||||
credentials: EncryptedCredentialStore
|
||||
tools: ToolRegistry
|
||||
permissions: PermissionManager
|
||||
skills: SkillRuntime
|
||||
@@ -21,7 +26,10 @@ class ApplicationContainer:
|
||||
|
||||
|
||||
def build_container() -> ApplicationContainer:
|
||||
provider_factory = ProviderFactory(EnvironmentCredentialResolver())
|
||||
credentials = EncryptedCredentialStore()
|
||||
provider_factory = ProviderFactory(
|
||||
ChainedCredentialResolver(credentials, EnvironmentCredentialResolver())
|
||||
)
|
||||
providers = ProviderRegistry()
|
||||
providers.register(
|
||||
ProviderConfig(
|
||||
@@ -61,6 +69,7 @@ def build_container() -> ApplicationContainer:
|
||||
return ApplicationContainer(
|
||||
providers=providers,
|
||||
provider_factory=provider_factory,
|
||||
credentials=credentials,
|
||||
tools=tools,
|
||||
permissions=permissions,
|
||||
skills=skills,
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
||||
|
||||
|
||||
class Contract(BaseModel):
|
||||
@@ -438,6 +438,28 @@ class ProviderListResponse(Contract):
|
||||
items: list[ProviderConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ProviderPreset(Contract):
|
||||
preset_id: str
|
||||
name: str
|
||||
provider_type: ProviderType
|
||||
base_url: str
|
||||
default_credential_id: str | None = None
|
||||
requires_credential: bool = True
|
||||
|
||||
|
||||
class ProviderPresetListResponse(Contract):
|
||||
items: list[ProviderPreset] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CredentialWriteRequest(Contract):
|
||||
api_key: SecretStr = Field(min_length=1, max_length=8192)
|
||||
|
||||
|
||||
class CredentialStatus(Contract):
|
||||
credential_id: str
|
||||
configured: bool
|
||||
|
||||
|
||||
class ModelInfo(Contract):
|
||||
model: str
|
||||
display_name: str
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
_CREDENTIAL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
|
||||
|
||||
class CredentialStoreError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class CredentialResolver(Protocol):
|
||||
def resolve(self, credential_id: str | None) -> str | None: ...
|
||||
@@ -10,8 +24,142 @@ class CredentialResolver(Protocol):
|
||||
class EnvironmentCredentialResolver:
|
||||
"""解析由桌面 Host 注入 Sidecar 进程的临时凭证上下文。"""
|
||||
|
||||
_development_aliases = {
|
||||
"openai": "OPENAI_API_KEY",
|
||||
"deepseek": "DEEPSEEK_API_KEY",
|
||||
}
|
||||
|
||||
def resolve(self, credential_id: str | None) -> str | None:
|
||||
if not credential_id:
|
||||
return None
|
||||
normalized = re.sub(r"[^A-Za-z0-9]", "_", credential_id).upper()
|
||||
return os.getenv(f"AINOTE_CREDENTIAL_{normalized}")
|
||||
injected = os.getenv(f"AINOTE_CREDENTIAL_{normalized}")
|
||||
if injected:
|
||||
return injected
|
||||
alias = self._development_aliases.get(credential_id.lower())
|
||||
return os.getenv(alias) if alias else None
|
||||
|
||||
|
||||
class EncryptedCredentialStore:
|
||||
"""将本地开发凭据作为 Fernet 密文存储,Provider 使用时按 ID 解密。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@staticmethod
|
||||
def _validate_id(credential_id: str) -> None:
|
||||
if not _CREDENTIAL_ID.fullmatch(credential_id):
|
||||
raise CredentialStoreError("Credential ID contains unsupported characters.")
|
||||
|
||||
@staticmethod
|
||||
def _paths() -> tuple[Path, Path]:
|
||||
directory = get_settings().data_dir / "credentials"
|
||||
return directory / "master.key", directory / "credentials.json"
|
||||
|
||||
@staticmethod
|
||||
def _restrict(path: Path, mode: int) -> None:
|
||||
try:
|
||||
path.chmod(mode)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _fernet(self) -> Fernet:
|
||||
key_path, _ = self._paths()
|
||||
environment_key = os.getenv("APP_CREDENTIAL_MASTER_KEY")
|
||||
if environment_key:
|
||||
try:
|
||||
return Fernet(environment_key.encode("ascii"))
|
||||
except (ValueError, UnicodeEncodeError) as exc:
|
||||
raise CredentialStoreError("APP_CREDENTIAL_MASTER_KEY is invalid.") from exc
|
||||
|
||||
key_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._restrict(key_path.parent, 0o700)
|
||||
if not key_path.exists():
|
||||
temporary = key_path.with_suffix(".tmp")
|
||||
temporary.write_bytes(Fernet.generate_key())
|
||||
self._restrict(temporary, 0o600)
|
||||
try:
|
||||
temporary.replace(key_path)
|
||||
except FileExistsError:
|
||||
temporary.unlink(missing_ok=True)
|
||||
self._restrict(key_path, 0o600)
|
||||
try:
|
||||
return Fernet(key_path.read_bytes().strip())
|
||||
except (OSError, ValueError) as exc:
|
||||
raise CredentialStoreError("Credential master key cannot be loaded.") from exc
|
||||
|
||||
def _read_tokens(self) -> dict[str, str]:
|
||||
_, store_path = self._paths()
|
||||
if not store_path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(store_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise CredentialStoreError("Encrypted credential store cannot be loaded.") from exc
|
||||
if not isinstance(data, dict) or not all(
|
||||
isinstance(key, str) and isinstance(value, str) for key, value in data.items()
|
||||
):
|
||||
raise CredentialStoreError("Encrypted credential store has an invalid format.")
|
||||
return data
|
||||
|
||||
def _write_tokens(self, tokens: dict[str, str]) -> None:
|
||||
_, store_path = self._paths()
|
||||
store_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._restrict(store_path.parent, 0o700)
|
||||
temporary = store_path.with_suffix(".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(tokens, ensure_ascii=True, sort_keys=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
self._restrict(temporary, 0o600)
|
||||
temporary.replace(store_path)
|
||||
self._restrict(store_path, 0o600)
|
||||
|
||||
def put(self, credential_id: str, secret: str) -> None:
|
||||
self._validate_id(credential_id)
|
||||
if not secret:
|
||||
raise CredentialStoreError("Credential secret cannot be empty.")
|
||||
with self._lock:
|
||||
tokens = self._read_tokens()
|
||||
token = self._fernet().encrypt(secret.encode("utf-8")).decode("ascii")
|
||||
tokens[credential_id] = token
|
||||
self._write_tokens(tokens)
|
||||
|
||||
def resolve(self, credential_id: str | None) -> str | None:
|
||||
if not credential_id:
|
||||
return None
|
||||
self._validate_id(credential_id)
|
||||
with self._lock:
|
||||
token = self._read_tokens().get(credential_id)
|
||||
if token is None:
|
||||
return None
|
||||
try:
|
||||
return self._fernet().decrypt(token.encode("ascii")).decode("utf-8")
|
||||
except (InvalidToken, UnicodeDecodeError) as exc:
|
||||
raise CredentialStoreError("Credential cannot be decrypted.") from exc
|
||||
|
||||
def has(self, credential_id: str) -> bool:
|
||||
self._validate_id(credential_id)
|
||||
with self._lock:
|
||||
return credential_id in self._read_tokens()
|
||||
|
||||
def delete(self, credential_id: str) -> bool:
|
||||
self._validate_id(credential_id)
|
||||
with self._lock:
|
||||
tokens = self._read_tokens()
|
||||
removed = tokens.pop(credential_id, None) is not None
|
||||
if removed:
|
||||
self._write_tokens(tokens)
|
||||
return removed
|
||||
|
||||
|
||||
class ChainedCredentialResolver:
|
||||
def __init__(self, *resolvers: CredentialResolver) -> None:
|
||||
self._resolvers = resolvers
|
||||
|
||||
def resolve(self, credential_id: str | None) -> str | None:
|
||||
for resolver in self._resolvers:
|
||||
value = resolver.resolve(credential_id)
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from app.contracts import ModelCapability, ProviderConfig, ProviderType
|
||||
from app.contracts import ModelCapability, ProviderConfig, ProviderPreset, ProviderType
|
||||
from app.providers.base import ModelProvider
|
||||
from app.providers.credentials import CredentialResolver
|
||||
from app.providers.ollama import OllamaProvider
|
||||
@@ -27,6 +27,32 @@ class ProviderFactory:
|
||||
return OllamaProvider(config.base_url or "http://127.0.0.1:11434")
|
||||
raise UnsupportedProviderError(config.provider_type.value)
|
||||
|
||||
@staticmethod
|
||||
def presets() -> list[ProviderPreset]:
|
||||
return [
|
||||
ProviderPreset(
|
||||
preset_id="openai",
|
||||
name="OpenAI",
|
||||
provider_type=ProviderType.openai_chat,
|
||||
base_url="https://api.openai.com/v1",
|
||||
default_credential_id="openai",
|
||||
),
|
||||
ProviderPreset(
|
||||
preset_id="deepseek",
|
||||
name="DeepSeek",
|
||||
provider_type=ProviderType.openai_compatible,
|
||||
base_url="https://api.deepseek.com",
|
||||
default_credential_id="deepseek",
|
||||
),
|
||||
ProviderPreset(
|
||||
preset_id="ollama",
|
||||
name="Ollama",
|
||||
provider_type=ProviderType.ollama,
|
||||
base_url="http://127.0.0.1:11434",
|
||||
requires_credential=False,
|
||||
),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def capabilities(provider_type: ProviderType) -> list[ModelCapability]:
|
||||
if provider_type in {
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.contracts import (
|
||||
ModelRequest,
|
||||
)
|
||||
from app.providers.base import ProviderError, ProviderToolCall, ProviderTurn
|
||||
from app.providers.credentials import CredentialResolver
|
||||
from app.providers.credentials import CredentialResolver, CredentialStoreError
|
||||
from app.providers.http_base import TurnStreamingMixin, decode_tool_arguments
|
||||
|
||||
|
||||
@@ -263,7 +263,18 @@ class OpenAICompatibleProvider(TurnStreamingMixin):
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
api_key = self.credentials.resolve(self.credential_id)
|
||||
try:
|
||||
api_key = self.credentials.resolve(self.credential_id)
|
||||
except CredentialStoreError as exc:
|
||||
raise ProviderError(
|
||||
"PROVIDER_CREDENTIAL_UNAVAILABLE",
|
||||
"Credential could not be decrypted by the AI Core.",
|
||||
) from exc
|
||||
if self.credential_id and not api_key:
|
||||
raise ProviderError(
|
||||
"PROVIDER_CREDENTIAL_MISSING",
|
||||
f'Credential "{self.credential_id}" is not available in the AI Core process.',
|
||||
)
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
return headers
|
||||
|
||||
+73
-1
@@ -10,6 +10,8 @@ from app.contracts import (
|
||||
AgentRunCreateRequest,
|
||||
AgentRunListResponse,
|
||||
ChatRequest,
|
||||
CredentialStatus,
|
||||
CredentialWriteRequest,
|
||||
ExtensionInstallRequest,
|
||||
IndexJob,
|
||||
IndexRebuildRequest,
|
||||
@@ -31,6 +33,7 @@ from app.contracts import (
|
||||
ProviderCreateRequest,
|
||||
ProviderListResponse,
|
||||
ProviderModelsResponse,
|
||||
ProviderPresetListResponse,
|
||||
ProviderTestRequest,
|
||||
ProviderTestResponse,
|
||||
ProviderUpdateRequest,
|
||||
@@ -52,6 +55,8 @@ from app.errors import ApiError
|
||||
from app.extensions import ExtensionError
|
||||
from app.providers.registry import ProviderNotFoundError
|
||||
from app.providers.factory import UnsupportedProviderError
|
||||
from app.providers.base import ProviderError
|
||||
from app.providers.credentials import CredentialStoreError
|
||||
from app.retrieval.engine import engine
|
||||
from app.services import index_service, note_service, task_service, transcription_service
|
||||
|
||||
@@ -411,11 +416,61 @@ async def uninstall_plugin(plugin_id: str) -> OperationResponse:
|
||||
|
||||
|
||||
# Providers
|
||||
@router.get(
|
||||
"/credentials/{credential_id}",
|
||||
response_model=CredentialStatus,
|
||||
tags=["Providers"],
|
||||
)
|
||||
async def get_credential_status(credential_id: str) -> CredentialStatus:
|
||||
try:
|
||||
configured = container.credentials.has(credential_id)
|
||||
except CredentialStoreError as exc:
|
||||
raise ApiError(422, "CREDENTIAL_INVALID", str(exc)) from exc
|
||||
return CredentialStatus(credential_id=credential_id, configured=configured)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/credentials/{credential_id}",
|
||||
response_model=CredentialStatus,
|
||||
tags=["Providers"],
|
||||
)
|
||||
async def put_credential(
|
||||
credential_id: str, request: CredentialWriteRequest
|
||||
) -> CredentialStatus:
|
||||
try:
|
||||
container.credentials.put(credential_id, request.api_key.get_secret_value())
|
||||
except CredentialStoreError as exc:
|
||||
raise ApiError(422, "CREDENTIAL_STORE_ERROR", str(exc)) from exc
|
||||
return CredentialStatus(credential_id=credential_id, configured=True)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/credentials/{credential_id}",
|
||||
response_model=CredentialStatus,
|
||||
tags=["Providers"],
|
||||
)
|
||||
async def delete_credential(credential_id: str) -> CredentialStatus:
|
||||
try:
|
||||
container.credentials.delete(credential_id)
|
||||
except CredentialStoreError as exc:
|
||||
raise ApiError(422, "CREDENTIAL_STORE_ERROR", str(exc)) from exc
|
||||
return CredentialStatus(credential_id=credential_id, configured=False)
|
||||
|
||||
|
||||
@router.get("/providers", response_model=ProviderListResponse, tags=["Providers"])
|
||||
async def list_providers() -> ProviderListResponse:
|
||||
return ProviderListResponse(items=container.providers.list_configs())
|
||||
|
||||
|
||||
@router.get(
|
||||
"/providers/presets",
|
||||
response_model=ProviderPresetListResponse,
|
||||
tags=["Providers"],
|
||||
)
|
||||
async def list_provider_presets() -> ProviderPresetListResponse:
|
||||
return ProviderPresetListResponse(items=container.provider_factory.presets())
|
||||
|
||||
|
||||
@router.get(
|
||||
"/providers/{provider_id}",
|
||||
response_model=ProviderConfig,
|
||||
@@ -502,9 +557,26 @@ async def delete_provider(provider_id: str) -> OperationResponse:
|
||||
)
|
||||
async def list_provider_models(provider_id: str) -> ProviderModelsResponse:
|
||||
provider_or_404(provider_id)
|
||||
try:
|
||||
models = await container.providers.list_models(provider_id)
|
||||
except ProviderError as exc:
|
||||
status_code = {
|
||||
"PROVIDER_CREDENTIAL_MISSING": 422,
|
||||
"PROVIDER_CREDENTIAL_UNAVAILABLE": 500,
|
||||
"PROVIDER_AUTH_FAILED": 401,
|
||||
"MODEL_NOT_FOUND": 404,
|
||||
"PROVIDER_RATE_LIMITED": 429,
|
||||
"PROVIDER_TIMEOUT": 504,
|
||||
}.get(exc.code, 502)
|
||||
raise ApiError(
|
||||
status_code,
|
||||
exc.code,
|
||||
exc.message,
|
||||
{"provider_id": provider_id},
|
||||
) from exc
|
||||
return ProviderModelsResponse(
|
||||
provider_id=provider_id,
|
||||
items=await container.providers.list_models(provider_id),
|
||||
items=models,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ description = "Notes Agent 的 FastAPI 基础壳子"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"cryptography>=45,<52",
|
||||
"fastapi>=0.116,<1.0",
|
||||
"httpx>=0.28,<1.0",
|
||||
"jsonschema>=4.25,<5.0",
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import asyncio
|
||||
|
||||
from app.main import health, service_status
|
||||
from app.routes import get_index_status, list_notes, list_plugins, list_providers, list_skills
|
||||
from app.routes import (
|
||||
get_index_status,
|
||||
list_notes,
|
||||
list_plugins,
|
||||
list_provider_presets,
|
||||
list_providers,
|
||||
list_skills,
|
||||
)
|
||||
from app.routes import (
|
||||
create_provider,
|
||||
create_task,
|
||||
@@ -53,6 +60,24 @@ def test_core_collections_are_typed() -> None:
|
||||
assert index.status == "idle"
|
||||
|
||||
|
||||
def test_provider_presets_include_openai_and_deepseek() -> None:
|
||||
presets = asyncio.run(list_provider_presets())
|
||||
by_id = {item.preset_id: item for item in presets.items}
|
||||
|
||||
assert by_id["openai"].base_url == "https://api.openai.com/v1"
|
||||
assert by_id["deepseek"].base_url == "https://api.deepseek.com"
|
||||
assert by_id["deepseek"].provider_type == ProviderType.openai_compatible
|
||||
assert by_id["deepseek"].default_credential_id == "deepseek"
|
||||
|
||||
|
||||
def test_provider_presets_static_route_precedes_provider_id_route() -> None:
|
||||
from app.routes import router
|
||||
|
||||
get_paths = [route.path for route in router.routes if "GET" in getattr(route, "methods", set())]
|
||||
|
||||
assert get_paths.index("/api/providers/presets") < get_paths.index("/api/providers/{provider_id}")
|
||||
|
||||
|
||||
def test_openapi_contains_documented_frontend_interfaces() -> None:
|
||||
from app.main import app
|
||||
|
||||
@@ -70,6 +95,8 @@ def test_openapi_contains_documented_frontend_interfaces() -> None:
|
||||
"/api/plugins/{plugin_id}/enable",
|
||||
"/api/plugins/{plugin_id}/disable",
|
||||
"/api/providers/test",
|
||||
"/api/providers/presets",
|
||||
"/api/credentials/{credential_id}",
|
||||
"/api/index/rebuild",
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
from app.contracts import CredentialWriteRequest
|
||||
from app.providers.credentials import (
|
||||
ChainedCredentialResolver,
|
||||
EncryptedCredentialStore,
|
||||
EnvironmentCredentialResolver,
|
||||
)
|
||||
from app.providers.openai_compatible import OpenAICompatibleProvider
|
||||
from app.routes import get_credential_status, put_credential
|
||||
|
||||
|
||||
def test_encrypted_credential_store_round_trip_without_plaintext_on_disk() -> None:
|
||||
store = EncryptedCredentialStore()
|
||||
secret = "sk-test-sensitive-value"
|
||||
|
||||
store.put("deepseek", secret)
|
||||
|
||||
store_path = get_settings().data_dir / "credentials" / "credentials.json"
|
||||
key_path = get_settings().data_dir / "credentials" / "master.key"
|
||||
assert store_path.exists()
|
||||
assert key_path.exists()
|
||||
assert secret not in store_path.read_text(encoding="utf-8")
|
||||
assert secret not in key_path.read_text(encoding="ascii")
|
||||
assert store.resolve("deepseek") == secret
|
||||
assert store.has("deepseek") is True
|
||||
assert store.delete("deepseek") is True
|
||||
assert store.resolve("deepseek") is None
|
||||
|
||||
|
||||
def test_credential_api_never_returns_secret() -> None:
|
||||
written = asyncio.run(
|
||||
put_credential(
|
||||
"deepseek",
|
||||
CredentialWriteRequest(api_key="sk-test-sensitive-value"),
|
||||
)
|
||||
)
|
||||
status = asyncio.run(get_credential_status("deepseek"))
|
||||
|
||||
assert written.model_dump() == {"credential_id": "deepseek", "configured": True}
|
||||
assert status.configured is True
|
||||
assert "sk-test-sensitive-value" not in written.model_dump_json()
|
||||
|
||||
|
||||
def test_provider_reads_decrypted_api_key_from_encrypted_store() -> None:
|
||||
store = EncryptedCredentialStore()
|
||||
store.put("deepseek", "sk-test-sensitive-value")
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.headers["Authorization"] == "Bearer sk-test-sensitive-value"
|
||||
return httpx.Response(200, json={"data": [{"id": "deepseek-chat"}]})
|
||||
|
||||
provider = OpenAICompatibleProvider(
|
||||
base_url="https://api.deepseek.test",
|
||||
credential_id="deepseek",
|
||||
credentials=store,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
models = asyncio.run(provider.list_models())
|
||||
|
||||
assert [model.model for model in models] == ["deepseek-chat"]
|
||||
|
||||
|
||||
def test_saved_credential_takes_precedence_over_environment_fallback(monkeypatch) -> None:
|
||||
monkeypatch.setenv("DEEPSEEK_API_KEY", "environment-key")
|
||||
store = EncryptedCredentialStore()
|
||||
store.put("deepseek", "saved-key")
|
||||
resolver = ChainedCredentialResolver(store, EnvironmentCredentialResolver())
|
||||
|
||||
assert resolver.resolve("deepseek") == "saved-key"
|
||||
@@ -12,6 +12,8 @@ from app.contracts import (
|
||||
ToolDefinition,
|
||||
)
|
||||
from app.providers.ollama import OllamaProvider
|
||||
from app.providers.base import ProviderError
|
||||
from app.providers.credentials import EnvironmentCredentialResolver
|
||||
from app.providers.openai_compatible import OpenAICompatibleProvider
|
||||
|
||||
|
||||
@@ -134,6 +136,53 @@ def test_openai_compatible_preserves_tool_call_context() -> None:
|
||||
assert turn.text == "done"
|
||||
|
||||
|
||||
def test_openai_compatible_fetches_and_maps_model_list() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "GET"
|
||||
assert request.url.path == "/v1/models"
|
||||
assert request.headers["Authorization"] == "Bearer secret-test-key"
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"data": [{"id": "model-b"}, {"id": "model-a"}]},
|
||||
)
|
||||
|
||||
provider = OpenAICompatibleProvider(
|
||||
base_url="https://provider.test/v1",
|
||||
credential_id="provider-test",
|
||||
credentials=StaticCredentials(),
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
models = run(provider.list_models())
|
||||
|
||||
assert [item.model for item in models] == ["model-b", "model-a"]
|
||||
|
||||
|
||||
def test_environment_credentials_support_deepseek_development_alias(monkeypatch) -> None:
|
||||
monkeypatch.setenv("DEEPSEEK_API_KEY", "secret-test-key")
|
||||
|
||||
assert EnvironmentCredentialResolver().resolve("deepseek") == "secret-test-key"
|
||||
|
||||
|
||||
def test_openai_compatible_rejects_missing_named_credential_before_request() -> None:
|
||||
class EmptyCredentials:
|
||||
def resolve(self, credential_id: str | None) -> str | None:
|
||||
return None
|
||||
|
||||
provider = OpenAICompatibleProvider(
|
||||
base_url="https://provider.test/v1",
|
||||
credential_id="deepseek",
|
||||
credentials=EmptyCredentials(),
|
||||
)
|
||||
|
||||
try:
|
||||
run(provider.list_models())
|
||||
except ProviderError as error:
|
||||
assert error.code == "PROVIDER_CREDENTIAL_MISSING"
|
||||
else:
|
||||
raise AssertionError("Missing credential should fail before the provider request")
|
||||
|
||||
|
||||
def test_ollama_maps_models_and_completion() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/api/tags":
|
||||
|
||||
Generated
+165
@@ -51,6 +51,104 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.5.0"
|
||||
@@ -69,6 +167,62 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "50.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.141.1"
|
||||
@@ -215,6 +369,7 @@ name = "notes-agent-backend"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jsonschema" },
|
||||
@@ -230,6 +385,7 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "cryptography", specifier = ">=45,<52" },
|
||||
{ name = "fastapi", specifier = ">=0.116,<1.0" },
|
||||
{ name = "httpx", specifier = ">=0.28,<1.0" },
|
||||
{ name = "jsonschema", specifier = ">=4.25,<5.0" },
|
||||
@@ -259,6 +415,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.4"
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# 前端写作体验优化开发说明
|
||||
|
||||
## 1. 本次目标
|
||||
|
||||
本次优化聚焦笔记写作主流程,不调整后端接口:
|
||||
|
||||
- 将界面中的装饰性 Emoji 统一替换为 Element Plus 图标;
|
||||
- 将“写作”模式由 Markdown 源码与预览双栏改为单一可视化编辑区;
|
||||
- 为写作区增加标题、加粗、斜体、有序列表、无序列表工具栏;
|
||||
- 写作页代码块默认展开为可编辑状态;只读 Markdown 区域使用 Shiki 提供亮暗主题高亮。
|
||||
|
||||
## 2. 实现说明
|
||||
|
||||
### 2.1 图标体系
|
||||
|
||||
新增 `AppIcon.vue` 作为轻量图标出口,页面直接传入 `@element-plus/icons-vue` 组件。侧边栏、文件树、Vault 入口、主题按钮、空状态及扩展列表不再使用 Emoji 表达操作含义。
|
||||
|
||||
这样处理后,图标尺寸、颜色和主题状态都由 CSS 统一控制,也避免不同系统 Emoji 字体造成的显示差异。
|
||||
|
||||
### 2.2 可视化 Markdown 编辑器
|
||||
|
||||
写作模式使用 Milkdown Crepe 渲染 Markdown 文档,磁盘中仍保存标准 Markdown 文本。编辑器监听 Markdown 更新并写回 Pinia 状态,继续复用原有自动保存逻辑。
|
||||
|
||||
“源码”模式保留为独立模式,便于需要精确编辑 Markdown 的用户使用;写作模式中不再同时展示 Markdown 源码。
|
||||
|
||||
编辑器按当前文件路径重新挂载,保证切换文件、切换源码模式后,展示内容与 Store 中的最新 Markdown 一致。
|
||||
|
||||
### 2.3 Markdown 工具栏
|
||||
|
||||
写作区顶部提供以下基础格式操作:
|
||||
|
||||
- H1 至 H6 标题下拉选择,标题默认使用粗体显示;
|
||||
- 加粗;
|
||||
- 斜体;
|
||||
- 有序列表;
|
||||
- 无序列表;
|
||||
- 12 px 至 32 px 字号选择;
|
||||
- 行内代码与代码块;
|
||||
- 行内公式与公式块;
|
||||
- 链接插入。
|
||||
|
||||
标题、加粗、斜体和列表工具调用 Milkdown Command 修改当前选区或块级结构,因此能正确处理光标、选区和嵌套列表。工具栏使用常见的 `H`、`B`、`I`、`1.` 和 `•` 排版符号,减少图标语义歧义。
|
||||
|
||||
标准 Markdown 没有字号语法。字号功能仅在用户已选择文本时生效,并将内容写为兼容 Markdown 的内联 HTML:
|
||||
|
||||
```markdown
|
||||
<span style="font-size: 18px">选中的文本</span>
|
||||
```
|
||||
|
||||
Milkdown 自定义插件在写作模式中隐藏 HTML 标记,并通过 ProseMirror Decoration 显示实际字号;切换到源码模式时可以直接看到并修改上述 Markdown 内容。
|
||||
|
||||
字号栏同时提供预设下拉框和 `8–96 px` 数值输入框。输入数值后按 Enter 或点击“应用”即可写入当前选区。标题下拉框提供“正文”选项,用于将标题恢复为普通段落;正文显式使用正常字重,只有 H1 至 H6 默认加粗。
|
||||
|
||||
选中文本后出现的 Crepe 浮动格式栏使用应用正文前景色、实色描边和悬浮强调色,避免亮暗主题下图标对比度不足。
|
||||
|
||||
浮动栏由 Crepe Tooltip Provider 挂载,不保证位于 Vue scoped 样式容器内部,因此对比度规则使用全局 `.milkdown-toolbar` 选择器,并通过主题变量适配亮暗模式。顶部格式按钮统一在 `pointerdown` 阶段阻止默认焦点迁移并执行命令,确保点击工具栏时不会丢失编辑器选区。
|
||||
|
||||
有序列表与无序列表使用相同尺寸、相同线条结构的经典列表符号,仅通过左侧的数字或圆点区分类型。亮色主题下,表格边框使用更高对比度的文本辅助色,列表序号、圆点及任务图标也改用辅助文本色并增加字重。
|
||||
|
||||
编辑器左侧加号打开的块菜单已完成中文本地化:
|
||||
|
||||
- “文本”分组包含正文、H1 至 H6、引用和分割线;
|
||||
- “列表”分组包含无序列表、有序列表和任务列表;
|
||||
- “插入”分组包含图片、代码块、表格和公式块。
|
||||
|
||||
代码语言搜索、复制操作、链接编辑及公式确认浮层也统一使用中文文案。
|
||||
|
||||
### 2.4 代码块编辑与 Shiki 高亮
|
||||
|
||||
代码高亮使用 Shiki 的 JavaScript 正则引擎,并只注册第一阶段常用语言:Markdown、HTML、CSS、JavaScript、TypeScript、JSON、Python、Shell 和 SQL。未知语言回退为 Markdown 语法展示,不阻塞整篇内容渲染。
|
||||
|
||||
高亮结果同时生成 `github-light` 和 `github-dark` 颜色变量。根节点的 `data-theme` 变化后由 CSS 选择对应颜色,因此切换主题无需重新解析整篇 Markdown。
|
||||
|
||||
写作编辑器中的普通代码块进入文档后直接展开 CodeMirror 编辑区,不再先显示 Shiki 预览,也不再提供“编辑代码/查看高亮”切换,减少一次多余操作。公式块仍由 Milkdown 的 LaTeX 功能负责编辑和渲染。
|
||||
|
||||
Shiki 仅应用于:
|
||||
|
||||
- AI 对话中的 Markdown 代码块。
|
||||
|
||||
Markdown HTML 仍在写入 DOM 前经过 DOMPurify 清理。
|
||||
|
||||
## 3. 新增依赖
|
||||
|
||||
- `@element-plus/icons-vue`:统一界面图标;
|
||||
- `@milkdown/crepe`、`@milkdown/kit`:可视化 Markdown 编辑器及命令;
|
||||
- `shiki`、`@shikijs/langs`、`@shikijs/themes`、`@shikijs/engine-javascript`:代码高亮和按需语言注册。
|
||||
|
||||
## 4. 验证记录
|
||||
|
||||
在 `frontend` 目录执行:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
pnpm test
|
||||
```
|
||||
|
||||
验证结果:TypeScript 类型检查与 Vite 生产构建均通过。组件回归测试共 7 项,全部通过:
|
||||
|
||||
- 顶部工具栏对选区应用加粗;
|
||||
- 浮动工具栏对选区应用斜体;
|
||||
- 自定义字号输入写入 Markdown;
|
||||
- 标题恢复为普通正文;
|
||||
- 连续切换文件后渲染新文件内容;
|
||||
- 从文件树连续点击时,活动路径与编辑器内容同步切换;
|
||||
- 欢迎笔记的异步初始化不会覆盖用户刚点击的文件。
|
||||
|
||||
文件切换失效包含两层原因。第一层是旧实现先更新 `currentFilePath`、后等待文件内容,导致编辑器使用新路径和旧内容提前重建;现在改为文件读取成功后一次性提交路径和内容。第二层是工作区欢迎笔记的异步初始化结束后会无条件设为活动文件,可能覆盖用户在此期间的真实点击;现在点击文件时立即同步工作区活动路径,默认初始化仅在用户尚未选择文件且欢迎笔记确实加载成功时提交。文件读取失败时则恢复点击前的活动文件。
|
||||
|
||||
本地内置浏览器测试运行时因环境资源路径缺失未能启动,因此本次没有把自动化交互测试列为已通过项。合并前建议人工检查一次工具栏选区操作、文件切换同步和亮暗主题下的代码块显示。
|
||||
|
||||
## 5. 后续建议
|
||||
|
||||
- 根据真实文档规模评估 Milkdown 与只读 Markdown 高亮模块的懒加载拆包;
|
||||
- 为工具栏补充撤销、重做、引用、行内代码和链接;
|
||||
- 增加编辑器选区命令与文件切换的组件测试。
|
||||
+1
-1
@@ -75,7 +75,7 @@
|
||||
| GET | `/api/providers/{provider_id}/models` | 获取模型及 Capability 列表 |
|
||||
| POST | `/api/providers/test` | 测试 Provider 连接 |
|
||||
|
||||
Provider Contract 只传递 `credential_id` 或临时 `credential_context_id`,不通过普通 JSON 接口传递明文 API Key。
|
||||
Provider Contract 只传递 `credential_id` 或临时 `credential_context_id`。当前前后端开发阶段通过独立的 `PUT /api/credentials/{credential_id}` 接收 API Key,并立即加密落盘;该接口只返回配置状态,不返回密钥。Provider CRUD、模型列表和测试接口均不携带明文 API Key。Tauri 集成后由 Stronghold 接管存储实现。
|
||||
|
||||
### Tasks、Media 与 Index
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# 模型提供商与模型发现开发说明
|
||||
|
||||
## 1. 本次目标
|
||||
|
||||
本次完善设置页的模型提供商配置,不改变 Agent、Chat 和 Skill 对统一 Model Core 接口的依赖:
|
||||
|
||||
- 提供 OpenAI、DeepSeek 和 Ollama 配置预设;
|
||||
- 保存 Provider 后自动获取该账号或服务当前可用的模型列表;
|
||||
- 支持手动刷新模型列表和选择默认模型;
|
||||
- 保留自定义 OpenAI-Compatible 服务入口;
|
||||
- 不在 Vue、FastAPI 配置或仓库文件中保存、回显 API Key 明文。
|
||||
|
||||
## 2. 接口与实现
|
||||
|
||||
### 2.1 Provider 预设
|
||||
|
||||
新增接口:
|
||||
|
||||
```http
|
||||
GET /api/providers/presets
|
||||
```
|
||||
|
||||
预设由后端 `ProviderFactory` 提供,前端只消费名称、协议类型、Base URL 和是否需要凭据等配置元数据,不直接实现厂商协议。
|
||||
|
||||
当前预设:
|
||||
|
||||
| 提供商 | Provider Type | Base URL | 默认 Credential ID |
|
||||
| --- | --- | --- | --- |
|
||||
| OpenAI | `openai_chat` | `https://api.openai.com/v1` | `openai` |
|
||||
| DeepSeek | `openai_compatible` | `https://api.deepseek.com` | `deepseek` |
|
||||
| Ollama | `ollama` | `http://127.0.0.1:11434` | 无 |
|
||||
|
||||
OpenAI 和 DeepSeek 都通过项目已有的 `OpenAICompatibleProvider` 访问。模型发现分别请求 Base URL 下的 `/models`,不引入厂商 SDK。
|
||||
|
||||
### 2.2 自动获取模型
|
||||
|
||||
模型列表继续使用既有接口:
|
||||
|
||||
```http
|
||||
GET /api/providers/{provider_id}/models
|
||||
```
|
||||
|
||||
设置页在以下时机调用该接口:
|
||||
|
||||
- Provider 列表加载完成后,为所有已启用 Provider 自动刷新;
|
||||
- 新增或编辑 Provider 保存成功后自动刷新;
|
||||
- 用户点击“刷新模型”时手动刷新;
|
||||
- 打开已有 Provider 的编辑窗口时刷新可选模型。
|
||||
|
||||
前端按模型名称排序并按 `model_id` 去重。获取结果保存在 `providerStore.modelsByProvider`,加载状态和错误按 Provider 隔离,单个外部服务失败不会阻止其他服务展示。
|
||||
|
||||
获取成功后,Provider 卡片展示模型数量和默认模型下拉框。更换默认模型会调用 Provider PATCH 接口写回配置;编辑窗口仍允许手动输入模型 ID,以兼容未出现在列表中的代理模型或部署别名。
|
||||
|
||||
### 2.3 错误处理
|
||||
|
||||
Provider Adapter 的错误在 FastAPI 路由转换为统一 API Error:
|
||||
|
||||
| Provider Error | HTTP 状态 |
|
||||
| --- | --- |
|
||||
| `PROVIDER_AUTH_FAILED` | 401 |
|
||||
| `MODEL_NOT_FOUND` | 404 |
|
||||
| `PROVIDER_RATE_LIMITED` | 429 |
|
||||
| `PROVIDER_TIMEOUT` | 504 |
|
||||
| 其他 Provider 可用性错误 | 502 |
|
||||
|
||||
前端在对应 Provider 卡片内展示失败原因,并允许用户修正 Credential ID、Base URL 后重新获取。
|
||||
|
||||
## 3. 凭据边界
|
||||
|
||||
设置页选择 OpenAI 或 DeepSeek 预设后展示密码类型的 API Key 输入框,不再要求用户理解 Credential ID。输入值只存在于表单的临时 `ref`,不会写入 Pinia 或 localStorage;请求完成、取消表单或失败后都会清空。
|
||||
|
||||
API Key 通过独立接口写入:
|
||||
|
||||
```http
|
||||
GET /api/credentials/{credential_id}
|
||||
PUT /api/credentials/{credential_id}
|
||||
DELETE /api/credentials/{credential_id}
|
||||
```
|
||||
|
||||
PUT 请求使用 Pydantic `SecretStr` 接收密钥,响应仅包含 Credential ID 和 `configured` 状态。后端使用 Fernet 认证加密,将密文保存到 `data/credentials/credentials.json`,主密钥保存到 `data/credentials/master.key`;目录和文件尽可能设置为仅当前用户可访问并整体排除版本控制。写入采用临时文件替换,避免进程中断留下半写文件。Provider 发起请求时按 Credential ID 解密,解密失败转换为统一 Provider Error,任何读取接口均不返回明文。
|
||||
|
||||
本地开发存储的主密钥与密文仍位于同一用户数据目录,因此它解决的是仓库泄漏、普通配置误提交和静态明文暴露,不等同于操作系统安全硬件或 Stronghold。Tauri 集成后应以 Stronghold 实现替换 `EncryptedCredentialStore`。无界面环境仍兼容 `OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 和 Host 注入的 `AINOTE_CREDENTIAL_<ID>`;设置页保存的本地密钥优先,环境变量仅作为回退。
|
||||
|
||||
自动化测试仅使用虚构测试值,验证磁盘文件不包含明文、加解密往返、API 响应不泄密,以及 Provider 能用解密后的值构造 Authorization Header。本次没有使用真实 OpenAI 或 DeepSeek Key,也没有向厂商发起真实请求。
|
||||
|
||||
## 4. 验证
|
||||
|
||||
后端:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv run pytest -q -p no:cacheprovider
|
||||
```
|
||||
|
||||
前端:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
自动化验证覆盖 Provider 预设、OpenAI-Compatible `/models` 请求与鉴权头、模型映射、前端自动刷新、排序去重及按 Provider 隔离错误。生产构建同时执行 Vue 和 TypeScript 类型检查。
|
||||
@@ -6,12 +6,16 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.0",
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@milkdown/crepe": "7.22.1",
|
||||
"@milkdown/kit": "7.22.1",
|
||||
"@milkdown/plugin-block": "^7.22.0",
|
||||
"@milkdown/plugin-cursor": "^7.22.0",
|
||||
"@milkdown/plugin-history": "^7.22.0",
|
||||
@@ -21,19 +25,26 @@
|
||||
"@milkdown/plugin-trailing": "^7.22.0",
|
||||
"@milkdown/theme-nord": "^7.22.0",
|
||||
"@milkdown/vue": "^7.22.0",
|
||||
"@shikijs/engine-javascript": "4.4.3",
|
||||
"@shikijs/langs": "4.4.3",
|
||||
"@shikijs/themes": "4.4.3",
|
||||
"@vueuse/core": "^14.0.0",
|
||||
"codemirror": "^6.0.0",
|
||||
"dompurify": "^3.4.14",
|
||||
"marked": "^15.0.0",
|
||||
"pinia": "^4.0.0",
|
||||
"shiki": "^4.4.3",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"@vue/test-utils": "^2.5.0",
|
||||
"happy-dom": "^20.11.15",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^4.1.11",
|
||||
"vue-tsc": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+707
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
|
||||
withDefaults(defineProps<{ icon: Component; size?: number }>(), { size: 18 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="icon" class="app-icon" :style="{ width: `${size}px`, height: `${size}px` }" aria-hidden="true" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-icon { display: inline-block; flex: 0 0 auto; color: currentColor; }
|
||||
</style>
|
||||
@@ -20,7 +20,7 @@ const commands = computed<Command[]>(() => [
|
||||
{ id: 'workspace', label: '打开工作区', hint: '导航', run: () => router.push('/workspace') },
|
||||
{ id: 'search', label: '全局搜索', hint: '导航', run: () => router.push('/search') },
|
||||
{ id: 'chat', label: '打开 AI 对话', hint: '导航', run: () => router.push('/chat') },
|
||||
{ id: 'agent', label: '创建 Agent Run', hint: '导航', run: () => router.push('/agent/runs') },
|
||||
{ id: 'agent', label: '创建智能体运行', hint: '导航', run: () => router.push('/agent/runs') },
|
||||
{ id: 'settings', label: '打开设置', hint: '导航', run: () => router.push('/settings') },
|
||||
{ id: 'mode', label: `切换为${editorStore.mode === 'source' ? '写作' : '源码'}模式`, hint: '编辑器', run: () => editorStore.toggleMode() },
|
||||
{ id: 'save', label: '保存当前笔记', hint: '编辑器', run: () => editorStore.save() },
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Connection, Lightning } from '@element-plus/icons-vue'
|
||||
import AppIcon from './AppIcon.vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { usePluginStore } from '@/stores/plugin'
|
||||
@@ -17,13 +19,13 @@ onMounted(() => { if (isPlugin.value) void pluginStore.loadPlugins(); else void
|
||||
<div v-if="isPlugin" class="sidebar-list">
|
||||
<button v-for="plugin in pluginStore.plugins" :key="plugin.plugin_id" class="sidebar-list-item extension-item"
|
||||
:class="{ active: pluginStore.selectedPluginId === plugin.plugin_id }" @click="pluginStore.selectPlugin(plugin.plugin_id)">
|
||||
<span>{{ plugin.icon || '🧩' }}</span><span><strong>{{ plugin.name }}</strong><small>{{ plugin.status }}</small></span>
|
||||
<AppIcon :icon="Connection" /><span><strong>{{ plugin.name }}</strong><small>{{ plugin.status }}</small></span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="sidebar-list">
|
||||
<button v-for="skill in skillStore.skills" :key="skill.skill_id" class="sidebar-list-item extension-item"
|
||||
:class="{ active: skillStore.selectedSkillId === skill.skill_id }" @click="skillStore.selectSkill(skill.skill_id)">
|
||||
<span>{{ skill.icon || '⚡' }}</span><span><strong>{{ skill.name }}</strong><small>{{ skill.status }}</small></span>
|
||||
<AppIcon :icon="Lightning" /><span><strong>{{ skill.name }}</strong><small>{{ skill.status }}</small></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { renderMarkdown } from '@/utils/markdown'
|
||||
|
||||
const props = defineProps<{ source: string }>()
|
||||
const html = ref('')
|
||||
let renderVersion = 0
|
||||
|
||||
watch(() => props.source, async (source) => {
|
||||
const version = ++renderVersion
|
||||
const result = await renderMarkdown(source)
|
||||
if (version === renderVersion) html.value = result
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="markdown-content" v-html="html" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.markdown-content { white-space: normal; user-select: text; }
|
||||
.markdown-content :deep(p), .markdown-content :deep(ul), .markdown-content :deep(ol), .markdown-content :deep(pre), .markdown-content :deep(blockquote) { margin: .65em 0; }
|
||||
.markdown-content :deep(h1), .markdown-content :deep(h2), .markdown-content :deep(h3) { margin: 1em 0 .5em; line-height: var(--line-height-tight); }
|
||||
.markdown-content :deep(ul) { padding-left: 1.5em; list-style: disc; }
|
||||
.markdown-content :deep(ol) { padding-left: 1.5em; list-style: decimal; }
|
||||
.markdown-content :deep(.shiki) { overflow: auto; padding: var(--space-md); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); }
|
||||
.markdown-content :deep(code) { padding: .1em .3em; border-radius: var(--radius-sm); background: var(--color-background-tertiary); font-family: var(--font-ui-mono); }
|
||||
.markdown-content :deep(pre code) { padding: 0; background: transparent; }
|
||||
.markdown-content :deep(blockquote) { padding-left: 1em; border-left: 3px solid var(--color-accent-primary); color: var(--color-text-secondary); }
|
||||
.markdown-content :deep(table) { width: 100%; margin: .65em 0; border-collapse: collapse; }
|
||||
.markdown-content :deep(th), .markdown-content :deep(td) { padding: .45em .65em; border: 1px solid var(--color-border-default); text-align: left; }
|
||||
.markdown-content :deep(img) { max-width: 100%; }
|
||||
.markdown-content :deep(hr) { margin: 1em 0; border: 0; border-top: 1px solid var(--color-border-default); }
|
||||
:global([data-theme='dark']) .markdown-content :deep(.shiki),
|
||||
:global([data-theme='dark']) .markdown-content :deep(.shiki span) {
|
||||
color: var(--shiki-dark) !important;
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
font-style: var(--shiki-dark-font-style) !important;
|
||||
font-weight: var(--shiki-dark-font-weight) !important;
|
||||
text-decoration: var(--shiki-dark-text-decoration) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,21 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ArrowLeftBold, ArrowRightBold, Brush, ChatDotRound, CircleCheck, Connection, Cpu, FolderOpened, Lightning, Search, Setting } from '@element-plus/icons-vue'
|
||||
import AppIcon from './AppIcon.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const expanded = ref(localStorage.getItem('primary-sidebar-expanded') === 'true')
|
||||
|
||||
const navItems = [
|
||||
{ name: 'workspace', icon: '📁', label: '工作区' },
|
||||
{ name: 'search', icon: '🔍', label: '搜索' },
|
||||
{ name: 'chat', icon: '💬', label: 'AI 对话' },
|
||||
{ name: 'agent', icon: '🤖', label: 'Agent' },
|
||||
{ name: 'tasks', icon: '✅', label: '任务' },
|
||||
{ name: 'skills', icon: '⚡', label: 'Skill' },
|
||||
{ name: 'plugins', icon: '🧩', label: 'Plugin' },
|
||||
{ name: 'themes', icon: '🎨', label: '主题' },
|
||||
{ name: 'settings', icon: '⚙️', label: '设置' },
|
||||
{ name: 'workspace', icon: FolderOpened, label: '工作区' },
|
||||
{ name: 'search', icon: Search, label: '搜索' },
|
||||
{ name: 'chat', icon: ChatDotRound, label: 'AI 对话' },
|
||||
{ name: 'agent', icon: Cpu, label: '智能体' },
|
||||
{ name: 'tasks', icon: CircleCheck, label: '任务' },
|
||||
{ name: 'skills', icon: Lightning, label: 'Skill' },
|
||||
{ name: 'plugins', icon: Connection, label: 'Plugin' },
|
||||
{ name: 'themes', icon: Brush, label: '主题' },
|
||||
{ name: 'settings', icon: Setting, label: '设置' },
|
||||
]
|
||||
|
||||
const currentName = computed(() => {
|
||||
@@ -43,13 +45,13 @@ function toggleExpanded() {
|
||||
@click="navigate(item.name)"
|
||||
:title="item.label"
|
||||
>
|
||||
<span class="nav-icon">{{ item.icon }}</span>
|
||||
<AppIcon class="nav-icon" :icon="item.icon" :size="20" />
|
||||
<span class="nav-label">{{ item.label }}</span>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<button class="nav-item collapse-button" type="button" :title="expanded ? '收起导航' : '展开导航'" @click="toggleExpanded">
|
||||
<span class="nav-icon">{{ expanded ? '«' : '»' }}</span>
|
||||
<AppIcon class="nav-icon" :icon="expanded ? ArrowLeftBold : ArrowRightBold" />
|
||||
<span class="nav-label">{{ expanded ? '收起' : '展开' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ const sidebarTitle = computed(() => {
|
||||
const titles: Record<string, string> = {
|
||||
'file-tree': '文件',
|
||||
'conversation-list': '对话',
|
||||
'run-list': 'Agent Run',
|
||||
'run-list': '智能体运行',
|
||||
'search-filters': '搜索筛选',
|
||||
'task-filters': '任务筛选',
|
||||
'extension-list': '扩展',
|
||||
|
||||
@@ -84,7 +84,7 @@ const showEditorInfo = computed(() => route.name === 'workspace')
|
||||
</span>
|
||||
<span v-if="agentStore.isRunning" class="status-item agent-status">
|
||||
<span class="spinner" />
|
||||
Agent 运行中
|
||||
智能体运行中
|
||||
</span>
|
||||
</div>
|
||||
<div class="statusbar-right">
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useRoute } from 'vue-router'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { Moon, Sunny } from '@element-plus/icons-vue'
|
||||
import AppIcon from './AppIcon.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
@@ -16,7 +18,7 @@ const pageTitle = computed(() => {
|
||||
workspace: '工作区',
|
||||
search: '搜索',
|
||||
chat: 'AI 对话',
|
||||
agent: 'Agent Trace',
|
||||
agent: '智能体执行轨迹',
|
||||
tasks: '任务',
|
||||
skills: 'Skill 管理',
|
||||
plugins: 'Plugin 管理',
|
||||
@@ -52,7 +54,7 @@ const isDirty = computed(() => editorStore.saveStatus === 'dirty' || editorStore
|
||||
</div>
|
||||
<div class="titlebar-right">
|
||||
<button class="icon-btn" @click="themeStore.toggleTheme()" :title="themeStore.isDark ? '切换浅色主题' : '切换深色主题'">
|
||||
<span class="icon">{{ themeStore.isDark ? '☀️' : '🌙' }}</span>
|
||||
<AppIcon :icon="themeStore.isDark ? Sunny : Moon" :size="16" />
|
||||
</button>
|
||||
<div class="window-controls">
|
||||
<span class="win-btn minimize">—</span>
|
||||
|
||||
@@ -290,6 +290,15 @@ export interface ProviderConfig {
|
||||
has_credential: boolean
|
||||
}
|
||||
|
||||
export interface ProviderPreset {
|
||||
preset_id: string
|
||||
name: string
|
||||
provider_type: ProviderType
|
||||
base_url: string
|
||||
default_credential_id?: string | null
|
||||
requires_credential: boolean
|
||||
}
|
||||
|
||||
// ============ Tasks ============
|
||||
|
||||
export type TaskStatus = 'todo' | 'in_progress' | 'done' | 'cancelled'
|
||||
@@ -510,6 +519,15 @@ export interface ApiProviderConfig {
|
||||
capabilities: string[]
|
||||
}
|
||||
|
||||
export interface ApiProviderPreset {
|
||||
preset_id: string
|
||||
name: string
|
||||
provider_type: ApiProviderType
|
||||
base_url: string
|
||||
default_credential_id?: string | null
|
||||
requires_credential: boolean
|
||||
}
|
||||
|
||||
export interface ApiModelInfo {
|
||||
model: string
|
||||
display_name: string
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import { useProviderStore } from '@/stores/provider'
|
||||
import { useSkillStore } from '@/stores/skill'
|
||||
import type { AgentEvent } from '@/contracts'
|
||||
import { eventLabel, localizeDetails, permissionLabel, runStatusLabel, toolDescription, toolLabel } from './labels'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -24,12 +26,12 @@ onMounted(async () => {
|
||||
try {
|
||||
await Promise.all([providerStore.loadProviders(), skillStore.loadSkills(), agentStore.loadTools()])
|
||||
await providerStore.loadModels(form.provider_id)
|
||||
} catch (error) { pageError.value = error instanceof Error ? error.message : 'Agent 配置加载失败' }
|
||||
} catch (error) { pageError.value = error instanceof Error ? error.message : '智能体配置加载失败' }
|
||||
})
|
||||
|
||||
watch(() => route.params.runId, async (runId) => {
|
||||
if (typeof runId !== 'string') return
|
||||
try { await agentStore.loadRun(runId) } catch (error) { pageError.value = error instanceof Error ? error.message : 'Run 加载失败' }
|
||||
try { await agentStore.loadRun(runId) } catch (error) { pageError.value = error instanceof Error ? error.message : '运行记录加载失败' }
|
||||
}, { immediate: true })
|
||||
|
||||
watch(() => form.provider_id, async (providerId) => {
|
||||
@@ -53,50 +55,54 @@ async function createRun() {
|
||||
allow_network: form.allow_network, max_concurrent_tools: form.max_concurrent_tools,
|
||||
})
|
||||
await router.replace({ name: 'agent', params: { runId: run.run_id } })
|
||||
} catch (error) { pageError.value = error instanceof Error ? error.message : 'Run 创建失败' }
|
||||
} catch (error) { pageError.value = error instanceof Error ? error.message : '运行创建失败' }
|
||||
}
|
||||
|
||||
function eventText(data: Record<string, unknown>) {
|
||||
return String(data.text ?? data.message ?? data.code ?? '')
|
||||
function eventText(event: AgentEvent) {
|
||||
if (event.event === 'RunCompleted') return '任务已成功完成。'
|
||||
if (event.event === 'RunCancelled') return '任务已取消。'
|
||||
const text = event.data.text ?? event.data.message ?? event.data.code
|
||||
if (text) return String(text)
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="feature-page agent-page">
|
||||
<header class="feature-header"><div><h1>{{ isNewRun ? '创建 Agent Run' : 'Agent Trace' }}</h1><p>配置执行边界,并实时查看模型、工具和权限事件。</p></div>
|
||||
<button v-if="!isNewRun" class="button-secondary" @click="router.push({ name: 'agent' })">新建 Run</button></header>
|
||||
<header class="feature-header"><div><h1>{{ isNewRun ? '创建智能体运行' : '智能体执行轨迹' }}</h1><p>配置执行边界,并实时查看模型、工具和权限事件。</p></div>
|
||||
<button v-if="!isNewRun" class="button-secondary" @click="router.push({ name: 'agent' })">新建运行</button></header>
|
||||
<div v-if="pageError || agentStore.error" class="error-banner">{{ pageError || agentStore.error }}</div>
|
||||
<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="描述希望 Agent 完成的任务" /></div>
|
||||
<div class="field"><label>任务</label><textarea v-model="form.input" class="textarea" required placeholder="描述希望智能体完成的任务" /></div>
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>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>Model</label><select v-model="form.model" class="select"><option v-for="m in models" :key="m.model_id" :value="m.model_id">{{ m.name }}</option></select></div>
|
||||
<div class="field"><label>Skill</label><select v-model="form.skill_id" class="select"><option value="">不使用 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><select v-model="form.provider_id" class="select"><option v-for="p in providerStore.enabledProviders" :key="p.provider_id" :value="p.provider_id">{{ p.name }}</option></select></div>
|
||||
<div class="field"><label>模型</label><select v-model="form.model" class="select"><option v-for="m in models" :key="m.model_id" :value="m.model_id">{{ m.name }}</option></select></div>
|
||||
<div class="field"><label>技能</label><select v-model="form.skill_id" class="select"><option value="">不使用技能</option><option v-for="s in skillStore.readySkills" :key="s.skill_id" :value="s.skill_id">{{ s.name }}</option></select></div>
|
||||
<div class="field"><label>最大步骤</label><input v-model.number="form.max_steps" class="input" type="number" min="1" max="100" /></div>
|
||||
<div class="field"><label>Tool Timeout(秒)</label><input v-model.number="form.tool_timeout_seconds" class="input" type="number" min="1" /></div>
|
||||
<div class="field"><label>Run Timeout(秒)</label><input v-model.number="form.run_timeout_seconds" class="input" type="number" min="1" /></div>
|
||||
<div class="field"><label>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.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>令牌预算</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>
|
||||
<div class="field"><label>允许的 Tool</label><div class="tool-grid"><label v-for="tool in agentStore.tools" :key="tool.name" class="tool-option"><input type="checkbox" :checked="form.allowed_tools.includes(tool.name)" @change="toggleTool(tool.name)" /><span><strong>{{ tool.name }}</strong><small>{{ tool.description }}</small></span></label></div></div>
|
||||
<label class="network"><input v-model="form.allow_network" type="checkbox" /> 允许本次 Run 调用网络工具</label>
|
||||
<div class="field"><label>允许使用的工具</label><div class="tool-grid"><label v-for="tool in agentStore.tools" :key="tool.name" class="tool-option"><input type="checkbox" :checked="form.allowed_tools.includes(tool.name)" @change="toggleTool(tool.name)" /><span><strong>{{ toolLabel(tool.name) }}</strong><code>{{ tool.name }}</code><small>{{ toolDescription(tool.name, tool.description) }}</small></span></label></div></div>
|
||||
<label class="network"><input v-model="form.allow_network" type="checkbox" /> 允许本次运行调用网络工具</label>
|
||||
<div class="inline-actions"><button class="button-primary" :disabled="agentStore.isCreating || !form.input.trim()">{{ agentStore.isCreating ? '创建中…' : '创建并运行' }}</button></div>
|
||||
</form>
|
||||
|
||||
<div v-else class="trace-layout">
|
||||
<div class="panel run-summary"><div><span class="badge info">{{ 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>步骤 {{ 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="timeline">
|
||||
<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' }">{{ event.event }}</span><span>#{{ event.sequence }} · {{ new Date(event.timestamp).toLocaleTimeString() }}</span></div>
|
||||
<p v-if="eventText(event.data)" class="event-text">{{ eventText(event.data) }}</p>
|
||||
<pre v-if="['ToolCall', 'ToolResult', 'Citation'].includes(event.event)">{{ JSON.stringify(event.data, null, 2) }}</pre>
|
||||
<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>
|
||||
<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>
|
||||
</article>
|
||||
<div v-if="!agentStore.events.length" class="empty-state"><div><strong>等待 Trace</strong><p>事件连接建立后将在这里实时显示。</p></div></div>
|
||||
<div v-if="!agentStore.events.length" class="empty-state"><div><strong>等待执行轨迹</strong><p>事件连接建立后将在这里实时显示。</p></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="agentStore.permissionRequest" class="modal-backdrop">
|
||||
<div class="modal"><span class="badge warning">权限确认</span><h2>{{ agentStore.permissionRequest.tool_name }}</h2><p>{{ agentStore.permissionRequest.impact }}</p><p class="subtle">权限:{{ agentStore.permissionRequest.permission }}</p><pre>{{ JSON.stringify(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">权限确认</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>
|
||||
</section>
|
||||
</template>
|
||||
@@ -106,6 +112,7 @@ function eventText(data: Record<string, unknown>) {
|
||||
.tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--space-sm); }
|
||||
.tool-option { display: flex; gap: var(--space-sm); padding: var(--space-sm); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); }
|
||||
.tool-option small { display: block; color: var(--color-text-secondary); }
|
||||
.tool-option code { display: block; margin: 2px 0; color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
|
||||
.network { display: flex; gap: var(--space-sm); }
|
||||
.trace-layout { display: grid; gap: var(--space-lg); }
|
||||
.run-summary, .event-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); }
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import { runStatusLabel } from './labels'
|
||||
|
||||
const agentStore = useAgentStore()
|
||||
const router = useRouter()
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try { await agentStore.loadRuns() } catch (reason) { error.value = reason instanceof Error ? reason.message : 'Run 列表加载失败' }
|
||||
try { await agentStore.loadRuns() } catch (reason) { error.value = reason instanceof Error ? reason.message : '运行记录加载失败' }
|
||||
})
|
||||
|
||||
function selectRun(runId: string) { void router.push({ name: 'agent', params: { runId } }) }
|
||||
@@ -16,12 +17,12 @@ function selectRun(runId: string) { void router.push({ name: 'agent', params: {
|
||||
|
||||
<template>
|
||||
<div class="sidebar-panel">
|
||||
<button class="button-primary new-button" @click="router.push({ name: 'agent' })">+ 新建 Run</button>
|
||||
<button class="button-primary new-button" @click="router.push({ name: 'agent' })">+ 新建运行</button>
|
||||
<p v-if="error" class="subtle error-text">{{ error }}</p>
|
||||
<div class="sidebar-list">
|
||||
<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)">
|
||||
<span class="badge" :class="{ success: run.status === 'completed', error: run.status === 'failed', warning: run.status === 'waiting_permission' }">{{ 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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
eventLabel,
|
||||
localizeDetails,
|
||||
permissionLabel,
|
||||
runStatusLabel,
|
||||
toolDescription,
|
||||
toolLabel,
|
||||
} from './labels'
|
||||
|
||||
describe('智能体页面中文标签', () => {
|
||||
it('转换运行状态和事件名称', () => {
|
||||
expect(runStatusLabel('waiting_permission')).toBe('等待授权')
|
||||
expect(eventLabel('ToolCall')).toBe('调用工具')
|
||||
})
|
||||
|
||||
it('转换工具、权限和工具说明', () => {
|
||||
expect(toolLabel('notes.search')).toBe('搜索笔记')
|
||||
expect(permissionLabel('notes.write')).toBe('修改笔记')
|
||||
expect(toolDescription('math.add', 'fallback')).toContain('两个数')
|
||||
expect(toolLabel('custom.tool')).toBe('custom.tool')
|
||||
})
|
||||
|
||||
it('递归转换事件详情中的键名、状态和布尔值', () => {
|
||||
expect(localizeDetails({
|
||||
tool_call_id: 'call-1',
|
||||
success: true,
|
||||
result: { status: 'completed' },
|
||||
})).toEqual({
|
||||
'工具调用 ID': 'call-1',
|
||||
'是否成功': '是',
|
||||
'结果': { '状态': '已完成' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { AgentEventType, AgentRunStatus } from '@/contracts'
|
||||
|
||||
const runStatusLabels: Record<AgentRunStatus, string> = {
|
||||
queued: '排队中',
|
||||
running: '运行中',
|
||||
waiting_permission: '等待授权',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
cancelled: '已取消',
|
||||
}
|
||||
|
||||
const eventLabels: Record<AgentEventType, string> = {
|
||||
RunStarted: '运行开始',
|
||||
TextDelta: '回复内容',
|
||||
ThinkingDelta: '思考过程',
|
||||
ToolCall: '调用工具',
|
||||
ToolResult: '工具结果',
|
||||
PermissionRequired: '请求权限',
|
||||
Usage: '用量统计',
|
||||
Citation: '引用来源',
|
||||
RunCompleted: '运行完成',
|
||||
RunFailed: '运行失败',
|
||||
RunCancelled: '运行取消',
|
||||
}
|
||||
|
||||
const toolLabels: Record<string, string> = {
|
||||
'system.echo': '回显测试',
|
||||
'math.add': '数值相加',
|
||||
'notes.search': '搜索笔记',
|
||||
'rag.search': '知识检索',
|
||||
'notes.read': '读取笔记',
|
||||
'notes.create': '创建笔记',
|
||||
'notes.update': '更新笔记',
|
||||
'notes.list': '列出笔记',
|
||||
'notes.move': '移动笔记',
|
||||
'tasks.create': '创建任务',
|
||||
'tasks.update': '更新任务',
|
||||
'tasks.list': '列出任务',
|
||||
'attachments.read': '读取附件',
|
||||
'audio.transcribe': '音频转写',
|
||||
}
|
||||
|
||||
const toolDescriptions: Record<string, string> = {
|
||||
'system.echo': '回显文本,用于本地智能体集成测试。',
|
||||
'math.add': '计算两个数的和,不产生外部副作用。',
|
||||
'notes.search': '搜索已建立索引的笔记,并返回摘要和引用。',
|
||||
'rag.search': '检索与当前任务相关的笔记内容块和引用。',
|
||||
'notes.read': '根据笔记 ID 读取笔记及其内容块。',
|
||||
'notes.create': '在当前知识库中创建 Markdown 笔记。',
|
||||
'notes.update': '更新已有 Markdown 笔记。',
|
||||
'notes.list': '按文件夹和标签筛选并列出笔记摘要。',
|
||||
'notes.move': '移动笔记到其他文件夹并保留笔记 ID。',
|
||||
'tasks.create': '创建并持久化任务。',
|
||||
'tasks.update': '更新已有任务。',
|
||||
'tasks.list': '列出已持久化的任务。',
|
||||
'attachments.read': '读取由宿主管理的 UTF-8 附件。',
|
||||
'audio.transcribe': '读取音频附件已有的宿主转写结果。',
|
||||
}
|
||||
|
||||
const permissionLabels: Record<string, string> = {
|
||||
'notes.search': '搜索笔记',
|
||||
'notes.read': '读取笔记',
|
||||
'notes.write': '修改笔记',
|
||||
'tasks.read': '读取任务',
|
||||
'tasks.write': '修改任务',
|
||||
'attachments.read': '读取附件',
|
||||
'network.request': '访问网络',
|
||||
'secrets.use': '使用密钥',
|
||||
}
|
||||
|
||||
const detailLabels: Record<string, string> = {
|
||||
tool_call_id: '工具调用 ID',
|
||||
name: '工具名称',
|
||||
arguments: '参数',
|
||||
parameters: '参数',
|
||||
success: '是否成功',
|
||||
output: '输出',
|
||||
result: '结果',
|
||||
error_code: '错误代码',
|
||||
error_message: '错误信息',
|
||||
note_id: '笔记 ID',
|
||||
block_id: '内容块 ID',
|
||||
heading_path: '标题路径',
|
||||
input_tokens: '输入令牌',
|
||||
output_tokens: '输出令牌',
|
||||
total_tokens: '令牌总数',
|
||||
status: '状态',
|
||||
duration_ms: '耗时(毫秒)',
|
||||
}
|
||||
|
||||
export function runStatusLabel(status?: AgentRunStatus): string {
|
||||
return status ? runStatusLabels[status] : '未知状态'
|
||||
}
|
||||
|
||||
export function eventLabel(event: AgentEventType): string {
|
||||
return eventLabels[event]
|
||||
}
|
||||
|
||||
export function toolLabel(name: string): string {
|
||||
return toolLabels[name] ?? name
|
||||
}
|
||||
|
||||
export function toolDescription(name: string, fallback: string): string {
|
||||
return toolDescriptions[name] ?? fallback
|
||||
}
|
||||
|
||||
export function permissionLabel(permission: string): string {
|
||||
return permissionLabels[permission] ?? permission
|
||||
}
|
||||
|
||||
function localizeValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(localizeValue)
|
||||
if (value && typeof value === 'object') return localizeDetails(value as Record<string, unknown>)
|
||||
if (value === true) return '是'
|
||||
if (value === false) return '否'
|
||||
if (typeof value === 'string' && value in runStatusLabels) {
|
||||
return runStatusLabels[value as AgentRunStatus]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function localizeDetails(data: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(data).map(([key, value]) => [detailLabels[key] ?? key, localizeValue(value)])
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { useEditorStore } from '@/stores/editor'
|
||||
import { useProviderStore } from '@/stores/provider'
|
||||
import { useSkillStore } from '@/stores/skill'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { renderMarkdown } from '@/utils/markdown'
|
||||
import MarkdownContent from '@/components/common/MarkdownContent.vue'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const providerStore = useProviderStore()
|
||||
@@ -69,7 +69,7 @@ async function openCitation(citation: Citation) {
|
||||
<div class="avatar">{{ message.role === 'user' ? '你' : 'AI' }}</div>
|
||||
<div class="message-body">
|
||||
<details v-if="message.thinking" class="thinking"><summary>思考过程</summary><p>{{ message.thinking }}</p></details>
|
||||
<div v-if="message.content" class="message-content markdown-content" v-html="renderMarkdown(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-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">
|
||||
@@ -104,15 +104,6 @@ async function openCitation(citation: Citation) {
|
||||
.avatar { display: grid; place-items: center; width: 34px; height: 34px; border-radius: var(--radius-full); background: var(--color-background-tertiary); font-weight: 700; }
|
||||
.assistant .avatar { background: var(--color-accent-soft); color: var(--color-accent-primary); }
|
||||
.message-content { white-space: pre-wrap; line-height: var(--line-height-relaxed); }
|
||||
.markdown-content { white-space: normal; user-select: text; }
|
||||
.markdown-content :deep(p), .markdown-content :deep(ul), .markdown-content :deep(ol), .markdown-content :deep(pre), .markdown-content :deep(blockquote) { margin: .65em 0; }
|
||||
.markdown-content :deep(h1), .markdown-content :deep(h2), .markdown-content :deep(h3) { margin: 1em 0 .5em; line-height: var(--line-height-tight); }
|
||||
.markdown-content :deep(ul) { padding-left: 1.5em; list-style: disc; }.markdown-content :deep(ol) { padding-left: 1.5em; list-style: decimal; }
|
||||
.markdown-content :deep(pre) { overflow: auto; padding: var(--space-md); border-radius: var(--radius-md); background: var(--color-background-secondary); }
|
||||
.markdown-content :deep(code) { padding: .1em .3em; border-radius: var(--radius-sm); background: var(--color-background-tertiary); font-family: var(--font-ui-mono); }.markdown-content :deep(pre code) { padding: 0; background: transparent; }
|
||||
.markdown-content :deep(blockquote) { padding-left: 1em; border-left: 3px solid var(--color-accent-primary); color: var(--color-text-secondary); }
|
||||
.markdown-content :deep(table) { width: 100%; margin: .65em 0; border-collapse: collapse; }.markdown-content :deep(th), .markdown-content :deep(td) { padding: .45em .65em; border: 1px solid var(--color-border-default); text-align: left; }
|
||||
.markdown-content :deep(img) { max-width: 100%; }.markdown-content :deep(hr) { margin: 1em 0; border: 0; border-top: 1px solid var(--color-border-default); }
|
||||
.thinking { margin-bottom: var(--space-sm); color: var(--color-text-secondary); }.thinking p { margin-top: var(--space-sm); white-space: pre-wrap; }
|
||||
.tool-calls { display: grid; gap: var(--space-sm); margin-top: var(--space-md); }.tool-calls .item-card { display: grid; gap: var(--space-xs); }.tool-calls pre { overflow: auto; font-size: var(--font-size-xs); }
|
||||
.usage { display: block; margin-top: var(--space-xs); color: var(--color-text-tertiary); }
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import EditorPane from './EditorPane.vue'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
async function waitForText(text: string) {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (wrapper?.text().includes(text)) return
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
throw new Error(`Editor did not render ${text}`)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('EditorPane file switching', () => {
|
||||
it('recreates the visual editor with the newly loaded file content', async () => {
|
||||
const store = useEditorStore()
|
||||
await store.loadFile('/欢迎使用知笔知己.md')
|
||||
wrapper = mount(EditorPane, { attachTo: document.body })
|
||||
await waitForText('欢迎使用知笔知己')
|
||||
|
||||
await store.loadFile('/数据结构/红黑树.md')
|
||||
await nextTick()
|
||||
await waitForText('红黑树')
|
||||
|
||||
expect(store.currentFilePath).toBe('/数据结构/红黑树.md')
|
||||
expect(wrapper.text()).not.toContain('祝你写作愉快')
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { renderMarkdown } from '@/utils/markdown'
|
||||
import VisualMarkdownEditor from './VisualMarkdownEditor.vue'
|
||||
|
||||
const editorStore = useEditorStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const renderedContent = computed(() => renderMarkdown(editorStore.content))
|
||||
|
||||
function updateContent(event: Event) {
|
||||
editorStore.updateContent((event.target as HTMLTextAreaElement).value)
|
||||
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
|
||||
@@ -15,11 +12,8 @@ function updateContent(event: Event) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="editorStore.mode === 'wysiwyg'" class="writing-layout">
|
||||
<textarea class="editor-pane writing-input" :value="editorStore.content" :spellcheck="settingsStore.spellCheck"
|
||||
aria-label="Markdown 写作编辑器" @input="updateContent" />
|
||||
<article class="markdown-preview" aria-label="Markdown 实时预览" v-html="renderedContent" />
|
||||
</div>
|
||||
<VisualMarkdownEditor v-if="editorStore.mode === 'wysiwyg'" :key="editorStore.currentFilePath ?? 'empty'"
|
||||
:initial-content="editorStore.content" />
|
||||
<textarea v-else class="editor-pane source" :value="editorStore.content" :spellcheck="false"
|
||||
aria-label="Markdown 源码编辑器" @input="updateContent" />
|
||||
</template>
|
||||
@@ -39,21 +33,5 @@ function updateContent(event: Event) {
|
||||
line-height: 1.7;
|
||||
user-select: text;
|
||||
}
|
||||
.writing-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); flex: 1; min-height: 0; }
|
||||
.writing-input { border-right: 1px solid var(--color-border-default); font-family: var(--font-editor-sans); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); }
|
||||
.markdown-preview { width: min(100%, var(--editor-line-width, 80ch)); overflow: auto; margin: 0 auto; padding: var(--space-3xl) var(--space-xl); font-family: var(--font-editor-sans); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); user-select: text; }
|
||||
.markdown-preview :deep(h1), .markdown-preview :deep(h2), .markdown-preview :deep(h3) { margin: 1.4em 0 .6em; line-height: var(--line-height-tight); color: var(--color-text-primary); }
|
||||
.markdown-preview :deep(h1:first-child), .markdown-preview :deep(h2:first-child) { margin-top: 0; }
|
||||
.markdown-preview :deep(p), .markdown-preview :deep(ul), .markdown-preview :deep(ol), .markdown-preview :deep(blockquote), .markdown-preview :deep(pre), .markdown-preview :deep(table) { margin: .8em 0; }
|
||||
.markdown-preview :deep(ul), .markdown-preview :deep(ol) { padding-left: 1.6em; }
|
||||
.markdown-preview :deep(ul) { list-style: disc; }.markdown-preview :deep(ol) { list-style: decimal; }
|
||||
.markdown-preview :deep(blockquote) { padding-left: 1em; border-left: 3px solid var(--color-accent-primary); color: var(--color-text-secondary); }
|
||||
.markdown-preview :deep(code) { padding: .15em .35em; border-radius: var(--radius-sm); background: var(--color-background-tertiary); font-family: var(--font-editor-mono); }
|
||||
.markdown-preview :deep(pre) { overflow: auto; padding: var(--space-lg); border-radius: var(--radius-md); background: var(--color-background-secondary); }
|
||||
.markdown-preview :deep(pre code) { padding: 0; background: transparent; }
|
||||
.markdown-preview :deep(table) { width: 100%; border-collapse: collapse; }.markdown-preview :deep(th), .markdown-preview :deep(td) { padding: .5em .7em; border: 1px solid var(--color-border-default); text-align: left; }
|
||||
.markdown-preview :deep(img) { max-width: 100%; }.markdown-preview :deep(a) { color: var(--color-text-link); }
|
||||
.markdown-preview :deep(hr) { margin: 1.5em 0; border: 0; border-top: 1px solid var(--color-border-default); }
|
||||
.editor-pane.source { font-family: var(--font-editor-mono); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); }
|
||||
@media (max-width: 900px) { .writing-layout { grid-template-columns: 1fr; grid-template-rows: minmax(220px, 1fr) minmax(220px, 1fr); overflow: auto; }.writing-input { min-height: 220px; border-right: 0; border-bottom: 1px solid var(--color-border-default); }.markdown-preview { min-height: 220px; } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { editorViewCtx, type Editor } from '@milkdown/kit/core'
|
||||
import { TextSelection } from '@milkdown/kit/prose/state'
|
||||
import { getMarkdown } from '@milkdown/kit/utils'
|
||||
import VisualMarkdownEditor from './VisualMarkdownEditor.vue'
|
||||
|
||||
type EditorComponent = { getEditor: () => Editor | undefined }
|
||||
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
async function waitForEditor(wrapper: VueWrapper): Promise<Editor> {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
const editor = (wrapper.vm as unknown as EditorComponent).getEditor()
|
||||
if (editor) {
|
||||
try {
|
||||
editor.action(getMarkdown())
|
||||
return editor
|
||||
} catch { /* editor is still creating */ }
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
throw new Error('Milkdown editor did not become ready')
|
||||
}
|
||||
|
||||
function selectText(editor: Editor, from: number, to: number) {
|
||||
editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, from, to)))
|
||||
view.focus()
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mounted.splice(0).forEach((wrapper) => wrapper.unmount())
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('VisualMarkdownEditor formatting toolbars', () => {
|
||||
it('applies bold from the top toolbar to the selected text', async () => {
|
||||
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'alpha beta' }, attachTo: document.body })
|
||||
mounted.push(wrapper)
|
||||
const editor = await waitForEditor(wrapper)
|
||||
selectText(editor, 1, 6)
|
||||
|
||||
await wrapper.get('[aria-label="加粗"]').trigger('pointerdown')
|
||||
|
||||
expect(editor.action(getMarkdown())).toContain('**alpha** beta')
|
||||
})
|
||||
|
||||
it('applies italic from the floating toolbar to the selected text', async () => {
|
||||
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'alpha beta' }, attachTo: document.body })
|
||||
mounted.push(wrapper)
|
||||
const editor = await waitForEditor(wrapper)
|
||||
selectText(editor, 1, 6)
|
||||
await new Promise((resolve) => setTimeout(resolve, 80))
|
||||
|
||||
const floatingItalic = document.querySelector<HTMLButtonElement>('.milkdown-toolbar [data-toolbar-item="italic"]')
|
||||
expect(floatingItalic).not.toBeNull()
|
||||
floatingItalic?.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
|
||||
expect(editor.action(getMarkdown())).toContain('*alpha* beta')
|
||||
})
|
||||
|
||||
it('writes a custom input font size into markdown for the selected text', async () => {
|
||||
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'alpha beta' }, attachTo: document.body })
|
||||
mounted.push(wrapper)
|
||||
const editor = await waitForEditor(wrapper)
|
||||
selectText(editor, 1, 6)
|
||||
|
||||
await wrapper.get('[aria-label="自定义字号"]').setValue(22)
|
||||
await wrapper.get('[aria-label="应用自定义字号"]').trigger('pointerdown')
|
||||
|
||||
expect(editor.action(getMarkdown())).toContain('<span style="font-size: 22px">alpha</span> beta')
|
||||
})
|
||||
|
||||
it('turns a heading back into a normal paragraph', async () => {
|
||||
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '# alpha' }, attachTo: document.body })
|
||||
mounted.push(wrapper)
|
||||
const editor = await waitForEditor(wrapper)
|
||||
|
||||
await wrapper.get('[aria-label="标题级别"]').setValue('paragraph')
|
||||
|
||||
expect(editor.action(getMarkdown()).trim()).toBe('alpha')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,282 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { Link } from '@element-plus/icons-vue'
|
||||
import { Crepe } from '@milkdown/crepe'
|
||||
import {
|
||||
createCodeBlockCommand,
|
||||
toggleEmphasisCommand,
|
||||
toggleInlineCodeCommand,
|
||||
toggleLinkCommand,
|
||||
toggleStrongCommand,
|
||||
turnIntoTextCommand,
|
||||
wrapInBulletListCommand,
|
||||
wrapInHeadingCommand,
|
||||
wrapInOrderedListCommand,
|
||||
} from '@milkdown/kit/preset/commonmark'
|
||||
import { commandsCtx, editorViewCtx } from '@milkdown/kit/core'
|
||||
import { TextSelection } from '@milkdown/kit/prose/state'
|
||||
import { callCommand } from '@milkdown/kit/utils'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { applyMarkdownFontSize, fontSizeMarkdownPlugin } from './fontSizeMarkdown'
|
||||
import '@milkdown/crepe/theme/common/style.css'
|
||||
import '@milkdown/crepe/theme/frame.css'
|
||||
|
||||
const props = defineProps<{ initialContent: string }>()
|
||||
const editorStore = useEditorStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const editorRoot = ref<HTMLElement | null>(null)
|
||||
const loading = ref(true)
|
||||
const fontSizeInput = ref(16)
|
||||
let crepe: Crepe | null = null
|
||||
|
||||
type ToolbarCommand = 'bold' | 'italic' | 'ordered-list' | 'bullet-list' | 'inline-code' | 'code-block' | 'inline-math' | 'math-block'
|
||||
|
||||
function runCommand(command: ToolbarCommand) {
|
||||
const editor = crepe?.editor
|
||||
if (!editor) return
|
||||
const actions = {
|
||||
bold: callCommand(toggleStrongCommand.key),
|
||||
italic: callCommand(toggleEmphasisCommand.key),
|
||||
'ordered-list': callCommand(wrapInOrderedListCommand.key),
|
||||
'bullet-list': callCommand(wrapInBulletListCommand.key),
|
||||
'inline-code': callCommand(toggleInlineCodeCommand.key),
|
||||
'code-block': callCommand(createCodeBlockCommand.key, ''),
|
||||
'inline-math': callCommand('ToggleLatex'),
|
||||
'math-block': callCommand(createCodeBlockCommand.key, 'LaTeX'),
|
||||
}
|
||||
editor.action(actions[command])
|
||||
editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')?.focus()
|
||||
}
|
||||
|
||||
function applyLink() {
|
||||
if (!crepe) return
|
||||
const href = window.prompt('请输入链接地址', 'https://')?.trim()
|
||||
if (!href) return
|
||||
|
||||
crepe.editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const commands = ctx.get(commandsCtx)
|
||||
if (view.state.selection.empty) {
|
||||
const label = window.prompt('请输入链接文字', href)?.trim() || href
|
||||
const from = view.state.selection.from
|
||||
const transaction = view.state.tr.insertText(label, from)
|
||||
transaction.setSelection(TextSelection.create(transaction.doc, from, from + label.length))
|
||||
view.dispatch(transaction)
|
||||
}
|
||||
return commands.call(toggleLinkCommand.key, { href })
|
||||
})
|
||||
editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')?.focus()
|
||||
}
|
||||
|
||||
function applyHeading(event: Event) {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
if (!value || !crepe) return
|
||||
crepe.editor.action(value === 'paragraph'
|
||||
? callCommand(turnIntoTextCommand.key)
|
||||
: callCommand(wrapInHeadingCommand.key, Number(value)))
|
||||
editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')?.focus()
|
||||
;(event.target as HTMLSelectElement).value = ''
|
||||
}
|
||||
|
||||
function applyFontSize(event: Event) {
|
||||
const size = Number((event.target as HTMLSelectElement).value)
|
||||
if (!size || !crepe) return
|
||||
fontSizeInput.value = size
|
||||
applyFontSizeValue()
|
||||
;(event.target as HTMLSelectElement).value = ''
|
||||
}
|
||||
|
||||
function applyFontSizeValue() {
|
||||
if (!crepe) return
|
||||
const size = Math.min(96, Math.max(8, Math.round(Number(fontSizeInput.value))))
|
||||
if (!Number.isFinite(size)) return
|
||||
fontSizeInput.value = size
|
||||
applyMarkdownFontSize(crepe.editor, size)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
crepe = new Crepe({
|
||||
root: editorRoot.value,
|
||||
defaultValue: props.initialContent,
|
||||
features: { [Crepe.Feature.TopBar]: false },
|
||||
featureConfigs: {
|
||||
[Crepe.Feature.Placeholder]: { text: '开始记录你的想法…' },
|
||||
[Crepe.Feature.CodeMirror]: {
|
||||
previewOnlyByDefault: false,
|
||||
searchPlaceholder: '搜索语言',
|
||||
noResultText: '没有匹配的语言',
|
||||
copyText: '复制',
|
||||
},
|
||||
[Crepe.Feature.Latex]: {
|
||||
inlineEditConfirm: '确认',
|
||||
},
|
||||
[Crepe.Feature.LinkTooltip]: {
|
||||
editButton: '编辑',
|
||||
removeButton: '移除',
|
||||
confirmButton: '确认',
|
||||
inputPlaceholder: '粘贴链接地址…',
|
||||
},
|
||||
[Crepe.Feature.Toolbar]: {
|
||||
boldLabel: '加粗',
|
||||
italicLabel: '斜体',
|
||||
strikethroughLabel: '删除线',
|
||||
codeLabel: '行内代码',
|
||||
latexLabel: '行内公式',
|
||||
linkLabel: '链接',
|
||||
},
|
||||
[Crepe.Feature.BlockEdit]: {
|
||||
textGroup: {
|
||||
label: '文本',
|
||||
text: { label: '正文' },
|
||||
h1: { label: '一级标题' },
|
||||
h2: { label: '二级标题' },
|
||||
h3: { label: '三级标题' },
|
||||
h4: { label: '四级标题' },
|
||||
h5: { label: '五级标题' },
|
||||
h6: { label: '六级标题' },
|
||||
quote: { label: '引用' },
|
||||
divider: { label: '分割线' },
|
||||
},
|
||||
listGroup: {
|
||||
label: '列表',
|
||||
bulletList: { label: '无序列表' },
|
||||
orderedList: { label: '有序列表' },
|
||||
taskList: { label: '任务列表' },
|
||||
},
|
||||
advancedGroup: {
|
||||
label: '插入',
|
||||
image: { label: '图片' },
|
||||
codeBlock: { label: '代码块' },
|
||||
table: { label: '表格' },
|
||||
math: { label: '公式块' },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
crepe.editor.use(fontSizeMarkdownPlugin)
|
||||
crepe.on((listener) => {
|
||||
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
|
||||
if (markdown === previousMarkdown || markdown === editorStore.content) return
|
||||
editorStore.updateContent(markdown)
|
||||
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
|
||||
})
|
||||
})
|
||||
await crepe.create()
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => { void crepe?.destroy() })
|
||||
|
||||
defineExpose({ getEditor: () => crepe?.editor })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="visual-editor">
|
||||
<div class="markdown-toolbar" role="toolbar" aria-label="Markdown 格式工具栏">
|
||||
<label class="toolbar-select heading-select" title="设置标题级别">
|
||||
<span class="format-glyph heading-glyph">H</span>
|
||||
<select aria-label="标题级别" @change="applyHeading">
|
||||
<option value="" selected>标题</option>
|
||||
<option value="paragraph">正文</option>
|
||||
<option v-for="level in 6" :key="level" :value="level">H{{ level }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" title="加粗 (Ctrl+B)" aria-label="加粗" @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>
|
||||
<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="无序列表" aria-label="无序列表" @pointerdown.prevent="runCommand('bullet-list')"><span class="list-marker">•</span><span class="list-lines">☰</span></button>
|
||||
<span class="toolbar-divider" />
|
||||
<label class="toolbar-select font-size-select" title="选择预设字号">
|
||||
<span class="format-glyph font-size-glyph">A</span>
|
||||
<select aria-label="文字字号" @change="applyFontSize">
|
||||
<option value="" selected>字号</option>
|
||||
<option v-for="size in [12, 14, 16, 18, 20, 24, 28, 32]" :key="size" :value="size">{{ size }} px</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="font-size-input" title="输入字号后按 Enter 或点击应用">
|
||||
<input v-model.number="fontSizeInput" type="number" min="8" max="96" step="1" aria-label="自定义字号"
|
||||
@keydown.enter.prevent="applyFontSizeValue" />
|
||||
<span>px</span>
|
||||
<button type="button" aria-label="应用自定义字号" @pointerdown.prevent="applyFontSizeValue">应用</button>
|
||||
</div>
|
||||
<span class="toolbar-divider" />
|
||||
<button type="button" title="行内代码" aria-label="行内代码" @pointerdown.prevent="runCommand('inline-code')"><code class="code-glyph"></></code></button>
|
||||
<button type="button" title="代码块" aria-label="代码块" @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="公式块" aria-label="公式块" @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>
|
||||
</div>
|
||||
<div v-if="loading" class="editor-loading">正在加载编辑器…</div>
|
||||
<div ref="editorRoot" class="milkdown-host" :class="{ loading }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.visual-editor { display: flex; flex: 1; min-height: 0; flex-direction: column; background: var(--color-background-primary); }
|
||||
.markdown-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 2px; min-height: 42px; padding: 5px var(--space-lg); border-bottom: 1px solid var(--color-border-subtle); background: var(--color-surface-primary); }
|
||||
.markdown-toolbar button { display: inline-grid; place-items: center; min-width: 32px; min-height: 30px; padding: 4px 8px; border-radius: var(--radius-sm); color: var(--color-text-primary); }
|
||||
.markdown-toolbar button:hover, .toolbar-select:hover { background: var(--color-background-hover); color: var(--color-text-primary); }
|
||||
.markdown-toolbar button:focus-visible, .toolbar-select:focus-within { outline: 2px solid var(--color-border-focus); outline-offset: 1px; }
|
||||
.format-glyph { font-family: Georgia, 'Times New Roman', serif; font-size: 17px; line-height: 1; }
|
||||
.heading-glyph { font-weight: 800; }
|
||||
.font-size-glyph { font-size: 18px; }
|
||||
.list-glyph { grid-template-columns: 8px 14px; column-gap: 2px; font-weight: 700; }
|
||||
.list-marker { font: 700 12px/1 var(--font-ui-sans); }
|
||||
.list-lines { overflow: hidden; width: 14px; font-size: 15px; line-height: 1; transform: scaleX(1.2); }
|
||||
.code-glyph, .block-glyph { padding: 0; background: transparent; color: inherit; font: 700 13px/1 var(--font-editor-mono); }
|
||||
.math-glyph { font: italic 700 16px/1 Georgia, 'Times New Roman', serif; }
|
||||
.toolbar-select { display: inline-flex; align-items: center; gap: 4px; min-height: 30px; padding: 3px 5px 3px 8px; border-radius: var(--radius-sm); color: var(--color-text-primary); }
|
||||
.toolbar-select select { min-width: 58px; border: 0; outline: 0; background: transparent; color: inherit; cursor: pointer; font-size: var(--font-size-sm); }
|
||||
.font-size-select select { min-width: 62px; }
|
||||
.font-size-input { display: inline-flex; align-items: center; height: 30px; margin-left: 2px; overflow: hidden; border: 1px solid var(--color-border-default); border-radius: var(--radius-sm); color: var(--color-text-secondary); background: var(--color-background-primary); }
|
||||
.font-size-input:focus-within { border-color: var(--color-border-focus); box-shadow: 0 0 0 1px var(--color-border-focus); }
|
||||
.font-size-input input { width: 42px; height: 100%; padding-left: 7px; border: 0; outline: 0; background: transparent; color: var(--color-text-primary); }
|
||||
.font-size-input span { font-size: var(--font-size-xs); }
|
||||
.font-size-input button { min-width: auto; min-height: 100%; margin-left: 4px; padding: 3px 7px; border-left: 1px solid var(--color-border-default); border-radius: 0; font-size: var(--font-size-xs); }
|
||||
.toolbar-divider { width: 1px; height: 20px; margin: 0 var(--space-xs); background: var(--color-border-default); }
|
||||
.milkdown-host { flex: 1; min-height: 0; overflow: auto; color: var(--color-text-primary); }
|
||||
.milkdown-host.loading { visibility: hidden; }
|
||||
.editor-loading { padding: var(--space-xl); color: var(--color-text-tertiary); }
|
||||
.milkdown-host :deep(.milkdown) {
|
||||
min-height: 100%;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
--crepe-color-background: var(--color-background-primary);
|
||||
--crepe-color-on-background: var(--color-text-primary);
|
||||
--crepe-color-surface: var(--color-surface-primary);
|
||||
--crepe-color-surface-low: var(--color-background-secondary);
|
||||
--crepe-color-on-surface: var(--color-text-primary);
|
||||
--crepe-color-on-surface-variant: var(--color-text-secondary);
|
||||
--crepe-color-outline: var(--color-border-default);
|
||||
--crepe-color-primary: var(--color-accent-primary);
|
||||
--crepe-color-secondary: var(--color-accent-soft);
|
||||
--crepe-color-on-secondary: var(--color-text-primary);
|
||||
--crepe-color-inverse: var(--color-text-primary);
|
||||
--crepe-color-on-inverse: var(--color-background-primary);
|
||||
--crepe-color-inline-code: var(--color-error);
|
||||
--crepe-color-error: var(--color-error);
|
||||
--crepe-color-hover: var(--color-background-hover);
|
||||
--crepe-color-selected: var(--color-accent-soft);
|
||||
--crepe-color-inline-area: var(--color-background-tertiary);
|
||||
--crepe-font-default: var(--font-editor-sans);
|
||||
--crepe-font-code: var(--font-editor-mono);
|
||||
}
|
||||
.milkdown-host :deep(.ProseMirror) { box-sizing: border-box; width: min(100%, var(--editor-line-width, 80ch)); min-height: 100%; margin: 0 auto; padding: var(--space-3xl) var(--space-xl); outline: none; font-family: var(--font-editor-sans); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); caret-color: var(--color-accent-primary); }
|
||||
.milkdown-host :deep(.ProseMirror-selectednode) { outline-color: var(--color-accent-primary); }
|
||||
.milkdown-host :deep(.ProseMirror p) { font-weight: 400; }
|
||||
.milkdown-host :deep(.ProseMirror h1), .milkdown-host :deep(.ProseMirror h2), .milkdown-host :deep(.ProseMirror h3), .milkdown-host :deep(.ProseMirror h4), .milkdown-host :deep(.ProseMirror h5), .milkdown-host :deep(.ProseMirror h6) { font-weight: 700; }
|
||||
.milkdown-host :deep(.font-size-marker) { display: none; }
|
||||
:global(.milkdown-toolbar) { border: 1px solid var(--color-border-default) !important; background: var(--color-surface-elevated) !important; box-shadow: var(--shadow-md) !important; }
|
||||
:global(.milkdown-toolbar .toolbar-item svg), :global(.milkdown-toolbar .toolbar-item.active svg) { color: var(--color-text-primary) !important; fill: var(--color-text-primary) !important; opacity: 1 !important; }
|
||||
:global(.milkdown-toolbar .toolbar-item:hover svg), :global(.milkdown-toolbar .toolbar-item.active svg) { color: var(--color-accent-primary) !important; fill: var(--color-accent-primary) !important; }
|
||||
:global([data-theme='light']) .milkdown-host :deep(.milkdown-table-block th),
|
||||
:global([data-theme='light']) .milkdown-host :deep(.milkdown-table-block td) { border-color: var(--color-text-tertiary); }
|
||||
:global([data-theme='light']) .milkdown-host :deep(.milkdown-list-item-block li .label-wrapper) { color: var(--color-text-secondary); font-weight: 600; }
|
||||
:global([data-theme='light']) .milkdown-host :deep(.milkdown-list-item-block li .label-wrapper svg) { fill: var(--color-text-secondary); }
|
||||
.milkdown-host :deep(code) { font-family: var(--font-editor-mono); }
|
||||
:global([data-theme='dark']) .milkdown-host :deep(.milkdown) { color-scheme: dark; }
|
||||
@media (max-width: 680px) { .toolbar-select select { min-width: 46px; width: 46px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
import { $prose } from '@milkdown/kit/utils'
|
||||
import { Plugin, TextSelection } from '@milkdown/kit/prose/state'
|
||||
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'
|
||||
import type { Editor } from '@milkdown/kit/core'
|
||||
import { editorViewCtx } from '@milkdown/kit/core'
|
||||
|
||||
const openingTag = /^<span style="font-size:\s*(\d+(?:\.\d+)?)px">$/i
|
||||
const closingTag = /^<\/span>$/i
|
||||
|
||||
export const fontSizeMarkdownPlugin = $prose(() => new Plugin({
|
||||
props: {
|
||||
decorations(state) {
|
||||
const decorations: Decoration[] = []
|
||||
const stack: Array<{ from: number; size: string }> = []
|
||||
|
||||
state.doc.descendants((node, position) => {
|
||||
if (node.type.name !== 'html') return
|
||||
const value = String(node.attrs.value ?? '')
|
||||
const match = value.match(openingTag)
|
||||
|
||||
if (match) {
|
||||
stack.push({ from: position + node.nodeSize, size: match[1] })
|
||||
decorations.push(Decoration.node(position, position + node.nodeSize, { class: 'font-size-marker' }))
|
||||
return
|
||||
}
|
||||
|
||||
if (closingTag.test(value)) {
|
||||
const opening = stack.pop()
|
||||
decorations.push(Decoration.node(position, position + node.nodeSize, { class: 'font-size-marker' }))
|
||||
if (opening && opening.from < position) {
|
||||
decorations.push(Decoration.inline(opening.from, position, {
|
||||
style: `font-size: ${opening.size}px`,
|
||||
'data-font-size': opening.size,
|
||||
}))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return DecorationSet.create(state.doc, decorations)
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
export function applyMarkdownFontSize(editor: Editor, size: number): boolean {
|
||||
return editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const { from, to, empty } = view.state.selection
|
||||
if (empty) return false
|
||||
|
||||
const htmlNode = view.state.schema.nodes.html
|
||||
if (!htmlNode) return false
|
||||
|
||||
const opening = htmlNode.create({ value: `<span style="font-size: ${size}px">` })
|
||||
const closing = htmlNode.create({ value: '</span>' })
|
||||
const transaction = view.state.tr
|
||||
.insert(to, closing)
|
||||
.insert(from, opening)
|
||||
transaction.setSelection(TextSelection.create(transaction.doc, from + 1, to + 1))
|
||||
|
||||
view.dispatch(transaction)
|
||||
view.focus()
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Connection } from '@element-plus/icons-vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { usePluginStore } from '@/stores/plugin'
|
||||
|
||||
@@ -23,7 +25,7 @@ async function uninstall(id: string, name: string) { if (!confirm(`卸载“${na
|
||||
<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>
|
||||
<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"><span class="icon">{{ plugin.icon || '🧩' }}</span><div><strong>{{ plugin.name }}</strong><p>v{{ plugin.version }}</p></div><span class="badge" :class="{ success: plugin.status === 'ready', error: plugin.status === 'error', warning: plugin.status === 'permission_required' }">{{ plugin.status }}</span></div><p class="muted">{{ plugin.description }}</p><p class="subtle">{{ plugin.permissions.length }} 项权限 · {{ plugin.contributions.length }} 项 Contribution</p></article></div>
|
||||
<div v-else class="feature-grid"><article v-for="plugin in pluginStore.plugins" :key="plugin.plugin_id" class="item-card extension-card" @click="pluginStore.selectPlugin(plugin.plugin_id)"><div class="extension-title"><AppIcon :icon="Connection" :size="22" /><div><strong>{{ plugin.name }}</strong><p>v{{ plugin.version }}</p></div><span class="badge" :class="{ success: plugin.status === 'ready', error: plugin.status === 'error', warning: plugin.status === 'permission_required' }">{{ plugin.status }}</span></div><p class="muted">{{ plugin.description }}</p><p class="subtle">{{ plugin.permissions.length }} 项权限 · {{ plugin.contributions.length }} 项 Contribution</p></article></div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import type { ProviderConfig, ProviderType } from '@/contracts'
|
||||
import { useProviderStore } from '@/stores/provider'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
@@ -18,24 +18,82 @@ const showProviderForm = ref(false)
|
||||
const editingProviderId = ref<string | null>(null)
|
||||
const providerAction = ref('')
|
||||
const testResults = ref<Record<string, string>>({})
|
||||
const providerForm = reactive({ provider_type: 'openai_compatible' as ProviderType, name: '', base_url: '', default_model: '', credential_id: '', enabled: true })
|
||||
const providerApiKey = ref('')
|
||||
const providerForm = reactive({ preset_id: '', provider_type: 'openai_compatible' as ProviderType, name: '', base_url: '', default_model: '', credential_id: '', enabled: true })
|
||||
const formModels = computed(() => editingProviderId.value ? providerStore.modelsByProvider[editingProviderId.value] ?? [] : [])
|
||||
const selectedPreset = computed(() => providerStore.presets.find((item) => item.preset_id === providerForm.preset_id) ?? null)
|
||||
|
||||
onMounted(() => { void providerStore.loadProviders(); void settingsStore.loadDiagnostics() })
|
||||
onMounted(async () => {
|
||||
await Promise.all([providerStore.loadProviders(), providerStore.loadPresets(), settingsStore.loadDiagnostics()])
|
||||
await providerStore.refreshEnabledModels()
|
||||
})
|
||||
|
||||
function presetIdFor(provider?: ProviderConfig) {
|
||||
if (!provider) return ''
|
||||
return providerStore.presets.find((preset) =>
|
||||
preset.provider_type === provider.provider_type && preset.base_url === provider.base_url
|
||||
)?.preset_id ?? ''
|
||||
}
|
||||
|
||||
function openProvider(provider?: ProviderConfig) {
|
||||
editingProviderId.value = provider?.provider_id ?? null
|
||||
Object.assign(providerForm, { provider_type: provider?.provider_type ?? 'openai_compatible', name: provider?.name ?? '', base_url: provider?.base_url ?? '', default_model: provider?.default_model ?? '', credential_id: provider?.credential_id ?? '', enabled: provider?.enabled ?? true })
|
||||
const presetId = presetIdFor(provider)
|
||||
const preset = providerStore.presets.find((item) => item.preset_id === presetId)
|
||||
Object.assign(providerForm, { preset_id: presetId, provider_type: provider?.provider_type ?? 'openai_compatible', name: provider?.name ?? '', base_url: provider?.base_url ?? '', default_model: provider?.default_model ?? '', credential_id: provider?.credential_id ?? preset?.default_credential_id ?? '', enabled: provider?.enabled ?? true })
|
||||
providerApiKey.value = ''
|
||||
showProviderForm.value = true
|
||||
if (providerForm.credential_id) void providerStore.loadCredentialStatus(providerForm.credential_id).catch(() => undefined)
|
||||
if (provider) void providerStore.loadModels(provider.provider_id).catch(() => undefined)
|
||||
}
|
||||
|
||||
function applyProviderPreset() {
|
||||
const preset = providerStore.presets.find((item) => item.preset_id === providerForm.preset_id)
|
||||
if (!preset) return
|
||||
Object.assign(providerForm, {
|
||||
provider_type: preset.provider_type,
|
||||
name: preset.name,
|
||||
base_url: preset.base_url,
|
||||
credential_id: preset.default_credential_id ?? '',
|
||||
})
|
||||
providerApiKey.value = ''
|
||||
if (providerForm.credential_id) void providerStore.loadCredentialStatus(providerForm.credential_id).catch(() => undefined)
|
||||
}
|
||||
|
||||
function closeProvider() {
|
||||
providerApiKey.value = ''
|
||||
showProviderForm.value = false
|
||||
}
|
||||
|
||||
async function saveProvider() {
|
||||
providerAction.value = ''
|
||||
const credentialId = providerForm.credential_id.trim()
|
||||
const requiresApiKey = Boolean(selectedPreset.value?.requires_credential)
|
||||
if (requiresApiKey && !providerApiKey.value && !providerStore.credentialConfiguredById[credentialId]) {
|
||||
providerAction.value = '请输入 API Key。密钥将由后端加密保存。'
|
||||
return
|
||||
}
|
||||
const data = { ...providerForm, base_url: providerForm.base_url || undefined, credential_id: providerForm.credential_id || undefined, capabilities: {}, has_credential: Boolean(providerForm.credential_id) }
|
||||
try { if (editingProviderId.value) await providerStore.updateProvider(editingProviderId.value, data); else await providerStore.addProvider(data); showProviderForm.value = false } catch (error) { providerAction.value = error instanceof Error ? error.message : 'Provider 保存失败' }
|
||||
try {
|
||||
if (providerApiKey.value) await providerStore.saveCredential(credentialId, providerApiKey.value)
|
||||
const saved = editingProviderId.value
|
||||
? await providerStore.updateProvider(editingProviderId.value, data)
|
||||
: await providerStore.addProvider(data)
|
||||
closeProvider()
|
||||
if (saved.enabled) void providerStore.loadModels(saved.provider_id).catch(() => undefined)
|
||||
} catch (error) {
|
||||
providerApiKey.value = ''
|
||||
providerAction.value = error instanceof Error ? error.message : 'Provider 保存失败'
|
||||
}
|
||||
}
|
||||
|
||||
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 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 refreshModels(provider: ProviderConfig) { await providerStore.loadModels(provider.provider_id).catch(() => undefined) }
|
||||
async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
|
||||
const defaultModel = (event.target as HTMLSelectElement).value
|
||||
try { await providerStore.updateProvider(provider.provider_id, { default_model: defaultModel }) }
|
||||
catch (error) { providerAction.value = error instanceof Error ? error.message : '默认模型更新失败' }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -47,7 +105,38 @@ async function testProvider(provider: ProviderConfig) { testResults.value[provid
|
||||
|
||||
<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 === 'providers'" class="settings-section"><div class="section-head"><h2>模型提供商</h2><button class="button-primary" @click="openProvider()">新增 Provider</button></div><div v-if="providerStore.error || providerAction" class="error-banner">{{ providerStore.error || providerAction }}</div><div class="provider-list"><article v-for="provider in providerStore.providers" :key="provider.provider_id" class="item-card provider-card"><div><div class="inline-actions"><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><div class="tag-list"><span v-for="(_, capability) in provider.capabilities" :key="capability" class="badge">{{ capability }}</span></div><p v-if="testResults[provider.provider_id]" class="test-result">{{ testResults[provider.provider_id] }}</p></div><div class="inline-actions"><button class="button-secondary" @click="testProvider(provider)">测试</button><button class="button-secondary" @click="openProvider(provider)">编辑</button><button class="button-danger" :disabled="provider.provider_id === 'mock'" @click="removeProvider(provider)">删除</button></div></article></div></div>
|
||||
<div v-else-if="activeSection === 'providers'" class="settings-section">
|
||||
<div class="section-head">
|
||||
<div><h2>模型提供商</h2><p class="subtle">支持 OpenAI、DeepSeek、Ollama 和自定义兼容服务。</p></div>
|
||||
<button class="button-primary" @click="openProvider()">新增 Provider</button>
|
||||
</div>
|
||||
<div v-if="providerStore.error || providerAction" class="error-banner">{{ providerStore.error || providerAction }}</div>
|
||||
<div class="provider-list">
|
||||
<article v-for="provider in providerStore.providers" :key="provider.provider_id" class="item-card provider-card">
|
||||
<div class="provider-main">
|
||||
<div class="inline-actions"><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>
|
||||
<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">
|
||||
<label :for="`default-model-${provider.provider_id}`">默认模型</label>
|
||||
<select :id="`default-model-${provider.provider_id}`" class="select" :value="provider.default_model" @change="chooseDefaultModel(provider, $event)">
|
||||
<option value="">未设置</option>
|
||||
<option v-for="model in providerStore.modelsByProvider[provider.provider_id]" :key="model.model_id" :value="model.model_id">{{ model.name }}</option>
|
||||
</select>
|
||||
<span class="subtle">已获取 {{ providerStore.modelsByProvider[provider.provider_id].length }} 个模型</span>
|
||||
</div>
|
||||
<p v-if="providerStore.modelErrorsByProvider[provider.provider_id]" class="error-text">模型获取失败:{{ providerStore.modelErrorsByProvider[provider.provider_id] }}</p>
|
||||
<p v-if="testResults[provider.provider_id]" class="test-result">{{ testResults[provider.provider_id] }}</p>
|
||||
</div>
|
||||
<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" @click="testProvider(provider)">测试</button>
|
||||
<button class="button-secondary" @click="openProvider(provider)">编辑</button>
|
||||
<button class="button-danger" :disabled="provider.provider_id === 'mock'" @click="removeProvider(provider)">删除</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeSection === 'index'" class="panel settings-section"><h2>索引与模型</h2><div class="index-summary"><div><span class="badge" :class="{ success: settingsStore.indexStatus.status === 'idle', error: settingsStore.indexStatus.status === 'error' }">{{ settingsStore.indexStatus.status }}</span><p>待处理任务 {{ settingsStore.indexStatus.pending_jobs }}</p></div><div><strong>{{ settingsStore.indexStatus.total_notes }}</strong><small>笔记</small></div><div><strong>{{ settingsStore.indexStatus.total_blocks }}</strong><small>Block</small></div></div><div v-if="settingsStore.indexStatus.error" class="error-banner">{{ settingsStore.indexStatus.error }}</div><div class="inline-actions"><button class="button-primary" @click="settingsStore.rebuildIndex('full')">重建全部</button><button class="button-secondary" @click="settingsStore.rebuildIndex('fts')">重建文本索引</button><button class="button-secondary" @click="settingsStore.rebuildIndex('vector')">重建向量索引</button></div></div>
|
||||
|
||||
@@ -55,7 +144,36 @@ async function testProvider(provider: ProviderConfig) { testResults.value[provid
|
||||
|
||||
<div v-else class="panel settings-section"><h2>AI Core 诊断</h2><div v-if="settingsStore.diagnosticsError" class="error-banner">{{ settingsStore.diagnosticsError }}</div><div class="diagnostic-grid"><div class="item-card"><span class="badge" :class="{ success: settingsStore.aiCoreStatus === 'running', error: settingsStore.aiCoreStatus === 'error' }">{{ settingsStore.aiCoreStatus }}</span><h3>Sidecar 状态</h3><p class="subtle">AI Core 不可用时,Markdown 编辑仍可继续使用。</p></div><div class="item-card"><strong>{{ settingsStore.aiCoreAddress }}</strong><h3>开发 API 地址</h3><p class="subtle">正式桌面环境由 Sidecar Manager 动态提供。</p></div></div><div class="inline-actions diagnostic-actions"><button class="button-primary" @click="settingsStore.loadDiagnostics">重新检测</button><button class="button-secondary" @click="settingsStore.restartAiCore">重启 AI Core</button></div></div>
|
||||
|
||||
<div v-if="showProviderForm" class="modal-backdrop" @click.self="showProviderForm = false"><div class="modal"><h2>{{ editingProviderId ? '编辑 Provider' : '新增 Provider' }}</h2><form @submit.prevent="saveProvider"><div class="field"><label>类型</label><select v-model="providerForm.provider_type" class="select"><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></div><div class="field"><label>名称</label><input v-model="providerForm.name" class="input" required /></div><div class="field"><label>Base URL</label><input v-model="providerForm.base_url" class="input" placeholder="https://api.example.com/v1" /></div><div class="field"><label>默认模型</label><input v-model="providerForm.default_model" class="input" /></div><div class="field"><label>Credential ID</label><input v-model="providerForm.credential_id" class="input" placeholder="密钥明文由 Stronghold 保存" /><small class="subtle">此处不输入或回显 API Key。</small></div><label class="inline-actions"><input v-model="providerForm.enabled" type="checkbox" /> 启用</label><div class="inline-actions"><button class="button-primary">保存</button><button type="button" class="button-secondary" @click="showProviderForm = false">取消</button></div></form></div></div>
|
||||
<div v-if="showProviderForm" class="modal-backdrop" @click.self="closeProvider">
|
||||
<div class="modal">
|
||||
<h2>{{ editingProviderId ? '编辑 Provider' : '新增 Provider' }}</h2>
|
||||
<form @submit.prevent="saveProvider">
|
||||
<div class="field">
|
||||
<label>提供商预设</label>
|
||||
<select v-model="providerForm.preset_id" class="select" @change="applyProviderPreset">
|
||||
<option value="">自定义</option>
|
||||
<option v-for="preset in providerStore.presets" :key="preset.preset_id" :value="preset.preset_id">{{ preset.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label>接入协议</label><select v-model="providerForm.provider_type" class="select"><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></div>
|
||||
<div class="field"><label>名称</label><input v-model="providerForm.name" class="input" required /></div>
|
||||
<div class="field"><label>Base URL</label><input v-model="providerForm.base_url" class="input" placeholder="https://api.example.com/v1" required /></div>
|
||||
<div class="field">
|
||||
<label>默认模型</label>
|
||||
<input v-model="providerForm.default_model" class="input" :list="editingProviderId ? 'provider-model-options' : undefined" placeholder="保存后自动获取,也可以手动输入" />
|
||||
<datalist id="provider-model-options"><option v-for="model in formModels" :key="model.model_id" :value="model.model_id">{{ model.name }}</option></datalist>
|
||||
</div>
|
||||
<div v-if="selectedPreset?.requires_credential" class="field">
|
||||
<label>API Key</label>
|
||||
<input v-model="providerApiKey" class="input" type="password" autocomplete="new-password" spellcheck="false" :placeholder="providerStore.credentialConfiguredById[providerForm.credential_id] ? '已配置,留空表示不修改' : '请输入 API Key'" />
|
||||
<small class="subtle">提交后由本地 AI Core 加密保存,页面不会回显已保存的密钥。</small>
|
||||
</div>
|
||||
<div v-else-if="!selectedPreset" class="field"><label>Credential ID</label><input v-model="providerForm.credential_id" class="input" placeholder="自定义凭据标识" /><small class="subtle">自定义服务可以引用 Host 注入或后端已保存的凭据。</small></div>
|
||||
<label class="inline-actions"><input v-model="providerForm.enabled" type="checkbox" /> 启用</label>
|
||||
<div class="inline-actions"><button class="button-primary">保存并获取模型</button><button type="button" class="button-secondary" @click="closeProvider">取消</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -66,8 +184,9 @@ async function testProvider(provider: ProviderConfig) { testResults.value[provid
|
||||
.setting-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); min-height: 54px; padding: var(--space-sm) 0; border-bottom: 1px solid var(--color-border-subtle); }
|
||||
.setting-row small { display: block; color: var(--color-text-tertiary); }.short { width: min(220px, 45%); }
|
||||
.section-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-lg); }
|
||||
.provider-list { display: grid; gap: var(--space-md); }.provider-card { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); }.provider-card p, .provider-card .tag-list { margin-top: var(--space-sm); }
|
||||
.provider-list { display: grid; gap: var(--space-md); }.provider-card { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); }.provider-main { min-width: 0; flex: 1; }.provider-card p, .provider-card .tag-list { margin-top: var(--space-sm); }
|
||||
.model-picker { display: flex; align-items: center; gap: var(--space-sm); margin-top: var(--space-md); }.model-picker label { white-space: nowrap; font-weight: 600; }.model-picker .select { width: min(360px, 100%); }.provider-actions { flex-wrap: wrap; justify-content: flex-end; }.error-text { color: var(--color-danger, #d33); }
|
||||
.test-result { color: var(--color-info); }.index-summary, .diagnostic-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-md); }.index-summary > div { padding: var(--space-lg); border-radius: var(--radius-md); background: var(--color-background-secondary); }.index-summary strong, .index-summary small { display: block; }.index-summary strong { font-size: var(--font-size-3xl); }
|
||||
.section-description { margin-top: calc(-1 * var(--space-md)); }.diagnostic-grid { grid-template-columns: repeat(2, 1fr); }.diagnostic-grid h3 { margin: var(--space-md) 0 var(--space-xs); }.diagnostic-actions { margin-top: var(--space-md); }
|
||||
@media (max-width: 700px) { .provider-card, .setting-row { align-items: flex-start; flex-direction: column; }.short { width: 100%; }.index-summary, .diagnostic-grid { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 700px) { .provider-card, .setting-row, .model-picker { align-items: flex-start; flex-direction: column; }.short, .model-picker .select { width: 100%; }.index-summary, .diagnostic-grid { grid-template-columns: 1fr; }.provider-actions { justify-content: flex-start; } }
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Lightning } from '@element-plus/icons-vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useSkillStore } from '@/stores/skill'
|
||||
|
||||
@@ -30,7 +32,7 @@ async function uninstall(skillId: string, name: string) {
|
||||
<div class="detail-grid"><div><h3>工具</h3><div class="tag-list"><span v-for="tool in skillStore.selectedSkill.tools" :key="tool" class="badge info">{{ tool }}</span></div></div><div><h3>权限</h3><div class="tag-list"><span v-for="permission in skillStore.selectedSkill.permissions" :key="permission" class="badge warning">{{ permission }}</span></div></div><div><h3>检索配置</h3><pre>{{ JSON.stringify(skillStore.selectedSkill.retrieval_config, null, 2) }}</pre></div><div><h3>模型能力</h3><div class="tag-list"><span v-for="cap in skillStore.selectedSkill.model_requirements?.capabilities" :key="cap" class="badge">{{ cap }}</span></div></div></div>
|
||||
<div v-if="skillStore.selectedSkill.missing_dependencies?.length" class="error-banner dependencies">缺失依赖:{{ skillStore.selectedSkill.missing_dependencies.join('、') }}</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"><span class="icon">{{ skill.icon || '⚡' }}</span><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>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useRouter } from 'vue-router'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { ArrowRight, Document, Folder, FolderOpened, Moon, Plus, Sunny } from '@element-plus/icons-vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
@@ -65,7 +67,7 @@ async function createVault() {
|
||||
<div class="bg-decoration" />
|
||||
<div class="entry-container">
|
||||
<div class="brand-section">
|
||||
<div class="logo">📝</div>
|
||||
<div class="logo"><AppIcon :icon="Document" :size="56" /></div>
|
||||
<h1 class="app-title">知笔知己</h1>
|
||||
<p class="app-subtitle">本地优先的 AI 笔记软件</p>
|
||||
</div>
|
||||
@@ -84,22 +86,22 @@ async function createVault() {
|
||||
@click="openVault(vault.path)"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<span class="vault-icon">📁</span>
|
||||
<AppIcon class="vault-icon" :icon="Folder" :size="20" />
|
||||
<div class="vault-info">
|
||||
<div class="vault-name">{{ vault.name }}</div>
|
||||
<div class="vault-path">{{ vault.path }}</div>
|
||||
</div>
|
||||
<span class="vault-arrow">›</span>
|
||||
<AppIcon class="vault-arrow" :icon="ArrowRight" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" @click="openFolderPicker" :disabled="isLoading">
|
||||
<span>📂</span> 打开本地 Vault
|
||||
<AppIcon :icon="FolderOpened" /> 打开本地 Vault
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="showCreateDialog = true" :disabled="isLoading">
|
||||
<span>➕</span> 创建新 Vault
|
||||
<AppIcon :icon="Plus" /> 创建新 Vault
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -114,7 +116,8 @@ async function createVault() {
|
||||
<div class="footer-info">
|
||||
<span>v0.1.0</span>
|
||||
<button class="theme-toggle" @click="themeStore.toggleTheme()">
|
||||
{{ themeStore.isDark ? '☀️ 浅色' : '🌙 深色' }}
|
||||
<AppIcon :icon="themeStore.isDark ? Sunny : Moon" :size="15" />
|
||||
{{ themeStore.isDark ? '浅色' : '深色' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -374,6 +377,9 @@ async function createVault() {
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
color: var(--color-text-secondary);
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { FileNode } from '@/contracts'
|
||||
import { Document, Folder, FolderOpened } from '@element-plus/icons-vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
|
||||
defineProps<{ node: FileNode; activePath: string | null }>()
|
||||
const emit = defineEmits<{
|
||||
@@ -12,7 +14,7 @@ const emit = defineEmits<{
|
||||
<div>
|
||||
<div class="tree-node" :class="{ active: node.path === activePath }"
|
||||
@click="emit('open', node)" @contextmenu="emit('contextMenu', $event, node)">
|
||||
<span>{{ node.type === 'folder' ? (node.is_open ? '📂' : '📁') : '📄' }}</span>
|
||||
<AppIcon :icon="node.type === 'folder' ? (node.is_open ? FolderOpened : Folder) : Document" :size="16" />
|
||||
<span class="name">{{ node.name }}</span>
|
||||
<span v-if="node.is_dirty">●</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import FileTreePanel from './FileTreePanel.vue'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
async function waitForPath(path: string) {
|
||||
const editorStore = useEditorStore()
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (editorStore.currentFilePath === path) return
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
throw new Error(`Editor did not open ${path}`)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('FileTreePanel file switching', () => {
|
||||
it('switches both workspace selection and editor content on consecutive clicks', async () => {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/workspace', component: { template: '<div />' } }],
|
||||
})
|
||||
await router.push('/workspace')
|
||||
await router.isReady()
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const editorStore = useEditorStore()
|
||||
await workspaceStore.openVault('/mock-vault')
|
||||
wrapper = mount(FileTreePanel, { attachTo: document.body, global: { plugins: [router] } })
|
||||
|
||||
const findNode = (name: string) => wrapper!.findAll('.tree-node').find((node) => node.text().includes(name))!
|
||||
await findNode('红黑树.md').trigger('click')
|
||||
await waitForPath('/数据结构/红黑树.md')
|
||||
expect(workspaceStore.activeFilePath).toBe('/数据结构/红黑树.md')
|
||||
expect(editorStore.content).toContain('# 红黑树')
|
||||
|
||||
await findNode('二叉搜索树.md').trigger('click')
|
||||
await waitForPath('/数据结构/二叉搜索树.md')
|
||||
expect(workspaceStore.activeFilePath).toBe('/数据结构/二叉搜索树.md')
|
||||
expect(editorStore.content).toContain('# 二叉搜索树')
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,8 @@ import * as workspaceService from '@/services/workspaceService'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import FileTreeNode from './FileTreeNode.vue'
|
||||
import { DocumentAdd, FolderAdd } from '@element-plus/icons-vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const editorStore = useEditorStore()
|
||||
@@ -42,9 +44,18 @@ async function createItem() {
|
||||
|
||||
async function openNode(node: FileNode) {
|
||||
if (node.type === 'folder') return workspaceStore.toggleFolder(node.path)
|
||||
await editorStore.loadFile(node.path)
|
||||
// 先同步活动文件,让真实点击立即生效;内容加载失败时再恢复原状态。
|
||||
const previousPath = workspaceStore.activeFilePath
|
||||
const wasOpen = workspaceStore.openFiles.includes(node.path)
|
||||
workspaceStore.openFile(node.path)
|
||||
await router.push('/workspace')
|
||||
try {
|
||||
await editorStore.loadFile(node.path)
|
||||
await router.push('/workspace')
|
||||
} catch (error) {
|
||||
if (!wasOpen) workspaceStore.closeFile(node.path)
|
||||
workspaceStore.setActiveFile(previousPath)
|
||||
console.error(`打开文件失败:${node.path}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
function openContextMenu(event: MouseEvent, node: FileNode) {
|
||||
@@ -90,8 +101,8 @@ async function deleteTarget() {
|
||||
<template>
|
||||
<section class="file-tree-panel" @click="closeContextMenu">
|
||||
<div class="toolbar">
|
||||
<button type="button" title="新建笔记" @click.stop="beginCreate('file')">+📄</button>
|
||||
<button type="button" title="新建文件夹" @click.stop="beginCreate('folder')">+📁</button>
|
||||
<button type="button" title="新建笔记" aria-label="新建笔记" @click.stop="beginCreate('file')"><AppIcon :icon="DocumentAdd" /></button>
|
||||
<button type="button" title="新建文件夹" aria-label="新建文件夹" @click.stop="beginCreate('folder')"><AppIcon :icon="FolderAdd" /></button>
|
||||
</div>
|
||||
<form v-if="newItemType" class="new-item" @submit.prevent="createItem">
|
||||
<input v-model="newItemName" :placeholder="newItemType === 'file' ? '笔记名称' : '文件夹名称'" autofocus />
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import WorkspaceView from './WorkspaceView.vue'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
})
|
||||
|
||||
describe('WorkspaceView initial file', () => {
|
||||
it('does not overwrite a file selected while the welcome note is loading', async () => {
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
wrapper = mount(WorkspaceView, {
|
||||
global: { stubs: { EditorHeader: true, EditorPane: true } },
|
||||
})
|
||||
|
||||
workspaceStore.openFile('/数据结构/红黑树.md')
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(workspaceStore.activeFilePath).toBe('/数据结构/红黑树.md')
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,8 @@ import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import EditorHeader from '@/features/editor/EditorHeader.vue'
|
||||
import EditorPane from '@/features/editor/EditorPane.vue'
|
||||
import { EditPen } from '@element-plus/icons-vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const editorStore = useEditorStore()
|
||||
@@ -14,7 +16,10 @@ onMounted(() => {
|
||||
}
|
||||
if (!workspaceStore.activeFilePath && workspaceStore.fileTree.length === 0) {
|
||||
void editorStore.loadFile('/欢迎使用知笔知己.md').then(() => {
|
||||
workspaceStore.openFile('/欢迎使用知笔知己.md')
|
||||
// 默认文件加载期间用户可能已经点击了其他文件,不能覆盖用户的选择。
|
||||
if (!workspaceStore.activeFilePath && editorStore.currentFilePath === '/欢迎使用知笔知己.md') {
|
||||
workspaceStore.openFile('/欢迎使用知笔知己.md')
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -28,7 +33,7 @@ onMounted(() => {
|
||||
</template>
|
||||
<div v-else class="empty-workspace">
|
||||
<div class="empty-content">
|
||||
<div class="empty-icon">📝</div>
|
||||
<AppIcon class="empty-icon" :icon="EditPen" :size="48" />
|
||||
<h2>开始写作</h2>
|
||||
<p>从左侧文件树选择笔记,或创建新的笔记</p>
|
||||
</div>
|
||||
|
||||
@@ -64,7 +64,7 @@ export const mockPlugins: Plugin[] = [
|
||||
name: 'GitHub 集成',
|
||||
version: '1.3.2',
|
||||
description: '接入 GitHub API,支持搜索 Issue、查看 PR 和管理仓库',
|
||||
icon: '🐙',
|
||||
icon: '',
|
||||
author: '知笔知己团队',
|
||||
status: 'ready',
|
||||
enabled: true,
|
||||
@@ -83,7 +83,7 @@ export const mockPlugins: Plugin[] = [
|
||||
name: '翻译助手',
|
||||
version: '1.0.0',
|
||||
description: '提供多语言翻译能力,支持文档批量翻译',
|
||||
icon: '🌐',
|
||||
icon: '',
|
||||
author: '社区贡献',
|
||||
status: 'ready',
|
||||
enabled: false,
|
||||
@@ -101,7 +101,7 @@ export const mockPlugins: Plugin[] = [
|
||||
name: '看板视图',
|
||||
version: '0.8.0',
|
||||
description: '为任务提供看板视图,支持拖拽排序和多维度筛选',
|
||||
icon: '📋',
|
||||
icon: '',
|
||||
author: '社区贡献',
|
||||
status: 'installed',
|
||||
enabled: false,
|
||||
@@ -116,7 +116,7 @@ export const mockPlugins: Plugin[] = [
|
||||
name: 'PDF 导入',
|
||||
version: '2.1.0',
|
||||
description: '导入 PDF 文档,提取文本和目录结构生成笔记',
|
||||
icon: '📄',
|
||||
icon: '',
|
||||
author: '知笔知己团队',
|
||||
status: 'error',
|
||||
enabled: false,
|
||||
@@ -133,7 +133,7 @@ export const mockPlugins: Plugin[] = [
|
||||
name: '日历同步',
|
||||
version: '0.5.0',
|
||||
description: '同步日历事件,自动生成相关笔记和任务提醒',
|
||||
icon: '📅',
|
||||
icon: '',
|
||||
author: '社区贡献',
|
||||
status: 'dependency_missing',
|
||||
enabled: false,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import apiClient from './apiClient'
|
||||
import type { ApiModelInfo, ApiProviderConfig, ModelCapability, ModelInfo, OperationResponse, ProviderConfig } from '@/contracts'
|
||||
import type { ApiModelInfo, ApiProviderConfig, ApiProviderPreset, ModelCapability, ModelInfo, OperationResponse, ProviderConfig, ProviderPreset } from '@/contracts'
|
||||
|
||||
function capabilityMap(capabilities: string[]): Partial<ModelCapability> {
|
||||
return Object.fromEntries(capabilities.map((capability) => [capability, true])) as Partial<ModelCapability>
|
||||
@@ -44,6 +44,23 @@ export async function createProvider(data: Omit<ProviderConfig, 'provider_id'>):
|
||||
return toProvider(response)
|
||||
}
|
||||
|
||||
export async function listProviderPresets(): Promise<ProviderPreset[]> {
|
||||
const response = await apiClient.get<{ items: ApiProviderPreset[] }>('/api/providers/presets')
|
||||
return response.items
|
||||
}
|
||||
|
||||
export async function getCredentialStatus(credentialId: string): Promise<boolean> {
|
||||
const response = await apiClient.get<{ credential_id: string; configured: boolean }>(`/api/credentials/${encodeURIComponent(credentialId)}`)
|
||||
return response.configured
|
||||
}
|
||||
|
||||
export async function putCredential(credentialId: string, apiKey: string): Promise<void> {
|
||||
await apiClient.put<{ credential_id: string; configured: boolean }>(
|
||||
`/api/credentials/${encodeURIComponent(credentialId)}`,
|
||||
{ api_key: apiKey },
|
||||
)
|
||||
}
|
||||
|
||||
export async function updateProvider(providerId: string, data: Partial<ProviderConfig>): Promise<ProviderConfig> {
|
||||
const response = await apiClient.patch<ApiProviderConfig>(`/api/providers/${providerId}`, {
|
||||
name: data.name,
|
||||
|
||||
@@ -49,7 +49,7 @@ export const mockSkills: Skill[] = [
|
||||
name: '期末复习助手',
|
||||
version: '1.0.0',
|
||||
description: '根据课程笔记生成复习要点和练习题,帮助高效备考',
|
||||
icon: '📚',
|
||||
icon: '',
|
||||
author: '知笔知己团队',
|
||||
permissions: ['notes.search', 'notes.read', 'tasks.create'],
|
||||
tools: ['notes.search', 'notes.read', 'tasks.create'],
|
||||
@@ -63,7 +63,7 @@ export const mockSkills: Skill[] = [
|
||||
name: '会议纪要生成',
|
||||
version: '1.1.0',
|
||||
description: '从音频或文本中提取会议要点、行动项和待办任务',
|
||||
icon: '📝',
|
||||
icon: '',
|
||||
author: '知笔知己团队',
|
||||
permissions: ['notes.search', 'notes.write', 'tasks.write', 'attachments.read'],
|
||||
tools: ['notes.search', 'notes.create', 'tasks.create', 'attachments.read'],
|
||||
@@ -77,7 +77,7 @@ export const mockSkills: Skill[] = [
|
||||
name: '代码解读助手',
|
||||
version: '0.9.0',
|
||||
description: '分析代码片段,解释功能、复杂度和优化建议',
|
||||
icon: '💻',
|
||||
icon: '',
|
||||
author: '社区贡献',
|
||||
permissions: ['notes.search', 'notes.read'],
|
||||
tools: ['notes.search', 'notes.read', 'rag.search'],
|
||||
@@ -91,7 +91,7 @@ export const mockSkills: Skill[] = [
|
||||
name: '文献研究助手',
|
||||
version: '1.2.0',
|
||||
description: '自动整理文献笔记,生成研究综述和引用关系图',
|
||||
icon: '🔬',
|
||||
icon: '',
|
||||
author: '社区贡献',
|
||||
permissions: ['notes.search', 'notes.read', 'notes.write'],
|
||||
tools: ['notes.search', 'notes.read', 'notes.create', 'rag.search'],
|
||||
@@ -106,7 +106,7 @@ export const mockSkills: Skill[] = [
|
||||
name: '语言学习助手',
|
||||
version: '0.5.0',
|
||||
description: '基于你的学习笔记生成语言练习和记忆卡片',
|
||||
icon: '🌍',
|
||||
icon: '',
|
||||
author: '社区贡献',
|
||||
permissions: ['notes.search', 'notes.read', 'tasks.create'],
|
||||
tools: ['notes.search', 'notes.read', 'tasks.create'],
|
||||
|
||||
@@ -83,18 +83,19 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
throw new Error('当前文件保存失败,已阻止切换以避免内容丢失。')
|
||||
}
|
||||
const version = ++loadVersion
|
||||
currentFilePath.value = filePath
|
||||
const previousStatus = saveStatus.value
|
||||
saveStatus.value = 'saving'
|
||||
try {
|
||||
const loadedContent = await workspaceService.readFileContent(filePath)
|
||||
if (version !== loadVersion || currentFilePath.value !== filePath) return
|
||||
if (version !== loadVersion) return
|
||||
currentFilePath.value = filePath
|
||||
content.value = loadedContent
|
||||
saveStatus.value = 'saved'
|
||||
lastSavedAt.value = new Date().toISOString()
|
||||
} catch {
|
||||
if (version !== loadVersion || currentFilePath.value !== filePath) return
|
||||
content.value = ''
|
||||
saveStatus.value = 'idle'
|
||||
} catch (error) {
|
||||
if (version !== loadVersion) return
|
||||
saveStatus.value = previousStatus
|
||||
throw error
|
||||
}
|
||||
highlightBlockId.value = null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import type { ProviderConfig, ProviderPreset } from '@/contracts'
|
||||
|
||||
vi.mock('@/services/providerService', () => ({
|
||||
mockProviders: [],
|
||||
mockModels: {},
|
||||
listProviders: vi.fn(),
|
||||
listProviderPresets: vi.fn(),
|
||||
getCredentialStatus: vi.fn(),
|
||||
putCredential: vi.fn(),
|
||||
listModels: vi.fn(),
|
||||
createProvider: vi.fn(),
|
||||
updateProvider: vi.fn(),
|
||||
deleteProvider: vi.fn(),
|
||||
testProvider: vi.fn(),
|
||||
}))
|
||||
|
||||
import { useProviderStore } from './provider'
|
||||
import { getCredentialStatus, listModels, listProviderPresets, listProviders, putCredential } from '@/services/providerService'
|
||||
import { ApiErrorClass } from '@/services/apiClient'
|
||||
|
||||
const providers: ProviderConfig[] = [
|
||||
{
|
||||
provider_id: 'openai',
|
||||
provider_type: 'openai_chat',
|
||||
name: 'OpenAI',
|
||||
base_url: 'https://api.openai.com/v1',
|
||||
default_model: '',
|
||||
enabled: true,
|
||||
capabilities: { chat: true },
|
||||
credential_id: 'deepseek',
|
||||
has_credential: true,
|
||||
},
|
||||
]
|
||||
|
||||
const presets: ProviderPreset[] = [
|
||||
{
|
||||
preset_id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
provider_type: 'openai_compatible',
|
||||
base_url: 'https://api.deepseek.com',
|
||||
default_credential_id: 'deepseek',
|
||||
requires_credential: true,
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(listProviders).mockResolvedValue(providers)
|
||||
vi.mocked(listProviderPresets).mockResolvedValue(presets)
|
||||
vi.mocked(getCredentialStatus).mockResolvedValue(false)
|
||||
vi.mocked(putCredential).mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('provider store model discovery', () => {
|
||||
it('loads provider presets and automatically refreshes enabled providers', async () => {
|
||||
vi.mocked(listModels).mockResolvedValue([
|
||||
{ model_id: 'model-z', name: 'Zulu', capabilities: { chat: true } },
|
||||
{ model_id: 'model-a', name: 'Alpha', capabilities: { chat: true } },
|
||||
{ model_id: 'model-a', name: 'Alpha duplicate', capabilities: { chat: true } },
|
||||
])
|
||||
const store = useProviderStore()
|
||||
|
||||
await store.loadProviders()
|
||||
await store.loadPresets()
|
||||
await store.refreshEnabledModels()
|
||||
|
||||
expect(store.presets[0].preset_id).toBe('deepseek')
|
||||
expect(listModels).toHaveBeenCalledWith('openai')
|
||||
expect(store.modelsByProvider.openai.map((model) => model.model_id)).toEqual(['model-a', 'model-z'])
|
||||
expect(store.modelErrorsByProvider.openai).toBeUndefined()
|
||||
})
|
||||
|
||||
it('records a provider-specific error when model discovery fails', async () => {
|
||||
vi.mocked(listModels).mockRejectedValue(new Error('认证失败'))
|
||||
const store = useProviderStore()
|
||||
|
||||
await expect(store.loadModels('openai')).rejects.toThrow('认证失败')
|
||||
|
||||
expect(store.modelLoadingByProvider.openai).toBe(false)
|
||||
expect(store.modelErrorsByProvider.openai).toBe('认证失败')
|
||||
})
|
||||
|
||||
it('explains how to inject a missing DeepSeek credential', async () => {
|
||||
vi.mocked(listModels).mockRejectedValue(
|
||||
new ApiErrorClass('PROVIDER_CREDENTIAL_MISSING', 'Credential is unavailable')
|
||||
)
|
||||
const store = useProviderStore()
|
||||
await store.loadProviders()
|
||||
|
||||
await expect(store.loadModels('openai')).rejects.toThrow('Credential is unavailable')
|
||||
|
||||
expect(store.modelErrorsByProvider.openai).toContain('填写并保存')
|
||||
})
|
||||
|
||||
it('sends an API key to the credential endpoint without storing it in Pinia', async () => {
|
||||
const store = useProviderStore()
|
||||
|
||||
await store.saveCredential('deepseek', 'sk-test-sensitive-value')
|
||||
|
||||
expect(putCredential).toHaveBeenCalledWith('deepseek', 'sk-test-sensitive-value')
|
||||
expect(store.credentialConfiguredById.deepseek).toBe(true)
|
||||
expect(JSON.stringify(store.$state)).not.toContain('sk-test-sensitive-value')
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,16 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { ProviderConfig, ModelInfo } from '@/contracts'
|
||||
import { createProvider, deleteProvider as deleteProviderRequest, listModels, listProviders, mockProviders, mockModels, testProvider as testProviderRequest, updateProvider as updateProviderRequest } from '@/services/providerService'
|
||||
import type { ProviderConfig, ModelInfo, ProviderPreset } from '@/contracts'
|
||||
import { createProvider, deleteProvider as deleteProviderRequest, getCredentialStatus, listModels, listProviderPresets, listProviders, mockProviders, mockModels, putCredential, testProvider as testProviderRequest, updateProvider as updateProviderRequest } from '@/services/providerService'
|
||||
import { ApiErrorClass } from '@/services/apiClient'
|
||||
|
||||
export const useProviderStore = defineStore('provider', () => {
|
||||
const providers = ref<ProviderConfig[]>(mockProviders)
|
||||
const presets = ref<ProviderPreset[]>([])
|
||||
const modelsByProvider = ref<Record<string, ModelInfo[]>>(mockModels)
|
||||
const modelLoadingByProvider = ref<Record<string, boolean>>({})
|
||||
const modelErrorsByProvider = ref<Record<string, string>>({})
|
||||
const credentialConfiguredById = ref<Record<string, boolean>>({})
|
||||
const defaultProviderId = ref('mock')
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
@@ -27,8 +32,54 @@ export const useProviderStore = defineStore('provider', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModels(providerId: string) {
|
||||
modelsByProvider.value[providerId] = await listModels(providerId)
|
||||
async function loadPresets() {
|
||||
try {
|
||||
presets.value = await listProviderPresets()
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : 'Provider 预设加载失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModels(providerId: string): Promise<ModelInfo[]> {
|
||||
modelLoadingByProvider.value[providerId] = true
|
||||
delete modelErrorsByProvider.value[providerId]
|
||||
try {
|
||||
const models = await listModels(providerId)
|
||||
const uniqueModels = [...new Map(models.map((model) => [model.model_id, model])).values()]
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
modelsByProvider.value[providerId] = uniqueModels
|
||||
return uniqueModels
|
||||
} catch (reason) {
|
||||
const provider = providers.value.find((item) => item.provider_id === providerId)
|
||||
const credentialId = provider?.credential_id
|
||||
let message = reason instanceof Error ? reason.message : '模型列表获取失败'
|
||||
if (reason instanceof ApiErrorClass && reason.code === 'PROVIDER_CREDENTIAL_MISSING') {
|
||||
message = '尚未配置 API Key,请编辑该 Provider 后填写并保存。'
|
||||
} else if (reason instanceof ApiErrorClass && reason.code === 'PROVIDER_AUTH_FAILED') {
|
||||
message = `鉴权失败,请检查凭据“${credentialId || '未设置'}”对应的 API Key 是否有效。`
|
||||
}
|
||||
modelErrorsByProvider.value[providerId] = message
|
||||
throw reason
|
||||
} finally {
|
||||
modelLoadingByProvider.value[providerId] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshEnabledModels() {
|
||||
await Promise.allSettled(
|
||||
providers.value.filter((provider) => provider.enabled).map((provider) => loadModels(provider.provider_id))
|
||||
)
|
||||
}
|
||||
|
||||
async function loadCredentialStatus(credentialId: string): Promise<boolean> {
|
||||
const configured = await getCredentialStatus(credentialId)
|
||||
credentialConfiguredById.value[credentialId] = configured
|
||||
return configured
|
||||
}
|
||||
|
||||
async function saveCredential(credentialId: string, apiKey: string) {
|
||||
await putCredential(credentialId, apiKey)
|
||||
credentialConfiguredById.value[credentialId] = true
|
||||
}
|
||||
|
||||
async function addProvider(data: Omit<ProviderConfig, 'provider_id'>) {
|
||||
@@ -41,6 +92,7 @@ export const useProviderStore = defineStore('provider', () => {
|
||||
const updated = await updateProviderRequest(providerId, data)
|
||||
const index = providers.value.findIndex((provider) => provider.provider_id === providerId)
|
||||
if (index >= 0) providers.value[index] = updated
|
||||
return updated
|
||||
}
|
||||
|
||||
async function deleteProvider(providerId: string) {
|
||||
@@ -61,14 +113,22 @@ export const useProviderStore = defineStore('provider', () => {
|
||||
|
||||
return {
|
||||
providers,
|
||||
presets,
|
||||
modelsByProvider,
|
||||
modelLoadingByProvider,
|
||||
modelErrorsByProvider,
|
||||
credentialConfiguredById,
|
||||
defaultProviderId,
|
||||
enabledProviders,
|
||||
defaultProvider,
|
||||
isLoading,
|
||||
error,
|
||||
loadProviders,
|
||||
loadPresets,
|
||||
loadModels,
|
||||
refreshEnabledModels,
|
||||
loadCredentialStatus,
|
||||
saveCredential,
|
||||
addProvider,
|
||||
updateProvider,
|
||||
deleteProvider,
|
||||
|
||||
@@ -1,9 +1,51 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
import { marked } from 'marked'
|
||||
import { createHighlighterCore } from 'shiki/core'
|
||||
import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript'
|
||||
import css from '@shikijs/langs/css'
|
||||
import html from '@shikijs/langs/html'
|
||||
import javascript from '@shikijs/langs/javascript'
|
||||
import json from '@shikijs/langs/json'
|
||||
import markdown from '@shikijs/langs/markdown'
|
||||
import python from '@shikijs/langs/python'
|
||||
import shell from '@shikijs/langs/shellscript'
|
||||
import sql from '@shikijs/langs/sql'
|
||||
import typescript from '@shikijs/langs/typescript'
|
||||
import githubDark from '@shikijs/themes/github-dark'
|
||||
import githubLight from '@shikijs/themes/github-light'
|
||||
|
||||
marked.setOptions({ gfm: true, breaks: true })
|
||||
|
||||
export function renderMarkdown(source: string): string {
|
||||
const html = marked.parse(source, { async: false }) as string
|
||||
return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } })
|
||||
const highlighter = createHighlighterCore({
|
||||
themes: [githubLight, githubDark],
|
||||
langs: [markdown, html, css, javascript, typescript, json, python, shell, sql],
|
||||
engine: createJavaScriptRegexEngine(),
|
||||
})
|
||||
|
||||
const languageAliases: Record<string, string> = {
|
||||
bash: 'shell', js: 'javascript', md: 'markdown', plaintext: 'text', py: 'python', sh: 'shell', ts: 'typescript',
|
||||
}
|
||||
|
||||
export async function highlightCode(source: string, requestedLanguage = 'text'): Promise<string> {
|
||||
const shiki = await highlighter
|
||||
const language = languageAliases[requestedLanguage] ?? requestedLanguage
|
||||
const loadedLanguage = shiki.getLoadedLanguages().includes(language as never) ? language : 'markdown'
|
||||
return shiki.codeToHtml(source, {
|
||||
lang: loadedLanguage,
|
||||
themes: { light: 'github-light', dark: 'github-dark' },
|
||||
defaultColor: false,
|
||||
})
|
||||
}
|
||||
|
||||
export async function renderMarkdown(source: string): Promise<string> {
|
||||
const html = marked.parse(source, { async: false }) as string
|
||||
const documentNode = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html')
|
||||
for (const code of documentNode.querySelectorAll('pre > code')) {
|
||||
const requestedLanguage = [...code.classList].find((name) => name.startsWith('language-'))?.slice(9) || 'text'
|
||||
const highlighted = await highlightCode(code.textContent ?? '', requestedLanguage)
|
||||
const fragment = document.createRange().createContextualFragment(highlighted)
|
||||
code.parentElement?.replaceWith(fragment)
|
||||
}
|
||||
|
||||
return DOMPurify.sanitize(documentNode.body.innerHTML, { USE_PROFILES: { html: true } })
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2022", "ESNext.Disposable", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
|
||||
Reference in New Issue
Block a user