Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8480ed7f5e | ||
|
|
150cf0d994 | ||
|
|
9f621371b8 | ||
|
|
c04f4c1989 | ||
|
|
2e496462a9 | ||
|
|
a75d81a7d9 | ||
|
|
5dd5a46aae | ||
|
|
1fe75e3fd2 | ||
|
|
d31cd842c5 |
@@ -160,10 +160,11 @@ def read_attachment(arguments: AttachmentReadArguments, _: ToolExecutionContext)
|
|||||||
return attachment_service.read_attachment(**arguments.model_dump())
|
return attachment_service.read_attachment(**arguments.model_dump())
|
||||||
|
|
||||||
|
|
||||||
def transcribe_audio(arguments: AudioTranscribeArguments, _: ToolExecutionContext) -> dict:
|
async def transcribe_audio(arguments: AudioTranscribeArguments, _: ToolExecutionContext) -> dict:
|
||||||
return transcription_service.create_transcription(
|
job = await transcription_service.create_transcription(
|
||||||
arguments.attachment_id, arguments.language
|
arguments.attachment_id, arguments.language
|
||||||
).model_dump(mode="json")
|
)
|
||||||
|
return job.model_dump(mode="json")
|
||||||
|
|
||||||
|
|
||||||
def _register(
|
def _register(
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from app.contracts import (
|
|||||||
SearchRequest,
|
SearchRequest,
|
||||||
)
|
)
|
||||||
from app.retrieval.engine import engine
|
from app.retrieval.engine import engine
|
||||||
|
from app.retrieval.provenance import capture_embedding
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -90,7 +91,9 @@ async def _evaluate_one(
|
|||||||
score_threshold=request.retrieval.score_threshold,
|
score_threshold=request.retrieval.score_threshold,
|
||||||
)
|
)
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
|
embedding = {}
|
||||||
try:
|
try:
|
||||||
|
with capture_embedding() as embedding:
|
||||||
response = await engine.search(search_request)
|
response = await engine.search(search_request)
|
||||||
latency_ms = (time.perf_counter() - start) * 1000.0
|
latency_ms = (time.perf_counter() - start) * 1000.0
|
||||||
except Exception as exc: # 单个样本失败不中断整个 Benchmark
|
except Exception as exc: # 单个样本失败不中断整个 Benchmark
|
||||||
@@ -100,6 +103,7 @@ async def _evaluate_one(
|
|||||||
exc_info=exc,
|
exc_info=exc,
|
||||||
)
|
)
|
||||||
return RAGCaseResult(
|
return RAGCaseResult(
|
||||||
|
embedding=embedding,
|
||||||
case_id=case.case_id,
|
case_id=case.case_id,
|
||||||
mode=mode,
|
mode=mode,
|
||||||
repeat=repeat,
|
repeat=repeat,
|
||||||
@@ -115,6 +119,7 @@ async def _evaluate_one(
|
|||||||
k = request.retrieval.top_k
|
k = request.retrieval.top_k
|
||||||
|
|
||||||
return RAGCaseResult(
|
return RAGCaseResult(
|
||||||
|
embedding=embedding,
|
||||||
case_id=case.case_id,
|
case_id=case.case_id,
|
||||||
mode=mode,
|
mode=mode,
|
||||||
repeat=repeat,
|
repeat=repeat,
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ def _config_snapshot(request: RAGRunRequest, dataset: RAGDataset) -> dict:
|
|||||||
"modes": [m.value for m in request.modes],
|
"modes": [m.value for m in request.modes],
|
||||||
"retrieval": request.retrieval.model_dump(),
|
"retrieval": request.retrieval.model_dump(),
|
||||||
"repeat": request.repeat,
|
"repeat": request.repeat,
|
||||||
"embedding": {
|
"embedding": {"policy": "per_case", "details": "cases[].embedding"},
|
||||||
|
"local_embedding": {
|
||||||
"model_id": engine.embedding.model_id,
|
"model_id": engine.embedding.model_id,
|
||||||
"version": engine.embedding.version,
|
"version": engine.embedding.version,
|
||||||
"dim": engine.embedding.dim,
|
"dim": engine.embedding.dim,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from app.config import BACKEND_DIR, get_settings
|
|||||||
from app.extensions import PluginRuntime, SkillRuntime
|
from app.extensions import PluginRuntime, SkillRuntime
|
||||||
from app.extensions.mcp_registry import McpServerRegistry
|
from app.extensions.mcp_registry import McpServerRegistry
|
||||||
from app.providers import MockProvider, ProviderFactory, ProviderRegistry
|
from app.providers import MockProvider, ProviderFactory, ProviderRegistry
|
||||||
|
from app.providers.routing import ModelRoutingService
|
||||||
from app.providers.credentials import (
|
from app.providers.credentials import (
|
||||||
ChainedCredentialResolver,
|
ChainedCredentialResolver,
|
||||||
EncryptedCredentialStore,
|
EncryptedCredentialStore,
|
||||||
@@ -18,6 +19,7 @@ from app.providers.credentials import (
|
|||||||
class ApplicationContainer:
|
class ApplicationContainer:
|
||||||
providers: ProviderRegistry
|
providers: ProviderRegistry
|
||||||
provider_factory: ProviderFactory
|
provider_factory: ProviderFactory
|
||||||
|
model_routing: ModelRoutingService
|
||||||
credentials: EncryptedCredentialStore
|
credentials: EncryptedCredentialStore
|
||||||
tools: ToolRegistry
|
tools: ToolRegistry
|
||||||
permissions: PermissionManager
|
permissions: PermissionManager
|
||||||
@@ -33,7 +35,7 @@ def build_container() -> ApplicationContainer:
|
|||||||
provider_factory = ProviderFactory(
|
provider_factory = ProviderFactory(
|
||||||
ChainedCredentialResolver(credentials, EnvironmentCredentialResolver())
|
ChainedCredentialResolver(credentials, EnvironmentCredentialResolver())
|
||||||
)
|
)
|
||||||
providers = ProviderRegistry()
|
providers = ProviderRegistry(provider_factory)
|
||||||
providers.register(
|
providers.register(
|
||||||
ProviderConfig(
|
ProviderConfig(
|
||||||
provider_id="mock",
|
provider_id="mock",
|
||||||
@@ -86,6 +88,7 @@ def build_container() -> ApplicationContainer:
|
|||||||
return ApplicationContainer(
|
return ApplicationContainer(
|
||||||
providers=providers,
|
providers=providers,
|
||||||
provider_factory=provider_factory,
|
provider_factory=provider_factory,
|
||||||
|
model_routing=ModelRoutingService(providers, provider_factory.credentials),
|
||||||
credentials=credentials,
|
credentials=credentials,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
permissions=permissions,
|
permissions=permissions,
|
||||||
|
|||||||
@@ -236,6 +236,8 @@ class ModelCapability(str, Enum):
|
|||||||
streaming = "streaming"
|
streaming = "streaming"
|
||||||
structured_output = "structured_output"
|
structured_output = "structured_output"
|
||||||
embedding = "embedding"
|
embedding = "embedding"
|
||||||
|
transcription = "transcription"
|
||||||
|
speaker_matching = "speaker_matching"
|
||||||
|
|
||||||
|
|
||||||
class ModelRequest(Contract):
|
class ModelRequest(Contract):
|
||||||
@@ -763,7 +765,24 @@ class ProviderType(str, Enum):
|
|||||||
ollama = "ollama"
|
ollama = "ollama"
|
||||||
|
|
||||||
|
|
||||||
class ProviderConfig(Contract):
|
class ProviderConnectionFields(Contract):
|
||||||
|
base_url: str | None = None
|
||||||
|
credential_id: str | None = None
|
||||||
|
|
||||||
|
@field_validator("base_url")
|
||||||
|
@classmethod
|
||||||
|
def provider_url(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return value
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
parsed = urlsplit(value)
|
||||||
|
if (parsed.scheme not in {"http", "https"} or not parsed.hostname or
|
||||||
|
parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||||
|
raise ValueError("Base URL requires HTTP(S), without credentials, query or fragment")
|
||||||
|
return value.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderConfig(ProviderConnectionFields):
|
||||||
provider_id: str
|
provider_id: str
|
||||||
provider_type: ProviderType
|
provider_type: ProviderType
|
||||||
name: str
|
name: str
|
||||||
@@ -774,7 +793,7 @@ class ProviderConfig(Contract):
|
|||||||
capabilities: list[ModelCapability] = Field(default_factory=list)
|
capabilities: list[ModelCapability] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class ProviderCreateRequest(Contract):
|
class ProviderCreateRequest(ProviderConnectionFields):
|
||||||
provider_type: ProviderType
|
provider_type: ProviderType
|
||||||
name: str
|
name: str
|
||||||
base_url: str | None = None
|
base_url: str | None = None
|
||||||
@@ -783,7 +802,8 @@ class ProviderCreateRequest(Contract):
|
|||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
class ProviderUpdateRequest(Contract):
|
class ProviderUpdateRequest(ProviderConnectionFields):
|
||||||
|
provider_type: ProviderType | None = None
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
base_url: str | None = None
|
base_url: str | None = None
|
||||||
default_model: str | None = None
|
default_model: str | None = None
|
||||||
@@ -802,6 +822,80 @@ class ProviderPreset(Contract):
|
|||||||
base_url: str
|
base_url: str
|
||||||
default_credential_id: str | None = None
|
default_credential_id: str | None = None
|
||||||
requires_credential: bool = True
|
requires_credential: bool = True
|
||||||
|
logo_id: str = "custom"
|
||||||
|
description: str = ""
|
||||||
|
capabilities: list[ModelCapability] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ModelBinding(Contract):
|
||||||
|
provider_id: str = Field(min_length=1, max_length=128)
|
||||||
|
model: str = Field(min_length=1, max_length=256)
|
||||||
|
endpoint: str = Field(min_length=1, max_length=256)
|
||||||
|
dimensions: int | None = Field(default=None, ge=1, le=16384)
|
||||||
|
|
||||||
|
@field_validator("endpoint")
|
||||||
|
@classmethod
|
||||||
|
def relative_endpoint(cls, value: str) -> str:
|
||||||
|
# An endpoint is a path on the selected provider, never a second origin.
|
||||||
|
import re
|
||||||
|
if not re.fullmatch(r"/[A-Za-z0-9_/-]+", value) or value.startswith("//"):
|
||||||
|
raise ValueError("endpoint must be an absolute API path on the provider")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("model", "provider_id")
|
||||||
|
@classmethod
|
||||||
|
def non_blank(cls, value: str) -> str:
|
||||||
|
if not value.strip():
|
||||||
|
raise ValueError("value must not be blank")
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class ModelRoutingConfig(Contract):
|
||||||
|
version: int = Field(default=0, ge=0)
|
||||||
|
embedding: ModelBinding | None = None
|
||||||
|
transcription: ModelBinding | None = None
|
||||||
|
speaker_matching: ModelBinding | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LocalBackendStatus(Contract):
|
||||||
|
capability: Literal["embedding", "transcription", "speaker_matching"]
|
||||||
|
status: Literal["placeholder", "not_installed", "ready"]
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class ModelRoutingResponse(Contract):
|
||||||
|
config: ModelRoutingConfig
|
||||||
|
local_backends: list[LocalBackendStatus]
|
||||||
|
|
||||||
|
|
||||||
|
class EmbeddingRequest(Contract):
|
||||||
|
texts: list[str] = Field(min_length=1, max_length=256)
|
||||||
|
|
||||||
|
@field_validator("texts")
|
||||||
|
@classmethod
|
||||||
|
def bound_texts(cls, value: list[str]) -> list[str]:
|
||||||
|
if sum(len(text) for text in value) > 200_000:
|
||||||
|
raise ValueError("embedding input is too large")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class EmbeddingResult(Contract):
|
||||||
|
vectors: list[list[float]]
|
||||||
|
source: Literal["api", "local"]
|
||||||
|
model_id: str
|
||||||
|
dimensions: int
|
||||||
|
fallback_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SpeakerMatchRequest(Contract):
|
||||||
|
attachment_id: str
|
||||||
|
reference_attachment_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class SpeakerMatchResult(Contract):
|
||||||
|
score: float = Field(ge=0, le=1, allow_inf_nan=False)
|
||||||
|
source: Literal["api", "local"]
|
||||||
|
fallback_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProviderPresetListResponse(Contract):
|
class ProviderPresetListResponse(Contract):
|
||||||
@@ -894,9 +988,13 @@ class TranscriptionJob(Contract):
|
|||||||
error_code: str | None = None
|
error_code: str | None = None
|
||||||
error_message: str | None = None
|
error_message: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
source: Literal["api", "local", "sidecar"] | None = None
|
||||||
|
fallback_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class IndexStatus(Contract):
|
class IndexStatus(Contract):
|
||||||
|
total_notes: int = 0
|
||||||
|
total_blocks: int = 0
|
||||||
status: Literal["idle", "queued", "running", "failed"] = "idle"
|
status: Literal["idle", "queued", "running", "failed"] = "idle"
|
||||||
pending_jobs: int = 0
|
pending_jobs: int = 0
|
||||||
active_job_id: str | None = None
|
active_job_id: str | None = None
|
||||||
@@ -1035,6 +1133,7 @@ class BenchmarkEvent(Contract):
|
|||||||
|
|
||||||
|
|
||||||
class RAGCaseResult(Contract):
|
class RAGCaseResult(Contract):
|
||||||
|
embedding: dict[str, Any] = Field(default_factory=dict)
|
||||||
case_id: str
|
case_id: str
|
||||||
mode: SearchMode
|
mode: SearchMode
|
||||||
repeat: int
|
repeat: int
|
||||||
|
|||||||
@@ -36,7 +36,11 @@ async def validation_error_handler(_: Request, exc: RequestValidationError) -> J
|
|||||||
error=ErrorDetail(
|
error=ErrorDetail(
|
||||||
code="VALIDATION_ERROR",
|
code="VALIDATION_ERROR",
|
||||||
message="Request validation failed.",
|
message="Request validation failed.",
|
||||||
details={"errors": exc.errors()},
|
# Pydantic ctx can contain exception objects; input may contain API keys.
|
||||||
|
details={"errors": [
|
||||||
|
{key: error[key] for key in ("type", "loc", "msg") if key in error}
|
||||||
|
for error in exc.errors()
|
||||||
|
]},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return JSONResponse(status_code=422, content=jsonable_encoder(body))
|
return JSONResponse(status_code=422, content=jsonable_encoder(body))
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""Native Anthropic Messages protocol with incrementally decoded content blocks."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from contextlib import aclosing
|
||||||
|
|
||||||
|
from app.contracts import MessageRole, ModelEventType, ModelRequest
|
||||||
|
from app.providers.base import ProviderError, ProviderToolCall, ProviderTurn
|
||||||
|
from app.providers.http_base import (
|
||||||
|
UsageTracker, check_error, decode_tool_arguments, invalid_response, list_value,
|
||||||
|
object_value, string_value, token_count, truncated_stream,
|
||||||
|
)
|
||||||
|
from app.providers.openai_compatible import OpenAICompatibleProvider
|
||||||
|
from app.providers.tool_names import mapped_tool_names
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicMessagesProvider(OpenAICompatibleProvider):
|
||||||
|
stream_path = "/messages"
|
||||||
|
|
||||||
|
def _headers(self) -> dict[str, str]:
|
||||||
|
headers = super()._headers()
|
||||||
|
authorization = headers.pop("Authorization", None)
|
||||||
|
if authorization:
|
||||||
|
headers["x-api-key"] = authorization.removeprefix("Bearer ")
|
||||||
|
headers["anthropic-version"] = "2023-06-01"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
def _payload(self, request: ModelRequest, *, stream: bool) -> dict[str, object]:
|
||||||
|
systems = [request.system] if request.system else []
|
||||||
|
messages = []
|
||||||
|
for message in request.messages:
|
||||||
|
if message.role == MessageRole.system:
|
||||||
|
systems.append(message.content)
|
||||||
|
continue
|
||||||
|
if message.role == MessageRole.tool:
|
||||||
|
if not message.tool_call_id:
|
||||||
|
raise ProviderError("PROVIDER_INVALID_REQUEST", "Tool result requires a call identifier.")
|
||||||
|
role = "user"
|
||||||
|
content = [{"type": "tool_result", "tool_use_id": message.tool_call_id, "content": message.content}]
|
||||||
|
else:
|
||||||
|
role = message.role.value
|
||||||
|
content = [{"type": "text", "text": message.content}] if message.content else []
|
||||||
|
content += [{"type": "tool_use", "id": call.tool_call_id, "name": call.name,
|
||||||
|
"input": call.arguments} for call in message.tool_calls]
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
if messages and messages[-1]["role"] == role:
|
||||||
|
messages[-1]["content"].extend(content)
|
||||||
|
else:
|
||||||
|
messages.append({"role": role, "content": content})
|
||||||
|
payload: dict[str, object] = {"model": request.model, "messages": messages,
|
||||||
|
"max_tokens": request.max_tokens or 4096, "stream": stream}
|
||||||
|
if systems:
|
||||||
|
payload["system"] = "\n\n".join(systems)
|
||||||
|
if request.tools:
|
||||||
|
payload["tools"] = [{"name": tool.name, "description": tool.description,
|
||||||
|
"input_schema": tool.parameters} for tool in request.tools]
|
||||||
|
if request.temperature is not None:
|
||||||
|
payload["temperature"] = request.temperature
|
||||||
|
if request.response_format is not None:
|
||||||
|
format_ = request.response_format
|
||||||
|
if format_.get("type") != "json_schema":
|
||||||
|
raise ProviderError("PROVIDER_INVALID_REQUEST", "Messages requires a JSON schema response format.")
|
||||||
|
schema = object_value(format_.get("json_schema"))
|
||||||
|
payload["output_config"] = {"format": {"type": "json_schema", "schema": object_value(schema.get("schema"))}}
|
||||||
|
return payload
|
||||||
|
|
||||||
|
@mapped_tool_names
|
||||||
|
async def complete(self, request: ModelRequest) -> ProviderTurn:
|
||||||
|
data = await self._request("POST", self.stream_path, json=self._payload(request, stream=False))
|
||||||
|
texts = []
|
||||||
|
calls = []
|
||||||
|
for raw in list_value(data.get("content")):
|
||||||
|
block = object_value(raw)
|
||||||
|
if block.get("type") == "text":
|
||||||
|
texts.append(string_value(block.get("text")))
|
||||||
|
elif block.get("type") == "tool_use":
|
||||||
|
calls.append(ProviderToolCall(
|
||||||
|
tool_call_id=string_value(block.get("id"), nonempty=True),
|
||||||
|
name=string_value(block.get("name"), nonempty=True),
|
||||||
|
arguments=decode_tool_arguments(block.get("input")),
|
||||||
|
))
|
||||||
|
return ProviderTurn(text="".join(texts) or None, tool_calls=calls,
|
||||||
|
**UsageTracker(cache_tokens=True).update(data.get("usage") or {}))
|
||||||
|
|
||||||
|
async def _events(self, request: ModelRequest):
|
||||||
|
blocks: dict[int, dict] = {}
|
||||||
|
usage = UsageTracker(cache_tokens=True)
|
||||||
|
started = False
|
||||||
|
async with aclosing(self._stream_json(self._payload(request, stream=True))) as chunks:
|
||||||
|
async for data in chunks:
|
||||||
|
kind = string_value(data.get("type"), nonempty=True)
|
||||||
|
if kind == "message_start":
|
||||||
|
if started:
|
||||||
|
raise invalid_response()
|
||||||
|
started = True
|
||||||
|
message = object_value(data.get("message"))
|
||||||
|
check_error(message)
|
||||||
|
if message.get("usage") is not None:
|
||||||
|
yield ModelEventType.usage, usage.update(message["usage"])
|
||||||
|
elif kind == "content_block_start":
|
||||||
|
index = token_count(data.get("index"))
|
||||||
|
if not started or index in blocks:
|
||||||
|
raise invalid_response()
|
||||||
|
block = dict(object_value(data.get("content_block")))
|
||||||
|
blocks[index] = block
|
||||||
|
block["closed"] = False
|
||||||
|
if block.get("type") == "tool_use":
|
||||||
|
block["id"] = string_value(block.get("id"), nonempty=True)
|
||||||
|
block["name"] = string_value(block.get("name"), nonempty=True)
|
||||||
|
block["arguments"] = ""
|
||||||
|
block["input"] = object_value(block.get("input", {}))
|
||||||
|
yield ModelEventType.tool_call_start, {"tool_call_id": block["id"], "name": block["name"]}
|
||||||
|
elif block.get("type") == "text" and block.get("text"):
|
||||||
|
yield ModelEventType.text_delta, {"text": string_value(block["text"])}
|
||||||
|
elif block.get("type") == "thinking" and block.get("thinking"):
|
||||||
|
yield ModelEventType.thinking_delta, {"text": string_value(block["thinking"])}
|
||||||
|
elif kind == "content_block_delta":
|
||||||
|
block = blocks.get(token_count(data.get("index")))
|
||||||
|
if block is None or block["closed"]:
|
||||||
|
raise invalid_response()
|
||||||
|
delta = object_value(data.get("delta"))
|
||||||
|
delta_type = delta.get("type")
|
||||||
|
if delta_type == "text_delta":
|
||||||
|
if block.get("type") != "text":
|
||||||
|
raise invalid_response()
|
||||||
|
yield ModelEventType.text_delta, {"text": string_value(delta.get("text"))}
|
||||||
|
elif delta_type == "thinking_delta":
|
||||||
|
if block.get("type") != "thinking":
|
||||||
|
raise invalid_response()
|
||||||
|
yield ModelEventType.thinking_delta, {"text": string_value(delta.get("thinking"))}
|
||||||
|
elif delta_type == "input_json_delta" and block.get("type") == "tool_use":
|
||||||
|
fragment = string_value(delta.get("partial_json"))
|
||||||
|
block["arguments"] += fragment
|
||||||
|
yield ModelEventType.tool_call_delta, {"tool_call_id": block["id"], "arguments_delta": fragment}
|
||||||
|
# Signatures and future delta types have no representation in ModelEvent.
|
||||||
|
elif kind == "content_block_stop":
|
||||||
|
block = blocks.get(token_count(data.get("index")))
|
||||||
|
if block is None or block["closed"]:
|
||||||
|
raise invalid_response()
|
||||||
|
block["closed"] = True
|
||||||
|
if block.get("type") == "tool_use":
|
||||||
|
if block["arguments"]:
|
||||||
|
decode_tool_arguments(block["arguments"])
|
||||||
|
else:
|
||||||
|
yield ModelEventType.tool_call_delta, {
|
||||||
|
"tool_call_id": block["id"], "arguments_delta": json.dumps(block["input"]),
|
||||||
|
}
|
||||||
|
yield ModelEventType.tool_call_end, {"tool_call_id": block["id"]}
|
||||||
|
elif kind == "message_delta":
|
||||||
|
if not started:
|
||||||
|
raise invalid_response()
|
||||||
|
object_value(data.get("delta"))
|
||||||
|
if data.get("usage") is not None:
|
||||||
|
yield ModelEventType.usage, usage.update(data["usage"])
|
||||||
|
elif kind == "message_stop":
|
||||||
|
if not started:
|
||||||
|
raise invalid_response()
|
||||||
|
if any(not block["closed"] for block in blocks.values()):
|
||||||
|
raise truncated_stream()
|
||||||
|
return
|
||||||
|
elif kind == "[DONE]":
|
||||||
|
raise truncated_stream()
|
||||||
|
raise truncated_stream()
|
||||||
@@ -16,6 +16,18 @@ class ProviderFactory:
|
|||||||
self.credentials = ProviderCredentialResolver(credentials)
|
self.credentials = ProviderCredentialResolver(credentials)
|
||||||
|
|
||||||
def build(self, config: ProviderConfig) -> ModelProvider:
|
def build(self, config: ProviderConfig) -> ModelProvider:
|
||||||
|
if config.provider_type == ProviderType.openai_responses:
|
||||||
|
from app.providers.openai_responses import OpenAIResponsesProvider
|
||||||
|
return OpenAIResponsesProvider(
|
||||||
|
base_url=config.base_url or "https://api.openai.com/v1",
|
||||||
|
credential_id=config.credential_id, credentials=self.credentials,
|
||||||
|
)
|
||||||
|
if config.provider_type == ProviderType.anthropic_messages:
|
||||||
|
from app.providers.anthropic_messages import AnthropicMessagesProvider
|
||||||
|
return AnthropicMessagesProvider(
|
||||||
|
base_url=config.base_url or "https://api.anthropic.com/v1",
|
||||||
|
credential_id=config.credential_id, credentials=self.credentials,
|
||||||
|
)
|
||||||
if config.provider_type in {
|
if config.provider_type in {
|
||||||
ProviderType.openai_chat,
|
ProviderType.openai_chat,
|
||||||
ProviderType.openai_compatible,
|
ProviderType.openai_compatible,
|
||||||
@@ -31,7 +43,7 @@ class ProviderFactory:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def presets() -> list[ProviderPreset]:
|
def presets() -> list[ProviderPreset]:
|
||||||
return [
|
presets = [
|
||||||
ProviderPreset(
|
ProviderPreset(
|
||||||
preset_id="openai",
|
preset_id="openai",
|
||||||
name="OpenAI",
|
name="OpenAI",
|
||||||
@@ -54,12 +66,45 @@ class ProviderFactory:
|
|||||||
requires_credential=False,
|
requires_credential=False,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
# General API endpoints. Coding-plan endpoints and keys are separate products.
|
||||||
|
domestic = [
|
||||||
|
("kimi", "Kimi / 月之暗面", "https://api.moonshot.cn/v1", [], "长上下文对话;模型以账号权限为准。"),
|
||||||
|
("qwen", "阿里云百炼", "https://dashscope.aliyuncs.com/compatible-mode/v1", [ModelCapability.embedding], "中国内地兼容接口;海外地域需修改地址。"),
|
||||||
|
("zhipu", "智谱 GLM", "https://open.bigmodel.cn/api/paas/v4", [ModelCapability.embedding], "通用 API;Coding Plan 请使用其专用地址。"),
|
||||||
|
("volcengine", "火山方舟 / 豆包", "https://ark.cn-beijing.volces.com/api/v3", [ModelCapability.embedding], "按账号填写模型 ID 或推理接入点 ID。"),
|
||||||
|
("siliconflow", "硅基流动", "https://api.siliconflow.cn/v1", [ModelCapability.embedding, ModelCapability.transcription], "支持兼容 Embedding 和音频转写接口。"),
|
||||||
|
("baidu", "百度千帆", "https://qianfan.baidubce.com/v2", [ModelCapability.embedding], "使用千帆 API Key;模型列表取决于账号。"),
|
||||||
|
("hunyuan", "腾讯混元", "https://api.hunyuan.cloud.tencent.com/v1", [], "OpenAI 兼容对话接口。"),
|
||||||
|
("minimax", "MiniMax", "https://api.minimaxi.com/v1", [], "文本对话兼容接口;其他媒体协议需独立适配。"),
|
||||||
|
("stepfun", "阶跃星辰", "https://api.stepfun.com/v1", [], "通用 API;Step Plan 请使用其专用地址。"),
|
||||||
|
]
|
||||||
|
for preset_id, name, url, extra, description in domestic:
|
||||||
|
presets.append(ProviderPreset(
|
||||||
|
preset_id=preset_id, name=name, provider_type=ProviderType.openai_compatible,
|
||||||
|
base_url=url, default_credential_id=preset_id, logo_id=preset_id,
|
||||||
|
capabilities=[ModelCapability.chat, *extra], description=description,
|
||||||
|
))
|
||||||
|
presets.extend([
|
||||||
|
ProviderPreset(preset_id="openai-responses", name="OpenAI Responses", provider_type=ProviderType.openai_responses,
|
||||||
|
base_url="https://api.openai.com/v1", default_credential_id="openai", logo_id="openai"),
|
||||||
|
ProviderPreset(preset_id="anthropic", name="Anthropic / Claude", provider_type=ProviderType.anthropic_messages,
|
||||||
|
base_url="https://api.anthropic.com/v1", default_credential_id="anthropic", logo_id="anthropic"),
|
||||||
|
])
|
||||||
|
for preset in presets:
|
||||||
|
if preset.logo_id == "custom":
|
||||||
|
preset.logo_id = preset.preset_id
|
||||||
|
if not preset.capabilities:
|
||||||
|
preset.capabilities = [ModelCapability.chat]
|
||||||
|
presets[0].capabilities += [ModelCapability.embedding, ModelCapability.transcription]
|
||||||
|
return presets
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def capabilities(provider_type: ProviderType) -> list[ModelCapability]:
|
def capabilities(provider_type: ProviderType) -> list[ModelCapability]:
|
||||||
if provider_type in {
|
if provider_type in {
|
||||||
ProviderType.openai_chat,
|
ProviderType.openai_chat,
|
||||||
ProviderType.openai_compatible,
|
ProviderType.openai_compatible,
|
||||||
|
ProviderType.openai_responses,
|
||||||
|
ProviderType.anthropic_messages,
|
||||||
}:
|
}:
|
||||||
return [
|
return [
|
||||||
ModelCapability.chat,
|
ModelCapability.chat,
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import json
|
import json
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import aclosing
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
from app.contracts import ModelEvent, ModelEventType, ModelRequest
|
from app.contracts import ModelEvent, ModelEventType, ModelRequest
|
||||||
from app.providers.base import ProviderError, ProviderTurn
|
from app.providers.base import ProviderError, ProviderTurn
|
||||||
|
from app.providers.tool_names import prepare_tool_names
|
||||||
|
|
||||||
|
|
||||||
class TurnStreamingMixin:
|
class TurnStreamingMixin:
|
||||||
@@ -80,3 +84,229 @@ def decode_tool_arguments(value: object) -> dict[str, object]:
|
|||||||
if not isinstance(decoded, dict):
|
if not isinstance(decoded, dict):
|
||||||
raise ProviderError("PROVIDER_INVALID_RESPONSE", "Tool arguments must be an object.")
|
raise ProviderError("PROVIDER_INVALID_RESPONSE", "Tool arguments must be an object.")
|
||||||
return decoded
|
return decoded
|
||||||
|
|
||||||
|
|
||||||
|
def invalid_response() -> ProviderError:
|
||||||
|
return ProviderError("PROVIDER_INVALID_RESPONSE", "Provider returned an invalid response.")
|
||||||
|
|
||||||
|
|
||||||
|
def truncated_stream() -> ProviderError:
|
||||||
|
return ProviderError("PROVIDER_STREAM_TRUNCATED", "Provider stream ended before completion.")
|
||||||
|
|
||||||
|
|
||||||
|
def object_value(value: object) -> dict:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise invalid_response()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def list_value(value: object) -> list:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise invalid_response()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def string_value(value: object, *, nonempty: bool = False) -> str:
|
||||||
|
if not isinstance(value, str) or (nonempty and not value):
|
||||||
|
raise invalid_response()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def token_count(value: object) -> int:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||||
|
raise invalid_response()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def remote_error(value: object) -> ProviderError:
|
||||||
|
# Never reflect upstream messages, URLs, request bodies or credentials.
|
||||||
|
error = value if isinstance(value, dict) else {}
|
||||||
|
code = error.get("code") or error.get("type")
|
||||||
|
mapping = {
|
||||||
|
"authentication_error": "PROVIDER_AUTH_FAILED",
|
||||||
|
"invalid_api_key": "PROVIDER_AUTH_FAILED",
|
||||||
|
"permission_error": "PROVIDER_AUTH_FAILED",
|
||||||
|
"rate_limit_error": "PROVIDER_RATE_LIMITED",
|
||||||
|
"rate_limit_exceeded": "PROVIDER_RATE_LIMITED",
|
||||||
|
"insufficient_quota": "PROVIDER_RATE_LIMITED",
|
||||||
|
"not_found_error": "MODEL_NOT_FOUND",
|
||||||
|
"model_not_found": "MODEL_NOT_FOUND",
|
||||||
|
"invalid_request_error": "PROVIDER_INVALID_REQUEST",
|
||||||
|
"context_length_exceeded": "PROVIDER_INVALID_REQUEST",
|
||||||
|
}
|
||||||
|
mapped = mapping.get(code, "PROVIDER_UNAVAILABLE") if isinstance(code, str) else "PROVIDER_UNAVAILABLE"
|
||||||
|
return ProviderError(mapped, "Provider could not complete the request.")
|
||||||
|
|
||||||
|
|
||||||
|
def check_error(data: dict) -> None:
|
||||||
|
if data.get("error") is not None or data.get("type") == "error":
|
||||||
|
raise remote_error(data.get("error") or data)
|
||||||
|
|
||||||
|
|
||||||
|
class UsageTracker:
|
||||||
|
"""Merge cumulative snapshots, including partial usage updates."""
|
||||||
|
|
||||||
|
def __init__(self, input_key: str = "input_tokens", output_key: str = "output_tokens",
|
||||||
|
*, cache_tokens: bool = False) -> None:
|
||||||
|
self.input_key = input_key
|
||||||
|
self.output_key = output_key
|
||||||
|
self.cache_tokens = cache_tokens
|
||||||
|
self.counts: dict[str, int] = {}
|
||||||
|
|
||||||
|
def update(self, value: object) -> dict[str, int]:
|
||||||
|
usage = object_value(value)
|
||||||
|
keys = [self.input_key, self.output_key]
|
||||||
|
if self.cache_tokens:
|
||||||
|
keys += ["cache_creation_input_tokens", "cache_read_input_tokens"]
|
||||||
|
for key in keys:
|
||||||
|
if key in usage:
|
||||||
|
self.counts[key] = max(self.counts.get(key, 0), token_count(usage[key]))
|
||||||
|
inputs = self.counts.get(self.input_key, 0)
|
||||||
|
if self.cache_tokens:
|
||||||
|
inputs += sum(self.counts.get(key, 0) for key in keys[2:])
|
||||||
|
return {"input_tokens": inputs, "output_tokens": self.counts.get(self.output_key, 0)}
|
||||||
|
|
||||||
|
|
||||||
|
class EventStreamingMixin:
|
||||||
|
async def stream(self, request: ModelRequest) -> AsyncIterator[ModelEvent]:
|
||||||
|
sequence = 0
|
||||||
|
status = "completed"
|
||||||
|
try:
|
||||||
|
request, originals = prepare_tool_names(request)
|
||||||
|
# Closing the public iterator must synchronously close every nested iterator.
|
||||||
|
async with aclosing(self._events(request)) as events:
|
||||||
|
async for kind, data in events:
|
||||||
|
if kind == ModelEventType.tool_call_start and "name" in data:
|
||||||
|
data = {**data, "name": originals.get(data["name"], data["name"])}
|
||||||
|
if kind == ModelEventType.usage:
|
||||||
|
data = {**data, "total_tokens": data["input_tokens"] + data["output_tokens"]}
|
||||||
|
yield ModelEvent(event=kind, data=data, sequence=sequence,
|
||||||
|
timestamp=datetime.now(timezone.utc))
|
||||||
|
sequence += 1
|
||||||
|
except ProviderError as exc:
|
||||||
|
status = "failed"
|
||||||
|
yield ModelEvent(event=ModelEventType.error, sequence=sequence,
|
||||||
|
data={"code": exc.code, "message": exc.message},
|
||||||
|
timestamp=datetime.now(timezone.utc))
|
||||||
|
sequence += 1
|
||||||
|
except (ValueError, TypeError, KeyError, IndexError, AttributeError, OverflowError):
|
||||||
|
status = "failed"
|
||||||
|
error = invalid_response()
|
||||||
|
yield ModelEvent(event=ModelEventType.error, sequence=sequence,
|
||||||
|
data={"code": error.code, "message": error.message},
|
||||||
|
timestamp=datetime.now(timezone.utc))
|
||||||
|
sequence += 1
|
||||||
|
# CancelledError and GeneratorExit deliberately propagate without a Done event.
|
||||||
|
yield ModelEvent(event=ModelEventType.done, sequence=sequence,
|
||||||
|
data={"status": status},
|
||||||
|
timestamp=datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
async def sse_objects(response: httpx.Response) -> AsyncIterator[dict]:
|
||||||
|
"""Read SSE frames, accepting the adjacent data lines used by some gateways."""
|
||||||
|
parts: list[str] = []
|
||||||
|
event_name = ""
|
||||||
|
|
||||||
|
def decode() -> dict:
|
||||||
|
value = "\n".join(parts)
|
||||||
|
if value.strip() == "[DONE]":
|
||||||
|
return {"type": "[DONE]"}
|
||||||
|
try:
|
||||||
|
data = object_value(json.loads(value))
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise invalid_response() from exc
|
||||||
|
if event_name and "type" not in data:
|
||||||
|
data["type"] = event_name
|
||||||
|
check_error(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if not line:
|
||||||
|
if parts:
|
||||||
|
yield decode()
|
||||||
|
parts = []
|
||||||
|
event_name = ""
|
||||||
|
elif line.startswith(":"):
|
||||||
|
continue
|
||||||
|
elif line.startswith("event:"):
|
||||||
|
if parts:
|
||||||
|
yield decode()
|
||||||
|
parts = []
|
||||||
|
event_name = line[6:].strip()
|
||||||
|
elif line.startswith("data:"):
|
||||||
|
if parts:
|
||||||
|
# Legacy compatible endpoints sometimes omit blank separators.
|
||||||
|
try:
|
||||||
|
json.loads("\n".join(parts))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
yield decode()
|
||||||
|
parts = []
|
||||||
|
event_name = ""
|
||||||
|
parts.append(line[5:].removeprefix(" "))
|
||||||
|
if parts:
|
||||||
|
yield decode()
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPProviderMixin:
|
||||||
|
stream_path = "/chat/completions"
|
||||||
|
stream_format = "sse"
|
||||||
|
|
||||||
|
def _headers(self) -> dict[str, str]:
|
||||||
|
return {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _status_error(exc: httpx.HTTPStatusError) -> ProviderError:
|
||||||
|
status = exc.response.status_code
|
||||||
|
code = {400: "PROVIDER_INVALID_REQUEST", 401: "PROVIDER_AUTH_FAILED",
|
||||||
|
403: "PROVIDER_AUTH_FAILED", 404: "MODEL_NOT_FOUND",
|
||||||
|
408: "PROVIDER_TIMEOUT", 413: "PROVIDER_INVALID_REQUEST",
|
||||||
|
422: "PROVIDER_INVALID_REQUEST", 429: "PROVIDER_RATE_LIMITED"}.get(
|
||||||
|
status, "PROVIDER_UNAVAILABLE")
|
||||||
|
return ProviderError(code, f"Provider returned HTTP {status}.")
|
||||||
|
|
||||||
|
async def _request(self, method: str, path: str, **kwargs) -> dict:
|
||||||
|
headers = self._headers()
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout_seconds, transport=self.transport) as client:
|
||||||
|
response = await client.request(method, f"{self.base_url}{path}", headers=headers, **kwargs)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = object_value(response.json())
|
||||||
|
check_error(data)
|
||||||
|
return data
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise ProviderError("PROVIDER_TIMEOUT", "Provider request timed out.") from exc
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise self._status_error(exc) from exc
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise ProviderError("PROVIDER_UNAVAILABLE", "Provider is unavailable.") from exc
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise invalid_response() from exc
|
||||||
|
|
||||||
|
async def _stream_json(self, payload: dict[str, object]) -> AsyncIterator[dict]:
|
||||||
|
headers = self._headers()
|
||||||
|
headers["Accept"] = "text/event-stream" if self.stream_format == "sse" else "application/x-ndjson"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout_seconds, transport=self.transport) as client:
|
||||||
|
async with client.stream("POST", f"{self.base_url}{self.stream_path}",
|
||||||
|
headers=headers, json=payload) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
if self.stream_format == "sse":
|
||||||
|
async with aclosing(sse_objects(response)) as objects:
|
||||||
|
async for data in objects:
|
||||||
|
yield data
|
||||||
|
else:
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if line.strip():
|
||||||
|
data = object_value(json.loads(line))
|
||||||
|
check_error(data)
|
||||||
|
yield data
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise ProviderError("PROVIDER_TIMEOUT", "Provider request timed out.") from exc
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise self._status_error(exc) from exc
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise ProviderError("PROVIDER_UNAVAILABLE", "Provider is unavailable.") from exc
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise invalid_response() from exc
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
from uuid import uuid4
|
|
||||||
import json
|
import json
|
||||||
from collections.abc import AsyncIterator
|
from contextlib import aclosing
|
||||||
from datetime import datetime, timezone
|
from uuid import uuid4
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.contracts import ModelCapability, ModelEvent, ModelEventType, ModelInfo, ModelRequest
|
from app.contracts import MessageRole, ModelCapability, ModelEventType, ModelInfo, ModelRequest
|
||||||
from app.providers.base import ProviderError, ProviderToolCall, ProviderTurn
|
from app.providers.base import ProviderError, ProviderToolCall, ProviderTurn
|
||||||
from app.providers.http_base import TurnStreamingMixin, decode_tool_arguments
|
from app.providers.tool_names import mapped_tool_names
|
||||||
|
from app.providers.http_base import (
|
||||||
|
EventStreamingMixin, HTTPProviderMixin, UsageTracker, decode_tool_arguments,
|
||||||
|
invalid_response, list_value, object_value, string_value, truncated_stream,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class OllamaProvider(TurnStreamingMixin):
|
class OllamaProvider(EventStreamingMixin, HTTPProviderMixin):
|
||||||
|
stream_path = "/api/chat"
|
||||||
|
stream_format = "jsonl"
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
base_url: str = "http://127.0.0.1:11434",
|
base_url: str = "http://127.0.0.1:11434",
|
||||||
@@ -21,126 +27,55 @@ class OllamaProvider(TurnStreamingMixin):
|
|||||||
self.timeout_seconds = timeout_seconds
|
self.timeout_seconds = timeout_seconds
|
||||||
self.transport = transport
|
self.transport = transport
|
||||||
|
|
||||||
|
@mapped_tool_names
|
||||||
async def complete(self, request: ModelRequest) -> ProviderTurn:
|
async def complete(self, request: ModelRequest) -> ProviderTurn:
|
||||||
messages = []
|
data = await self._request("POST", self.stream_path, json=self._chat_payload(request, stream=False))
|
||||||
if request.system:
|
message = object_value(data.get("message"))
|
||||||
messages.append({"role": "system", "content": request.system})
|
calls = [self._tool_call(raw) for raw in list_value(message.get("tool_calls", []))]
|
||||||
for message in request.messages:
|
content = message.get("content")
|
||||||
item: dict[str, object] = {
|
if content is not None:
|
||||||
"role": message.role.value,
|
content = string_value(content)
|
||||||
"content": message.content,
|
return ProviderTurn(text=content or None, tool_calls=calls,
|
||||||
}
|
**UsageTracker("prompt_eval_count", "eval_count").update(data))
|
||||||
if message.tool_calls:
|
|
||||||
item["tool_calls"] = [
|
@staticmethod
|
||||||
{
|
def _tool_call(raw: object) -> ProviderToolCall:
|
||||||
"function": {
|
call = object_value(raw)
|
||||||
"name": call.name,
|
function = object_value(call.get("function"))
|
||||||
"arguments": call.arguments,
|
return ProviderToolCall(
|
||||||
}
|
tool_call_id=string_value(call.get("id") or f"call_{uuid4().hex}"),
|
||||||
}
|
name=string_value(function.get("name"), nonempty=True),
|
||||||
for call in message.tool_calls
|
|
||||||
]
|
|
||||||
messages.append(item)
|
|
||||||
payload: dict[str, object] = {
|
|
||||||
"model": request.model,
|
|
||||||
"messages": messages,
|
|
||||||
"stream": False,
|
|
||||||
}
|
|
||||||
if request.tools:
|
|
||||||
payload["tools"] = [
|
|
||||||
{
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": tool.name,
|
|
||||||
"description": tool.description,
|
|
||||||
"parameters": tool.parameters,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for tool in request.tools
|
|
||||||
]
|
|
||||||
data = await self._request("POST", "/api/chat", json=payload)
|
|
||||||
message = data.get("message") or {}
|
|
||||||
tool_calls = []
|
|
||||||
for raw_call in message.get("tool_calls") or []:
|
|
||||||
function = raw_call.get("function") or {}
|
|
||||||
tool_calls.append(
|
|
||||||
ProviderToolCall(
|
|
||||||
tool_call_id=raw_call.get("id") or f"call_{uuid4().hex}",
|
|
||||||
name=function.get("name") or "",
|
|
||||||
arguments=decode_tool_arguments(function.get("arguments", {})),
|
arguments=decode_tool_arguments(function.get("arguments", {})),
|
||||||
)
|
)
|
||||||
)
|
|
||||||
return ProviderTurn(
|
|
||||||
text=message.get("content") or None,
|
|
||||||
tool_calls=tool_calls,
|
|
||||||
input_tokens=int(data.get("prompt_eval_count") or 0),
|
|
||||||
output_tokens=int(data.get("eval_count") or 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def list_models(self) -> list[ModelInfo]:
|
async def _events(self, request: ModelRequest):
|
||||||
data = await self._request("GET", "/api/tags")
|
usage = UsageTracker("prompt_eval_count", "eval_count")
|
||||||
return [
|
async with aclosing(self._stream_json(self._chat_payload(request, stream=True))) as chunks:
|
||||||
ModelInfo(
|
async for data in chunks:
|
||||||
model=item["name"],
|
message = object_value(data.get("message", {}))
|
||||||
display_name=item.get("name", ""),
|
|
||||||
capabilities=[ModelCapability.chat, ModelCapability.streaming],
|
|
||||||
)
|
|
||||||
for item in data.get("models", [])
|
|
||||||
if isinstance(item, dict) and item.get("name")
|
|
||||||
]
|
|
||||||
|
|
||||||
async def stream(self, request: ModelRequest) -> AsyncIterator[ModelEvent]:
|
|
||||||
payload = self._chat_payload(request, stream=True)
|
|
||||||
sequence = 0
|
|
||||||
|
|
||||||
def event(kind: ModelEventType, data: dict | None = None) -> ModelEvent:
|
|
||||||
nonlocal sequence
|
|
||||||
item = ModelEvent(
|
|
||||||
event=kind, sequence=sequence, data=data or {},
|
|
||||||
timestamp=datetime.now(timezone.utc),
|
|
||||||
)
|
|
||||||
sequence += 1
|
|
||||||
return item
|
|
||||||
|
|
||||||
try:
|
|
||||||
async for data in self._stream_json(payload):
|
|
||||||
message = data.get("message") or {}
|
|
||||||
if message.get("thinking"):
|
if message.get("thinking"):
|
||||||
yield event(ModelEventType.thinking_delta, {"text": message["thinking"]})
|
yield ModelEventType.thinking_delta, {"text": string_value(message["thinking"])}
|
||||||
if message.get("content"):
|
if message.get("content"):
|
||||||
yield event(ModelEventType.text_delta, {"text": message["content"]})
|
yield ModelEventType.text_delta, {"text": string_value(message["content"])}
|
||||||
for raw_call in message.get("tool_calls") or []:
|
for raw in list_value(message.get("tool_calls", [])):
|
||||||
function = raw_call.get("function") or {}
|
call = self._tool_call(raw)
|
||||||
call_id = raw_call.get("id") or f"call_{uuid4().hex}"
|
yield ModelEventType.tool_call_start, {"tool_call_id": call.tool_call_id, "name": call.name}
|
||||||
yield event(
|
yield ModelEventType.tool_call_delta, {
|
||||||
ModelEventType.tool_call_start,
|
"tool_call_id": call.tool_call_id,
|
||||||
{"tool_call_id": call_id, "name": function.get("name") or ""},
|
"arguments_delta": json.dumps(call.arguments, ensure_ascii=False),
|
||||||
)
|
}
|
||||||
yield event(
|
yield ModelEventType.tool_call_end, {"tool_call_id": call.tool_call_id}
|
||||||
ModelEventType.tool_call_delta,
|
if "done" in data and not isinstance(data["done"], bool):
|
||||||
{
|
raise invalid_response()
|
||||||
"tool_call_id": call_id,
|
if "prompt_eval_count" in data or "eval_count" in data or data.get("done"):
|
||||||
"arguments_delta": json.dumps(
|
yield ModelEventType.usage, usage.update(data)
|
||||||
function.get("arguments") or {}, ensure_ascii=False
|
if data.get("done") is True:
|
||||||
),
|
return
|
||||||
},
|
raise truncated_stream()
|
||||||
)
|
|
||||||
yield event(ModelEventType.tool_call_end, {"tool_call_id": call_id})
|
|
||||||
if data.get("done"):
|
|
||||||
yield event(
|
|
||||||
ModelEventType.usage,
|
|
||||||
{
|
|
||||||
"input_tokens": int(data.get("prompt_eval_count") or 0),
|
|
||||||
"output_tokens": int(data.get("eval_count") or 0),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
yield event(ModelEventType.done)
|
|
||||||
except ProviderError as exc:
|
|
||||||
yield event(ModelEventType.error, {"code": exc.code, "message": exc.message})
|
|
||||||
yield event(ModelEventType.done)
|
|
||||||
|
|
||||||
def _chat_payload(self, request: ModelRequest, *, stream: bool) -> dict[str, object]:
|
def _chat_payload(self, request: ModelRequest, *, stream: bool) -> dict[str, object]:
|
||||||
messages = []
|
messages = []
|
||||||
|
names: dict[str, str] = {}
|
||||||
if request.system:
|
if request.system:
|
||||||
messages.append({"role": "system", "content": request.system})
|
messages.append({"role": "system", "content": request.system})
|
||||||
for message in request.messages:
|
for message in request.messages:
|
||||||
@@ -150,53 +85,49 @@ class OllamaProvider(TurnStreamingMixin):
|
|||||||
{"function": {"name": call.name, "arguments": call.arguments}}
|
{"function": {"name": call.name, "arguments": call.arguments}}
|
||||||
for call in message.tool_calls
|
for call in message.tool_calls
|
||||||
]
|
]
|
||||||
|
names.update({call.tool_call_id: call.name for call in message.tool_calls})
|
||||||
|
if message.role == MessageRole.tool:
|
||||||
|
name = message.name or names.get(message.tool_call_id or "")
|
||||||
|
if name:
|
||||||
|
item["tool_name"] = name
|
||||||
messages.append(item)
|
messages.append(item)
|
||||||
payload: dict[str, object] = {
|
payload: dict[str, object] = {
|
||||||
"model": request.model, "messages": messages, "stream": stream
|
"model": request.model, "messages": messages, "stream": stream,
|
||||||
}
|
}
|
||||||
if request.tools:
|
if request.tools:
|
||||||
payload["tools"] = [
|
payload["tools"] = [
|
||||||
{
|
{"type": "function", "function": {
|
||||||
"type": "function",
|
"name": tool.name, "description": tool.description, "parameters": tool.parameters,
|
||||||
"function": {
|
}} for tool in request.tools
|
||||||
"name": tool.name,
|
|
||||||
"description": tool.description,
|
|
||||||
"parameters": tool.parameters,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for tool in request.tools
|
|
||||||
]
|
]
|
||||||
|
options = {}
|
||||||
|
if request.temperature is not None:
|
||||||
|
options["temperature"] = request.temperature
|
||||||
|
if request.max_tokens is not None:
|
||||||
|
options["num_predict"] = request.max_tokens
|
||||||
|
if options:
|
||||||
|
payload["options"] = options
|
||||||
|
if request.response_format:
|
||||||
|
format_ = request.response_format
|
||||||
|
if format_.get("type") == "json_object":
|
||||||
|
payload["format"] = "json"
|
||||||
|
elif format_.get("type") == "json_schema":
|
||||||
|
payload["format"] = object_value(object_value(format_.get("json_schema")).get("schema"))
|
||||||
|
else:
|
||||||
|
payload["format"] = format_
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
async def _stream_json(self, payload: dict[str, object]) -> AsyncIterator[dict]:
|
async def list_models(self) -> list[ModelInfo]:
|
||||||
try:
|
data = await self._request("GET", "/api/tags")
|
||||||
async with httpx.AsyncClient(
|
return [
|
||||||
timeout=self.timeout_seconds, transport=self.transport
|
ModelInfo(
|
||||||
) as client:
|
model=string_value(item["name"]), display_name=item["name"],
|
||||||
async with client.stream(
|
capabilities=([ModelCapability.embedding] if "embed" in item["name"].lower()
|
||||||
"POST", f"{self.base_url}/api/chat", json=payload
|
else [ModelCapability.chat, ModelCapability.streaming]),
|
||||||
) as response:
|
)
|
||||||
response.raise_for_status()
|
for item in list_value(data.get("models"))
|
||||||
async for line in response.aiter_lines():
|
if isinstance(item, dict) and isinstance(item.get("name"), str) and item["name"]
|
||||||
if not line.strip():
|
]
|
||||||
continue
|
|
||||||
try:
|
|
||||||
data = json.loads(line)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise ProviderError(
|
|
||||||
"PROVIDER_INVALID_RESPONSE", "Ollama returned invalid JSONL."
|
|
||||||
) from exc
|
|
||||||
if isinstance(data, dict):
|
|
||||||
yield data
|
|
||||||
except httpx.TimeoutException as exc:
|
|
||||||
raise ProviderError("PROVIDER_TIMEOUT", "Ollama request timed out.") from exc
|
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
raise ProviderError(
|
|
||||||
"MODEL_NOT_FOUND" if exc.response.status_code == 404 else "PROVIDER_UNAVAILABLE",
|
|
||||||
f"Ollama returned HTTP {exc.response.status_code}.",
|
|
||||||
) from exc
|
|
||||||
except httpx.HTTPError as exc:
|
|
||||||
raise ProviderError("PROVIDER_UNAVAILABLE", "Ollama is unavailable.") from exc
|
|
||||||
|
|
||||||
async def test_connection(self, model: str | None = None) -> tuple[bool, str]:
|
async def test_connection(self, model: str | None = None) -> tuple[bool, str]:
|
||||||
try:
|
try:
|
||||||
@@ -206,24 +137,3 @@ class OllamaProvider(TurnStreamingMixin):
|
|||||||
if model and model not in {item.model for item in models}:
|
if model and model not in {item.model for item in models}:
|
||||||
return False, f"Model is not installed: {model}"
|
return False, f"Model is not installed: {model}"
|
||||||
return True, f"Connected; discovered {len(models)} local model(s)."
|
return True, f"Connected; discovered {len(models)} local model(s)."
|
||||||
|
|
||||||
async def _request(self, method: str, path: str, **kwargs) -> dict:
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
timeout=self.timeout_seconds, transport=self.transport
|
|
||||||
) as client:
|
|
||||||
response = await client.request(method, f"{self.base_url}{path}", **kwargs)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
except httpx.TimeoutException as exc:
|
|
||||||
raise ProviderError("PROVIDER_TIMEOUT", "Ollama request timed out.") from exc
|
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
raise ProviderError(
|
|
||||||
"MODEL_NOT_FOUND" if exc.response.status_code == 404 else "PROVIDER_UNAVAILABLE",
|
|
||||||
f"Ollama returned HTTP {exc.response.status_code}.",
|
|
||||||
) from exc
|
|
||||||
except (httpx.HTTPError, ValueError) as exc:
|
|
||||||
raise ProviderError("PROVIDER_UNAVAILABLE", "Ollama is unavailable.") from exc
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise ProviderError("PROVIDER_INVALID_RESPONSE", "Ollama returned non-object JSON.")
|
|
||||||
return data
|
|
||||||
|
|||||||
@@ -1,24 +1,20 @@
|
|||||||
import json
|
import json
|
||||||
from collections.abc import AsyncIterator
|
from contextlib import aclosing
|
||||||
from datetime import datetime, timezone
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.contracts import (
|
from app.contracts import MessageRole, ModelCapability, ModelEventType, ModelInfo, ModelRequest
|
||||||
MessageRole,
|
|
||||||
ModelCapability,
|
|
||||||
ModelEvent,
|
|
||||||
ModelEventType,
|
|
||||||
ModelInfo,
|
|
||||||
ModelRequest,
|
|
||||||
)
|
|
||||||
from app.providers.base import ProviderError, ProviderToolCall, ProviderTurn
|
from app.providers.base import ProviderError, ProviderToolCall, ProviderTurn
|
||||||
from app.providers.credentials import CredentialResolver, CredentialStoreError
|
from app.providers.credentials import CredentialResolver, CredentialStoreError
|
||||||
from app.providers.http_base import TurnStreamingMixin, decode_tool_arguments
|
from app.providers.tool_names import mapped_tool_names
|
||||||
|
from app.providers.http_base import (
|
||||||
|
EventStreamingMixin, HTTPProviderMixin, UsageTracker, decode_tool_arguments,
|
||||||
|
invalid_response, list_value, object_value, string_value, token_count, truncated_stream,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class OpenAICompatibleProvider(TurnStreamingMixin):
|
class OpenAICompatibleProvider(EventStreamingMixin, HTTPProviderMixin):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
base_url: str,
|
base_url: str,
|
||||||
@@ -33,50 +29,37 @@ class OpenAICompatibleProvider(TurnStreamingMixin):
|
|||||||
self.timeout_seconds = timeout_seconds
|
self.timeout_seconds = timeout_seconds
|
||||||
self.transport = transport
|
self.transport = transport
|
||||||
|
|
||||||
|
@mapped_tool_names
|
||||||
async def complete(self, request: ModelRequest) -> ProviderTurn:
|
async def complete(self, request: ModelRequest) -> ProviderTurn:
|
||||||
payload = self._payload(request, stream=False)
|
data = await self._request("POST", self.stream_path, json=self._payload(request, stream=False))
|
||||||
|
choices = list_value(data.get("choices"))
|
||||||
data = await self._request("POST", "/chat/completions", json=payload)
|
if not choices:
|
||||||
try:
|
raise invalid_response()
|
||||||
message = data["choices"][0]["message"]
|
message = object_value(object_value(choices[0]).get("message"))
|
||||||
except (KeyError, IndexError, TypeError) as exc:
|
calls = []
|
||||||
raise ProviderError("PROVIDER_INVALID_RESPONSE", "Missing completion message.") from exc
|
for raw in list_value(message.get("tool_calls", [])):
|
||||||
|
raw = object_value(raw)
|
||||||
tool_calls = []
|
function = object_value(raw.get("function"))
|
||||||
for raw_call in message.get("tool_calls") or []:
|
calls.append(ProviderToolCall(
|
||||||
function = raw_call.get("function") or {}
|
tool_call_id=string_value(raw.get("id") or f"call_{uuid4().hex}"),
|
||||||
tool_calls.append(
|
name=string_value(function.get("name"), nonempty=True),
|
||||||
ProviderToolCall(
|
|
||||||
tool_call_id=raw_call.get("id") or f"call_{uuid4().hex}",
|
|
||||||
name=function.get("name") or "",
|
|
||||||
arguments=decode_tool_arguments(function.get("arguments", "{}")),
|
arguments=decode_tool_arguments(function.get("arguments", "{}")),
|
||||||
)
|
))
|
||||||
)
|
text = message.get("content")
|
||||||
usage = data.get("usage") or {}
|
if text is not None:
|
||||||
return ProviderTurn(
|
text = string_value(text)
|
||||||
text=message.get("content"),
|
usage = UsageTracker("prompt_tokens", "completion_tokens").update(data.get("usage") or {})
|
||||||
tool_calls=tool_calls,
|
return ProviderTurn(text=text, tool_calls=calls, **usage)
|
||||||
input_tokens=int(usage.get("prompt_tokens") or 0),
|
|
||||||
output_tokens=int(usage.get("completion_tokens") or 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _payload(self, request: ModelRequest, *, stream: bool) -> dict[str, object]:
|
def _payload(self, request: ModelRequest, *, stream: bool) -> dict[str, object]:
|
||||||
payload: dict[str, object] = {
|
payload: dict[str, object] = {
|
||||||
"model": request.model,
|
"model": request.model, "messages": self._messages(request), "stream": stream,
|
||||||
"messages": self._messages(request),
|
|
||||||
"stream": stream,
|
|
||||||
}
|
}
|
||||||
if request.tools:
|
if request.tools:
|
||||||
payload["tools"] = [
|
payload["tools"] = [
|
||||||
{
|
{"type": "function", "function": {
|
||||||
"type": "function",
|
"name": tool.name, "description": tool.description, "parameters": tool.parameters,
|
||||||
"function": {
|
}} for tool in request.tools
|
||||||
"name": tool.name,
|
|
||||||
"description": tool.description,
|
|
||||||
"parameters": tool.parameters,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for tool in request.tools
|
|
||||||
]
|
]
|
||||||
if request.temperature is not None:
|
if request.temperature is not None:
|
||||||
payload["temperature"] = request.temperature
|
payload["temperature"] = request.temperature
|
||||||
@@ -84,124 +67,78 @@ class OpenAICompatibleProvider(TurnStreamingMixin):
|
|||||||
payload["max_tokens"] = request.max_tokens
|
payload["max_tokens"] = request.max_tokens
|
||||||
if request.response_format is not None:
|
if request.response_format is not None:
|
||||||
payload["response_format"] = request.response_format
|
payload["response_format"] = request.response_format
|
||||||
|
if stream:
|
||||||
|
payload["stream_options"] = {"include_usage": True}
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
async def stream(self, request: ModelRequest) -> AsyncIterator[ModelEvent]:
|
async def _events(self, request: ModelRequest):
|
||||||
sequence = 0
|
calls: dict[int, dict] = {}
|
||||||
open_calls: dict[int, str] = {}
|
usage = UsageTracker("prompt_tokens", "completion_tokens")
|
||||||
|
finished = False
|
||||||
def event(kind: ModelEventType, data: dict | None = None) -> ModelEvent:
|
seen = False
|
||||||
nonlocal sequence
|
async with aclosing(self._stream_json(self._payload(request, stream=True))) as chunks:
|
||||||
item = ModelEvent(
|
async for data in chunks:
|
||||||
event=kind,
|
if data.get("type") == "[DONE]":
|
||||||
sequence=sequence,
|
if not seen:
|
||||||
data=data or {},
|
raise invalid_response()
|
||||||
timestamp=datetime.now(timezone.utc),
|
finished = True
|
||||||
)
|
break
|
||||||
sequence += 1
|
if data.get("usage") is not None:
|
||||||
return item
|
yield ModelEventType.usage, usage.update(data["usage"])
|
||||||
|
choices = list_value(data.get("choices", []))
|
||||||
try:
|
|
||||||
async for data in self._stream_json(self._payload(request, stream=True)):
|
|
||||||
usage = data.get("usage") or {}
|
|
||||||
if usage:
|
|
||||||
yield event(
|
|
||||||
ModelEventType.usage,
|
|
||||||
{
|
|
||||||
"input_tokens": int(usage.get("prompt_tokens") or 0),
|
|
||||||
"output_tokens": int(usage.get("completion_tokens") or 0),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
choices = data.get("choices") or []
|
|
||||||
if not choices:
|
if not choices:
|
||||||
continue
|
continue
|
||||||
choice = choices[0]
|
seen = True
|
||||||
delta = choice.get("delta") or {}
|
choice = object_value(choices[0])
|
||||||
|
delta = object_value(choice.get("delta") or {})
|
||||||
if delta.get("reasoning_content"):
|
if delta.get("reasoning_content"):
|
||||||
yield event(
|
yield ModelEventType.thinking_delta, {"text": string_value(delta["reasoning_content"])}
|
||||||
ModelEventType.thinking_delta,
|
|
||||||
{"text": delta["reasoning_content"]},
|
|
||||||
)
|
|
||||||
if delta.get("content"):
|
if delta.get("content"):
|
||||||
yield event(ModelEventType.text_delta, {"text": delta["content"]})
|
yield ModelEventType.text_delta, {"text": string_value(delta["content"])}
|
||||||
for raw_call in delta.get("tool_calls") or []:
|
for raw in list_value(delta.get("tool_calls", [])):
|
||||||
index = int(raw_call.get("index") or 0)
|
raw = object_value(raw)
|
||||||
function = raw_call.get("function") or {}
|
index = token_count(raw.get("index", 0))
|
||||||
call_id = raw_call.get("id") or open_calls.get(index) or f"call_{uuid4().hex}"
|
function = object_value(raw.get("function") or {})
|
||||||
if index not in open_calls:
|
call = calls.setdefault(index, {"id": "", "name": "", "arguments": ""})
|
||||||
open_calls[index] = call_id
|
if raw.get("id"):
|
||||||
yield event(
|
call["id"] = string_value(raw["id"])
|
||||||
ModelEventType.tool_call_start,
|
if function.get("name"):
|
||||||
{"tool_call_id": call_id, "name": function.get("name") or ""},
|
call["name"] += string_value(function["name"])
|
||||||
)
|
fragment = string_value(function.get("arguments", ""))
|
||||||
if function.get("arguments"):
|
call["arguments"] += fragment
|
||||||
yield event(
|
if choice.get("finish_reason"):
|
||||||
ModelEventType.tool_call_delta,
|
finished = True
|
||||||
{
|
if not finished:
|
||||||
"tool_call_id": open_calls[index],
|
raise truncated_stream()
|
||||||
"arguments_delta": function["arguments"],
|
for call in calls.values():
|
||||||
},
|
if not call["name"]:
|
||||||
)
|
raise invalid_response()
|
||||||
if choice.get("finish_reason") == "tool_calls":
|
decode_tool_arguments(call["arguments"] or "{}")
|
||||||
for call_id in open_calls.values():
|
# A name can span multiple chunks; publish only the complete identity.
|
||||||
yield event(
|
call["id"] = call["id"] or f"call_{uuid4().hex}"
|
||||||
ModelEventType.tool_call_end, {"tool_call_id": call_id}
|
yield ModelEventType.tool_call_start, {"tool_call_id": call["id"], "name": call["name"]}
|
||||||
)
|
yield ModelEventType.tool_call_delta, {"tool_call_id": call["id"], "arguments_delta": call["arguments"] or "{}"}
|
||||||
open_calls.clear()
|
yield ModelEventType.tool_call_end, {"tool_call_id": call["id"]}
|
||||||
for call_id in open_calls.values():
|
|
||||||
yield event(ModelEventType.tool_call_end, {"tool_call_id": call_id})
|
|
||||||
yield event(ModelEventType.done)
|
|
||||||
except ProviderError as exc:
|
|
||||||
yield event(ModelEventType.error, {"code": exc.code, "message": exc.message})
|
|
||||||
yield event(ModelEventType.done)
|
|
||||||
|
|
||||||
async def _stream_json(self, payload: dict[str, object]) -> AsyncIterator[dict]:
|
|
||||||
headers = self._headers()
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
timeout=self.timeout_seconds, transport=self.transport
|
|
||||||
) as client:
|
|
||||||
async with client.stream(
|
|
||||||
"POST", f"{self.base_url}/chat/completions", headers=headers, json=payload
|
|
||||||
) as response:
|
|
||||||
response.raise_for_status()
|
|
||||||
async for line in response.aiter_lines():
|
|
||||||
if not line.startswith("data:"):
|
|
||||||
continue
|
|
||||||
value = line[5:].strip()
|
|
||||||
if not value or value == "[DONE]":
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
data = json.loads(value)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise ProviderError(
|
|
||||||
"PROVIDER_INVALID_RESPONSE", "Provider returned invalid SSE JSON."
|
|
||||||
) from exc
|
|
||||||
if isinstance(data, dict):
|
|
||||||
yield data
|
|
||||||
except httpx.TimeoutException as exc:
|
|
||||||
raise ProviderError("PROVIDER_TIMEOUT", "Provider request timed out.") from exc
|
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
raise self._status_error(exc) from exc
|
|
||||||
except httpx.HTTPError as exc:
|
|
||||||
raise ProviderError("PROVIDER_UNAVAILABLE", "Provider is unavailable.") from exc
|
|
||||||
|
|
||||||
async def list_models(self) -> list[ModelInfo]:
|
async def list_models(self) -> list[ModelInfo]:
|
||||||
data = await self._request("GET", "/models")
|
data = await self._request("GET", "/models")
|
||||||
return [
|
return [ModelInfo(model=string_value(item["id"]), display_name=item["id"],
|
||||||
ModelInfo(
|
capabilities=self._model_capabilities(string_value(item["id"])))
|
||||||
model=item["id"],
|
for item in list_value(data.get("data"))
|
||||||
display_name=item["id"],
|
if isinstance(item, dict) and item.get("id")]
|
||||||
capabilities=[
|
|
||||||
ModelCapability.chat,
|
@staticmethod
|
||||||
ModelCapability.tool_calling,
|
def _model_capabilities(model: str) -> list[ModelCapability]:
|
||||||
ModelCapability.streaming,
|
# /models does not advertise capabilities. Avoid known non-chat families;
|
||||||
],
|
# these are discovery hints, not a guarantee of support by a gateway.
|
||||||
)
|
name = model.lower()
|
||||||
for item in data.get("data", [])
|
if "embed" in name or name.startswith(("bge-", "bge/")):
|
||||||
if isinstance(item, dict) and item.get("id")
|
return [ModelCapability.embedding]
|
||||||
]
|
if any(marker in name for marker in (
|
||||||
|
"whisper", "tts", "transcri", "audio", "realtime", "dall-e", "image", "moderation", "rerank",
|
||||||
|
)):
|
||||||
|
return []
|
||||||
|
return [ModelCapability.chat]
|
||||||
|
|
||||||
async def test_connection(self, model: str | None = None) -> tuple[bool, str]:
|
async def test_connection(self, model: str | None = None) -> tuple[bool, str]:
|
||||||
try:
|
try:
|
||||||
@@ -217,73 +154,30 @@ class OpenAICompatibleProvider(TurnStreamingMixin):
|
|||||||
if request.system:
|
if request.system:
|
||||||
result.append({"role": "system", "content": request.system})
|
result.append({"role": "system", "content": request.system})
|
||||||
for message in request.messages:
|
for message in request.messages:
|
||||||
item: dict[str, object] = {
|
item: dict[str, object] = {"role": message.role.value, "content": message.content}
|
||||||
"role": message.role.value,
|
|
||||||
"content": message.content,
|
|
||||||
}
|
|
||||||
if message.name:
|
if message.name:
|
||||||
item["name"] = message.name
|
item["name"] = message.name
|
||||||
if message.role == MessageRole.tool and message.tool_call_id:
|
if message.role == MessageRole.tool and message.tool_call_id:
|
||||||
item["tool_call_id"] = message.tool_call_id
|
item["tool_call_id"] = message.tool_call_id
|
||||||
if message.tool_calls:
|
if message.tool_calls:
|
||||||
item["tool_calls"] = [
|
item["tool_calls"] = [
|
||||||
{
|
{"id": call.tool_call_id, "type": "function", "function": {
|
||||||
"id": call.tool_call_id,
|
"name": call.name, "arguments": json.dumps(call.arguments),
|
||||||
"type": "function",
|
}} for call in message.tool_calls
|
||||||
"function": {
|
|
||||||
"name": call.name,
|
|
||||||
"arguments": json.dumps(call.arguments),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for call in message.tool_calls
|
|
||||||
]
|
]
|
||||||
result.append(item)
|
result.append(item)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def _request(self, method: str, path: str, **kwargs) -> dict:
|
|
||||||
headers = self._headers()
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
timeout=self.timeout_seconds, transport=self.transport
|
|
||||||
) as client:
|
|
||||||
response = await client.request(
|
|
||||||
method, f"{self.base_url}{path}", headers=headers, **kwargs
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
except httpx.TimeoutException as exc:
|
|
||||||
raise ProviderError("PROVIDER_TIMEOUT", "Provider request timed out.") from exc
|
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
raise self._status_error(exc) from exc
|
|
||||||
except (httpx.HTTPError, ValueError) as exc:
|
|
||||||
raise ProviderError("PROVIDER_UNAVAILABLE", "Provider is unavailable.") from exc
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise ProviderError("PROVIDER_INVALID_RESPONSE", "Provider returned non-object JSON.")
|
|
||||||
return data
|
|
||||||
|
|
||||||
def _headers(self) -> dict[str, str]:
|
def _headers(self) -> dict[str, str]:
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
try:
|
try:
|
||||||
api_key = self.credentials.resolve(self.credential_id)
|
api_key = self.credentials.resolve(self.credential_id)
|
||||||
except CredentialStoreError as exc:
|
except CredentialStoreError as exc:
|
||||||
raise ProviderError(
|
raise ProviderError("PROVIDER_CREDENTIAL_UNAVAILABLE",
|
||||||
"PROVIDER_CREDENTIAL_UNAVAILABLE",
|
"Credential could not be decrypted by the AI Core.") from exc
|
||||||
"Credential could not be decrypted by the AI Core.",
|
|
||||||
) from exc
|
|
||||||
if self.credential_id and not api_key:
|
if self.credential_id and not api_key:
|
||||||
raise ProviderError(
|
raise ProviderError("PROVIDER_CREDENTIAL_MISSING",
|
||||||
"PROVIDER_CREDENTIAL_MISSING",
|
"Credential is not available in the AI Core process.")
|
||||||
f'Credential "{self.credential_id}" is not available in the AI Core process.',
|
|
||||||
)
|
|
||||||
if api_key:
|
if api_key:
|
||||||
headers["Authorization"] = f"Bearer {api_key}"
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _status_error(exc: httpx.HTTPStatusError) -> ProviderError:
|
|
||||||
code = {
|
|
||||||
401: "PROVIDER_AUTH_FAILED",
|
|
||||||
404: "MODEL_NOT_FOUND",
|
|
||||||
429: "PROVIDER_RATE_LIMITED",
|
|
||||||
}.get(exc.response.status_code, "PROVIDER_UNAVAILABLE")
|
|
||||||
return ProviderError(code, f"Provider returned HTTP {exc.response.status_code}.")
|
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""Native /responses adapter; stateless history uses function_call/output items."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from contextlib import aclosing
|
||||||
|
|
||||||
|
from app.contracts import MessageRole, ModelEventType, ModelRequest
|
||||||
|
from app.providers.base import ProviderError, ProviderToolCall, ProviderTurn
|
||||||
|
from app.providers.http_base import (
|
||||||
|
UsageTracker, check_error, decode_tool_arguments, invalid_response, list_value,
|
||||||
|
object_value, remote_error, string_value, token_count, truncated_stream,
|
||||||
|
)
|
||||||
|
from app.providers.openai_compatible import OpenAICompatibleProvider
|
||||||
|
from app.providers.tool_names import mapped_tool_names
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAIResponsesProvider(OpenAICompatibleProvider):
|
||||||
|
stream_path = "/responses"
|
||||||
|
|
||||||
|
def _payload(self, request: ModelRequest, *, stream: bool) -> dict[str, object]:
|
||||||
|
inputs = []
|
||||||
|
for message in request.messages:
|
||||||
|
if message.role == MessageRole.tool:
|
||||||
|
if not message.tool_call_id:
|
||||||
|
raise ProviderError("PROVIDER_INVALID_REQUEST", "Tool result requires a call identifier.")
|
||||||
|
inputs.append({"type": "function_call_output", "call_id": message.tool_call_id,
|
||||||
|
"output": message.content})
|
||||||
|
continue
|
||||||
|
if message.content or not message.tool_calls:
|
||||||
|
inputs.append({"role": message.role.value, "content": message.content})
|
||||||
|
for call in message.tool_calls:
|
||||||
|
inputs.append({"type": "function_call", "call_id": call.tool_call_id,
|
||||||
|
"name": call.name, "arguments": json.dumps(call.arguments)})
|
||||||
|
payload: dict[str, object] = {"model": request.model, "input": inputs, "stream": stream}
|
||||||
|
if request.system:
|
||||||
|
payload["instructions"] = request.system
|
||||||
|
if request.tools:
|
||||||
|
payload["tools"] = [{"type": "function", "name": tool.name,
|
||||||
|
"description": tool.description, "parameters": tool.parameters}
|
||||||
|
for tool in request.tools]
|
||||||
|
if request.temperature is not None:
|
||||||
|
payload["temperature"] = request.temperature
|
||||||
|
if request.max_tokens is not None:
|
||||||
|
payload["max_output_tokens"] = request.max_tokens
|
||||||
|
if request.response_format is not None:
|
||||||
|
format_ = dict(request.response_format)
|
||||||
|
if format_.get("type") == "json_schema":
|
||||||
|
format_ = {"type": "json_schema", **object_value(format_.get("json_schema"))}
|
||||||
|
payload["text"] = {"format": format_}
|
||||||
|
return payload
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _check_response(data: dict) -> None:
|
||||||
|
check_error(data)
|
||||||
|
status = data.get("status")
|
||||||
|
if status == "incomplete":
|
||||||
|
raise ProviderError("PROVIDER_INCOMPLETE_RESPONSE", "Provider response is incomplete.")
|
||||||
|
if status == "failed":
|
||||||
|
raise remote_error(data.get("error"))
|
||||||
|
if status is not None and status != "completed":
|
||||||
|
raise invalid_response()
|
||||||
|
|
||||||
|
@mapped_tool_names
|
||||||
|
async def complete(self, request: ModelRequest) -> ProviderTurn:
|
||||||
|
data = await self._request("POST", self.stream_path, json=self._payload(request, stream=False))
|
||||||
|
self._check_response(data)
|
||||||
|
texts = []
|
||||||
|
calls = []
|
||||||
|
for raw in list_value(data.get("output")):
|
||||||
|
item = object_value(raw)
|
||||||
|
if item.get("type") == "message":
|
||||||
|
for raw_part in list_value(item.get("content")):
|
||||||
|
part = object_value(raw_part)
|
||||||
|
if part.get("type") == "output_text":
|
||||||
|
texts.append(string_value(part.get("text")))
|
||||||
|
elif part.get("type") == "refusal":
|
||||||
|
texts.append(string_value(part.get("refusal")))
|
||||||
|
elif item.get("type") == "function_call":
|
||||||
|
calls.append(ProviderToolCall(
|
||||||
|
tool_call_id=string_value(item.get("call_id"), nonempty=True),
|
||||||
|
name=string_value(item.get("name"), nonempty=True),
|
||||||
|
arguments=decode_tool_arguments(item.get("arguments")),
|
||||||
|
))
|
||||||
|
return ProviderTurn(text="".join(texts) or None, tool_calls=calls,
|
||||||
|
**UsageTracker().update(data.get("usage") or {}))
|
||||||
|
|
||||||
|
async def _events(self, request: ModelRequest):
|
||||||
|
calls: dict[int, dict] = {}
|
||||||
|
usage = UsageTracker()
|
||||||
|
|
||||||
|
def finish_call(index: int, final: object = None):
|
||||||
|
call = calls[index]
|
||||||
|
if call["ended"]:
|
||||||
|
return []
|
||||||
|
events = []
|
||||||
|
if final is not None:
|
||||||
|
arguments = string_value(final)
|
||||||
|
if not arguments.startswith(call["arguments"]):
|
||||||
|
raise invalid_response()
|
||||||
|
remainder = arguments[len(call["arguments"]):]
|
||||||
|
if remainder:
|
||||||
|
events.append((ModelEventType.tool_call_delta,
|
||||||
|
{"tool_call_id": call["id"], "arguments_delta": remainder}))
|
||||||
|
call["arguments"] = arguments
|
||||||
|
decode_tool_arguments(call["arguments"])
|
||||||
|
call["ended"] = True
|
||||||
|
events.append((ModelEventType.tool_call_end, {"tool_call_id": call["id"]}))
|
||||||
|
return events
|
||||||
|
|
||||||
|
async with aclosing(self._stream_json(self._payload(request, stream=True))) as chunks:
|
||||||
|
async for data in chunks:
|
||||||
|
kind = string_value(data.get("type"), nonempty=True)
|
||||||
|
if kind in {"response.failed", "response.incomplete"}:
|
||||||
|
response = object_value(data.get("response"))
|
||||||
|
self._check_response({**response, "status": kind.split(".")[1]})
|
||||||
|
elif kind in {"response.output_text.delta", "response.refusal.delta"}:
|
||||||
|
yield ModelEventType.text_delta, {"text": string_value(data.get("delta"))}
|
||||||
|
elif kind in {"response.reasoning_summary_text.delta", "response.reasoning_text.delta"}:
|
||||||
|
yield ModelEventType.thinking_delta, {"text": string_value(data.get("delta"))}
|
||||||
|
elif kind in {"response.output_item.added", "response.output_item.done"}:
|
||||||
|
item = object_value(data.get("item"))
|
||||||
|
if item.get("type") != "function_call":
|
||||||
|
continue
|
||||||
|
index = token_count(data.get("output_index"))
|
||||||
|
call_id = string_value(item.get("call_id"), nonempty=True)
|
||||||
|
name = string_value(item.get("name"), nonempty=True)
|
||||||
|
if index not in calls:
|
||||||
|
calls[index] = {"id": call_id, "name": name, "arguments": "", "ended": False,
|
||||||
|
"item_id": item.get("id")}
|
||||||
|
yield ModelEventType.tool_call_start, {"tool_call_id": call_id, "name": name}
|
||||||
|
elif calls[index]["id"] != call_id or calls[index]["name"] != name:
|
||||||
|
raise invalid_response()
|
||||||
|
if kind == "response.output_item.done":
|
||||||
|
for event in finish_call(index, item.get("arguments")):
|
||||||
|
yield event
|
||||||
|
elif item.get("arguments"):
|
||||||
|
arguments = string_value(item["arguments"])
|
||||||
|
calls[index]["arguments"] += arguments
|
||||||
|
yield ModelEventType.tool_call_delta, {"tool_call_id": call_id, "arguments_delta": arguments}
|
||||||
|
elif kind in {"response.function_call_arguments.delta", "response.function_call_arguments.done"}:
|
||||||
|
index = token_count(data.get("output_index"))
|
||||||
|
call = calls.get(index)
|
||||||
|
if call is None or (data.get("item_id") and call["item_id"] != data["item_id"]):
|
||||||
|
raise invalid_response()
|
||||||
|
if kind.endswith(".done"):
|
||||||
|
for event in finish_call(index, data.get("arguments")):
|
||||||
|
yield event
|
||||||
|
else:
|
||||||
|
if call["ended"]:
|
||||||
|
raise invalid_response()
|
||||||
|
fragment = string_value(data.get("delta"))
|
||||||
|
call["arguments"] += fragment
|
||||||
|
yield ModelEventType.tool_call_delta, {"tool_call_id": call["id"], "arguments_delta": fragment}
|
||||||
|
elif kind == "response.completed":
|
||||||
|
response = object_value(data.get("response"))
|
||||||
|
self._check_response(response)
|
||||||
|
if any(not call["ended"] for call in calls.values()):
|
||||||
|
raise truncated_stream()
|
||||||
|
if response.get("usage") is not None:
|
||||||
|
yield ModelEventType.usage, usage.update(response["usage"])
|
||||||
|
return
|
||||||
|
elif kind == "[DONE]":
|
||||||
|
raise truncated_stream()
|
||||||
|
elif kind in {"response.created", "response.in_progress"}:
|
||||||
|
response = object_value(data.get("response"))
|
||||||
|
check_error(response)
|
||||||
|
if response.get("usage") is not None:
|
||||||
|
yield ModelEventType.usage, usage.update(response["usage"])
|
||||||
|
raise truncated_stream()
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.database.db import connect
|
||||||
|
from app.errors import ApiError
|
||||||
|
|
||||||
from app.contracts import ModelInfo, ProviderConfig, ProviderTestResponse
|
from app.contracts import ModelInfo, ProviderConfig, ProviderTestResponse
|
||||||
from app.providers.base import ModelProvider
|
from app.providers.base import ModelProvider
|
||||||
@@ -16,20 +21,64 @@ class RegisteredProvider:
|
|||||||
|
|
||||||
|
|
||||||
class ProviderRegistry:
|
class ProviderRegistry:
|
||||||
def __init__(self) -> None:
|
def __init__(self, factory=None) -> None:
|
||||||
self._providers: dict[str, RegisteredProvider] = {}
|
self._providers: dict[str, RegisteredProvider] = {}
|
||||||
|
self._factory = factory
|
||||||
|
self._loaded_path: Path | None = None
|
||||||
|
|
||||||
|
def _restore(self) -> None:
|
||||||
|
if self._factory is None or self._loaded_path == get_settings().db_path:
|
||||||
|
return
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
conn.execute("CREATE TABLE IF NOT EXISTS provider_configs (provider_id TEXT PRIMARY KEY, config_json TEXT NOT NULL)")
|
||||||
|
restored = {}
|
||||||
|
for row in conn.execute("SELECT config_json FROM provider_configs"):
|
||||||
|
config = ProviderConfig.model_validate_json(row["config_json"])
|
||||||
|
if config.provider_id == "mock":
|
||||||
|
raise ValueError("reserved provider")
|
||||||
|
restored[config.provider_id] = RegisteredProvider(config, self._factory.build(config))
|
||||||
|
if "mock" in self._providers:
|
||||||
|
restored["mock"] = self._providers["mock"]
|
||||||
|
self._providers = restored
|
||||||
|
self._loaded_path = get_settings().db_path
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise ApiError(500, "PROVIDER_STORAGE_INVALID", "Saved provider configuration could not be loaded.") from exc
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _save(self, config: ProviderConfig) -> None:
|
||||||
|
if self._factory is None or config.provider_id == "mock":
|
||||||
|
return
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
conn.execute("INSERT OR REPLACE INTO provider_configs VALUES (?, ?)", (config.provider_id, config.model_dump_json()))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def register(self, config: ProviderConfig, adapter: ModelProvider) -> None:
|
def register(self, config: ProviderConfig, adapter: ModelProvider) -> None:
|
||||||
|
if config.provider_id != "mock":
|
||||||
|
self._restore()
|
||||||
if config.provider_id in self._providers:
|
if config.provider_id in self._providers:
|
||||||
raise ValueError(f"Provider already registered: {config.provider_id}")
|
raise ValueError(f"Provider already registered: {config.provider_id}")
|
||||||
|
self._save(config)
|
||||||
self._providers[config.provider_id] = RegisteredProvider(config=config, adapter=adapter)
|
self._providers[config.provider_id] = RegisteredProvider(config=config, adapter=adapter)
|
||||||
|
|
||||||
def unregister(self, provider_id: str) -> None:
|
def unregister(self, provider_id: str) -> None:
|
||||||
|
self._restore()
|
||||||
|
if self._factory is not None:
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
conn.execute("DELETE FROM provider_configs WHERE provider_id = ?", (provider_id,))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
self._providers.pop(provider_id, None)
|
self._providers.pop(provider_id, None)
|
||||||
|
|
||||||
def replace(self, config: ProviderConfig, adapter: ModelProvider) -> None:
|
def replace(self, config: ProviderConfig, adapter: ModelProvider) -> None:
|
||||||
|
self._restore()
|
||||||
if config.provider_id not in self._providers:
|
if config.provider_id not in self._providers:
|
||||||
raise ProviderNotFoundError(config.provider_id)
|
raise ProviderNotFoundError(config.provider_id)
|
||||||
|
self._save(config)
|
||||||
self._providers[config.provider_id] = RegisteredProvider(config=config, adapter=adapter)
|
self._providers[config.provider_id] = RegisteredProvider(config=config, adapter=adapter)
|
||||||
|
|
||||||
def get(self, provider_id: str) -> RegisteredProvider:
|
def get(self, provider_id: str) -> RegisteredProvider:
|
||||||
@@ -39,12 +88,14 @@ class ProviderRegistry:
|
|||||||
return provider
|
return provider
|
||||||
|
|
||||||
def get_any(self, provider_id: str) -> RegisteredProvider:
|
def get_any(self, provider_id: str) -> RegisteredProvider:
|
||||||
|
self._restore()
|
||||||
try:
|
try:
|
||||||
return self._providers[provider_id]
|
return self._providers[provider_id]
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise ProviderNotFoundError(provider_id) from exc
|
raise ProviderNotFoundError(provider_id) from exc
|
||||||
|
|
||||||
def list_configs(self) -> list[ProviderConfig]:
|
def list_configs(self) -> list[ProviderConfig]:
|
||||||
|
self._restore()
|
||||||
return [item.config.model_copy(deep=True) for item in self._providers.values()]
|
return [item.config.model_copy(deep=True) for item in self._providers.values()]
|
||||||
|
|
||||||
async def list_models(self, provider_id: str) -> list[ModelInfo]:
|
async def list_models(self, provider_id: str) -> list[ModelInfo]:
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
"""Capability routing: validated remote results, then an explicit local backend.
|
||||||
|
|
||||||
|
Phase E supplies HTTP adapters and injectable local contracts. Hash embeddings are
|
||||||
|
still a development placeholder; speech models are installed in phase F.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.contracts import (
|
||||||
|
EmbeddingResult, LocalBackendStatus, ModelBinding, ModelRoutingConfig,
|
||||||
|
ModelRoutingResponse, ProviderType, SpeakerMatchResult,
|
||||||
|
)
|
||||||
|
from app.database.db import connect, transaction
|
||||||
|
from app.errors import ApiError
|
||||||
|
from app.providers.base import ProviderError
|
||||||
|
from app.providers.credentials import CredentialResolver, CredentialStoreError
|
||||||
|
from app.providers.registry import ProviderNotFoundError, ProviderRegistry
|
||||||
|
from app.retrieval.embedding import EmbeddingProvider, HashEmbeddingProvider
|
||||||
|
from app.retrieval.provenance import record_embedding
|
||||||
|
|
||||||
|
CAPABILITIES = ("embedding", "transcription", "speaker_matching")
|
||||||
|
HTTP_TYPES = {ProviderType.openai_chat, ProviderType.openai_compatible}
|
||||||
|
MAX_MEDIA_BYTES = 25 * 1024 * 1024
|
||||||
|
MAX_RESPONSE_BYTES = 16 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class LocalSpeechBackend(Protocol):
|
||||||
|
available: bool
|
||||||
|
|
||||||
|
async def transcribe(self, source: Path, language: str | None) -> str: ...
|
||||||
|
|
||||||
|
async def match(self, source: Path, reference: Path) -> float: ...
|
||||||
|
|
||||||
|
|
||||||
|
class PendingSpeechBackend:
|
||||||
|
available = False
|
||||||
|
|
||||||
|
async def transcribe(self, source: Path, language: str | None) -> str:
|
||||||
|
raise ProviderError("LOCAL_MODEL_NOT_INSTALLED", "本地音频转写模型尚未安装,将在阶段 F 接入。")
|
||||||
|
|
||||||
|
async def match(self, source: Path, reference: Path) -> float:
|
||||||
|
raise ProviderError("LOCAL_MODEL_NOT_INSTALLED", "本地声纹模型尚未安装,将在阶段 F 接入。")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RoutedTranscript:
|
||||||
|
text: str
|
||||||
|
source: str
|
||||||
|
fallback_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def invalid_response() -> ProviderError:
|
||||||
|
return ProviderError("PROVIDER_INVALID_RESPONSE", "Model API returned an invalid result.")
|
||||||
|
|
||||||
|
|
||||||
|
def finite_number(value: object) -> bool:
|
||||||
|
if type(value) not in (int, float):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return math.isfinite(value)
|
||||||
|
except (OverflowError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class ModelRoutingService:
|
||||||
|
def __init__(self, providers: ProviderRegistry, credentials: CredentialResolver, *,
|
||||||
|
local_embedding: EmbeddingProvider | None = None,
|
||||||
|
local_speech: LocalSpeechBackend | None = None,
|
||||||
|
transport: httpx.AsyncBaseTransport | None = None) -> None:
|
||||||
|
self.providers = providers
|
||||||
|
self.credentials = credentials
|
||||||
|
self.local_embedding = local_embedding or HashEmbeddingProvider()
|
||||||
|
self.local_speech = local_speech or PendingSpeechBackend()
|
||||||
|
self.transport = transport
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _connection():
|
||||||
|
conn = connect()
|
||||||
|
conn.execute("CREATE TABLE IF NOT EXISTS model_routing (id INTEGER PRIMARY KEY CHECK(id=1), config_json TEXT NOT NULL)")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def configuration(self) -> ModelRoutingConfig:
|
||||||
|
conn = self._connection()
|
||||||
|
try:
|
||||||
|
row = conn.execute("SELECT config_json FROM model_routing WHERE id=1").fetchone()
|
||||||
|
return ModelRoutingConfig.model_validate_json(row[0]) if row else ModelRoutingConfig()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ApiError(500, "MODEL_ROUTING_STORAGE_INVALID", "Saved model routing could not be loaded.") from exc
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def describe(self) -> ModelRoutingResponse:
|
||||||
|
return ModelRoutingResponse(config=self.configuration(), local_backends=[
|
||||||
|
LocalBackendStatus(capability="embedding", status="placeholder" if isinstance(self.local_embedding, HashEmbeddingProvider) else "ready",
|
||||||
|
message="当前为 hash-v1 确定性占位向量,真实本地语义模型尚未集成。" if isinstance(self.local_embedding, HashEmbeddingProvider) else "本地 Embedding 模型已就绪。"),
|
||||||
|
*[LocalBackendStatus(capability=capability, status="ready" if self.local_speech.available else "not_installed",
|
||||||
|
message="本地模型已就绪。" if self.local_speech.available else "阶段 F 接入本地模型;当前保留回退接口。")
|
||||||
|
for capability in ("transcription", "speaker_matching")],
|
||||||
|
])
|
||||||
|
|
||||||
|
def update(self, config: ModelRoutingConfig) -> ModelRoutingResponse:
|
||||||
|
for capability in CAPABILITIES:
|
||||||
|
binding = getattr(config, capability)
|
||||||
|
if binding:
|
||||||
|
try:
|
||||||
|
provider = self.providers.get_any(binding.provider_id).config
|
||||||
|
except ProviderNotFoundError as exc:
|
||||||
|
raise ApiError(422, "PROVIDER_NOT_FOUND", "请选择已保存的提供商。") from exc
|
||||||
|
if provider.provider_type not in HTTP_TYPES:
|
||||||
|
raise ApiError(422, "MODEL_ROUTING_PROTOCOL_UNSUPPORTED", "该能力当前需要 OpenAI Compatible HTTP 接口。")
|
||||||
|
conn = self._connection()
|
||||||
|
try:
|
||||||
|
with transaction(conn):
|
||||||
|
row = conn.execute("SELECT config_json FROM model_routing WHERE id=1").fetchone()
|
||||||
|
current = ModelRoutingConfig.model_validate_json(row[0]) if row else ModelRoutingConfig()
|
||||||
|
if current.version != config.version:
|
||||||
|
raise ApiError(409, "MODEL_ROUTING_VERSION_CONFLICT", "配置已更新,请重新加载后再保存。")
|
||||||
|
saved = config.model_copy(update={"version": config.version + 1})
|
||||||
|
conn.execute("INSERT OR REPLACE INTO model_routing VALUES (1, ?)", (saved.model_dump_json(),))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return self.describe()
|
||||||
|
|
||||||
|
def uses_provider(self, provider_id: str) -> bool:
|
||||||
|
config = self.configuration()
|
||||||
|
return any(binding and binding.provider_id == provider_id for binding in
|
||||||
|
(getattr(config, name) for name in CAPABILITIES))
|
||||||
|
|
||||||
|
def _remote(self, binding: ModelBinding) -> tuple[str, dict[str, str]]:
|
||||||
|
try:
|
||||||
|
provider = self.providers.get(binding.provider_id).config
|
||||||
|
except ProviderNotFoundError as exc:
|
||||||
|
raise ProviderError("PROVIDER_UNAVAILABLE", "Configured provider is unavailable.") from exc
|
||||||
|
if provider.provider_type not in HTTP_TYPES:
|
||||||
|
raise ProviderError("PROVIDER_CAPABILITY_UNSUPPORTED", "Provider does not support this HTTP capability.")
|
||||||
|
try:
|
||||||
|
key = self.credentials.resolve(provider.credential_id)
|
||||||
|
except CredentialStoreError as exc:
|
||||||
|
raise ProviderError("PROVIDER_CREDENTIAL_UNAVAILABLE", "Provider credential is unavailable.") from exc
|
||||||
|
if provider.credential_id and not key:
|
||||||
|
raise ProviderError("PROVIDER_CREDENTIAL_MISSING", "Provider credential is not configured.")
|
||||||
|
url = (provider.base_url or "https://api.openai.com/v1").rstrip("/") + binding.endpoint
|
||||||
|
return url, {"Authorization": f"Bearer {key}"} if key else {}
|
||||||
|
|
||||||
|
async def _request(self, binding: ModelBinding, *, remote: tuple[str, dict[str, str]] | None = None, **kwargs) -> tuple[dict, str]:
|
||||||
|
url, headers = remote or self._remote(binding)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30, transport=self.transport) as client:
|
||||||
|
async with client.stream("POST", url, headers=headers, **kwargs) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
body = bytearray()
|
||||||
|
async for chunk in response.aiter_bytes():
|
||||||
|
body.extend(chunk)
|
||||||
|
if len(body) > MAX_RESPONSE_BYTES:
|
||||||
|
raise invalid_response()
|
||||||
|
data = json.loads(body)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise ProviderError("PROVIDER_TIMEOUT", "Model API timed out.") from exc
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
code = {401: "PROVIDER_AUTH_FAILED", 403: "PROVIDER_AUTH_FAILED", 404: "MODEL_NOT_FOUND", 429: "PROVIDER_RATE_LIMITED"}.get(exc.response.status_code, "PROVIDER_UNAVAILABLE")
|
||||||
|
raise ProviderError(code, f"Model API returned HTTP {exc.response.status_code}.") from exc
|
||||||
|
except (httpx.HTTPError, httpx.InvalidURL) as exc:
|
||||||
|
raise ProviderError("PROVIDER_UNAVAILABLE", "Model API is unavailable.") from exc
|
||||||
|
except (ValueError, UnicodeError) as exc:
|
||||||
|
raise invalid_response() from exc
|
||||||
|
if not isinstance(data, dict) or data.get("error"):
|
||||||
|
raise invalid_response()
|
||||||
|
return data, url
|
||||||
|
|
||||||
|
async def embed(self, texts: list[str]) -> EmbeddingResult:
|
||||||
|
config = self.configuration()
|
||||||
|
binding = config.embedding
|
||||||
|
record_embedding(route_version=config.version,
|
||||||
|
requested_route=binding.model_dump() if binding else None)
|
||||||
|
reason = None
|
||||||
|
if binding and texts:
|
||||||
|
try:
|
||||||
|
vectors = []
|
||||||
|
dimension = binding.dimensions
|
||||||
|
# Freeze the origin across batches, even if the user edits the provider.
|
||||||
|
remote = self._remote(binding)
|
||||||
|
for start in range(0, len(texts), 32):
|
||||||
|
batch = texts[start:start + 32]
|
||||||
|
payload = {"model": binding.model, "input": batch, "encoding_format": "float"}
|
||||||
|
if binding.dimensions is not None:
|
||||||
|
payload["dimensions"] = binding.dimensions
|
||||||
|
data, url = await self._request(binding, remote=remote, json=payload)
|
||||||
|
items = data.get("data")
|
||||||
|
if not isinstance(items, list) or len(items) != len(batch):
|
||||||
|
raise invalid_response()
|
||||||
|
indexed = {}
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise invalid_response()
|
||||||
|
index, vector = item.get("index"), item.get("embedding")
|
||||||
|
if type(index) is not int or index in indexed or not 0 <= index < len(batch):
|
||||||
|
raise invalid_response()
|
||||||
|
if not isinstance(vector, list) or not 1 <= len(vector) <= 16384:
|
||||||
|
raise invalid_response()
|
||||||
|
if any(not finite_number(value) for value in vector):
|
||||||
|
raise invalid_response()
|
||||||
|
dimension = dimension or len(vector)
|
||||||
|
norm = math.hypot(*vector)
|
||||||
|
if len(vector) != dimension or not norm or not math.isfinite(norm):
|
||||||
|
raise invalid_response()
|
||||||
|
indexed[index] = [value / norm for value in vector]
|
||||||
|
vectors.extend(indexed[index] for index in range(len(batch)))
|
||||||
|
identity = json.dumps([url, binding.model, dimension], separators=(",", ":"))
|
||||||
|
return EmbeddingResult(vectors=vectors, source="api", dimensions=dimension,
|
||||||
|
model_id="api-" + hashlib.sha256(identity.encode()).hexdigest())
|
||||||
|
except ProviderError as exc:
|
||||||
|
reason = exc.code
|
||||||
|
vectors = await self.local_embedding.embed_documents(texts)
|
||||||
|
return EmbeddingResult(vectors=vectors, source="local", model_id=self.local_embedding.model_id,
|
||||||
|
dimensions=self.local_embedding.dim, fallback_reason=reason)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _media_file(path: Path):
|
||||||
|
try:
|
||||||
|
handle = path.open("rb")
|
||||||
|
except OSError as exc:
|
||||||
|
raise ApiError(404, "ATTACHMENT_NOT_FOUND", "Audio attachment was not found.") from exc
|
||||||
|
import os
|
||||||
|
if not 0 < os.fstat(handle.fileno()).st_size <= MAX_MEDIA_BYTES:
|
||||||
|
handle.close()
|
||||||
|
raise ApiError(413, "ATTACHMENT_TOO_LARGE", "Audio attachment must be between 1 byte and 25 MiB.")
|
||||||
|
return handle
|
||||||
|
|
||||||
|
async def transcribe(self, source: Path, language: str | None) -> RoutedTranscript:
|
||||||
|
binding = self.configuration().transcription
|
||||||
|
if binding is None:
|
||||||
|
with self._media_file(source):
|
||||||
|
pass
|
||||||
|
reason = None
|
||||||
|
if binding:
|
||||||
|
try:
|
||||||
|
fields = {"model": binding.model}
|
||||||
|
if language:
|
||||||
|
fields["language"] = language
|
||||||
|
with self._media_file(source) as handle:
|
||||||
|
data, _ = await self._request(binding, data=fields,
|
||||||
|
files={"file": (source.name, handle, "application/octet-stream")})
|
||||||
|
text = data.get("text")
|
||||||
|
if not isinstance(text, str) or not text.strip():
|
||||||
|
raise invalid_response()
|
||||||
|
return RoutedTranscript(text=text, source="api")
|
||||||
|
except ProviderError as exc:
|
||||||
|
reason = exc.code
|
||||||
|
try:
|
||||||
|
text = await self.local_speech.transcribe(source, language)
|
||||||
|
if not isinstance(text, str) or not text.strip():
|
||||||
|
raise ProviderError("LOCAL_MODEL_INVALID_RESPONSE", "Local transcription was empty.")
|
||||||
|
return RoutedTranscript(text=text, source="local", fallback_reason=reason)
|
||||||
|
except ProviderError as exc:
|
||||||
|
raise ApiError(503, exc.code, exc.message, {"fallback_reason": reason}) from exc
|
||||||
|
|
||||||
|
async def match_speakers(self, source: Path, reference: Path) -> SpeakerMatchResult:
|
||||||
|
binding = self.configuration().speaker_matching
|
||||||
|
if binding is None:
|
||||||
|
with self._media_file(source), self._media_file(reference):
|
||||||
|
pass
|
||||||
|
reason = None
|
||||||
|
if binding:
|
||||||
|
try:
|
||||||
|
# Explicit application contract, not an OpenAI-standard endpoint.
|
||||||
|
with self._media_file(source) as audio, self._media_file(reference) as sample:
|
||||||
|
data, _ = await self._request(binding, data={"model": binding.model}, files={
|
||||||
|
"file": (source.name, audio, "application/octet-stream"),
|
||||||
|
"reference_file": (reference.name, sample, "application/octet-stream"),
|
||||||
|
})
|
||||||
|
score = data.get("score")
|
||||||
|
if not finite_number(score) or not 0 <= score <= 1:
|
||||||
|
raise invalid_response()
|
||||||
|
return SpeakerMatchResult(score=score, source="api")
|
||||||
|
except ProviderError as exc:
|
||||||
|
reason = exc.code
|
||||||
|
try:
|
||||||
|
score = await self.local_speech.match(source, reference)
|
||||||
|
if not finite_number(score) or not 0 <= score <= 1:
|
||||||
|
raise ProviderError("LOCAL_MODEL_INVALID_RESPONSE", "Local speaker matching was invalid.")
|
||||||
|
return SpeakerMatchResult(score=score, source="local", fallback_reason=reason)
|
||||||
|
except ProviderError as exc:
|
||||||
|
raise ApiError(503, exc.code, exc.message, {"fallback_reason": reason}) from exc
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Keep internal namespaced tools compatible with providers' 64-character names."""
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from app.contracts import MessageRole, ModelRequest
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_tool_names(request: ModelRequest) -> tuple[ModelRequest, dict[str, str]]:
|
||||||
|
names = {tool.name for tool in request.tools}
|
||||||
|
for message in request.messages:
|
||||||
|
names.update(call.name for call in message.tool_calls)
|
||||||
|
if message.role == MessageRole.tool and message.name:
|
||||||
|
names.add(message.name)
|
||||||
|
mapping = {name: name for name in names if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", name)}
|
||||||
|
used = set(mapping)
|
||||||
|
for name in sorted(names - mapping.keys()):
|
||||||
|
salt = 0
|
||||||
|
while True:
|
||||||
|
alias = "tool_" + hashlib.sha256(f"{name}:{salt}".encode()).hexdigest()[:56]
|
||||||
|
if alias not in used:
|
||||||
|
break
|
||||||
|
salt += 1
|
||||||
|
mapping[name] = alias
|
||||||
|
used.add(alias)
|
||||||
|
if all(name == alias for name, alias in mapping.items()):
|
||||||
|
return request, {}
|
||||||
|
wire = request.model_copy(deep=True)
|
||||||
|
for tool in wire.tools:
|
||||||
|
tool.name = mapping[tool.name]
|
||||||
|
for message in wire.messages:
|
||||||
|
for call in message.tool_calls:
|
||||||
|
call.name = mapping[call.name]
|
||||||
|
if message.role == MessageRole.tool and message.name:
|
||||||
|
message.name = mapping[message.name]
|
||||||
|
return wire, {alias: name for name, alias in mapping.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def mapped_tool_names(complete):
|
||||||
|
@wraps(complete)
|
||||||
|
async def wrapped(self, request: ModelRequest):
|
||||||
|
wire, originals = prepare_tool_names(request)
|
||||||
|
turn = await complete(self, wire)
|
||||||
|
for call in turn.tool_calls:
|
||||||
|
call.name = originals.get(call.name, call.name)
|
||||||
|
return turn
|
||||||
|
return wrapped
|
||||||
@@ -460,15 +460,17 @@ def get_index_meta() -> dict[str, str]:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def clear_all() -> None:
|
def clear_all(*, conn: sqlite3.Connection | None = None) -> None:
|
||||||
"""清空元数据、Block 与 FTS5(重建索引用,向量由 VectorStore.clear 处理)。"""
|
"""Clear rebuildable metadata using the caller's transaction when provided."""
|
||||||
conn = connect()
|
owns = conn is None
|
||||||
|
conn = conn or connect()
|
||||||
try:
|
try:
|
||||||
with transaction(conn):
|
with transaction(conn) if owns else nullcontext():
|
||||||
conn.execute("DELETE FROM blocks_fts")
|
conn.execute("DELETE FROM blocks_fts")
|
||||||
conn.execute("DELETE FROM blocks")
|
conn.execute("DELETE FROM blocks")
|
||||||
conn.execute("DELETE FROM notes")
|
conn.execute("DELETE FROM notes")
|
||||||
finally:
|
finally:
|
||||||
|
if owns:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ from app.repository import BlockHit
|
|||||||
from app.retrieval.embedding import EmbeddingProvider, HashEmbeddingProvider
|
from app.retrieval.embedding import EmbeddingProvider, HashEmbeddingProvider
|
||||||
from app.retrieval.hybrid import normalize_scores, rrf_fuse
|
from app.retrieval.hybrid import normalize_scores, rrf_fuse
|
||||||
from app.retrieval.reranker import LexicalReranker, RankedCandidate, RerankerProvider
|
from app.retrieval.reranker import LexicalReranker, RankedCandidate, RerankerProvider
|
||||||
|
from app.retrieval import routed_vectors
|
||||||
|
from app.retrieval.provenance import record_embedding
|
||||||
from app.retrieval.vectorstore import SqliteVecStore, VectorStore
|
from app.retrieval.vectorstore import SqliteVecStore, VectorStore
|
||||||
from app.textutils import make_snippet, match_query
|
from app.textutils import make_snippet, match_query
|
||||||
|
|
||||||
@@ -39,10 +41,15 @@ class RetrievalEngine:
|
|||||||
embedding: EmbeddingProvider,
|
embedding: EmbeddingProvider,
|
||||||
reranker: RerankerProvider,
|
reranker: RerankerProvider,
|
||||||
vector_store: VectorStore,
|
vector_store: VectorStore,
|
||||||
|
*,
|
||||||
|
route_embeddings: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.embedding = embedding
|
self.embedding = embedding
|
||||||
self.reranker = reranker
|
self.reranker = reranker
|
||||||
self.vector_store = vector_store
|
self.vector_store = vector_store
|
||||||
|
# Only the production instance opts in. Replaced test dependencies must
|
||||||
|
# remain authoritative, including monkeypatches on the singleton.
|
||||||
|
self._routed_defaults = (embedding, vector_store) if route_embeddings else None
|
||||||
|
|
||||||
async def search(self, request: SearchRequest) -> SearchResponse:
|
async def search(self, request: SearchRequest) -> SearchResponse:
|
||||||
if request.mode == SearchMode.fts:
|
if request.mode == SearchMode.fts:
|
||||||
@@ -74,8 +81,19 @@ class RetrievalEngine:
|
|||||||
fts_scores = {h.block_id: -h.bm25 for h in fts_hits}
|
fts_scores = {h.block_id: -h.bm25 for h in fts_hits}
|
||||||
|
|
||||||
if request.mode in (SearchMode.vector, SearchMode.hybrid):
|
if request.mode in (SearchMode.vector, SearchMode.hybrid):
|
||||||
|
record_embedding(source="unavailable")
|
||||||
|
vec_hits = None
|
||||||
|
if (
|
||||||
|
self._routed_defaults is not None
|
||||||
|
and self.embedding is self._routed_defaults[0]
|
||||||
|
and self.vector_store is self._routed_defaults[1]
|
||||||
|
):
|
||||||
|
vec_hits = await routed_vectors.search_remote(request.query, top_k=recall)
|
||||||
|
if vec_hits is None:
|
||||||
query_vec = await self.embedding.embed_query(request.query)
|
query_vec = await self.embedding.embed_query(request.query)
|
||||||
vec_hits = await self.vector_store.search(query_vec, top_k=recall)
|
vec_hits = await self.vector_store.search(query_vec, top_k=recall)
|
||||||
|
record_embedding(source="local", model_id=self.embedding.model_id,
|
||||||
|
dimensions=self.embedding.dim, version=self.embedding.version)
|
||||||
vec_ranked = [v.id for v in vec_hits]
|
vec_ranked = [v.id for v in vec_hits]
|
||||||
vec_scores = {v.id: v.score for v in vec_hits}
|
vec_scores = {v.id: v.score for v in vec_hits}
|
||||||
|
|
||||||
@@ -268,4 +286,6 @@ def _utc(dt: datetime) -> datetime:
|
|||||||
|
|
||||||
|
|
||||||
# 默认引擎实例:轻量实现跑通链路,后续可替换真实模型实现
|
# 默认引擎实例:轻量实现跑通链路,后续可替换真实模型实现
|
||||||
engine = RetrievalEngine(HashEmbeddingProvider(), LexicalReranker(), SqliteVecStore())
|
engine = RetrievalEngine(
|
||||||
|
HashEmbeddingProvider(), LexicalReranker(), SqliteVecStore(), route_embeddings=True,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Task-local observations of the embedding path actually used by a search."""
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from contextvars import ContextVar
|
||||||
|
|
||||||
|
_observation: ContextVar[dict | None] = ContextVar("embedding_observation", default=None)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def capture_embedding():
|
||||||
|
result = {"source": "not_used"}
|
||||||
|
token = _observation.set(result)
|
||||||
|
try:
|
||||||
|
yield result
|
||||||
|
finally:
|
||||||
|
_observation.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def record_embedding(**fields) -> None:
|
||||||
|
result = _observation.get()
|
||||||
|
if result is not None:
|
||||||
|
result.update(fields)
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""Optional API embeddings, isolated from the stable hash/sqlite-vec index.
|
||||||
|
|
||||||
|
The runtime's model_id is the authoritative space ID (including provider URL,
|
||||||
|
endpoint, model and dimensions); equal dimensions alone never imply compatibility.
|
||||||
|
This phase uses a lazy, rebuildable SQLite side table instead of a schema migration.
|
||||||
|
Search scans only current blocks in one database snapshot and requires complete
|
||||||
|
coverage. Cosine ranking costs O(blocks * dimensions) with an O(top_k) heap; this
|
||||||
|
small-vault implementation should become a per-space ANN index at larger scale.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import heapq
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import sqlite3
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from app.database.db import connect, transaction
|
||||||
|
from app.retrieval.vectorstore import VectorHit
|
||||||
|
from app.retrieval.provenance import record_embedding
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EmbeddingResult(Protocol):
|
||||||
|
vectors: list[list[float]]
|
||||||
|
source: str
|
||||||
|
model_id: str
|
||||||
|
dimensions: int
|
||||||
|
fallback_reason: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class EmbeddingRuntime(Protocol):
|
||||||
|
async def embed(self, texts: list[str]) -> EmbeddingResult: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RemoteEmbeddings:
|
||||||
|
space_id: str
|
||||||
|
dimensions: int
|
||||||
|
vectors: list[list[float]]
|
||||||
|
|
||||||
|
|
||||||
|
def get_model_routing() -> EmbeddingRuntime | None:
|
||||||
|
"""Lazy integration hook; tests can inject a runtime without any network I/O."""
|
||||||
|
from app.container import container
|
||||||
|
|
||||||
|
return getattr(container, "model_routing", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _unit_vector(vector: list[float], dimensions: int) -> list[float]:
|
||||||
|
if len(vector) != dimensions:
|
||||||
|
raise ValueError("embedding dimension mismatch")
|
||||||
|
if any(isinstance(value, bool) or not isinstance(value, (int, float)) for value in vector):
|
||||||
|
raise ValueError("embedding must be numeric")
|
||||||
|
if not all(math.isfinite(value) for value in vector):
|
||||||
|
raise ValueError("embedding must be finite")
|
||||||
|
scale = max(abs(value) for value in vector)
|
||||||
|
if scale == 0:
|
||||||
|
raise ValueError("embedding must be nonzero")
|
||||||
|
# Scaling first avoids overflow/underflow for finite but extreme API values.
|
||||||
|
scaled = [value / scale for value in vector]
|
||||||
|
norm = math.sqrt(math.fsum(value * value for value in scaled))
|
||||||
|
return [value / norm for value in scaled]
|
||||||
|
|
||||||
|
|
||||||
|
async def embed_remote(texts: list[str]) -> RemoteEmbeddings | None:
|
||||||
|
"""Return validated API vectors, or None to use the caller's local baseline.
|
||||||
|
|
||||||
|
Do not use the runtime's local result: the caller may have injected its own
|
||||||
|
embedding/store pair. Exception deliberately excludes cancellation.
|
||||||
|
"""
|
||||||
|
if not texts:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
runtime = get_model_routing()
|
||||||
|
if runtime is None:
|
||||||
|
return None
|
||||||
|
result = await runtime.embed(texts)
|
||||||
|
if result.source != "api":
|
||||||
|
record_embedding(fallback_reason=result.fallback_reason)
|
||||||
|
return None
|
||||||
|
if not isinstance(result.model_id, str) or not result.model_id or result.model_id == "hash-v1":
|
||||||
|
raise ValueError("API embedding needs a distinct space ID")
|
||||||
|
if type(result.dimensions) is not int or result.dimensions <= 0:
|
||||||
|
raise ValueError("invalid embedding dimensions")
|
||||||
|
if len(result.vectors) != len(texts):
|
||||||
|
raise ValueError("embedding count mismatch")
|
||||||
|
return RemoteEmbeddings(
|
||||||
|
space_id=result.model_id,
|
||||||
|
dimensions=result.dimensions,
|
||||||
|
vectors=[_unit_vector(vector, result.dimensions) for vector in result.vectors],
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
# Avoid logging provider exceptions containing credentials or note text.
|
||||||
|
record_embedding(fallback_reason="REMOTE_EMBEDDING_UNAVAILABLE")
|
||||||
|
logger.warning("Remote embedding unavailable (%s); using local index", type(exc).__name__)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_table(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS routed_block_vectors (
|
||||||
|
space_id TEXT NOT NULL,
|
||||||
|
block_id TEXT NOT NULL REFERENCES blocks(block_id) ON DELETE CASCADE,
|
||||||
|
dimensions INTEGER NOT NULL CHECK (dimensions > 0),
|
||||||
|
vector TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (space_id, block_id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS routed_block_vectors_block_id
|
||||||
|
ON routed_block_vectors(block_id)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def store_remote(
|
||||||
|
conn: sqlite3.Connection, block_ids: list[str], batch: RemoteEmbeddings | None,
|
||||||
|
) -> None:
|
||||||
|
"""Best-effort side-index write inside the caller's metadata transaction.
|
||||||
|
|
||||||
|
A savepoint prevents partial remote batches and isolates storage failures from
|
||||||
|
note saving. Replacing/deleting blocks cascades all old spaces automatically.
|
||||||
|
"""
|
||||||
|
if batch is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
conn.execute("SAVEPOINT routed_vectors_write")
|
||||||
|
try:
|
||||||
|
if len(block_ids) != len(batch.vectors):
|
||||||
|
raise ValueError("block/vector count mismatch")
|
||||||
|
_ensure_table(conn)
|
||||||
|
conn.executemany(
|
||||||
|
"""INSERT INTO routed_block_vectors (space_id, block_id, dimensions, vector)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT (space_id, block_id) DO UPDATE SET
|
||||||
|
dimensions = excluded.dimensions, vector = excluded.vector""",
|
||||||
|
[
|
||||||
|
(batch.space_id, block_id, batch.dimensions, json.dumps(vector, allow_nan=False))
|
||||||
|
for block_id, vector in zip(block_ids, batch.vectors)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
except BaseException:
|
||||||
|
conn.execute("ROLLBACK TO routed_vectors_write")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.execute("RELEASE routed_vectors_write")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Remote vector storage unavailable (%s); local index retained", type(exc).__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def search_remote(query: str, *, top_k: int) -> list[VectorHit] | None:
|
||||||
|
"""None means fallback, including any missing/invalid current-block vector.
|
||||||
|
|
||||||
|
Read coverage and vectors together so concurrent note updates cannot produce
|
||||||
|
an apparently complete subset. Never fill missing remote hits with local hits.
|
||||||
|
"""
|
||||||
|
batch = await embed_remote([query])
|
||||||
|
if batch is None:
|
||||||
|
return None
|
||||||
|
record_embedding(attempted_space={"model_id": batch.space_id, "dimensions": batch.dimensions})
|
||||||
|
try:
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
with transaction(conn):
|
||||||
|
exists = conn.execute(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'routed_block_vectors'"
|
||||||
|
).fetchone()
|
||||||
|
if exists is None:
|
||||||
|
record_embedding(fallback_reason="REMOTE_INDEX_MISSING")
|
||||||
|
return None
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT b.block_id, r.vector
|
||||||
|
FROM blocks AS b
|
||||||
|
LEFT JOIN routed_block_vectors AS r
|
||||||
|
ON r.block_id = b.block_id AND r.space_id = ? AND r.dimensions = ?
|
||||||
|
ORDER BY b.block_id""",
|
||||||
|
(batch.space_id, batch.dimensions),
|
||||||
|
)
|
||||||
|
|
||||||
|
def hits():
|
||||||
|
for row in rows:
|
||||||
|
if row["vector"] is None:
|
||||||
|
raise ValueError("remote space has incomplete block coverage")
|
||||||
|
vector = _unit_vector(json.loads(row["vector"]), batch.dimensions)
|
||||||
|
score = math.fsum(a * b for a, b in zip(batch.vectors[0], vector))
|
||||||
|
yield VectorHit(id=row["block_id"], score=max(0.0, min(1.0, score)))
|
||||||
|
|
||||||
|
result = heapq.nlargest(top_k, hits(), key=lambda hit: hit.score)
|
||||||
|
record_embedding(source="api", model_id=batch.space_id,
|
||||||
|
dimensions=batch.dimensions, fallback_reason=None)
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception as exc:
|
||||||
|
record_embedding(fallback_reason="REMOTE_INDEX_UNAVAILABLE")
|
||||||
|
logger.debug("Remote vector search unavailable (%s); using local index", type(exc).__name__)
|
||||||
|
return None
|
||||||
@@ -86,12 +86,14 @@ class SqliteVecStore:
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
async def clear(self) -> None:
|
async def clear(self, *, conn: sqlite3.Connection | None = None) -> None:
|
||||||
conn = connect()
|
owns = conn is None
|
||||||
|
conn = conn or connect()
|
||||||
try:
|
try:
|
||||||
with transaction(conn):
|
with transaction(conn) if owns else nullcontext():
|
||||||
conn.execute("DELETE FROM vec_blocks")
|
conn.execute("DELETE FROM vec_blocks")
|
||||||
finally:
|
finally:
|
||||||
|
if owns:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
async def count(self) -> int:
|
async def count(self) -> int:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import aclosing
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -41,6 +42,12 @@ from app.contracts import (
|
|||||||
McpToolSummaryListResponse,
|
McpToolSummaryListResponse,
|
||||||
ModelEvent,
|
ModelEvent,
|
||||||
ModelEventType,
|
ModelEventType,
|
||||||
|
EmbeddingRequest,
|
||||||
|
EmbeddingResult,
|
||||||
|
ModelRoutingConfig,
|
||||||
|
ModelRoutingResponse,
|
||||||
|
SpeakerMatchRequest,
|
||||||
|
SpeakerMatchResult,
|
||||||
Note,
|
Note,
|
||||||
NoteCreateRequest,
|
NoteCreateRequest,
|
||||||
NoteListResponse,
|
NoteListResponse,
|
||||||
@@ -108,10 +115,18 @@ from app.services import (
|
|||||||
transcription_service,
|
transcription_service,
|
||||||
workspace_service,
|
workspace_service,
|
||||||
)
|
)
|
||||||
|
from app.services.attachment_service import attachment_path
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/permissions/policy", tags=["Permissions"])
|
||||||
|
async def get_permission_policy() -> dict[str, str]:
|
||||||
|
from app.agent.permissions import KNOWN_PERMISSIONS
|
||||||
|
return {permission: container.permissions.policy.mode_for(permission).value
|
||||||
|
for permission in sorted(KNOWN_PERMISSIONS)}
|
||||||
|
|
||||||
|
|
||||||
async def mcp_call_async(operation):
|
async def mcp_call_async(operation):
|
||||||
"""Even registry reads can wait on lifecycle locks; keep all MCP work off the event loop."""
|
"""Even registry reads can wait on lifecycle locks; keep all MCP work off the event loop."""
|
||||||
try:
|
try:
|
||||||
@@ -306,17 +321,22 @@ async def chat(request: ChatRequest) -> StreamingResponse:
|
|||||||
provider = provider_or_404(request.provider_id)
|
provider = provider_or_404(request.provider_id)
|
||||||
|
|
||||||
async def stream() -> AsyncIterator[str]:
|
async def stream() -> AsyncIterator[str]:
|
||||||
|
sequence = 0
|
||||||
try:
|
try:
|
||||||
async for event in provider.adapter.stream(request):
|
async with aclosing(provider.adapter.stream(request)) as events:
|
||||||
|
async for event in events:
|
||||||
|
sequence = event.sequence + 1
|
||||||
yield as_sse(event.event.value, event.model_dump_json())
|
yield as_sse(event.event.value, event.model_dump_json())
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
error = ModelEvent(
|
error = ModelEvent(
|
||||||
event=ModelEventType.error,
|
event=ModelEventType.error,
|
||||||
data={"code": "PROVIDER_ERROR", "message": str(exc)},
|
sequence=sequence,
|
||||||
|
data={"code": "PROVIDER_ERROR", "message": "Provider could not complete the request."},
|
||||||
timestamp=utc_now(),
|
timestamp=utc_now(),
|
||||||
)
|
)
|
||||||
done = ModelEvent(
|
done = ModelEvent(
|
||||||
event=ModelEventType.done, sequence=1, timestamp=utc_now()
|
event=ModelEventType.done, sequence=sequence + 1,
|
||||||
|
data={"status": "failed"}, timestamp=utc_now()
|
||||||
)
|
)
|
||||||
yield as_sse(error.event.value, error.model_dump_json())
|
yield as_sse(error.event.value, error.model_dump_json())
|
||||||
yield as_sse(done.event.value, done.model_dump_json())
|
yield as_sse(done.event.value, done.model_dump_json())
|
||||||
@@ -923,13 +943,13 @@ async def update_provider(
|
|||||||
409, "BUILTIN_PROVIDER_IMMUTABLE", "Mock provider cannot be modified."
|
409, "BUILTIN_PROVIDER_IMMUTABLE", "Mock provider cannot be modified."
|
||||||
)
|
)
|
||||||
fields = request.model_fields_set
|
fields = request.model_fields_set
|
||||||
if ("name" in fields and request.name is None) or (
|
if ("provider_type" in fields and request.provider_type is None) or ("name" in fields and request.name is None) or (
|
||||||
"enabled" in fields and request.enabled is None
|
"enabled" in fields and request.enabled is None
|
||||||
):
|
):
|
||||||
raise ApiError(
|
raise ApiError(
|
||||||
422,
|
422,
|
||||||
"VALIDATION_ERROR",
|
"VALIDATION_ERROR",
|
||||||
"name and enabled cannot be null when explicitly provided.",
|
"provider_type, name and enabled cannot be null when explicitly provided.",
|
||||||
)
|
)
|
||||||
updates = {name: getattr(request, name) for name in fields}
|
updates = {name: getattr(request, name) for name in fields}
|
||||||
if "credential_id" in fields:
|
if "credential_id" in fields:
|
||||||
@@ -937,7 +957,11 @@ async def update_provider(
|
|||||||
config = ProviderConfig.model_validate(
|
config = ProviderConfig.model_validate(
|
||||||
{**current.model_dump(mode="python"), **updates}
|
{**current.model_dump(mode="python"), **updates}
|
||||||
)
|
)
|
||||||
|
config.capabilities = container.provider_factory.capabilities(config.provider_type)
|
||||||
|
try:
|
||||||
adapter = container.provider_factory.build(config)
|
adapter = container.provider_factory.build(config)
|
||||||
|
except UnsupportedProviderError as exc:
|
||||||
|
raise ApiError(422, "PROVIDER_TYPE_UNSUPPORTED", "Provider adapter is not supported.") from exc
|
||||||
container.providers.replace(config, adapter)
|
container.providers.replace(config, adapter)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
@@ -953,6 +977,8 @@ async def delete_provider(provider_id: str) -> OperationResponse:
|
|||||||
raise ApiError(
|
raise ApiError(
|
||||||
409, "BUILTIN_PROVIDER_IMMUTABLE", "Mock provider cannot be deleted."
|
409, "BUILTIN_PROVIDER_IMMUTABLE", "Mock provider cannot be deleted."
|
||||||
)
|
)
|
||||||
|
if container.model_routing.uses_provider(provider_id):
|
||||||
|
raise ApiError(409, "PROVIDER_IN_USE", "请先在索引与模型中解除该提供商的模型绑定。")
|
||||||
container.providers.unregister(provider_id)
|
container.providers.unregister(provider_id)
|
||||||
return OperationResponse(status="completed", resource_id=provider_id)
|
return OperationResponse(status="completed", resource_id=provider_id)
|
||||||
|
|
||||||
@@ -1055,6 +1081,28 @@ async def delete_task(task_id: str) -> OperationResponse:
|
|||||||
|
|
||||||
|
|
||||||
# Media and index
|
# Media and index
|
||||||
|
@router.get("/model-routing", response_model=ModelRoutingResponse, tags=["Providers"])
|
||||||
|
async def get_model_routing() -> ModelRoutingResponse:
|
||||||
|
return container.model_routing.describe()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/model-routing", response_model=ModelRoutingResponse, tags=["Providers"])
|
||||||
|
async def update_model_routing(request: ModelRoutingConfig) -> ModelRoutingResponse:
|
||||||
|
return container.model_routing.update(request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/models/embeddings", response_model=EmbeddingResult, tags=["Providers"])
|
||||||
|
async def create_embeddings(request: EmbeddingRequest) -> EmbeddingResult:
|
||||||
|
return await container.model_routing.embed(request.texts)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/media/speaker-matches", response_model=SpeakerMatchResult, tags=["Media"])
|
||||||
|
async def match_speakers(request: SpeakerMatchRequest) -> SpeakerMatchResult:
|
||||||
|
return await container.model_routing.match_speakers(
|
||||||
|
attachment_path(request.attachment_id), attachment_path(request.reference_attachment_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/media/transcriptions",
|
"/media/transcriptions",
|
||||||
response_model=TranscriptionJob,
|
response_model=TranscriptionJob,
|
||||||
@@ -1062,8 +1110,8 @@ async def delete_task(task_id: str) -> OperationResponse:
|
|||||||
tags=["Media"],
|
tags=["Media"],
|
||||||
)
|
)
|
||||||
async def create_transcription(request: TranscriptionRequest) -> TranscriptionJob:
|
async def create_transcription(request: TranscriptionRequest) -> TranscriptionJob:
|
||||||
return transcription_service.create_transcription(
|
return await transcription_service.create_transcription(
|
||||||
request.attachment_id, request.language
|
request.attachment_id, request.language, diarization=request.diarization
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ MVP 阶段重建是同步的(数据量小),完成后直接返回 completed
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import shutil
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -16,8 +15,8 @@ from app.config import get_settings
|
|||||||
from app.contracts import IndexJob, IndexRebuildRequest, IndexStatus
|
from app.contracts import IndexJob, IndexRebuildRequest, IndexStatus
|
||||||
from app.errors import ApiError
|
from app.errors import ApiError
|
||||||
from app.knowledge.parser import parse_note
|
from app.knowledge.parser import parse_note
|
||||||
from app.services.note_service import index_note
|
from app.services.note_service import index_note, prepare_note_index
|
||||||
from app.services import task_service
|
from app.database.db import connect, transaction
|
||||||
from app.services.coordination import serialized_vault_mutation
|
from app.services.coordination import serialized_vault_mutation
|
||||||
from app.retrieval.vectorstore import SqliteVecStore
|
from app.retrieval.vectorstore import SqliteVecStore
|
||||||
|
|
||||||
@@ -74,18 +73,7 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
|
|||||||
{"scope": request.scope, "note_ids": request.note_ids},
|
{"scope": request.scope, "note_ids": request.note_ids},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 先扫描到内存(失败不会清旧索引),再快照旧库用于失败回滚
|
|
||||||
docs = _scan_vault()
|
docs = _scan_vault()
|
||||||
settings = get_settings()
|
|
||||||
database_existed = settings.db_path.exists()
|
|
||||||
task_note_links = task_service.note_links() if database_existed else {}
|
|
||||||
backup_path = (
|
|
||||||
settings.db_path.with_name(f"{settings.db_path.name}.{job_id}.bak")
|
|
||||||
if database_existed
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
if backup_path is not None:
|
|
||||||
shutil.copy2(settings.db_path, backup_path)
|
|
||||||
|
|
||||||
_active_job_id = job_id
|
_active_job_id = job_id
|
||||||
_last_error = None
|
_last_error = None
|
||||||
@@ -94,21 +82,34 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
|
|||||||
created_at=datetime.now(timezone.utc),
|
created_at=datetime.now(timezone.utc),
|
||||||
))
|
))
|
||||||
try:
|
try:
|
||||||
repository.clear_all()
|
prepared_notes = []
|
||||||
await vector_store.clear()
|
|
||||||
for rel, folder, markdown, created, updated in docs:
|
for rel, folder, markdown, created, updated in docs:
|
||||||
parsed = parse_note(
|
parsed = parse_note(
|
||||||
markdown=markdown, file_path=rel, folder=folder, tags=None,
|
markdown=markdown, file_path=rel, folder=folder, tags=None,
|
||||||
created_at=created, updated_at=updated,
|
created_at=created, updated_at=updated,
|
||||||
)
|
)
|
||||||
await index_note(parsed)
|
prepared_notes.append((parsed, await prepare_note_index(parsed)))
|
||||||
task_service.restore_note_links(task_note_links)
|
# All network/model awaits precede the transaction. The concrete SQLite
|
||||||
|
# methods below complete synchronously despite their async interfaces.
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
with transaction(conn):
|
||||||
|
task_note_links = dict(conn.execute(
|
||||||
|
"SELECT task_id, note_id FROM tasks WHERE note_id IS NOT NULL"
|
||||||
|
).fetchall())
|
||||||
|
repository.clear_all(conn=conn)
|
||||||
|
await vector_store.clear(conn=conn)
|
||||||
|
for parsed, prepared in prepared_notes:
|
||||||
|
await index_note(parsed, prepared=prepared, conn=conn)
|
||||||
|
for task_id, note_id in task_note_links.items():
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE tasks SET note_id = ? WHERE task_id = ? "
|
||||||
|
"AND EXISTS (SELECT 1 FROM notes WHERE note_id = ?)",
|
||||||
|
(note_id, task_id, note_id),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
except BaseException as exc:
|
except BaseException as exc:
|
||||||
# 重建失败:恢复旧索引,避免留下半成品;记录 failed 任务后向上抛
|
|
||||||
if backup_path is not None and backup_path.exists():
|
|
||||||
shutil.copy2(backup_path, settings.db_path)
|
|
||||||
elif not database_existed:
|
|
||||||
settings.db_path.unlink(missing_ok=True)
|
|
||||||
_remember_job(IndexJob(
|
_remember_job(IndexJob(
|
||||||
job_id=job_id, status="failed", scope=request.scope,
|
job_id=job_id, status="failed", scope=request.scope,
|
||||||
created_at=datetime.now(timezone.utc),
|
created_at=datetime.now(timezone.utc),
|
||||||
@@ -117,8 +118,6 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
|
|||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
_active_job_id = None
|
_active_job_id = None
|
||||||
if backup_path is not None:
|
|
||||||
backup_path.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
job = IndexJob(job_id=job_id, status="completed", scope=request.scope, created_at=datetime.now(timezone.utc))
|
job = IndexJob(job_id=job_id, status="completed", scope=request.scope, created_at=datetime.now(timezone.utc))
|
||||||
_remember_job(job)
|
_remember_job(job)
|
||||||
@@ -127,9 +126,12 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
|
|||||||
|
|
||||||
|
|
||||||
def get_status() -> IndexStatus:
|
def get_status() -> IndexStatus:
|
||||||
|
counts = repository.stats()
|
||||||
if _active_job_id is not None:
|
if _active_job_id is not None:
|
||||||
return IndexStatus(status="running", pending_jobs=0, active_job_id=_active_job_id)
|
return IndexStatus(status="running", pending_jobs=0, active_job_id=_active_job_id,
|
||||||
|
total_notes=counts["notes"], total_blocks=counts["blocks"])
|
||||||
return IndexStatus(
|
return IndexStatus(
|
||||||
|
total_notes=counts["notes"], total_blocks=counts["blocks"],
|
||||||
status="failed" if _last_error else "idle",
|
status="failed" if _last_error else "idle",
|
||||||
pending_jobs=0,
|
pending_jobs=0,
|
||||||
last_completed_at=_last_completed_at,
|
last_completed_at=_last_completed_at,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ Markdown 文件是笔记正文的持久化载体(Vault),SQLite/FTS5/向量
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from contextlib import nullcontext
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -16,6 +18,7 @@ from app.database.db import connect, transaction
|
|||||||
from app.errors import ApiError
|
from app.errors import ApiError
|
||||||
from app.knowledge.parser import ParsedNote, parse_note
|
from app.knowledge.parser import ParsedNote, parse_note
|
||||||
from app.retrieval.embedding import HashEmbeddingProvider
|
from app.retrieval.embedding import HashEmbeddingProvider
|
||||||
|
from app.retrieval import routed_vectors
|
||||||
from app.retrieval.vectorstore import SqliteVecStore, VectorRecord
|
from app.retrieval.vectorstore import SqliteVecStore, VectorRecord
|
||||||
from app.services.coordination import serialized_vault_mutation
|
from app.services.coordination import serialized_vault_mutation
|
||||||
from app.services.vault_paths import (
|
from app.services.vault_paths import (
|
||||||
@@ -71,17 +74,34 @@ def _delete_markdown(rel_path: str) -> None:
|
|||||||
path.unlink()
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
async def index_note(parsed: ParsedNote) -> None:
|
PreparedIndex = tuple[list[list[float]], routed_vectors.RemoteEmbeddings | None]
|
||||||
|
|
||||||
|
|
||||||
|
async def prepare_note_index(parsed: ParsedNote) -> PreparedIndex:
|
||||||
|
"""Compute vectors before opening a write transaction (including API I/O)."""
|
||||||
|
texts = [block.content for block in parsed.blocks]
|
||||||
|
vectors = await embedding.embed_documents(texts)
|
||||||
|
remote = await routed_vectors.embed_remote(texts)
|
||||||
|
return vectors, remote
|
||||||
|
|
||||||
|
|
||||||
|
async def index_note(
|
||||||
|
parsed: ParsedNote, *, prepared: PreparedIndex | None = None,
|
||||||
|
conn: sqlite3.Connection | None = None,
|
||||||
|
) -> None:
|
||||||
"""把解析结果写入元数据 + FTS5 + 向量(三层可重建索引),单事务保证原子性。
|
"""把解析结果写入元数据 + FTS5 + 向量(三层可重建索引),单事务保证原子性。
|
||||||
|
|
||||||
元数据与向量在同一连接、同一事务内提交,避免「新元数据已提交、向量写入失败」的
|
元数据与向量在同一连接、同一事务内提交,避免「新元数据已提交、向量写入失败」的
|
||||||
半提交状态。替换元数据时拿到旧 block_id:清理已删除/内容变化的旧向量,只为新增
|
半提交状态。替换元数据时拿到旧 block_id:清理已删除/内容变化的旧向量,只为新增
|
||||||
block 写向量(内容未变的 block 其向量仍有效,无需重复写入)。
|
block 写向量(内容未变的 block 其向量仍有效,无需重复写入)。
|
||||||
"""
|
"""
|
||||||
vectors = await embedding.embed_documents([block.content for block in parsed.blocks])
|
if conn is not None and prepared is None:
|
||||||
conn = connect()
|
raise ValueError("Prepare embeddings before supplying a write connection")
|
||||||
|
vectors, remote = prepared if prepared is not None else await prepare_note_index(parsed)
|
||||||
|
owns = conn is None
|
||||||
|
conn = conn or connect()
|
||||||
try:
|
try:
|
||||||
with transaction(conn):
|
with transaction(conn) if owns else nullcontext():
|
||||||
old_block_ids = repository.replace_note_metadata(
|
old_block_ids = repository.replace_note_metadata(
|
||||||
conn=conn,
|
conn=conn,
|
||||||
note_id=parsed.note_id,
|
note_id=parsed.note_id,
|
||||||
@@ -105,11 +125,13 @@ async def index_note(parsed: ParsedNote) -> None:
|
|||||||
if block.block_id in missing_ids
|
if block.block_id in missing_ids
|
||||||
]
|
]
|
||||||
await vector_store.upsert(records, conn=conn)
|
await vector_store.upsert(records, conn=conn)
|
||||||
|
routed_vectors.store_remote(conn, [block.block_id for block in parsed.blocks], remote)
|
||||||
repository.set_index_meta(
|
repository.set_index_meta(
|
||||||
{"embedding_model": embedding.model_id, "embedding_dim": str(embedding.dim)},
|
{"embedding_model": embedding.model_id, "embedding_dim": str(embedding.dim)},
|
||||||
conn=conn,
|
conn=conn,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
|
if owns:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,59 @@
|
|||||||
"""转写适配层;第一阶段消费文本附件或桌面 Host 预生成的旁路文本。"""
|
"""转写作业:API 优先,本地模型回退;保留已有 Host 文本入口。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from app.contracts import TranscriptionJob
|
from app.contracts import TranscriptionJob
|
||||||
|
from app.errors import ApiError
|
||||||
from app.services.attachment_service import attachment_path
|
from app.services.attachment_service import attachment_path
|
||||||
|
|
||||||
_jobs: OrderedDict[str, TranscriptionJob] = OrderedDict()
|
_jobs: OrderedDict[str, TranscriptionJob] = OrderedDict()
|
||||||
MAX_JOBS = 100
|
MAX_JOBS = 100
|
||||||
|
|
||||||
|
|
||||||
def create_transcription(attachment_id: str, language: str | None = None) -> TranscriptionJob:
|
async def create_transcription(attachment_id: str, language: str | None = None, *, diarization: bool = False) -> TranscriptionJob:
|
||||||
# TODO(ai-core): 第二阶段接入本地 ASR 队列后,保留相同 Job 契约替换此同步降级实现。
|
from app.container import container
|
||||||
del language # 预生成 transcript 暂不需要语言识别。
|
|
||||||
source = attachment_path(attachment_id)
|
source = attachment_path(attachment_id)
|
||||||
transcript = source if source.suffix.lower() in {".txt", ".md"} else Path(f"{source}.txt")
|
|
||||||
job = TranscriptionJob(
|
job = TranscriptionJob(
|
||||||
job_id=f"transcription_{uuid4().hex}",
|
job_id=f"transcription_{uuid4().hex}",
|
||||||
attachment_id=attachment_id,
|
attachment_id=attachment_id,
|
||||||
status="completed" if transcript.is_file() else "failed",
|
status="processing",
|
||||||
text=transcript.read_text(encoding="utf-8") if transcript.is_file() else None,
|
|
||||||
error_code=None if transcript.is_file() else "TRANSCRIPTION_BACKEND_UNAVAILABLE",
|
|
||||||
error_message=(
|
|
||||||
None
|
|
||||||
if transcript.is_file()
|
|
||||||
else "No host-generated transcript is available; local speech models are phase two."
|
|
||||||
),
|
|
||||||
created_at=datetime.now(timezone.utc),
|
created_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
if diarization:
|
||||||
|
# Speaker verification and diarization are different capabilities.
|
||||||
|
raise ApiError(501, "DIARIZATION_NOT_IMPLEMENTED", "说话人分离将在阶段 F 接入,当前不能忽略 diarization 请求。")
|
||||||
|
transcript = source if source.suffix.lower() in {".txt", ".md"} else attachment_path(f"{attachment_id}.txt")
|
||||||
|
# A saved transcript remains an explicit import path, never faked ASR.
|
||||||
|
if transcript.is_file() and (source == transcript or container.model_routing.configuration().transcription is None):
|
||||||
|
with transcript.open("rb") as handle:
|
||||||
|
content = handle.read(1024 * 1024 + 1)
|
||||||
|
if len(content) > 1024 * 1024:
|
||||||
|
raise ApiError(413, "TRANSCRIPT_TOO_LARGE", "Transcript exceeds 1 MiB.")
|
||||||
|
job.text = content.decode("utf-8")
|
||||||
|
if not job.text.strip():
|
||||||
|
raise ApiError(422, "TRANSCRIPT_EMPTY", "Transcript is empty.")
|
||||||
|
job.source = "sidecar"
|
||||||
|
else:
|
||||||
|
result = await container.model_routing.transcribe(source, language)
|
||||||
|
job.text = result.text
|
||||||
|
job.source = result.source
|
||||||
|
job.fallback_reason = result.fallback_reason
|
||||||
|
job.status = "completed"
|
||||||
|
except ApiError as exc:
|
||||||
|
job.status = "failed"
|
||||||
|
job.error_code = exc.code
|
||||||
|
job.error_message = exc.message
|
||||||
|
job.fallback_reason = exc.details.get("fallback_reason")
|
||||||
|
except (OSError, UnicodeError):
|
||||||
|
job.status = "failed"
|
||||||
|
job.error_code = "TRANSCRIPT_UNREADABLE"
|
||||||
|
job.error_message = "Transcript could not be read."
|
||||||
_jobs[job.job_id] = job
|
_jobs[job.job_id] = job
|
||||||
while len(_jobs) > MAX_JOBS:
|
while len(_jobs) > MAX_JOBS:
|
||||||
_jobs.popitem(last=False)
|
_jobs.popitem(last=False)
|
||||||
|
|||||||
@@ -221,8 +221,9 @@ def test_config_snapshot_records_index_and_models() -> None:
|
|||||||
|
|
||||||
snapshot = run.config_snapshot
|
snapshot = run.config_snapshot
|
||||||
assert snapshot["index_meta"] is not None
|
assert snapshot["index_meta"] is not None
|
||||||
assert snapshot["embedding"]["version"]
|
assert snapshot["embedding"]["policy"] == "per_case"
|
||||||
assert snapshot["embedding"]["dim"]
|
assert snapshot["local_embedding"]["version"]
|
||||||
|
assert snapshot["local_embedding"]["dim"]
|
||||||
assert snapshot["reranker"]["version"]
|
assert snapshot["reranker"]["version"]
|
||||||
assert snapshot["retrieval"]["rrf_k"] == 60
|
assert snapshot["retrieval"]["rrf_k"] == 60
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.container import container
|
||||||
|
from app.agent.permissions import PermissionMode
|
||||||
|
from app.services.note_service import create_note
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_status_returns_real_counts():
|
||||||
|
with TestClient(app) as client:
|
||||||
|
initial = client.get('/api/index/status').json()
|
||||||
|
assert (initial['total_notes'], initial['total_blocks']) == (0, 0)
|
||||||
|
note = asyncio.run(create_note(title='Real note', markdown='# Real note\n\ncontent', folder=None, tags=[]))
|
||||||
|
result = client.get('/api/index/status').json()
|
||||||
|
assert result['total_notes'] == 1
|
||||||
|
assert result['total_blocks'] == len(note.blocks)
|
||||||
|
|
||||||
|
|
||||||
|
def test_permissions_endpoint_reads_effective_backend_policy():
|
||||||
|
policy = container.permissions.policy
|
||||||
|
original = policy.mode_for('attachments.read')
|
||||||
|
try:
|
||||||
|
policy.set_rule('attachments.read', PermissionMode.deny)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.get('/api/permissions/policy')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()['attachments.read'] == 'deny'
|
||||||
|
finally:
|
||||||
|
policy.set_rule('attachments.read', original)
|
||||||
@@ -0,0 +1,663 @@
|
|||||||
|
"""Offline model-routing contracts, HTTP validation, media lifetimes and persistence.
|
||||||
|
|
||||||
|
All HTTP uses MockTransport (or the in-process API). Credentials, models and
|
||||||
|
attachments are fakes, and conftest redirects all storage to temporary paths.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from email import policy
|
||||||
|
from email.parser import BytesParser
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.contracts import ModelBinding, ModelRoutingConfig, ProviderConfig, ProviderType
|
||||||
|
from app.errors import ApiError
|
||||||
|
from app.providers import MockProvider
|
||||||
|
from app.providers.credentials import CredentialStoreError
|
||||||
|
from app.providers.registry import ProviderRegistry
|
||||||
|
from app.providers.routing import ModelRoutingService, PendingSpeechBackend
|
||||||
|
from app.retrieval.embedding import HashEmbeddingProvider
|
||||||
|
|
||||||
|
|
||||||
|
def run(awaitable):
|
||||||
|
return asyncio.run(awaitable)
|
||||||
|
|
||||||
|
|
||||||
|
def response(data, status=200):
|
||||||
|
# Raw JSON intentionally permits NaN/Infinity to exercise hostile API output.
|
||||||
|
return httpx.Response(status, content=json.dumps(data).encode(), headers={"content-type": "application/json"})
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCredentials:
|
||||||
|
def __init__(self):
|
||||||
|
self.value = "unit-test-placeholder"
|
||||||
|
self.error = None
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def resolve(self, credential_id):
|
||||||
|
self.calls.append(credential_id)
|
||||||
|
if self.error:
|
||||||
|
raise self.error
|
||||||
|
return self.value if credential_id else None
|
||||||
|
|
||||||
|
|
||||||
|
class FakeEmbedding:
|
||||||
|
model_id = "fake-local-model"
|
||||||
|
dim = 3
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
self.error = None
|
||||||
|
|
||||||
|
async def embed_documents(self, texts):
|
||||||
|
self.calls.append(list(texts))
|
||||||
|
if self.error:
|
||||||
|
raise self.error
|
||||||
|
return [[0.6, 0.8, 0.0] for _ in texts]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSpeech:
|
||||||
|
available = True
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
self.text = "local transcript"
|
||||||
|
self.score = 0.25
|
||||||
|
self.error = None
|
||||||
|
|
||||||
|
async def transcribe(self, source, language):
|
||||||
|
self.calls.append(("transcribe", source, language))
|
||||||
|
if self.error:
|
||||||
|
raise self.error
|
||||||
|
return self.text
|
||||||
|
|
||||||
|
async def match(self, source, reference):
|
||||||
|
self.calls.append(("match", source, reference))
|
||||||
|
if self.error:
|
||||||
|
raise self.error
|
||||||
|
return self.score
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def no_real_http(monkeypatch):
|
||||||
|
async def reject_async(*args, **kwargs):
|
||||||
|
pytest.fail("Real HTTP transport is forbidden in model-routing tests")
|
||||||
|
|
||||||
|
def reject_sync(*args, **kwargs):
|
||||||
|
pytest.fail("Real HTTP transport is forbidden in model-routing tests")
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx.AsyncHTTPTransport, "handle_async_request", reject_async)
|
||||||
|
monkeypatch.setattr(httpx.HTTPTransport, "handle_request", reject_sync)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def rig():
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
def unexpected(request):
|
||||||
|
pytest.fail(f"Unexpected model HTTP request: {request.url}")
|
||||||
|
|
||||||
|
state = SimpleNamespace(handler=unexpected)
|
||||||
|
|
||||||
|
async def dispatch(request):
|
||||||
|
requests.append(request)
|
||||||
|
result = state.handler(request)
|
||||||
|
return await result if hasattr(result, "__await__") else result
|
||||||
|
|
||||||
|
providers = ProviderRegistry()
|
||||||
|
config = ProviderConfig(
|
||||||
|
provider_id="test-provider", provider_type=ProviderType.openai_compatible,
|
||||||
|
name="Fake provider", base_url="https://models.invalid/v1/", credential_id="test-credential",
|
||||||
|
)
|
||||||
|
providers.register(config, MockProvider())
|
||||||
|
credentials, embedding, speech = FakeCredentials(), FakeEmbedding(), FakeSpeech()
|
||||||
|
service = ModelRoutingService(
|
||||||
|
providers, credentials, local_embedding=embedding, local_speech=speech,
|
||||||
|
transport=httpx.MockTransport(dispatch),
|
||||||
|
)
|
||||||
|
return SimpleNamespace(
|
||||||
|
service=service, providers=providers, credentials=credentials,
|
||||||
|
embedding=embedding, speech=speech, requests=requests, http=state,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def bind(rig, capability="embedding", **overrides):
|
||||||
|
endpoints = {
|
||||||
|
"embedding": "/embeddings", "transcription": "/audio/transcriptions",
|
||||||
|
"speaker_matching": "/audio/speaker-matches",
|
||||||
|
}
|
||||||
|
binding = ModelBinding(**{
|
||||||
|
"provider_id": "test-provider", "model": "test-model",
|
||||||
|
"endpoint": endpoints[capability], **overrides,
|
||||||
|
})
|
||||||
|
current = rig.service.configuration()
|
||||||
|
return rig.service.update(current.model_copy(update={capability: binding}))
|
||||||
|
|
||||||
|
|
||||||
|
def assert_local(rig, result, texts, reason):
|
||||||
|
assert result.source == "local"
|
||||||
|
assert result.model_id == rig.embedding.model_id
|
||||||
|
assert result.dimensions == 3
|
||||||
|
assert result.vectors == [[0.6, 0.8, 0.0] for _ in texts]
|
||||||
|
assert result.fallback_reason == reason
|
||||||
|
assert rig.embedding.calls == [texts]
|
||||||
|
|
||||||
|
|
||||||
|
def test_embedding_observation_keeps_request_binding_when_config_changes(rig):
|
||||||
|
from app.retrieval.provenance import capture_embedding
|
||||||
|
initial = bind(rig, model="original-model")
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
assert json.loads(request.content)["model"] == "original-model"
|
||||||
|
bind(rig, model="next-model")
|
||||||
|
return response({"data": [{"index": 0, "embedding": [1, 0, 0]}]})
|
||||||
|
|
||||||
|
rig.http.handler = handler
|
||||||
|
with capture_embedding() as observation:
|
||||||
|
result = run(rig.service.embed(["query"]))
|
||||||
|
assert result.source == "api"
|
||||||
|
assert observation["route_version"] == initial.config.version
|
||||||
|
assert observation["requested_route"]["model"] == "original-model"
|
||||||
|
assert observation["requested_route"]["provider_id"] == "test-provider"
|
||||||
|
assert rig.service.configuration().embedding.model == "next-model"
|
||||||
|
assert rig.credentials.value not in json.dumps(observation)
|
||||||
|
assert "credential_id" not in json.dumps(observation)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def audio(tmp_path):
|
||||||
|
source, reference = tmp_path / "audio.wav", tmp_path / "reference.wav"
|
||||||
|
source.write_bytes(b"fake-audio-content")
|
||||||
|
reference.write_bytes(b"fake-reference-content")
|
||||||
|
return source, reference
|
||||||
|
|
||||||
|
|
||||||
|
def media_call(rig, capability, audio):
|
||||||
|
if capability == "transcription":
|
||||||
|
return rig.service.transcribe(audio[0], "zh")
|
||||||
|
return rig.service.match_speakers(*audio)
|
||||||
|
|
||||||
|
|
||||||
|
def track_media_handles(rig, monkeypatch):
|
||||||
|
handles = []
|
||||||
|
original = rig.service._media_file
|
||||||
|
|
||||||
|
def tracked(path):
|
||||||
|
handle = original(path)
|
||||||
|
handles.append(handle)
|
||||||
|
return handle
|
||||||
|
|
||||||
|
monkeypatch.setattr(rig.service, "_media_file", tracked)
|
||||||
|
return handles
|
||||||
|
|
||||||
|
|
||||||
|
def test_absent_binding_uses_hash_without_network(rig):
|
||||||
|
rig.service.local_embedding = HashEmbeddingProvider()
|
||||||
|
texts = ["hello retrieval", "向量检索"]
|
||||||
|
result = run(rig.service.embed(texts))
|
||||||
|
assert result.source == "local"
|
||||||
|
assert result.model_id == "hash-v1"
|
||||||
|
assert result.dimensions == 128
|
||||||
|
assert result.vectors == run(HashEmbeddingProvider().embed_documents(texts))
|
||||||
|
assert result.fallback_reason is None
|
||||||
|
assert rig.requests == rig.credentials.calls == []
|
||||||
|
statuses = {item.capability: item.status for item in rig.service.describe().local_backends}
|
||||||
|
assert statuses == {"embedding": "placeholder", "transcription": "ready", "speaker_matching": "ready"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_embedding_input_does_not_call_remote(rig):
|
||||||
|
bind(rig)
|
||||||
|
result = run(rig.service.embed([]))
|
||||||
|
assert result.vectors == [] and result.source == "local"
|
||||||
|
assert rig.requests == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_embedding_restores_batch_order_normalizes_and_sends_auth(rig):
|
||||||
|
bind(rig, dimensions=2)
|
||||||
|
texts = [str(index) for index in range(35)]
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
assert request.method == "POST"
|
||||||
|
assert str(request.url) == "https://models.invalid/v1/embeddings"
|
||||||
|
assert request.headers["authorization"] == "Bearer unit-test-placeholder"
|
||||||
|
payload = json.loads(request.content)
|
||||||
|
assert payload["model"] == "test-model"
|
||||||
|
assert payload["dimensions"] == 2
|
||||||
|
assert payload["encoding_format"] == "float"
|
||||||
|
return response({"data": [
|
||||||
|
{"index": index, "embedding": [float(int(text) + 1), 1.0]}
|
||||||
|
for index, text in reversed(list(enumerate(payload["input"])))
|
||||||
|
]})
|
||||||
|
|
||||||
|
rig.http.handler = handler
|
||||||
|
result = run(rig.service.embed(texts))
|
||||||
|
assert result.source == "api" and result.fallback_reason is None
|
||||||
|
assert result.dimensions == 2 and len(result.vectors) == 35
|
||||||
|
for index, vector in enumerate(result.vectors):
|
||||||
|
assert sum(value * value for value in vector) == pytest.approx(1.0)
|
||||||
|
assert vector[0] / vector[1] == pytest.approx(index + 1)
|
||||||
|
assert [json.loads(req.content)["input"] for req in rig.requests] == [texts[:32], texts[32:]]
|
||||||
|
assert rig.embedding.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_space_id_is_stable_and_includes_full_url_model_and_inferred_dimensions(rig):
|
||||||
|
dimensions = 2
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
assert "dimensions" not in json.loads(request.content)
|
||||||
|
return response({"data": [{"index": 0, "embedding": [1.0] * dimensions}]})
|
||||||
|
|
||||||
|
rig.http.handler = handler
|
||||||
|
bind(rig, model=" trimmed-model ")
|
||||||
|
|
||||||
|
def check(url, model, dimension):
|
||||||
|
result = run(rig.service.embed(["hello"]))
|
||||||
|
digest = hashlib.sha256(json.dumps([url, model, dimension], separators=(",", ":")).encode()).hexdigest()
|
||||||
|
assert result.model_id == "api-" + digest
|
||||||
|
assert result.source == "api"
|
||||||
|
return result.model_id
|
||||||
|
|
||||||
|
first = check("https://models.invalid/v1/embeddings", "trimmed-model", 2)
|
||||||
|
assert first == check("https://models.invalid/v1/embeddings", "trimmed-model", 2)
|
||||||
|
config = rig.providers.get_any("test-provider").config.model_copy(update={"base_url": "https://models.invalid/v1"})
|
||||||
|
rig.providers.replace(config, MockProvider())
|
||||||
|
assert first == check("https://models.invalid/v1/embeddings", "trimmed-model", 2)
|
||||||
|
bind(rig, model="trimmed-model", endpoint="/custom/embeddings")
|
||||||
|
endpoint_id = check("https://models.invalid/v1/custom/embeddings", "trimmed-model", 2)
|
||||||
|
bind(rig, model="another-model", endpoint="/custom/embeddings")
|
||||||
|
model_id = check("https://models.invalid/v1/custom/embeddings", "another-model", 2)
|
||||||
|
dimensions = 3
|
||||||
|
dim_id = check("https://models.invalid/v1/custom/embeddings", "another-model", 3)
|
||||||
|
config = config.model_copy(update={"base_url": "https://other.invalid/v1"})
|
||||||
|
rig.providers.replace(config, MockProvider())
|
||||||
|
provider_id = check("https://other.invalid/v1/custom/embeddings", "another-model", 3)
|
||||||
|
assert len({first, endpoint_id, model_id, dim_id, provider_id}) == 5
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("data", [
|
||||||
|
{"data": []},
|
||||||
|
{"data": [{"index": 0, "embedding": [1, 0]}]},
|
||||||
|
{"data": [{"index": 0, "embedding": [1, 0]}, {"index": 0, "embedding": [0, 1]}]},
|
||||||
|
{"data": [{"index": 0, "embedding": [1, 0]}, {"index": 2, "embedding": [0, 1]}]},
|
||||||
|
{"data": [{"index": False, "embedding": [1, 0]}, {"index": 1, "embedding": [0, 1]}]},
|
||||||
|
{"data": [{"index": 0, "embedding": [1, 0]}, {"index": 1, "embedding": [0, 1, 0]}]},
|
||||||
|
{"data": [{"index": 0, "embedding": [1, 0]}, {"index": 1, "embedding": [float("nan"), 1]}]},
|
||||||
|
{"data": [{"index": 0, "embedding": [1, 0]}, {"index": 1, "embedding": [float("inf"), 1]}]},
|
||||||
|
{"data": [{"index": 0, "embedding": [1, 0]}, {"index": 1, "embedding": [True, 1]}]},
|
||||||
|
{"data": [{"index": 0, "embedding": [1, 0]}, {"index": 1, "embedding": [0, 0]}]},
|
||||||
|
{"data": [{"index": 0, "embedding": [1, 0]}, {"index": 1, "embedding": []}]},
|
||||||
|
{"data": [{"index": 0, "embedding": [1, 0]}, {"index": 1, "embedding": ["1", 0]}]},
|
||||||
|
{"data": [None, None]},
|
||||||
|
{"error": {"message": "in-band failure"}, "data": []},
|
||||||
|
[],
|
||||||
|
], ids=["empty", "count", "duplicate-index", "out-of-range-index", "bool-index", "dimensions", "nan", "infinity", "bool", "zero", "empty-vector", "string", "invalid-items", "in-band-error", "non-object"])
|
||||||
|
def test_invalid_remote_embeddings_fall_back_as_a_whole(rig, data):
|
||||||
|
bind(rig)
|
||||||
|
rig.http.handler = lambda request: response(data)
|
||||||
|
texts = ["first", "second"]
|
||||||
|
assert_local(rig, run(rig.service.embed(texts)), texts, "PROVIDER_INVALID_RESPONSE")
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_embedding_dimension_mismatch_falls_back(rig):
|
||||||
|
bind(rig, dimensions=3)
|
||||||
|
rig.http.handler = lambda request: response({"data": [{"index": 0, "embedding": [1, 0]}]})
|
||||||
|
assert_local(rig, run(rig.service.embed(["text"])), ["text"], "PROVIDER_INVALID_RESPONSE")
|
||||||
|
|
||||||
|
|
||||||
|
def test_later_batch_dimension_mismatch_discards_earlier_remote_vectors(rig):
|
||||||
|
bind(rig)
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
batch = json.loads(request.content)["input"]
|
||||||
|
dimension = 2 if len(rig.requests) == 1 else 3
|
||||||
|
return response({"data": [{"index": i, "embedding": [1] * dimension} for i in range(len(batch))]})
|
||||||
|
|
||||||
|
rig.http.handler = handler
|
||||||
|
texts = [str(i) for i in range(33)]
|
||||||
|
assert_local(rig, run(rig.service.embed(texts)), texts, "PROVIDER_INVALID_RESPONSE")
|
||||||
|
assert len(rig.requests) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("failure, reason", [
|
||||||
|
(401, "PROVIDER_AUTH_FAILED"), (403, "PROVIDER_AUTH_FAILED"),
|
||||||
|
(404, "MODEL_NOT_FOUND"), (429, "PROVIDER_RATE_LIMITED"), (500, "PROVIDER_UNAVAILABLE"),
|
||||||
|
("timeout", "PROVIDER_TIMEOUT"), ("connect", "PROVIDER_UNAVAILABLE"),
|
||||||
|
("json", "PROVIDER_INVALID_RESPONSE"),
|
||||||
|
])
|
||||||
|
def test_embedding_http_failures_use_injected_local(rig, failure, reason):
|
||||||
|
bind(rig)
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
if failure == "timeout":
|
||||||
|
raise httpx.ReadTimeout("simulated timeout", request=request)
|
||||||
|
if failure == "connect":
|
||||||
|
raise httpx.ConnectError("simulated connection failure", request=request)
|
||||||
|
if failure == "json":
|
||||||
|
return httpx.Response(200, content=b"not JSON")
|
||||||
|
return response({"error": "failed"}, failure)
|
||||||
|
|
||||||
|
rig.http.handler = handler
|
||||||
|
assert_local(rig, run(rig.service.embed(["text"])), ["text"], reason)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("failure, reason", [
|
||||||
|
("missing-key", "PROVIDER_CREDENTIAL_MISSING"),
|
||||||
|
("unreadable-key", "PROVIDER_CREDENTIAL_UNAVAILABLE"),
|
||||||
|
("disabled-provider", "PROVIDER_UNAVAILABLE"),
|
||||||
|
])
|
||||||
|
def test_unavailable_remote_configuration_falls_back_without_http(rig, failure, reason):
|
||||||
|
bind(rig)
|
||||||
|
if failure == "missing-key":
|
||||||
|
rig.credentials.value = None
|
||||||
|
elif failure == "unreadable-key":
|
||||||
|
rig.credentials.error = CredentialStoreError("fake unavailable store")
|
||||||
|
else:
|
||||||
|
config = rig.providers.get_any("test-provider").config.model_copy(update={"enabled": False})
|
||||||
|
rig.providers.replace(config, MockProvider())
|
||||||
|
assert_local(rig, run(rig.service.embed(["text"])), ["text"], reason)
|
||||||
|
assert rig.requests == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("capability", ["transcription", "speaker_matching"])
|
||||||
|
def test_media_success_sends_expected_multipart_and_closes_files(rig, audio, monkeypatch, capability):
|
||||||
|
bind(rig, capability)
|
||||||
|
handles = track_media_handles(rig, monkeypatch)
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
assert request.headers["authorization"] == "Bearer unit-test-placeholder"
|
||||||
|
assert str(request.url).endswith("/audio/transcriptions" if capability == "transcription" else "/audio/speaker-matches")
|
||||||
|
message = BytesParser(policy=policy.default).parsebytes(
|
||||||
|
b"Content-Type: " + request.headers["content-type"].encode() + b"\r\nMIME-Version: 1.0\r\n\r\n" + request.content,
|
||||||
|
)
|
||||||
|
parts = {part.get_param("name", header="content-disposition"): part for part in message.iter_parts()}
|
||||||
|
assert parts["model"].get_payload(decode=True) == b"test-model"
|
||||||
|
assert parts["file"].get_filename() == audio[0].name
|
||||||
|
assert parts["file"].get_payload(decode=True) == audio[0].read_bytes()
|
||||||
|
if capability == "transcription":
|
||||||
|
assert set(parts) == {"model", "language", "file"}
|
||||||
|
assert parts["language"].get_payload(decode=True) == b"zh"
|
||||||
|
return response({"text": "remote transcript"})
|
||||||
|
assert set(parts) == {"model", "file", "reference_file"}
|
||||||
|
assert parts["reference_file"].get_filename() == audio[1].name
|
||||||
|
assert parts["reference_file"].get_payload(decode=True) == audio[1].read_bytes()
|
||||||
|
return response({"score": 0.875})
|
||||||
|
|
||||||
|
rig.http.handler = handler
|
||||||
|
result = run(media_call(rig, capability, audio))
|
||||||
|
assert result.source == "api" and result.fallback_reason is None
|
||||||
|
assert result.text == "remote transcript" if capability == "transcription" else result.score == 0.875
|
||||||
|
assert len(handles) == (1 if capability == "transcription" else 2)
|
||||||
|
assert all(handle.closed for handle in handles)
|
||||||
|
assert rig.speech.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("capability, data", [
|
||||||
|
("transcription", {}), ("transcription", {"text": " "}), ("transcription", {"text": False}),
|
||||||
|
("transcription", {"error": "in-band", "text": "must not use"}),
|
||||||
|
("speaker_matching", {}), ("speaker_matching", {"score": -0.1}),
|
||||||
|
("speaker_matching", {"score": 1.1}), ("speaker_matching", {"score": True}),
|
||||||
|
("speaker_matching", {"score": float("nan")}), ("speaker_matching", {"score": "0.5"}),
|
||||||
|
("speaker_matching", {"error": "in-band", "score": 0.9}),
|
||||||
|
])
|
||||||
|
def test_invalid_remote_media_falls_back_to_injected_local(rig, audio, monkeypatch, capability, data):
|
||||||
|
bind(rig, capability)
|
||||||
|
handles = track_media_handles(rig, monkeypatch)
|
||||||
|
rig.http.handler = lambda request: response(data)
|
||||||
|
result = run(media_call(rig, capability, audio))
|
||||||
|
assert result.source == "local" and result.fallback_reason == "PROVIDER_INVALID_RESPONSE"
|
||||||
|
assert result.text == "local transcript" if capability == "transcription" else result.score == 0.25
|
||||||
|
assert rig.speech.calls == [
|
||||||
|
("transcribe", audio[0], "zh") if capability == "transcription" else ("match", *audio)
|
||||||
|
]
|
||||||
|
assert handles and all(handle.closed for handle in handles)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("capability", ["transcription", "speaker_matching"])
|
||||||
|
@pytest.mark.parametrize("configured", [False, True])
|
||||||
|
def test_pending_local_backend_has_explicit_503_and_fallback_details(rig, audio, capability, configured):
|
||||||
|
rig.service.local_speech = PendingSpeechBackend()
|
||||||
|
if configured:
|
||||||
|
bind(rig, capability)
|
||||||
|
rig.http.handler = lambda request: response({"error": "unauthorized"}, 401)
|
||||||
|
with pytest.raises(ApiError) as caught:
|
||||||
|
run(media_call(rig, capability, audio))
|
||||||
|
assert caught.value.status_code == 503
|
||||||
|
assert caught.value.code == "LOCAL_MODEL_NOT_INSTALLED"
|
||||||
|
assert caught.value.details == {"fallback_reason": "PROVIDER_AUTH_FAILED" if configured else None}
|
||||||
|
statuses = {item.capability: item.status for item in rig.service.describe().local_backends}
|
||||||
|
assert statuses["transcription"] == statuses["speaker_matching"] == "not_installed"
|
||||||
|
assert len(rig.requests) == int(configured)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("capability", ["transcription", "speaker_matching"])
|
||||||
|
def test_invalid_local_speech_returns_explicit_503(rig, audio, capability):
|
||||||
|
rig.speech.text = ""
|
||||||
|
rig.speech.score = True
|
||||||
|
with pytest.raises(ApiError) as caught:
|
||||||
|
run(media_call(rig, capability, audio))
|
||||||
|
assert (caught.value.status_code, caught.value.code) == (503, "LOCAL_MODEL_INVALID_RESPONSE")
|
||||||
|
assert caught.value.details == {"fallback_reason": None}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("capability", ["embedding", "transcription", "speaker_matching"])
|
||||||
|
@pytest.mark.parametrize("stage", ["remote", "local"])
|
||||||
|
def test_cancellation_propagates_and_upload_handles_close(rig, audio, monkeypatch, capability, stage):
|
||||||
|
bind(rig, capability)
|
||||||
|
handles = track_media_handles(rig, monkeypatch)
|
||||||
|
|
||||||
|
async def cancelled(request):
|
||||||
|
raise asyncio.CancelledError()
|
||||||
|
|
||||||
|
if stage == "remote":
|
||||||
|
rig.http.handler = cancelled
|
||||||
|
else:
|
||||||
|
rig.http.handler = lambda request: response({"error": "fallback"}, 500)
|
||||||
|
rig.embedding.error = rig.speech.error = asyncio.CancelledError()
|
||||||
|
operation = rig.service.embed(["text"]) if capability == "embedding" else media_call(rig, capability, audio)
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
run(operation)
|
||||||
|
assert len(handles) == {"embedding": 0, "transcription": 1, "speaker_matching": 2}[capability]
|
||||||
|
assert all(handle.closed for handle in handles)
|
||||||
|
if stage == "remote":
|
||||||
|
assert rig.embedding.calls == rig.speech.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_reference_closes_already_open_source(rig, audio, monkeypatch):
|
||||||
|
bind(rig, "speaker_matching")
|
||||||
|
handles = track_media_handles(rig, monkeypatch)
|
||||||
|
audio[1].unlink()
|
||||||
|
with pytest.raises(ApiError) as caught:
|
||||||
|
run(rig.service.match_speakers(*audio))
|
||||||
|
assert caught.value.status_code == 404
|
||||||
|
assert len(handles) == 1 and handles[0].closed
|
||||||
|
assert rig.requests == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_optimistic_conflict_preserves_saved_bindings(rig):
|
||||||
|
assert rig.service.configuration().version == 0
|
||||||
|
saved = bind(rig).config
|
||||||
|
assert saved.version == 1
|
||||||
|
with pytest.raises(ApiError) as caught:
|
||||||
|
rig.service.update(ModelRoutingConfig(version=0))
|
||||||
|
assert (caught.value.status_code, caught.value.code) == (409, "MODEL_ROUTING_VERSION_CONFLICT")
|
||||||
|
assert rig.service.configuration() == saved
|
||||||
|
assert rig.service.uses_provider("test-provider")
|
||||||
|
assert not rig.service.uses_provider("not-a-provider")
|
||||||
|
cleared = rig.service.update(ModelRoutingConfig(version=1)).config
|
||||||
|
assert cleared.version == 2 and cleared.embedding is None
|
||||||
|
assert not rig.service.uses_provider("test-provider")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("capability", ["embedding", "transcription", "speaker_matching"])
|
||||||
|
@pytest.mark.parametrize("provider_id, code", [
|
||||||
|
("missing", "PROVIDER_NOT_FOUND"), ("unsupported", "MODEL_ROUTING_PROTOCOL_UNSUPPORTED"),
|
||||||
|
])
|
||||||
|
def test_config_references_require_existing_supported_providers(rig, capability, provider_id, code):
|
||||||
|
rig.providers.register(
|
||||||
|
ProviderConfig(provider_id="unsupported", provider_type=ProviderType.ollama, name="unsupported"), MockProvider(),
|
||||||
|
)
|
||||||
|
with pytest.raises(ApiError) as caught:
|
||||||
|
bind(rig, capability, provider_id=provider_id)
|
||||||
|
assert (caught.value.status_code, caught.value.code) == (422, code)
|
||||||
|
assert rig.service.configuration() == ModelRoutingConfig()
|
||||||
|
assert rig.requests == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def api(monkeypatch, no_real_http, _isolate_data_dir):
|
||||||
|
# Import the production container only after temporary storage is configured.
|
||||||
|
from app import container as container_module, routes
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
containers = []
|
||||||
|
|
||||||
|
def restart():
|
||||||
|
container = container_module.build_container()
|
||||||
|
container.model_routing.credentials = FakeCredentials()
|
||||||
|
|
||||||
|
def unexpected(request):
|
||||||
|
pytest.fail(f"Unexpected API-side provider HTTP: {request.url}")
|
||||||
|
|
||||||
|
container.model_routing.transport = httpx.MockTransport(unexpected)
|
||||||
|
monkeypatch.setattr(container_module, "container", container)
|
||||||
|
monkeypatch.setattr(routes, "container", container)
|
||||||
|
containers.append(container)
|
||||||
|
return container
|
||||||
|
|
||||||
|
container = restart()
|
||||||
|
client = TestClient(app)
|
||||||
|
yield SimpleNamespace(client=client, container=container, restart=restart)
|
||||||
|
client.close()
|
||||||
|
for container in containers:
|
||||||
|
container.plugins.shutdown()
|
||||||
|
container.mcp_servers.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def create_api_provider(api):
|
||||||
|
result = api.client.post("/api/providers", json={
|
||||||
|
"provider_type": "openai_compatible", "name": "Persisted fake",
|
||||||
|
"base_url": "https://persist.invalid/v1", "default_model": "fake-model",
|
||||||
|
})
|
||||||
|
assert result.status_code == 200, result.text
|
||||||
|
return result.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_config_conflict_reference_delete_and_restart_persistence(api):
|
||||||
|
provider = create_api_provider(api)
|
||||||
|
provider_id = provider["provider_id"]
|
||||||
|
assert api.client.get("/api/model-routing").json()["config"]["version"] == 0
|
||||||
|
config = {"version": 0, "embedding": {"provider_id": provider_id, "model": "embed-model", "endpoint": "/embeddings"}}
|
||||||
|
saved = api.client.put("/api/model-routing", json=config)
|
||||||
|
assert saved.status_code == 200
|
||||||
|
assert saved.json()["config"]["version"] == 1
|
||||||
|
conflict = api.client.put("/api/model-routing", json=config)
|
||||||
|
assert conflict.status_code == 409
|
||||||
|
assert conflict.json()["error"]["code"] == "MODEL_ROUTING_VERSION_CONFLICT"
|
||||||
|
blocked = api.client.delete(f"/api/providers/{provider_id}")
|
||||||
|
assert blocked.status_code == 409 and blocked.json()["error"]["code"] == "PROVIDER_IN_USE"
|
||||||
|
restarted = api.restart()
|
||||||
|
assert restarted.providers.get_any(provider_id).config.model_dump(mode="json") == provider
|
||||||
|
assert api.client.get("/api/model-routing").json()["config"] == saved.json()["config"]
|
||||||
|
assert {item["provider_id"] for item in api.client.get("/api/providers").json()["items"]} == {"mock", provider_id}
|
||||||
|
cleared = api.client.put("/api/model-routing", json={"version": 1})
|
||||||
|
assert cleared.status_code == 200
|
||||||
|
assert api.client.delete(f"/api/providers/{provider_id}").status_code == 200
|
||||||
|
api.restart()
|
||||||
|
assert api.client.get(f"/api/providers/{provider_id}").status_code == 404
|
||||||
|
assert api.client.get("/api/model-routing").json()["config"]["version"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_provider_type_patch_rebuilds_adapter_and_persists(api):
|
||||||
|
from app.providers.anthropic_messages import AnthropicMessagesProvider
|
||||||
|
|
||||||
|
provider = create_api_provider(api)
|
||||||
|
provider_id = provider["provider_id"]
|
||||||
|
changed = api.client.patch(f"/api/providers/{provider_id}", json={
|
||||||
|
"provider_type": "anthropic_messages", "base_url": "https://anthropic.invalid/v1",
|
||||||
|
})
|
||||||
|
assert changed.status_code == 200, changed.text
|
||||||
|
assert changed.json()["provider_type"] == "anthropic_messages"
|
||||||
|
assert changed.json()["name"] == provider["name"]
|
||||||
|
assert isinstance(api.container.providers.get_any(provider_id).adapter, AnthropicMessagesProvider)
|
||||||
|
restarted = api.restart()
|
||||||
|
assert isinstance(restarted.providers.get_any(provider_id).adapter, AnthropicMessagesProvider)
|
||||||
|
assert api.client.get(f"/api/providers/{provider_id}").json() == changed.json()
|
||||||
|
for invalid_type in (None, "mock", "nonexistent-type"):
|
||||||
|
rejected = api.client.patch(f"/api/providers/{provider_id}", json={"provider_type": invalid_type})
|
||||||
|
assert rejected.status_code == 422
|
||||||
|
assert api.client.get(f"/api/providers/{provider_id}").json() == changed.json()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("endpoint", ["https://elsewhere.invalid/embed", "//elsewhere.invalid/embed", "relative", "/../embed", "/embed?key=test"])
|
||||||
|
def test_api_config_rejects_non_provider_endpoint_paths(api, endpoint):
|
||||||
|
provider = create_api_provider(api)
|
||||||
|
result = api.client.put("/api/model-routing", json={
|
||||||
|
"embedding": {"provider_id": provider["provider_id"], "model": "embed", "endpoint": endpoint},
|
||||||
|
})
|
||||||
|
assert result.status_code == 422
|
||||||
|
assert api.client.get("/api/model-routing").json()["config"]["version"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_embedding_reports_remote_and_fallback_sources(api):
|
||||||
|
provider = create_api_provider(api)
|
||||||
|
assert api.client.put("/api/model-routing", json={
|
||||||
|
"embedding": {"provider_id": provider["provider_id"], "model": "embed", "endpoint": "/embeddings"},
|
||||||
|
}).status_code == 200
|
||||||
|
api.container.model_routing.transport = httpx.MockTransport(
|
||||||
|
lambda request: response({"data": [{"index": 0, "embedding": [3, 4]}]}),
|
||||||
|
)
|
||||||
|
result = api.client.post("/api/models/embeddings", json={"texts": ["hello"]})
|
||||||
|
assert result.status_code == 200
|
||||||
|
assert result.json()["source"] == "api" and result.json()["vectors"][0] == pytest.approx([0.6, 0.8])
|
||||||
|
api.container.model_routing.transport = httpx.MockTransport(lambda request: response({"error": "denied"}, 401))
|
||||||
|
result = api.client.post("/api/models/embeddings", json={"texts": ["hello"]})
|
||||||
|
assert result.status_code == 200
|
||||||
|
assert result.json()["source"] == "local" and result.json()["model_id"] == "hash-v1"
|
||||||
|
assert result.json()["fallback_reason"] == "PROVIDER_AUTH_FAILED"
|
||||||
|
assert api.client.post("/api/models/embeddings", json={"texts": []}).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_speech_failure_reports_reason_in_503_and_transcription_job(api):
|
||||||
|
from app.services.attachment_service import attachment_path
|
||||||
|
|
||||||
|
source, reference = attachment_path("audio.wav"), attachment_path("reference.wav")
|
||||||
|
source.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
source.write_bytes(b"test audio")
|
||||||
|
reference.write_bytes(b"test reference")
|
||||||
|
provider = create_api_provider(api)
|
||||||
|
assert api.client.put("/api/model-routing", json={
|
||||||
|
"transcription": {"provider_id": provider["provider_id"], "model": "asr", "endpoint": "/audio/transcriptions"},
|
||||||
|
"speaker_matching": {"provider_id": provider["provider_id"], "model": "voice", "endpoint": "/audio/speaker-matches"},
|
||||||
|
}).status_code == 200
|
||||||
|
api.container.model_routing.transport = httpx.MockTransport(lambda request: response({"error": "offline"}, 500))
|
||||||
|
match = api.client.post("/api/media/speaker-matches", json={"attachment_id": source.name, "reference_attachment_id": reference.name})
|
||||||
|
assert match.status_code == 503
|
||||||
|
assert match.json()["error"]["code"] == "LOCAL_MODEL_NOT_INSTALLED"
|
||||||
|
assert match.json()["error"]["details"] == {"fallback_reason": "PROVIDER_UNAVAILABLE"}
|
||||||
|
transcript = api.client.post("/api/media/transcriptions", json={"attachment_id": source.name, "language": "zh"})
|
||||||
|
assert transcript.status_code == 202
|
||||||
|
job = transcript.json()
|
||||||
|
assert job["status"] == "failed" and job["error_code"] == "LOCAL_MODEL_NOT_INSTALLED"
|
||||||
|
assert job["fallback_reason"] == "PROVIDER_UNAVAILABLE"
|
||||||
|
assert api.client.get(f"/api/media/transcriptions/{job['job_id']}").json() == job
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("capability", ["embedding", "speaker_matching"])
|
||||||
|
def test_out_of_float_range_json_number_is_invalid_remote_and_falls_back(rig, audio, capability):
|
||||||
|
"""JSON integers may be finite but too large to convert to a Python float."""
|
||||||
|
bind(rig, capability)
|
||||||
|
data = {"data": [{"index": 0, "embedding": [10 ** 400, 1]}]} if capability == "embedding" else {"score": 10 ** 400}
|
||||||
|
rig.http.handler = lambda request: response(data)
|
||||||
|
if capability == "embedding":
|
||||||
|
assert_local(rig, run(rig.service.embed(["text"])), ["text"], "PROVIDER_INVALID_RESPONSE")
|
||||||
|
else:
|
||||||
|
result = run(media_call(rig, capability, audio))
|
||||||
|
assert result.source == "local" and result.score == rig.speech.score
|
||||||
|
assert result.fallback_reason == "PROVIDER_INVALID_RESPONSE"
|
||||||
@@ -84,7 +84,7 @@ def test_openai_compatible_maps_tool_call_and_credentials() -> None:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert captured["tools"][0]["function"]["name"] == "math.add"
|
assert captured["tools"][0]["function"]["name"].startswith("tool_")
|
||||||
assert turn.tool_calls[0].name == "math.add"
|
assert turn.tool_calls[0].name == "math.add"
|
||||||
assert turn.tool_calls[0].arguments == {"left": 1, "right": 2}
|
assert turn.tool_calls[0].arguments == {"left": 1, "right": 2}
|
||||||
assert turn.input_tokens == 8
|
assert turn.input_tokens == 8
|
||||||
|
|||||||
@@ -0,0 +1,610 @@
|
|||||||
|
"""Wire-level provider tests: no credentials, SDKs, clocks, or network services."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.contracts import Message, MessageRole, ModelCapability, ModelEventType as E, ModelRequest, ToolCall, ToolDefinition
|
||||||
|
from app.providers.anthropic_messages import AnthropicMessagesProvider
|
||||||
|
from app.providers.base import ProviderError
|
||||||
|
from app.providers.ollama import OllamaProvider
|
||||||
|
from app.providers.openai_compatible import OpenAICompatibleProvider
|
||||||
|
from app.providers.openai_responses import OpenAIResponsesProvider
|
||||||
|
|
||||||
|
|
||||||
|
NATIVE = ["responses", "anthropic"]
|
||||||
|
PROTOCOLS = [*NATIVE, "compatible", "ollama"]
|
||||||
|
SECRET = "test-only-sensitive-upstream-body"
|
||||||
|
|
||||||
|
|
||||||
|
class Credentials:
|
||||||
|
def resolve(self, credential_id):
|
||||||
|
return SECRET if credential_id else None
|
||||||
|
|
||||||
|
|
||||||
|
class Bytes(httpx.AsyncByteStream):
|
||||||
|
def __init__(self, body: bytes, *, fragment: int = 17):
|
||||||
|
self.body = body
|
||||||
|
self.fragment = fragment
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def __aiter__(self):
|
||||||
|
for offset in range(0, len(self.body), self.fragment):
|
||||||
|
yield self.body[offset:offset + self.fragment]
|
||||||
|
|
||||||
|
async def aclose(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
class GatedBytes(Bytes):
|
||||||
|
def __init__(self, body):
|
||||||
|
super().__init__(body)
|
||||||
|
self.waiting = asyncio.Event()
|
||||||
|
self.release = asyncio.Event()
|
||||||
|
|
||||||
|
async def __aiter__(self):
|
||||||
|
yield self.body
|
||||||
|
self.waiting.set()
|
||||||
|
await self.release.wait()
|
||||||
|
|
||||||
|
|
||||||
|
def provider(protocol, handler, *, credential_id="test"):
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
if protocol == "ollama":
|
||||||
|
return OllamaProvider("https://provider.test", transport=transport)
|
||||||
|
cls = {"responses": OpenAIResponsesProvider, "anthropic": AnthropicMessagesProvider,
|
||||||
|
"compatible": OpenAICompatibleProvider}[protocol]
|
||||||
|
return cls("https://provider.test/v1/", credential_id, Credentials(), transport=transport)
|
||||||
|
|
||||||
|
|
||||||
|
def request(*, history=False):
|
||||||
|
messages = [Message(role=MessageRole.user, content="查笔记")]
|
||||||
|
if history:
|
||||||
|
messages += [
|
||||||
|
Message(role=MessageRole.system, content="Additional rules"),
|
||||||
|
Message(role=MessageRole.assistant, content="Checking", tool_calls=[
|
||||||
|
ToolCall(tool_call_id="old_1", name="lookup", arguments={"query": "a"}),
|
||||||
|
ToolCall(tool_call_id="old_2", name="lookup", arguments={"query": "b"}),
|
||||||
|
]),
|
||||||
|
Message(role=MessageRole.tool, tool_call_id="old_1", content='{"found":1}'),
|
||||||
|
Message(role=MessageRole.tool, tool_call_id="old_2", content='{"found":2}'),
|
||||||
|
]
|
||||||
|
return ModelRequest(
|
||||||
|
provider_id="test", model="model", system="System rules", messages=messages,
|
||||||
|
tools=[ToolDefinition(name="lookup", description="Find notes", parameters={"type": "object"})],
|
||||||
|
max_tokens=512, temperature=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def collect(iterator):
|
||||||
|
return [event async for event in iterator]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", ["lookup", "notes.search"])
|
||||||
|
def test_compatible_split_tool_name_preserves_identity(name):
|
||||||
|
from app.providers.tool_names import prepare_tool_names
|
||||||
|
req = request()
|
||||||
|
req.tools[0].name = name
|
||||||
|
wire, _ = prepare_tool_names(req)
|
||||||
|
alias = wire.tools[0].name
|
||||||
|
|
||||||
|
def handler(_):
|
||||||
|
return httpx.Response(200, content=sse(
|
||||||
|
{"choices": [{"delta": {"tool_calls": [{"index": 0, "id": "call_1",
|
||||||
|
"function": {"name": alias[:3], "arguments": ""}}]}}]},
|
||||||
|
{"choices": [{"delta": {"tool_calls": [{"index": 0,
|
||||||
|
"function": {"name": alias[3:], "arguments": '{"query":"x"}'}}]},
|
||||||
|
"finish_reason": "tool_calls"}]},
|
||||||
|
{"type": "[DONE]"},
|
||||||
|
))
|
||||||
|
|
||||||
|
events = asyncio.run(collect(provider("compatible", handler).stream(req)))
|
||||||
|
assert [e.data["name"] for e in events if e.event == E.tool_call_start] == [name]
|
||||||
|
assert json.loads("".join(e.data["arguments_delta"] for e in events
|
||||||
|
if e.event == E.tool_call_delta)) == {"query": "x"}
|
||||||
|
assert events[-1].data["status"] == "completed"
|
||||||
|
|
||||||
|
|
||||||
|
def sse(*events):
|
||||||
|
return "".join(
|
||||||
|
f"event: {event.get('type', 'message')}\r\ndata: {json.dumps(event, ensure_ascii=False)}\r\n\r\n"
|
||||||
|
for event in events
|
||||||
|
).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def wire(protocol, *events):
|
||||||
|
if protocol == "ollama":
|
||||||
|
return ("\n".join(json.dumps(event, ensure_ascii=False) for event in events) + "\n").encode()
|
||||||
|
return sse(*events)
|
||||||
|
|
||||||
|
|
||||||
|
def start(protocol):
|
||||||
|
if protocol == "responses":
|
||||||
|
return [{"type": "response.output_text.delta", "delta": "你好"}]
|
||||||
|
if protocol == "anthropic":
|
||||||
|
return [{"type": "message_start", "message": {"usage": {"input_tokens": 7, "output_tokens": 0}}},
|
||||||
|
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
|
||||||
|
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "你好"}}]
|
||||||
|
if protocol == "compatible":
|
||||||
|
return [{"choices": [{"delta": {"content": "你好"}}]}]
|
||||||
|
return [{"message": {"content": "你好"}, "done": False}]
|
||||||
|
|
||||||
|
|
||||||
|
def terminal(protocol):
|
||||||
|
if protocol == "responses":
|
||||||
|
return [{"type": "response.completed", "response": {"status": "completed", "usage": {"input_tokens": 7, "output_tokens": 2}}}]
|
||||||
|
if protocol == "anthropic":
|
||||||
|
return [{"type": "content_block_stop", "index": 0},
|
||||||
|
{"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 2}},
|
||||||
|
{"type": "message_stop"}]
|
||||||
|
if protocol == "compatible":
|
||||||
|
return [{"choices": [{"delta": {}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 7, "completion_tokens": 2}}]
|
||||||
|
return [{"message": {}, "done": True, "prompt_eval_count": 7, "eval_count": 2}]
|
||||||
|
|
||||||
|
|
||||||
|
def assert_events(events):
|
||||||
|
assert events[-1].event == E.done
|
||||||
|
assert events[-1].data["status"] == ("failed" if any(event.event == E.error for event in events) else "completed")
|
||||||
|
assert sum(event.event == E.done for event in events) == 1
|
||||||
|
assert [event.sequence for event in events] == list(range(len(events)))
|
||||||
|
assert all(event.timestamp.tzinfo is not None for event in events)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_error(events, code):
|
||||||
|
assert_events(events)
|
||||||
|
assert events[-2].event == E.error
|
||||||
|
assert events[-2].data["code"] == code
|
||||||
|
assert SECRET not in str(events[-2].data)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("protocol", NATIVE)
|
||||||
|
def test_native_completion_and_history(protocol):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def handler(req):
|
||||||
|
captured.update(json.loads(req.content))
|
||||||
|
assert req.url.path == ("/v1/responses" if protocol == "responses" else "/v1/messages")
|
||||||
|
if protocol == "responses":
|
||||||
|
assert req.headers["authorization"] == f"Bearer {SECRET}"
|
||||||
|
body = {"status": "completed", "output": [
|
||||||
|
{"type": "reasoning", "summary": [{"type": "summary_text", "text": "thinking"}]},
|
||||||
|
{"type": "message", "content": [{"type": "output_text", "text": "完成"}]},
|
||||||
|
{"type": "function_call", "call_id": "next", "name": "lookup", "arguments": '{"query":"c"}'},
|
||||||
|
], "usage": {"input_tokens": 10, "output_tokens": 3}}
|
||||||
|
else:
|
||||||
|
assert "authorization" not in req.headers
|
||||||
|
assert req.headers["x-api-key"] == SECRET
|
||||||
|
assert req.headers["anthropic-version"] == "2023-06-01"
|
||||||
|
body = {"type": "message", "content": [
|
||||||
|
{"type": "thinking", "thinking": "thinking", "signature": "sig"},
|
||||||
|
{"type": "text", "text": "完成"},
|
||||||
|
{"type": "tool_use", "id": "next", "name": "lookup", "input": {"query": "c"}},
|
||||||
|
], "usage": {"input_tokens": 5, "cache_creation_input_tokens": 2, "cache_read_input_tokens": 3, "output_tokens": 3}}
|
||||||
|
return httpx.Response(200, json=body)
|
||||||
|
|
||||||
|
turn = asyncio.run(provider(protocol, handler).complete(request(history=True)))
|
||||||
|
assert turn.text == "完成"
|
||||||
|
assert (turn.input_tokens, turn.output_tokens) == (10, 3)
|
||||||
|
assert turn.tool_calls[0].tool_call_id == "next"
|
||||||
|
assert turn.tool_calls[0].arguments == {"query": "c"}
|
||||||
|
assert captured["stream"] is False
|
||||||
|
assert captured["temperature"] == 0
|
||||||
|
if protocol == "responses":
|
||||||
|
assert captured["instructions"] == "System rules"
|
||||||
|
assert captured["max_output_tokens"] == 512
|
||||||
|
assert captured["tools"][0]["parameters"] == {"type": "object"}
|
||||||
|
calls = [item for item in captured["input"] if item.get("type") == "function_call"]
|
||||||
|
outputs = [item for item in captured["input"] if item.get("type") == "function_call_output"]
|
||||||
|
assert [call["call_id"] for call in calls] == ["old_1", "old_2"]
|
||||||
|
assert json.loads(calls[1]["arguments"]) == {"query": "b"}
|
||||||
|
assert outputs == [{"type": "function_call_output", "call_id": "old_1", "output": '{"found":1}'},
|
||||||
|
{"type": "function_call_output", "call_id": "old_2", "output": '{"found":2}'}]
|
||||||
|
assert {"role": "system", "content": "Additional rules"} in captured["input"]
|
||||||
|
else:
|
||||||
|
assert captured["system"] == "System rules\n\nAdditional rules"
|
||||||
|
assert captured["max_tokens"] == 512
|
||||||
|
assert captured["tools"][0]["input_schema"] == {"type": "object"}
|
||||||
|
assert captured["messages"][1]["content"][2] == {
|
||||||
|
"type": "tool_use", "id": "old_2", "name": "lookup", "input": {"query": "b"},
|
||||||
|
}
|
||||||
|
assert captured["messages"][-1] == {"role": "user", "content": [
|
||||||
|
{"type": "tool_result", "tool_use_id": "old_1", "content": '{"found":1}'},
|
||||||
|
{"type": "tool_result", "tool_use_id": "old_2", "content": '{"found":2}'},
|
||||||
|
]}
|
||||||
|
|
||||||
|
|
||||||
|
def responses_tool_events():
|
||||||
|
events = [
|
||||||
|
{"type": "response.created", "response": {"usage": {"input_tokens": 10, "output_tokens": 0}}},
|
||||||
|
{"type": "response.reasoning_summary_text.delta", "delta": "计划"},
|
||||||
|
{"type": "response.output_text.delta", "delta": "查"},
|
||||||
|
{"type": "response.output_text.delta", "delta": "找"},
|
||||||
|
]
|
||||||
|
for index in (2, 3):
|
||||||
|
events.append({"type": "response.output_item.added", "output_index": index, "item": {
|
||||||
|
"id": f"item_{index}", "type": "function_call", "call_id": f"call_{index}", "name": "lookup", "arguments": "",
|
||||||
|
}})
|
||||||
|
for index, fragment in [(2, '{"query":'), (3, '{}'), (2, '"笔记"}')]:
|
||||||
|
events.append({"type": "response.function_call_arguments.delta", "output_index": index,
|
||||||
|
"item_id": f"item_{index}", "delta": fragment})
|
||||||
|
for index, arguments in [(3, '{}'), (2, '{"query":"笔记"}')]:
|
||||||
|
events += [
|
||||||
|
{"type": "response.function_call_arguments.done", "output_index": index, "item_id": f"item_{index}", "arguments": arguments},
|
||||||
|
{"type": "response.output_item.done", "output_index": index, "item": {
|
||||||
|
"id": f"item_{index}", "type": "function_call", "call_id": f"call_{index}", "name": "lookup", "arguments": arguments,
|
||||||
|
}},
|
||||||
|
]
|
||||||
|
events += [{"type": "future.event"}, {"type": "response.completed", "response": {
|
||||||
|
"status": "completed", "usage": {"input_tokens": 10, "output_tokens": 9},
|
||||||
|
}}]
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def anthropic_tool_events():
|
||||||
|
events = [
|
||||||
|
{"type": "message_start", "message": {"usage": {
|
||||||
|
"input_tokens": 5, "cache_read_input_tokens": 3, "cache_creation_input_tokens": 2, "output_tokens": 1,
|
||||||
|
}}},
|
||||||
|
{"type": "ping"},
|
||||||
|
{"type": "content_block_start", "index": 0, "content_block": {"type": "thinking", "thinking": ""}},
|
||||||
|
{"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "计划"}},
|
||||||
|
{"type": "content_block_delta", "index": 0, "delta": {"type": "signature_delta", "signature": "sig"}},
|
||||||
|
{"type": "content_block_stop", "index": 0},
|
||||||
|
{"type": "content_block_start", "index": 1, "content_block": {"type": "text", "text": "查"}},
|
||||||
|
{"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "找"}},
|
||||||
|
{"type": "content_block_stop", "index": 1},
|
||||||
|
]
|
||||||
|
for index, fragments in [(2, ['{"query":', '"笔记"}']), (3, [])]:
|
||||||
|
events.append({"type": "content_block_start", "index": index, "content_block": {
|
||||||
|
"type": "tool_use", "id": f"call_{index}", "name": "lookup", "input": {},
|
||||||
|
}})
|
||||||
|
for fragment in fragments:
|
||||||
|
events.append({"type": "content_block_delta", "index": index,
|
||||||
|
"delta": {"type": "input_json_delta", "partial_json": fragment}})
|
||||||
|
events.append({"type": "content_block_stop", "index": index})
|
||||||
|
events += [
|
||||||
|
{"type": "message_delta", "delta": {"stop_reason": "tool_use"}, "usage": {"output_tokens": 4}},
|
||||||
|
{"type": "future.event"},
|
||||||
|
{"type": "message_delta", "delta": {}, "usage": {"output_tokens": 9}},
|
||||||
|
{"type": "message_stop"},
|
||||||
|
]
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("protocol", NATIVE)
|
||||||
|
def test_native_stream_tools_reasoning_usage_and_fragmented_utf8(protocol):
|
||||||
|
frames = responses_tool_events() if protocol == "responses" else anthropic_tool_events()
|
||||||
|
body = Bytes(b": comment\r\n\r\n" + sse(*frames) + b"data: malformed after completion\n\n", fragment=1)
|
||||||
|
|
||||||
|
def handler(req):
|
||||||
|
payload = json.loads(req.content)
|
||||||
|
assert payload["stream"] is True
|
||||||
|
assert payload["tools"]
|
||||||
|
assert (payload.get("input") or payload.get("messages"))
|
||||||
|
return httpx.Response(200, stream=body)
|
||||||
|
|
||||||
|
events = asyncio.run(collect(provider(protocol, handler).stream(request(history=True))))
|
||||||
|
assert_events(events)
|
||||||
|
assert not any(event.event == E.error for event in events)
|
||||||
|
assert [event.data["text"] for event in events if event.event == E.text_delta] == ["查", "找"]
|
||||||
|
assert [event.data["text"] for event in events if event.event == E.thinking_delta] == ["计划"]
|
||||||
|
assert [event.data["tool_call_id"] for event in events if event.event == E.tool_call_start] == ["call_2", "call_3"]
|
||||||
|
assert sorted(event.data["tool_call_id"] for event in events if event.event == E.tool_call_end) == ["call_2", "call_3"]
|
||||||
|
for call_id, expected in [("call_2", {"query": "笔记"}), ("call_3", {})]:
|
||||||
|
arguments = "".join(event.data["arguments_delta"] for event in events
|
||||||
|
if event.event == E.tool_call_delta and event.data["tool_call_id"] == call_id)
|
||||||
|
assert json.loads(arguments) == expected
|
||||||
|
usages = [event.data for event in events if event.event == E.usage]
|
||||||
|
assert usages[-1] == {"input_tokens": 10, "output_tokens": 9, "total_tokens": 19}
|
||||||
|
assert all(usage["input_tokens"] == 10 for usage in usages)
|
||||||
|
if protocol == "anthropic":
|
||||||
|
assert [usage["output_tokens"] for usage in usages] == [1, 4, 9]
|
||||||
|
assert body.closed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("protocol", PROTOCOLS)
|
||||||
|
def test_stream_terminal_usage_and_closure(protocol):
|
||||||
|
body = Bytes(wire(protocol, *start(protocol), *terminal(protocol)))
|
||||||
|
events = asyncio.run(collect(provider(protocol, lambda _: httpx.Response(200, stream=body)).stream(request())))
|
||||||
|
assert_events(events)
|
||||||
|
assert not any(event.event == E.error for event in events)
|
||||||
|
assert [event.data["text"] for event in events if event.event == E.text_delta] == ["你好"]
|
||||||
|
assert [event.data for event in events if event.event == E.usage][-1] == {"input_tokens": 7, "output_tokens": 2, "total_tokens": 9}
|
||||||
|
assert body.closed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("protocol", PROTOCOLS)
|
||||||
|
@pytest.mark.parametrize("empty", [False, True])
|
||||||
|
def test_truncated_stream(protocol, empty):
|
||||||
|
body = Bytes(b"" if empty else wire(protocol, *start(protocol)))
|
||||||
|
events = asyncio.run(collect(provider(protocol, lambda _: httpx.Response(200, stream=body)).stream(request())))
|
||||||
|
assert_error(events, "PROVIDER_STREAM_TRUNCATED")
|
||||||
|
assert body.closed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("protocol", PROTOCOLS)
|
||||||
|
@pytest.mark.parametrize("bad", [b"not-json", b"[]", b"null", b'{"usage":'])
|
||||||
|
def test_malformed_stream_is_sanitized(protocol, bad):
|
||||||
|
suffix = bad + b"\n" if protocol == "ollama" else b"data: " + bad + b"\n\n"
|
||||||
|
body = Bytes(wire(protocol, *start(protocol)) + suffix)
|
||||||
|
events = asyncio.run(collect(provider(protocol, lambda _: httpx.Response(200, stream=body)).stream(request())))
|
||||||
|
assert_error(events, "PROVIDER_INVALID_RESPONSE")
|
||||||
|
assert body.closed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("protocol", PROTOCOLS)
|
||||||
|
@pytest.mark.parametrize("error_type,code", [("rate_limit_error", "PROVIDER_RATE_LIMITED"),
|
||||||
|
("authentication_error", "PROVIDER_AUTH_FAILED"),
|
||||||
|
("overloaded_error", "PROVIDER_UNAVAILABLE")])
|
||||||
|
def test_in_band_error_after_partial_output(protocol, error_type, code):
|
||||||
|
body = Bytes(wire(protocol, *start(protocol), {"type": "error", "error": {"type": error_type, "message": SECRET}}))
|
||||||
|
events = asyncio.run(collect(provider(protocol, lambda _: httpx.Response(200, stream=body)).stream(request())))
|
||||||
|
assert any(event.event == E.text_delta for event in events)
|
||||||
|
assert_error(events, code)
|
||||||
|
assert body.closed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("protocol", PROTOCOLS)
|
||||||
|
@pytest.mark.parametrize("status,code", [(400, "PROVIDER_INVALID_REQUEST"), (401, "PROVIDER_AUTH_FAILED"),
|
||||||
|
(403, "PROVIDER_AUTH_FAILED"), (404, "MODEL_NOT_FOUND"),
|
||||||
|
(429, "PROVIDER_RATE_LIMITED"), (500, "PROVIDER_UNAVAILABLE")])
|
||||||
|
def test_http_errors_completion_and_stream(protocol, status, code):
|
||||||
|
adapter = provider(protocol, lambda _: httpx.Response(status, text=SECRET))
|
||||||
|
with pytest.raises(ProviderError) as exc:
|
||||||
|
asyncio.run(adapter.complete(request()))
|
||||||
|
assert exc.value.code == code
|
||||||
|
assert SECRET not in str(exc.value)
|
||||||
|
assert_error(asyncio.run(collect(adapter.stream(request()))), code)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("protocol", PROTOCOLS)
|
||||||
|
@pytest.mark.parametrize("body,code", [(b"broken", "PROVIDER_INVALID_RESPONSE"),
|
||||||
|
(b"[]", "PROVIDER_INVALID_RESPONSE"),
|
||||||
|
(b"{}", "PROVIDER_INVALID_RESPONSE"),
|
||||||
|
(json.dumps({"error": {"code": "invalid_api_key", "message": SECRET}}).encode(), "PROVIDER_AUTH_FAILED")])
|
||||||
|
def test_bad_completion(protocol, body, code):
|
||||||
|
adapter = provider(protocol, lambda _: httpx.Response(200, content=body))
|
||||||
|
with pytest.raises(ProviderError) as exc:
|
||||||
|
asyncio.run(adapter.complete(request()))
|
||||||
|
assert exc.value.code == code
|
||||||
|
assert SECRET not in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("protocol", PROTOCOLS)
|
||||||
|
@pytest.mark.parametrize("error,code", [(httpx.ReadTimeout, "PROVIDER_TIMEOUT"),
|
||||||
|
(httpx.ConnectError, "PROVIDER_UNAVAILABLE")])
|
||||||
|
def test_transport_error_mapping(protocol, error, code):
|
||||||
|
def handler(req):
|
||||||
|
raise error(SECRET, request=req)
|
||||||
|
|
||||||
|
adapter = provider(protocol, handler)
|
||||||
|
with pytest.raises(ProviderError) as exc:
|
||||||
|
asyncio.run(adapter.complete(request()))
|
||||||
|
assert exc.value.code == code
|
||||||
|
assert SECRET not in str(exc.value)
|
||||||
|
assert_error(asyncio.run(collect(adapter.stream(request()))), code)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("protocol", PROTOCOLS)
|
||||||
|
@pytest.mark.parametrize("cancel", [True, False])
|
||||||
|
def test_incremental_delivery_cancellation_and_explicit_close(protocol, cancel):
|
||||||
|
async def scenario():
|
||||||
|
body = GatedBytes(wire(protocol, *start(protocol)))
|
||||||
|
adapter = provider(protocol, lambda _: httpx.Response(200, stream=body))
|
||||||
|
iterator = adapter.stream(request())
|
||||||
|
seen = []
|
||||||
|
while True:
|
||||||
|
event = await asyncio.wait_for(anext(iterator), timeout=1)
|
||||||
|
seen.append(event)
|
||||||
|
if event.event == E.text_delta:
|
||||||
|
break
|
||||||
|
# The first token arrives while the response is still open and blocked.
|
||||||
|
assert seen[-1].data["text"] == "你好"
|
||||||
|
assert not body.closed
|
||||||
|
if cancel:
|
||||||
|
pending = asyncio.create_task(anext(iterator))
|
||||||
|
await asyncio.wait_for(body.waiting.wait(), timeout=1)
|
||||||
|
pending.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await pending
|
||||||
|
else:
|
||||||
|
await iterator.aclose()
|
||||||
|
assert body.closed
|
||||||
|
assert not any(event.event in {E.error, E.done} for event in seen)
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("protocol", NATIVE)
|
||||||
|
def test_cancellation_before_response_headers(protocol):
|
||||||
|
async def scenario():
|
||||||
|
entered = asyncio.Event()
|
||||||
|
closed = asyncio.Event()
|
||||||
|
|
||||||
|
async def handler(req):
|
||||||
|
entered.set()
|
||||||
|
try:
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
finally:
|
||||||
|
closed.set()
|
||||||
|
|
||||||
|
adapter = provider(protocol, handler)
|
||||||
|
pending = asyncio.create_task(adapter.complete(request()))
|
||||||
|
await asyncio.wait_for(entered.wait(), timeout=1)
|
||||||
|
pending.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await pending
|
||||||
|
assert closed.is_set()
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("protocol", NATIVE)
|
||||||
|
def test_native_discovery_does_not_claim_non_chat_capabilities(protocol):
|
||||||
|
def handler(req):
|
||||||
|
assert req.url.path == "/v1/models"
|
||||||
|
return httpx.Response(200, json={"data": [{"id": name} for name in ["chat-model", "text-embedding-3-small", "whisper-1", "gpt-audio"]]})
|
||||||
|
|
||||||
|
models = asyncio.run(provider(protocol, handler).list_models())
|
||||||
|
assert ModelCapability.chat in models[0].capabilities
|
||||||
|
assert models[1].capabilities == [ModelCapability.embedding]
|
||||||
|
assert all(ModelCapability.chat not in model.capabilities for model in models[1:])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("protocol", NATIVE)
|
||||||
|
def test_native_structured_format_mapping(protocol):
|
||||||
|
adapter = provider(protocol, lambda _: pytest.fail("No network expected"))
|
||||||
|
req = request()
|
||||||
|
req.response_format = {"type": "json_schema", "json_schema": {
|
||||||
|
"name": "answer", "strict": True, "schema": {"type": "object", "properties": {}},
|
||||||
|
}}
|
||||||
|
payload = adapter._payload(req, stream=False)
|
||||||
|
format_ = payload["text"]["format"] if protocol == "responses" else payload["output_config"]["format"]
|
||||||
|
assert format_["type"] == "json_schema"
|
||||||
|
assert format_["schema"] == {"type": "object", "properties": {}}
|
||||||
|
if protocol == "responses":
|
||||||
|
assert format_["name"] == "answer"
|
||||||
|
assert format_["strict"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("protocol", NATIVE)
|
||||||
|
def test_invalid_tool_arguments_and_unclosed_tool(protocol):
|
||||||
|
frames = responses_tool_events() if protocol == "responses" else anthropic_tool_events()
|
||||||
|
# A syntactically valid terminal cannot rescue an unfinished tool block.
|
||||||
|
index = next(i for i, frame in enumerate(frames)
|
||||||
|
if frame["type"] in {"response.function_call_arguments.delta", "content_block_delta"}
|
||||||
|
and (frame.get("output_index") == 2 or frame.get("index") == 2))
|
||||||
|
partial = frames[:index + 1]
|
||||||
|
final = frames[-1]
|
||||||
|
events = asyncio.run(collect(provider(protocol, lambda _: httpx.Response(200, content=sse(*partial, final))).stream(request())))
|
||||||
|
assert_error(events, "PROVIDER_STREAM_TRUNCATED")
|
||||||
|
assert not any(event.event == E.tool_call_end for event in events)
|
||||||
|
|
||||||
|
for frame in frames:
|
||||||
|
if frame["type"] == "response.function_call_arguments.done":
|
||||||
|
frame["arguments"] = "[]"
|
||||||
|
break
|
||||||
|
if frame["type"] == "content_block_delta" and frame.get("index") == 2:
|
||||||
|
frame["delta"]["partial_json"] = "malformed"
|
||||||
|
break
|
||||||
|
events = asyncio.run(collect(provider(protocol, lambda _: httpx.Response(200, content=sse(*frames))).stream(request())))
|
||||||
|
assert_error(events, "PROVIDER_INVALID_RESPONSE")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind,code", [("response.failed", "PROVIDER_UNAVAILABLE"),
|
||||||
|
("response.incomplete", "PROVIDER_INCOMPLETE_RESPONSE")])
|
||||||
|
def test_responses_failed_and_incomplete(kind, code):
|
||||||
|
frame = {"type": kind, "response": {"status": kind.split(".")[1], "incomplete_details": {"reason": SECRET}}}
|
||||||
|
events = asyncio.run(collect(provider("responses", lambda _: httpx.Response(200, content=sse(*start("responses"), frame))).stream(request())))
|
||||||
|
assert_error(events, code)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sse_multiline_data_and_event_name_without_json_type():
|
||||||
|
body = (b': keepalive\n\nevent: response.output_text.delta\ndata: {\ndata: "delta": "hello"\ndata: }\n\n'
|
||||||
|
+ sse({"type": "response.completed", "response": {"status": "completed"}}))
|
||||||
|
events = asyncio.run(collect(provider("responses", lambda _: httpx.Response(200, content=body)).stream(request())))
|
||||||
|
assert_events(events)
|
||||||
|
assert [event.data["text"] for event in events if event.event == E.text_delta] == ["hello"]
|
||||||
|
assert not any(event.event == E.error for event in events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ollama_history_options_and_in_band_string_error():
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def handler(req):
|
||||||
|
captured.update(json.loads(req.content))
|
||||||
|
return httpx.Response(200, json={"error": SECRET})
|
||||||
|
|
||||||
|
with pytest.raises(ProviderError) as exc:
|
||||||
|
asyncio.run(provider("ollama", handler).complete(request(history=True)))
|
||||||
|
assert exc.value.code == "PROVIDER_UNAVAILABLE"
|
||||||
|
assert SECRET not in str(exc.value)
|
||||||
|
assert captured["messages"][-1]["tool_name"] == "lookup"
|
||||||
|
assert captured["options"] == {"temperature": 0.0, "num_predict": 512}
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("protocol", PROTOCOLS)
|
||||||
|
@pytest.mark.parametrize("streaming", [False, True])
|
||||||
|
def test_namespaced_tools_roundtrip_without_changing_internal_request(protocol, streaming):
|
||||||
|
import re
|
||||||
|
model_request = request(history=True)
|
||||||
|
original_name = "mcp.my-server.search.notes"
|
||||||
|
model_request.tools[0].name = original_name
|
||||||
|
for message in model_request.messages:
|
||||||
|
for call in message.tool_calls:
|
||||||
|
call.name = original_name
|
||||||
|
before = model_request.model_dump()
|
||||||
|
|
||||||
|
def handler(req):
|
||||||
|
payload = json.loads(req.content)
|
||||||
|
definition = payload["tools"][0]
|
||||||
|
name = (definition.get("function") or definition)["name"]
|
||||||
|
assert name != original_name and re.fullmatch(r"[a-zA-Z0-9_-]{1,64}", name)
|
||||||
|
assert original_name not in req.content.decode()
|
||||||
|
if protocol == "responses":
|
||||||
|
item = {"type": "function_call", "id": "item1", "call_id": "call1", "name": name, "arguments": "{}"}
|
||||||
|
body = {"status": "completed", "output": [item]}
|
||||||
|
events = [
|
||||||
|
{"type": "response.output_item.done", "output_index": 0, "item": item},
|
||||||
|
{"type": "response.completed", "response": {"status": "completed"}},
|
||||||
|
]
|
||||||
|
elif protocol == "anthropic":
|
||||||
|
item = {"type": "tool_use", "id": "call1", "name": name, "input": {}}
|
||||||
|
body = {"content": [item]}
|
||||||
|
events = [
|
||||||
|
{"type": "message_start", "message": {}},
|
||||||
|
{"type": "content_block_start", "index": 0, "content_block": item},
|
||||||
|
{"type": "content_block_stop", "index": 0},
|
||||||
|
{"type": "message_stop"},
|
||||||
|
]
|
||||||
|
elif protocol == "compatible":
|
||||||
|
item = {"id": "call1", "function": {"name": name, "arguments": "{}"}}
|
||||||
|
body = {"choices": [{"message": {"tool_calls": [item]}}]}
|
||||||
|
events = [{"choices": [{"delta": {"tool_calls": [{"index": 0, **item}]}, "finish_reason": "tool_calls"}]}]
|
||||||
|
else:
|
||||||
|
item = {"function": {"name": name, "arguments": {}}}
|
||||||
|
body = {"message": {"tool_calls": [item]}, "done": True}
|
||||||
|
events = [body]
|
||||||
|
return httpx.Response(200, content=wire(protocol, *events)) if streaming else httpx.Response(200, json=body)
|
||||||
|
|
||||||
|
adapter = provider(protocol, handler)
|
||||||
|
if streaming:
|
||||||
|
events = asyncio.run(collect(adapter.stream(model_request)))
|
||||||
|
assert_events(events)
|
||||||
|
assert [event.data["name"] for event in events if event.event == E.tool_call_start] == [original_name]
|
||||||
|
else:
|
||||||
|
assert asyncio.run(adapter.complete(model_request)).tool_calls[0].name == original_name
|
||||||
|
assert model_request.model_dump() == before
|
||||||
|
|
||||||
|
def test_chat_route_closes_upstream_and_sanitizes_unexpected_errors(monkeypatch):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from app import routes
|
||||||
|
from app.contracts import ChatRequest, ModelEvent
|
||||||
|
closed = []
|
||||||
|
|
||||||
|
class Adapter:
|
||||||
|
async def stream(self, request):
|
||||||
|
try:
|
||||||
|
yield ModelEvent(event=E.text_delta, sequence=0, data={"text": "first"}, timestamp=datetime.now(timezone.utc))
|
||||||
|
raise RuntimeError(SECRET)
|
||||||
|
finally:
|
||||||
|
closed.append(True)
|
||||||
|
|
||||||
|
monkeypatch.setattr(routes, "provider_or_404", lambda _: SimpleNamespace(adapter=Adapter()))
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
response = await routes.chat(ChatRequest(provider_id="test", model="test", messages=[]))
|
||||||
|
iterator = response.body_iterator
|
||||||
|
await anext(iterator)
|
||||||
|
await iterator.aclose()
|
||||||
|
assert len(closed) == 1
|
||||||
|
response = await routes.chat(ChatRequest(provider_id="test", model="test", messages=[]))
|
||||||
|
items = [json.loads(chunk.split("data: ")[1].strip()) async for chunk in response.body_iterator]
|
||||||
|
assert [item["sequence"] for item in items] == [0, 1, 2]
|
||||||
|
assert items[-1]["data"]["status"] == "failed"
|
||||||
|
assert SECRET not in str(items)
|
||||||
|
assert len(closed) == 2
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
@@ -640,9 +640,10 @@ def test_rebuild_failure_restores_old_index(vault, monkeypatch) -> None:
|
|||||||
assert repository.stats() == before # 旧索引已恢复,无半成品
|
assert repository.stats() == before # 旧索引已恢复,无半成品
|
||||||
|
|
||||||
|
|
||||||
def test_first_rebuild_failure_removes_partial_database(vault, monkeypatch) -> None:
|
def test_first_rebuild_failure_leaves_no_partial_index(vault, monkeypatch) -> None:
|
||||||
"""首次启动没有旧库时,失败也不能留下已经写入的部分索引。"""
|
"""首次启动没有旧库时,失败也不能留下已经写入的部分索引。"""
|
||||||
from app.services import index_service
|
from app.services import index_service
|
||||||
|
from app import repository
|
||||||
|
|
||||||
_write_vault(
|
_write_vault(
|
||||||
vault,
|
vault,
|
||||||
@@ -651,17 +652,17 @@ def test_first_rebuild_failure_removes_partial_database(vault, monkeypatch) -> N
|
|||||||
real_index = index_service.index_note
|
real_index = index_service.index_note
|
||||||
calls = {"count": 0}
|
calls = {"count": 0}
|
||||||
|
|
||||||
async def fail_on_second(parsed):
|
async def fail_on_second(parsed, **kwargs):
|
||||||
calls["count"] += 1
|
calls["count"] += 1
|
||||||
if calls["count"] == 2:
|
if calls["count"] == 2:
|
||||||
raise RuntimeError("injected first-rebuild failure")
|
raise RuntimeError("injected first-rebuild failure")
|
||||||
await real_index(parsed)
|
await real_index(parsed, **kwargs)
|
||||||
|
|
||||||
monkeypatch.setattr(index_service, "index_note", fail_on_second)
|
monkeypatch.setattr(index_service, "index_note", fail_on_second)
|
||||||
with pytest.raises(RuntimeError):
|
with pytest.raises(RuntimeError):
|
||||||
asyncio.run(index_service.rebuild(IndexRebuildRequest(scope="all")))
|
asyncio.run(index_service.rebuild(IndexRebuildRequest(scope="all")))
|
||||||
|
|
||||||
assert not get_settings().db_path.exists()
|
assert repository.stats() == {"notes": 0, "blocks": 0}
|
||||||
|
|
||||||
|
|
||||||
def test_rebuild_preserves_task_note_links(vault) -> None:
|
def test_rebuild_preserves_task_note_links(vault) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,466 @@
|
|||||||
|
"""Phase E route integration: deterministic runtimes, isolated DBs, no network."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import repository
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.contracts import IndexRebuildRequest, SearchMode, SearchRequest
|
||||||
|
from app.database.db import connect, transaction
|
||||||
|
from app.retrieval import routed_vectors
|
||||||
|
from app.retrieval.embedding import HashEmbeddingProvider
|
||||||
|
from app.retrieval.engine import RetrievalEngine, engine
|
||||||
|
from app.retrieval.reranker import LexicalReranker
|
||||||
|
from app.retrieval.vectorstore import SqliteVecStore, VectorHit
|
||||||
|
from app.services import index_service, note_service
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FakeRuntime:
|
||||||
|
model_id: str = "space-a"
|
||||||
|
dimensions: int = 3 # Deliberately differs from sqlite-vec's fixed 128.
|
||||||
|
source: str = "api"
|
||||||
|
error: BaseException | None = None
|
||||||
|
calls: list[list[str]] = field(default_factory=list)
|
||||||
|
result_override: object | None = None
|
||||||
|
|
||||||
|
async def embed(self, texts):
|
||||||
|
self.calls.append(list(texts))
|
||||||
|
if self.error is not None:
|
||||||
|
raise self.error
|
||||||
|
if self.result_override is not None:
|
||||||
|
return self.result_override
|
||||||
|
vectors = []
|
||||||
|
for text in texts:
|
||||||
|
# The API associates "apple" with banana; hash retrieval picks apple.
|
||||||
|
first = text == "apple orchard"
|
||||||
|
if self.model_id == "space-b":
|
||||||
|
first = not first
|
||||||
|
vectors.append(([1.0, 0.0] if first else [0.0, 1.0]) + [0.0] * (self.dimensions - 2))
|
||||||
|
return SimpleNamespace(
|
||||||
|
vectors=vectors, source=self.source, model_id=self.model_id,
|
||||||
|
dimensions=self.dimensions, fallback_reason=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def runtime(monkeypatch):
|
||||||
|
runtime = FakeRuntime()
|
||||||
|
monkeypatch.setattr(routed_vectors, "get_model_routing", lambda: runtime)
|
||||||
|
return runtime
|
||||||
|
|
||||||
|
|
||||||
|
async def seed():
|
||||||
|
apple = await note_service.create_note(
|
||||||
|
title="Apple", markdown="apple orchard", folder=None, tags=[],
|
||||||
|
)
|
||||||
|
banana = await note_service.create_note(
|
||||||
|
title="Banana", markdown="banana grove", folder=None, tags=[],
|
||||||
|
)
|
||||||
|
return apple, banana
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("outcome", ["api", "api_failure", "missing_space"])
|
||||||
|
def test_benchmark_reports_actual_embedding_and_fallback(runtime, outcome):
|
||||||
|
from app.benchmarks import service
|
||||||
|
from app.contracts import RAGRunRequest
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
apple, banana = await seed()
|
||||||
|
if outcome == "api_failure":
|
||||||
|
runtime.result_override = SimpleNamespace(source="local", fallback_reason="PROVIDER_TIMEOUT")
|
||||||
|
elif outcome == "missing_space":
|
||||||
|
runtime.model_id = "space-without-index"
|
||||||
|
directory = get_settings().benchmark_datasets_path
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
(directory / "routing.json").write_text(json.dumps({
|
||||||
|
"dataset_id": "routing", "kind": "rag", "version": "1",
|
||||||
|
"cases": [{"case_id": "query", "query": "apple", "expected_note_ids": [banana.note_id]}],
|
||||||
|
}), encoding="utf-8")
|
||||||
|
run = await service.create_rag_run(RAGRunRequest(
|
||||||
|
dataset_id="routing", modes=[SearchMode.fts, SearchMode.vector],
|
||||||
|
))
|
||||||
|
await service.wait_for_run(run.run_id)
|
||||||
|
report = service.get_report(run.run_id)
|
||||||
|
assert report.config_snapshot["embedding"]["policy"] == "per_case"
|
||||||
|
fts, vector = report.cases
|
||||||
|
assert fts.embedding == {"source": "not_used"}
|
||||||
|
if outcome == "api":
|
||||||
|
assert vector.embedding["source"] == "api"
|
||||||
|
assert vector.embedding["model_id"] == "space-a"
|
||||||
|
assert vector.embedding["dimensions"] == 3
|
||||||
|
assert vector.retrieved_note_ids[0] == banana.note_id
|
||||||
|
else:
|
||||||
|
assert vector.embedding["source"] == "local"
|
||||||
|
assert vector.embedding["model_id"] == "hash-v1"
|
||||||
|
assert vector.embedding["dimensions"] == 128
|
||||||
|
assert vector.retrieved_note_ids[0] == apple.note_id
|
||||||
|
if outcome == "api_failure":
|
||||||
|
assert vector.embedding["fallback_reason"] == "PROVIDER_TIMEOUT"
|
||||||
|
if outcome == "missing_space":
|
||||||
|
assert vector.embedding["fallback_reason"] == "REMOTE_INDEX_UNAVAILABLE"
|
||||||
|
assert vector.embedding["attempted_space"]["model_id"] == "space-without-index"
|
||||||
|
events = service.get_events(run.run_id)
|
||||||
|
case_events = [e for e in events if e.event.value == "CaseCompleted"]
|
||||||
|
assert case_events[-1].data["embedding"] == vector.embedding
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_embedding_observations_are_isolated_between_concurrent_searches(runtime, monkeypatch):
|
||||||
|
from app.retrieval.provenance import capture_embedding
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
await seed()
|
||||||
|
original = runtime.embed
|
||||||
|
|
||||||
|
async def embed(texts):
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
if texts == ["offline"]:
|
||||||
|
raise RuntimeError("private upstream details")
|
||||||
|
return await original(texts)
|
||||||
|
|
||||||
|
monkeypatch.setattr(runtime, "embed", embed)
|
||||||
|
|
||||||
|
async def query(text):
|
||||||
|
with capture_embedding() as observation:
|
||||||
|
await engine.search(SearchRequest(query=text, mode=SearchMode.vector))
|
||||||
|
return observation
|
||||||
|
|
||||||
|
remote, local, another = await asyncio.gather(query("apple"), query("offline"), query("apple"))
|
||||||
|
assert remote["source"] == another["source"] == "api"
|
||||||
|
assert local["source"] == "local"
|
||||||
|
assert local["fallback_reason"] == "REMOTE_EMBEDDING_UNAVAILABLE"
|
||||||
|
assert "fallback_reason" not in remote or remote["fallback_reason"] is None
|
||||||
|
assert "private upstream" not in json.dumps(local)
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("failure", ["cancel", "write"])
|
||||||
|
def test_rebuild_failure_preserves_concurrent_configuration_and_all_indexes(runtime, monkeypatch, failure):
|
||||||
|
from app.container import container
|
||||||
|
from app.contracts import ModelRoutingConfig, ProviderConfig, ProviderType
|
||||||
|
from app.services import task_service
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
apple, _ = await seed()
|
||||||
|
task = task_service.create_task(title="before", note_id=apple.note_id)
|
||||||
|
before = {table: [tuple(row) for row in rows(f"SELECT * FROM {table}")]
|
||||||
|
for table in ("notes", "blocks", "blocks_fts", "vec_blocks", "index_meta", "routed_block_vectors")}
|
||||||
|
container.model_routing.update(ModelRoutingConfig())
|
||||||
|
entered, release = asyncio.Event(), asyncio.Event()
|
||||||
|
original_embed = runtime.embed
|
||||||
|
|
||||||
|
async def pending_embed(texts):
|
||||||
|
entered.set()
|
||||||
|
await release.wait()
|
||||||
|
return await original_embed(texts)
|
||||||
|
|
||||||
|
monkeypatch.setattr(runtime, "embed", pending_embed)
|
||||||
|
original_index = index_service.index_note
|
||||||
|
writes = 0
|
||||||
|
|
||||||
|
async def fail_write(parsed, **kwargs):
|
||||||
|
nonlocal writes
|
||||||
|
await original_index(parsed, **kwargs)
|
||||||
|
writes += 1
|
||||||
|
if writes == 2:
|
||||||
|
raise RuntimeError("injected write failure")
|
||||||
|
|
||||||
|
if failure == "write":
|
||||||
|
monkeypatch.setattr(index_service, "index_note", fail_write)
|
||||||
|
rebuilding = asyncio.create_task(index_service.rebuild(IndexRebuildRequest()))
|
||||||
|
await asyncio.wait_for(entered.wait(), timeout=5)
|
||||||
|
saved = container.model_routing.update(container.model_routing.configuration())
|
||||||
|
config = ProviderConfig(provider_id="concurrent", provider_type=ProviderType.openai_compatible,
|
||||||
|
name="saved during rebuild", base_url="https://unused.invalid/v1")
|
||||||
|
container.providers.register(config, container.provider_factory.build(config))
|
||||||
|
task_service.update_task(task.task_id, {"title": "saved during rebuild"})
|
||||||
|
# Preparation keeps the old searchable index intact while API I/O is pending.
|
||||||
|
assert repository.stats()["notes"] == 2
|
||||||
|
if failure == "cancel":
|
||||||
|
rebuilding.cancel()
|
||||||
|
expected = asyncio.CancelledError
|
||||||
|
else:
|
||||||
|
release.set()
|
||||||
|
expected = RuntimeError
|
||||||
|
with pytest.raises(expected):
|
||||||
|
await rebuilding
|
||||||
|
assert container.model_routing.configuration().version == saved.config.version
|
||||||
|
assert rows("SELECT provider_id FROM provider_configs")[-1][0] == "concurrent"
|
||||||
|
restored = task_service.get_task(task.task_id)
|
||||||
|
assert restored.title == "saved during rebuild"
|
||||||
|
assert restored.note_id == apple.note_id
|
||||||
|
for table, values in before.items():
|
||||||
|
assert [tuple(row) for row in rows(f"SELECT * FROM {table}")] == values
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def local_engine():
|
||||||
|
return RetrievalEngine(HashEmbeddingProvider(), LexicalReranker(), SqliteVecStore())
|
||||||
|
|
||||||
|
|
||||||
|
def request(mode=SearchMode.vector):
|
||||||
|
return SearchRequest(query="apple", mode=mode, limit=10)
|
||||||
|
|
||||||
|
|
||||||
|
def rows(sql, parameters=()):
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
return conn.execute(sql, parameters).fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_index_and_query_use_matching_space_and_keep_local_metadata(runtime):
|
||||||
|
async def scenario():
|
||||||
|
apple, banana = await seed()
|
||||||
|
result = await engine.search(request())
|
||||||
|
assert result.items[0].note_id == banana.note_id
|
||||||
|
baseline = await local_engine().search(request())
|
||||||
|
assert baseline.items[0].note_id == apple.note_id
|
||||||
|
assert rows("SELECT DISTINCT space_id, dimensions FROM routed_block_vectors")[0][:] == ("space-a", 3)
|
||||||
|
assert rows("SELECT COUNT(*) FROM routed_block_vectors")[0][0] == len(apple.blocks) + len(banana.blocks)
|
||||||
|
meta = repository.get_index_meta()
|
||||||
|
assert meta["embedding_model"] == "hash-v1"
|
||||||
|
assert meta["embedding_dim"] == "128"
|
||||||
|
assert len(runtime.calls) == 3
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("failure", ["exception", "local", "missing", "dimension", "corrupt"])
|
||||||
|
def test_query_falls_back_to_exact_local_results(runtime, failure):
|
||||||
|
async def scenario():
|
||||||
|
await seed()
|
||||||
|
if failure == "exception":
|
||||||
|
runtime.error = RuntimeError("offline")
|
||||||
|
elif failure == "local":
|
||||||
|
runtime.source = "local"
|
||||||
|
elif failure == "missing":
|
||||||
|
rows("DELETE FROM routed_block_vectors WHERE block_id = (SELECT MIN(block_id) FROM blocks)")
|
||||||
|
elif failure == "dimension":
|
||||||
|
runtime.dimensions = 4
|
||||||
|
else:
|
||||||
|
rows("UPDATE routed_block_vectors SET vector = ?", ("[0, 0, 0]",))
|
||||||
|
actual = await engine.search(request())
|
||||||
|
baseline = await local_engine().search(request())
|
||||||
|
assert actual == baseline
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_dimension_model_switch_never_combines_partial_spaces(runtime):
|
||||||
|
async def scenario():
|
||||||
|
apple, banana = await seed()
|
||||||
|
baseline = await local_engine().search(request())
|
||||||
|
runtime.model_id = "space-b"
|
||||||
|
assert await engine.search(request()) == baseline
|
||||||
|
await note_service.update_note(apple.note_id, markdown="apple orchard")
|
||||||
|
assert {row[0] for row in rows("SELECT DISTINCT space_id FROM routed_block_vectors")} == {"space-a", "space-b"}
|
||||||
|
assert await routed_vectors.search_remote("apple", top_k=10) is None
|
||||||
|
assert await engine.search(request()) == baseline
|
||||||
|
runtime.model_id = "space-a"
|
||||||
|
assert await engine.search(request()) == baseline
|
||||||
|
runtime.model_id = "space-b"
|
||||||
|
await note_service.update_note(banana.note_id, markdown="banana grove")
|
||||||
|
hits = await routed_vectors.search_remote("apple", top_k=10)
|
||||||
|
assert hits is not None and hits[0].id == banana.blocks[0].block_id
|
||||||
|
assert (await engine.search(request())).items[0].note_id == banana.note_id
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_spaces_coexist_but_only_requested_space_is_ranked(runtime):
|
||||||
|
async def scenario():
|
||||||
|
apple, banana = await seed()
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
with transaction(conn):
|
||||||
|
routed_vectors.store_remote(
|
||||||
|
conn, [apple.blocks[0].block_id, banana.blocks[0].block_id],
|
||||||
|
routed_vectors.RemoteEmbeddings("space-b", 3, [[1, 0, 0], [0, 1, 0]]),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
assert (await engine.search(request())).items[0].note_id == banana.note_id
|
||||||
|
runtime.model_id = "space-b"
|
||||||
|
result = await engine.search(request())
|
||||||
|
assert len(result.items) == 2
|
||||||
|
assert result.items[0].note_id == apple.note_id
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_note_embedding_preserves_save_and_forces_coverage_fallback(runtime):
|
||||||
|
async def scenario():
|
||||||
|
apple, banana = await seed()
|
||||||
|
runtime.error = RuntimeError("offline")
|
||||||
|
await note_service.update_note(banana.note_id, markdown="banana changed")
|
||||||
|
assert (await note_service.get_note(banana.note_id)).markdown == "banana changed"
|
||||||
|
assert rows("SELECT COUNT(*) FROM routed_block_vectors")[0][0] == len(apple.blocks)
|
||||||
|
runtime.error = None
|
||||||
|
assert await engine.search(request()) == await local_engine().search(request())
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("vectors, dimensions, space", [
|
||||||
|
([], 3, "space-a"),
|
||||||
|
([[1, 0]], 3, "space-a"),
|
||||||
|
([[0, 0, 0]], 3, "space-a"),
|
||||||
|
([[float("nan"), 0, 0]], 3, "space-a"),
|
||||||
|
([[float("inf"), 0, 0]], 3, "space-a"),
|
||||||
|
([[True, 0, 0]], 3, "space-a"),
|
||||||
|
([[1, 0, 0]], 0, "space-a"),
|
||||||
|
([[1, 0, 0]], 3, "hash-v1"),
|
||||||
|
])
|
||||||
|
def test_invalid_remote_batch_does_not_break_note_saving(runtime, vectors, dimensions, space):
|
||||||
|
runtime.result_override = SimpleNamespace(
|
||||||
|
source="api", vectors=vectors, dimensions=dimensions, model_id=space,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
note = await note_service.create_note(title="Apple", markdown="apple orchard", folder=None, tags=[])
|
||||||
|
assert (await local_engine().search(request())).items[0].note_id == note.note_id
|
||||||
|
assert await routed_vectors.search_remote("apple", top_k=10) is None
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_storage_failure_rolls_back_batch_but_keeps_local_index(runtime):
|
||||||
|
async def scenario():
|
||||||
|
await seed()
|
||||||
|
rows("""CREATE TRIGGER reject_remote_vector BEFORE INSERT ON routed_block_vectors
|
||||||
|
WHEN (SELECT content FROM blocks WHERE block_id = NEW.block_id) = 'second'
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'simulated storage failure'); END""")
|
||||||
|
note = await note_service.create_note(
|
||||||
|
title="Multi", markdown="first\n\nsecond", folder=None, tags=[],
|
||||||
|
)
|
||||||
|
assert len(note.blocks) == 2
|
||||||
|
assert rows(
|
||||||
|
"SELECT COUNT(*) FROM routed_block_vectors r JOIN blocks b USING(block_id) WHERE b.note_id = ?",
|
||||||
|
(note.note_id,),
|
||||||
|
)[0][0] == 0
|
||||||
|
assert rows("SELECT COUNT(*) FROM vec_blocks")[0][0] == rows("SELECT COUNT(*) FROM blocks")[0][0]
|
||||||
|
assert (get_settings().vault_path / note.file_path).exists()
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_rebuild_and_delete_clear_old_remote_rows_through_foreign_keys(runtime):
|
||||||
|
async def scenario():
|
||||||
|
apple, _ = await seed()
|
||||||
|
await note_service.delete_note(apple.note_id)
|
||||||
|
assert rows("SELECT COUNT(*) FROM routed_block_vectors")[0][0] == 1
|
||||||
|
runtime.source = "local"
|
||||||
|
job = await index_service.rebuild(IndexRebuildRequest())
|
||||||
|
assert job.status == "completed"
|
||||||
|
assert rows("SELECT COUNT(*) FROM routed_block_vectors")[0][0] == 0
|
||||||
|
assert rows("SELECT COUNT(*) FROM vec_blocks")[0][0] == 1
|
||||||
|
runtime.source = "api"
|
||||||
|
runtime.model_id = "space-b"
|
||||||
|
await index_service.rebuild(IndexRebuildRequest())
|
||||||
|
assert [row[0] for row in rows("SELECT space_id FROM routed_block_vectors")] == ["space-b"]
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("operation", ["save", "query", "rebuild"])
|
||||||
|
def test_cancellation_propagates_and_mutations_roll_back(runtime, operation):
|
||||||
|
async def scenario():
|
||||||
|
apple, _ = await seed()
|
||||||
|
before = [tuple(row) for row in rows("SELECT * FROM routed_block_vectors ORDER BY block_id")]
|
||||||
|
runtime.error = asyncio.CancelledError()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
if operation == "query":
|
||||||
|
await engine.search(request())
|
||||||
|
elif operation == "rebuild":
|
||||||
|
await index_service.rebuild(IndexRebuildRequest())
|
||||||
|
else:
|
||||||
|
await note_service.update_note(apple.note_id, markdown="changed")
|
||||||
|
assert (await note_service.get_note(apple.note_id)).markdown == "apple orchard"
|
||||||
|
assert [tuple(row) for row in rows("SELECT * FROM routed_block_vectors ORDER BY block_id")] == before
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("injected", ["embedding", "vector_store", "constructor"])
|
||||||
|
def test_injected_engine_dependencies_are_respected(runtime, monkeypatch, injected):
|
||||||
|
async def scenario():
|
||||||
|
apple, _ = await seed()
|
||||||
|
target = engine
|
||||||
|
if injected == "constructor":
|
||||||
|
target = local_engine()
|
||||||
|
elif injected == "embedding":
|
||||||
|
monkeypatch.setattr(engine, "embedding", HashEmbeddingProvider())
|
||||||
|
else:
|
||||||
|
class FakeStore:
|
||||||
|
async def search(self, vector, *, top_k):
|
||||||
|
assert len(vector) == 128
|
||||||
|
return [VectorHit(id=apple.blocks[0].block_id, score=1.0)]
|
||||||
|
|
||||||
|
monkeypatch.setattr(engine, "vector_store", FakeStore())
|
||||||
|
runtime.calls.clear()
|
||||||
|
assert (await target.search(request())).items[0].note_id == apple.note_id
|
||||||
|
assert runtime.calls == []
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_fts_skips_routing_and_hybrid_uses_routed_vector_channel(runtime, monkeypatch):
|
||||||
|
async def scenario():
|
||||||
|
_, banana = await seed()
|
||||||
|
runtime.calls.clear()
|
||||||
|
await engine.search(request(SearchMode.fts))
|
||||||
|
assert runtime.calls == []
|
||||||
|
# Empty lexical channel isolates the vector contribution to hybrid fusion.
|
||||||
|
monkeypatch.setattr(repository, "fts_search", lambda *_: [])
|
||||||
|
|
||||||
|
class PreserveOrder:
|
||||||
|
async def rerank(self, query, candidates):
|
||||||
|
return sorted(candidates, key=lambda candidate: -candidate.score)
|
||||||
|
|
||||||
|
monkeypatch.setattr(engine, "reranker", PreserveOrder())
|
||||||
|
result = await engine.search(request(SearchMode.hybrid))
|
||||||
|
assert result.items[0].note_id == banana.note_id
|
||||||
|
assert runtime.calls == [["apple"]]
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_arbitrary_dimensions_and_extreme_finite_values(runtime):
|
||||||
|
dimensions = 257
|
||||||
|
runtime.result_override = SimpleNamespace(
|
||||||
|
source="api", model_id="space-wide", dimensions=dimensions,
|
||||||
|
vectors=[[1e308, 1e308] + [0.0] * (dimensions - 2)],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
note = await note_service.create_note(title="Apple", markdown="apple orchard", folder=None, tags=[])
|
||||||
|
hits = await routed_vectors.search_remote("apple", top_k=1)
|
||||||
|
assert hits is not None and hits[0].id == note.blocks[0].block_id
|
||||||
|
assert hits[0].score == pytest.approx(1.0)
|
||||||
|
vector = json.loads(rows("SELECT vector FROM routed_block_vectors")[0][0])
|
||||||
|
assert len(vector) == dimensions
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_runtime_uses_unchanged_local_retrieval(runtime, monkeypatch):
|
||||||
|
monkeypatch.setattr(routed_vectors, "get_model_routing", lambda: None)
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
await seed()
|
||||||
|
assert await engine.search(request()) == await local_engine().search(request())
|
||||||
|
assert runtime.calls == []
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
> 适用范围:桌面客户端、本地知识库、RAG、Agent、Skill、多模型接入、多模态处理与可选云同步
|
> 适用范围:桌面客户端、本地知识库、RAG、Agent、Skill、多模型接入、多模态处理与可选云同步
|
||||||
> 目标读者:前端、Rust 桌面端、Python AI Core、算法、测试与后续接手项目的开发成员
|
> 目标读者:前端、Rust 桌面端、Python AI Core、算法、测试与后续接手项目的开发成员
|
||||||
|
|
||||||
> 实施状态更新:2026-09-03。本文同时包含目标架构、当前实现和第二阶段接口基线。第一阶段已完成 Vue Web 联调前端、FastAPI、Knowledge/Retrieval、Agent/Tool/Permission、Skill/Plugin 声明式运行时、Mock/OpenAI-Compatible/Ollama Provider、DeepSeek/OpenAI 预设、模型发现及开发阶段 Fernet 凭据存储。Web Workspace 已通过 FastAPI 接入后端配置的真实单 Vault;第二阶段 Agent Trace 持久化、分页快照、可恢复 SSE、stdio MCP Bridge、隔离 Plugin Host、Plugin Command 与 Plugin Settings/Secret Contract 已完成;RAG Benchmark 检索评测(Dataset 加载、异步运行、SSE 进度、指标聚合与报告)已完成,Agent Benchmark 暂缓。后续继续接入真实音频处理、Provider 协议增强、文档导出、主题包、Trace 可视化、Mermaid 和函数图像。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统和 Sync Server 仍未实现。
|
> 实施状态更新:2026-09-04。本文同时包含目标架构、当前实现和第二阶段接口基线。第一阶段已完成 Vue Web 联调前端、FastAPI、Knowledge/Retrieval、Agent/Tool/Permission、Skill/Plugin 声明式运行时、Mock/OpenAI-Compatible/Ollama Provider、DeepSeek/OpenAI 预设、模型发现及开发阶段 Fernet 凭据存储。Web Workspace 已通过 FastAPI 接入后端配置的真实单 Vault;第二阶段 Agent Trace 持久化、分页快照、可恢复 SSE、stdio MCP Bridge、隔离 Plugin Host、Plugin Command 与 Plugin Settings/Secret Contract 已完成。阶段 E 已完成 Responses/Anthropic 协议、国内 logo 预设、Provider 配置恢复和 Embedding/转写/声纹 API 路由;本地语音模型仍为阶段 F 接口预留。RAG Benchmark 检索评测(Dataset 加载、异步运行、SSE 进度、指标聚合与报告)已完成,Agent Benchmark 暂缓。后续继续接入真实音频处理、文档导出、主题包、Trace 可视化、Mermaid 和函数图像。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统和 Sync Server 仍未实现。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ RunCancelled
|
|||||||
|
|
||||||
- Chat、Agent Run、Agent Events、Tool 列表、Provider 配置生命周期、模型列表和连接测试已经接入 AI Core。
|
- Chat、Agent Run、Agent Events、Tool 列表、Provider 配置生命周期、模型列表和连接测试已经接入 AI Core。
|
||||||
- Agent Run/Event 已持久化到 SQLite;SSE 帧携带 sequence `id`,断线后可以回放缺失事件。Trace API 与 Benchmark 共用同一事件事实,并在入库前执行 Secret 脱敏和结果限长。
|
- Agent Run/Event 已持久化到 SQLite;SSE 帧携带 sequence `id`,断线后可以回放缺失事件。Trace API 与 Benchmark 共用同一事件事实,并在入库前执行 Secret 脱敏和结果限长。
|
||||||
- Provider Adapter 当前包含 Mock、真正增量 SSE 的 OpenAI-Compatible Chat Completions,以及 Ollama JSONL Streaming。
|
- Provider Adapter 当前包含 Mock、增量 SSE 的 OpenAI-Compatible Chat Completions、OpenAI Responses、Anthropic Messages,以及 Ollama JSONL Streaming。阶段 E 增加 `/api/model-routing`、`/api/models/embeddings`、`/api/media/speaker-matches`;具体请求和阶段边界见第二阶段契约 §8.5。
|
||||||
- Notes、Search、Index、Skills、Plugins、Tasks 和 Provider 生命周期均已接入业务服务。
|
- Notes、Search、Index、Skills、Plugins、Tasks 和 Provider 生命周期均已接入业务服务。
|
||||||
- Workspace 已接入后端配置的真实 Vault;文件树、笔记读写、文件/目录新建、重命名和删除不再使用前端 Mock Fallback。
|
- Workspace 已接入后端配置的真实 Vault;文件树、笔记读写、文件/目录新建、重命名和删除不再使用前端 Mock Fallback。
|
||||||
- Note Move 保留 `note_id`;Citation 的字符偏移统一使用 UTF-16 code unit,供浏览器编辑器直接定位。
|
- Note Move 保留 `note_id`;Citation 的字符偏移统一使用 UTF-16 code unit,供浏览器编辑器直接定位。
|
||||||
@@ -190,3 +190,9 @@ RunCancelled
|
|||||||
- 接入业务模块时保持当前路径和 Contract,不在 Router 中直接实现数据库、Provider 或 Agent 逻辑。
|
- 接入业务模块时保持当前路径和 Contract,不在 Router 中直接实现数据库、Provider 或 Agent 逻辑。
|
||||||
|
|
||||||
第二阶段开发保持本文件中已有路径兼容,并按 `第二阶段接口契约-开发版.md` 增加子资源、可选字段和事件。接口完成后先更新 OpenAPI 与本文件,再将第二阶段文档中的状态改为已实现。
|
第二阶段开发保持本文件中已有路径兼容,并按 `第二阶段接口契约-开发版.md` 增加子资源、可选字段和事件。接口完成后先更新 OpenAPI 与本文件,再将第二阶段文档中的状态改为已实现。
|
||||||
|
|
||||||
|
|
||||||
|
### 前端真实状态补充(2026-09-04)
|
||||||
|
|
||||||
|
- `GET /api/index/status` 额外返回 `total_notes: int` 和 `total_blocks: int`,来自当前 SQLite 索引;未建立内容索引时为 0。
|
||||||
|
- `GET /api/permissions/policy` 返回 `Record<string, "allow" | "confirm" | "deny">`,值取自后端当前生效的 PermissionPolicy。此接口只读,不提供全局修改能力,运行时权限确认仍使用既有 Agent permission endpoint。
|
||||||
|
|||||||
@@ -718,6 +718,8 @@ stdio 命令始终以 executable 与 args 数组通过 `shell=False` 启动;
|
|||||||
|
|
||||||
## 8. Provider Adapter 扩展
|
## 8. Provider Adapter 扩展
|
||||||
|
|
||||||
|
> 阶段 E 实施更新(2026-09-04):OpenAI Responses、Anthropic Messages、Chat Completions 与 Ollama Adapter 已接入;国内提供商 logo 预设、独立凭据输入、配置恢复、Embedding / 转写 / 声纹 API 路由已实现。真实本地语音模型仍属于阶段 F。实现细节见 [模型提供商与模型发现开发说明](../development/模型提供商与模型发现开发说明.md)。
|
||||||
|
|
||||||
第二阶段不新增平行 Provider CRUD,继续使用第一阶段接口:
|
第二阶段不新增平行 Provider CRUD,继续使用第一阶段接口:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -734,7 +736,7 @@ POST /api/chat
|
|||||||
|
|
||||||
### 8.1 ModelInfo 扩展
|
### 8.1 ModelInfo 扩展
|
||||||
|
|
||||||
`GET /api/providers/{provider_id}/models` 的 item 增加可选字段:
|
以下为后续计划的可选字段;阶段 E 的 `GET /api/providers/{provider_id}/models` 实际 item 仍只包含 `model`、`display_name`、`capabilities`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -774,6 +776,8 @@ Done
|
|||||||
- 浏览器取消 Fetch 或 SSE 后,服务端必须取消上游 Provider 请求。
|
- 浏览器取消 Fetch 或 SSE 后,服务端必须取消上游 Provider 请求。
|
||||||
- 不支持 reasoning 的 Provider 不发送伪造 ThinkingDelta。
|
- 不支持 reasoning 的 Provider 不发送伪造 ThinkingDelta。
|
||||||
|
|
||||||
|
阶段 E 补充:取消或关闭迭代器直接关闭上游连接并传播取消,不向已断开的客户端继续发送 Done。内部带点号、长名称的工具映射为合法的 64 字符以内名称,响应恢复原命名空间,映射在请求内隔离。实际流中断错误码为 `PROVIDER_STREAM_TRUNCATED`;`PROVIDER_INVALID_RESPONSE` 用于无效结构/参数。上面的 `Done.data.status` 适用于真实 HTTP Adapter;开发 Mock 保留原有测试事件。
|
||||||
|
|
||||||
### 8.3 Provider 一致性测试 Contract
|
### 8.3 Provider 一致性测试 Contract
|
||||||
|
|
||||||
每个 Adapter 使用相同 Case 描述:
|
每个 Adapter 使用相同 Case 描述:
|
||||||
@@ -811,6 +815,25 @@ MODEL_CONTEXT_LENGTH_EXCEEDED
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 8.5 阶段 E 模型路由接口(已实现)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 契约 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/model-routing` | `{config, local_backends}` |
|
||||||
|
| PUT | `/api/model-routing` | 提交 ModelRoutingConfig,返回递增版本配置 |
|
||||||
|
| POST | `/api/models/embeddings` | `{texts: string[]}` → `{vectors, source, model_id, dimensions, fallback_reason}` |
|
||||||
|
| POST | `/api/media/speaker-matches` | `{attachment_id, reference_attachment_id}` → `{score, source, fallback_reason}` |
|
||||||
|
|
||||||
|
`ModelRoutingConfig` 包含 `version`、`embedding`、`transcription`、`speaker_matching`。每个能力为 null 或 `{provider_id, model, endpoint, dimensions?}`。dimensions 仅 Embedding 使用,范围 1–16384;endpoint 是选定 Provider 下不带查询的路径,不能传第二个 URL。PUT 不提交 GET 返回的 local_backends;版本冲突返回 409 `MODEL_ROUTING_VERSION_CONFLICT`。删除仍被引用的 Provider 返回 409 `PROVIDER_IN_USE`。
|
||||||
|
|
||||||
|
本阶段远程能力仅接受 OpenAI Chat / Compatible HTTP 配置,默认路径分别是 `/embeddings`、`/audio/transcriptions`、`/audio/speaker-matches`。最后一个是本项目自定义 multipart 接口,**不是公共 OpenAI 标准协议**:请求 model、file、reference_file,响应有限 0–1 的 score。转写采用 multipart model、file、可选 language,必须返回非空 text。文件来自受控附件目录,限制 25 MiB。
|
||||||
|
|
||||||
|
`TranscriptionJob` 新增可选 `source: api|local|sidecar` 和 `fallback_reason`。保留已有转写 Job 路径;`diarization=true` 返回失败 Job,错误为 `DIARIZATION_NOT_IMPLEMENTED`,不能静默忽略。
|
||||||
|
|
||||||
|
无配置时调用本地接口;有配置时 API 优先,网络/鉴权/限流/结果无效时回退。本地 Embedding 当前为 hash 占位;本地 ASR / 声纹后端尚未安装时返回 `LOCAL_MODEL_NOT_INSTALLED`,而非伪成功。远程 Embedding 独立索引并检查完整覆盖,模型变化后需重建;不与本地向量混算。
|
||||||
|
|
||||||
|
Provider PATCH 支持 provider_type;普通配置持久化到 SQLite,凭据继续独立加密。预设新增 logo_id、description、capabilities,前端图标随应用打包。
|
||||||
|
|
||||||
## 9. RAG / Agent Benchmark
|
## 9. RAG / Agent Benchmark
|
||||||
|
|
||||||
Benchmark Service 同时提供 Python 调用接口和本地 HTTP 接口。CLI、测试和前端报告页调用同一 Service,不各自实现指标。
|
Benchmark Service 同时提供 Python 调用接口和本地 HTTP 接口。CLI、测试和前端报告页调用同一 Service,不各自实现指标。
|
||||||
@@ -1544,3 +1567,7 @@ frontend/src/
|
|||||||
```
|
```
|
||||||
|
|
||||||
目录调整应按实际代码规模渐进进行。Router 只做参数接收和错误映射,状态机、第三方 SDK 与文件处理继续放在 Service/Adapter 层。
|
目录调整应按实际代码规模渐进进行。Router 只做参数接收和错误映射,状态机、第三方 SDK 与文件处理继续放在 Service/Adapter 层。
|
||||||
|
|
||||||
|
### Benchmark Embedding 运行归属(阶段 E 集成修复)
|
||||||
|
|
||||||
|
`config_snapshot.local_embedding` 仅表示本地基线;`config_snapshot.embedding` 为 `{ "policy": "per_case", "details": "cases[].embedding" }`。报告与 CaseCompleted 事件的逐样本 `embedding` 包含实际 source(api/local/not_used/unavailable)、model_id、dimensions,以及可选 version、fallback_reason、requested_route、route_version、attempted_space。requested_route 仅含提供商引用、模型、相对端点和维度,不包含 API Key 或凭据引用。FTS 不使用 Embedding,标记 not_used;远程失败或索引不完整回退时记录实际本地模型及原因。
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ Skill Manifest
|
|||||||
|
|
||||||
前端智能体页面已经完成中文联调:运行状态、Agent Event、内置 Tool、Permission 和常用事件详情字段均通过集中标签映射展示中文;`notes.search` 等技术 ID 继续保留,便于与后端 Trace、日志和接口契约对应。
|
前端智能体页面已经完成中文联调:运行状态、Agent Event、内置 Tool、Permission 和常用事件详情字段均通过集中标签映射展示中文;`notes.search` 等技术 ID 继续保留,便于与后端 Trace、日志和接口契约对应。
|
||||||
|
|
||||||
- 已实现 Mock、OpenAI-Compatible Chat Completions 与 Ollama Adapter;OpenAI Responses 和 Anthropic Messages 尚未实现。
|
- 已实现 Mock、OpenAI-Compatible Chat Completions、Ollama、OpenAI Responses 和 Anthropic Messages Adapter;阶段 E 同时完成国内预设、持久化配置和能力模型路由,详见 [模型提供商与模型发现开发说明](模型提供商与模型发现开发说明.md)。
|
||||||
- Provider 配置暂存内存,后续通过 Repository 接入 SQLite;PATCH 已支持用显式 `null` 清空 base URL、默认模型和凭据引用。
|
- Provider 配置暂存内存,后续通过 Repository 接入 SQLite;PATCH 已支持用显式 `null` 清空 base URL、默认模型和凭据引用。
|
||||||
- Run/Trace 已通过 Repository 接入 SQLite;后续增加按保留策略归档和 Benchmark 引用保护。
|
- Run/Trace 已通过 Repository 接入 SQLite;后续增加按保留策略归档和 Benchmark 引用保护。
|
||||||
- Permission 已有核心等待/恢复机制,前端确认 UI 已完成联调和中文展示。
|
- Permission 已有核心等待/恢复机制,前端确认 UI 已完成联调和中文展示。
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ BENCHMARK_CASE_EVALUATION_FAILED
|
|||||||
|
|
||||||
## 配置快照
|
## 配置快照
|
||||||
|
|
||||||
报告与运行记录保存 `config_snapshot`:dataset hash/version、modes、retrieval 参数、Embedding model/version/dim、Reranker、索引元数据、App 版本与环境、Python 版本,保证不同实验结果可复现。
|
报告与运行记录保存 `config_snapshot`:dataset hash/version、modes、retrieval 参数、Reranker、索引元数据、App 版本与环境、Python 版本。`local_embedding` 记录本地基线 model/version/dim;`embedding.policy = per_case` 表示实际来源以逐样本结果为准,不能把本地基线当作本次使用的模型。
|
||||||
|
|
||||||
|
每个 `RAGCaseResult.embedding`(同时出现在报告 cases 和 CaseCompleted SSE 中)记录 `source`(api/local/not_used/unavailable)、实际 `model_id` 空间标识、`dimensions`、本地 `version`、`fallback_reason`。远程路由还记录请求时的 `route_version` 和 `requested_route`(provider_id/model/endpoint/dimensions,不含凭据)、成功生成查询向量后的 `attempted_space`。FTS 标记 not_used;调用失败而未完成向量检索时标记 unavailable。API 不可用或远程索引缺失时,实际模型仍记录最终使用的本地基线。配置允许在样本间改变,逐样本记录对应实际调用;汇总指标可能包含多种空间,比较实验时需检查 cases。记录使用任务局部上下文隔离,并发评测不会相互覆盖。
|
||||||
|
|
||||||
## 测试
|
## 测试
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 前端壳子与接口层开发说明
|
# 前端壳子与接口层开发说明
|
||||||
|
|
||||||
> 更新日期:2026-09-02
|
> 更新日期:2026-09-04
|
||||||
> 适用范围:Vue 3 + TypeScript 页面、Workspace、公共 Service、FastAPI 接口适配和 SSE。
|
> 适用范围:Vue 3 + TypeScript 页面、Workspace、公共 Service、FastAPI 接口适配和 SSE。
|
||||||
> 文档用途:帮助团队理解当前前端可用能力、模块边界、启动方式和后续页面开发入口。
|
> 文档用途:帮助团队理解当前前端可用能力、模块边界、启动方式和后续页面开发入口。
|
||||||
|
|
||||||
@@ -153,12 +153,7 @@ Service 已适配当前 FastAPI Contract:
|
|||||||
- 识别 `Done`、`RunCompleted`、`RunFailed` 和 `RunCancelled`;
|
- 识别 `Done`、`RunCompleted`、`RunFailed` 和 `RunCancelled`;
|
||||||
- 支持 AbortController 主动取消。
|
- 支持 AbortController 主动取消。
|
||||||
|
|
||||||
Chat Store 已从定时器模拟输出切换为真实 `/api/chat` SSE。默认离线联调配置为:
|
Chat Store 使用真实 `/api/chat` SSE。提供商从后端配置加载,前端不展示后端内置测试 Provider,也不预选模拟模型;模型 ID 使用所选提供商保存的默认值,并支持手动输入。
|
||||||
|
|
||||||
```text
|
|
||||||
provider_id = mock
|
|
||||||
model = mock-1
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. 环境和启动
|
## 8. 环境和启动
|
||||||
|
|
||||||
@@ -207,3 +202,34 @@ Vite 当前会提示 Chat 与 Workspace 的部分异步 Chunk 超过 500 kB,
|
|||||||
- Workspace 接入 Tauri 后,需要增加路径规范化、写入失败恢复和外部修改冲突测试;
|
- Workspace 接入 Tauri 后,需要增加路径规范化、写入失败恢复和外部修改冲突测试;
|
||||||
- 页面新增交互必须经过键盘、空状态、加载状态、错误状态和窄窗口检查;
|
- 页面新增交互必须经过键盘、空状态、加载状态、错误状态和窄窗口检查;
|
||||||
- Workspace 的 Milkdown 写作模式与 CodeMirror 源码模式共享同一 Markdown 数据源;后续修改编辑器时不得改变 Store/Service 边界,并必须保留文件切换、自动保存和选区格式化回归测试。
|
- Workspace 的 Milkdown 写作模式与 CodeMirror 源码模式共享同一 Markdown 数据源;后续修改编辑器时不得改变 Store/Service 边界,并必须保留文件切换、自动保存和选区格式化回归测试。
|
||||||
|
|
||||||
|
|
||||||
|
## 阶段 F 前:前端真实数据清理
|
||||||
|
|
||||||
|
已删除运行时的聊天示例、Agent Run/Event/Tool/权限示例、Provider/Model、Task、Skill、Plugin、IndexStatus 常量和 searchMock。测试文件中的隔离桩保留,仅用于自动化验证。
|
||||||
|
|
||||||
|
- 所有业务 Store 从空集合开始,由真实 API 填充;连接失败显示错误,不回退演示记录。
|
||||||
|
- 普通聊天仅显示用户实际输入和 SSE 响应;当前会话列表保留在页面会话内,刷新后清空,后端暂无聊天历史持久化接口。切换会话保留本次会话内的真实消息,取消旧流并屏蔽迟到回调。
|
||||||
|
- 聊天页移除尚未接入的知识库与 Skill 开关,知识库工具和 Skill 通过 Agent 使用。
|
||||||
|
- 设置页不再伪造健康状态、版本、42 篇笔记/318 个 Block、模型名称和索引能力开关。状态未获取时显示 unknown/未获取;应用版本来自 package.json,后端版本来自 /api/status。
|
||||||
|
- GET /api/index/status 增加 total_notes、total_blocks,直接读取 SQLite 的当前索引统计。
|
||||||
|
- GET /api/permissions/policy 返回 PermissionPolicy 的实际生效值。设置页只读展示;全局策略编辑暂未开放,运行权限确认仍走原有 Agent 接口。
|
||||||
|
- 删除模拟重启成功逻辑,说明 Web 端不具备进程重启能力;索引页面只保留后端已实现的全量重建。
|
||||||
|
- Task DTO 不再填充后端未返回的优先级和来源,Agent Token 用量不再把未知输入/输出拆分填成 0。
|
||||||
|
- Plugin/Skill/Provider 无记录时显示空状态,模型发现失败时允许使用真实的手动模型 ID。
|
||||||
|
|
||||||
|
验证:前端 81 项测试、类型检查与生产构建通过;后端 454 项测试通过。新增测试覆盖空初始状态、离线错误、真实统计与权限、测试 Provider 过滤、真实聊天历史及旧流隔离。本次未调用真实付费推理 API。
|
||||||
|
|
||||||
|
### MCP 工具中文展示补充
|
||||||
|
|
||||||
|
Agent 工具列表按 `mcp.<server_id>.<remote_name>` 的远程工具名匹配中文展示,支持 `web_search`(网页搜索)、`understand_image`(图像理解),并补充 `text.uppercase`(文本转大写)。此映射只影响界面,工具调用与权限选择仍使用完整原始 ID。
|
||||||
|
|
||||||
|
卡片默认显示三行摘要,完整服务原文可展开查看,展开操作不会改变工具选择。服务已提供中文说明时优先保留;未收录的 MCP 工具明确提示暂无中文说明,不将本地摘要当作服务协议或自动翻译结果。原始说明及其中的参数规则完整保留。
|
||||||
|
|
||||||
|
验证:前端 84 项测试、类型检查与生产构建通过。新增回归覆盖不同服务器命名空间、未知工具、服务中文说明、原文完整性,以及选择工具时保留原始 ID。
|
||||||
|
|
||||||
|
### 聊天模型选择审阅修复
|
||||||
|
|
||||||
|
返回聊天页时保留仍启用的提供商与手动模型 ID,仅刷新其模型列表;未选择、已删除或已禁用的提供商才回退到默认值。提供商加载失败时保留当前选择并展示错误。新增页面重新挂载与异常分支回归,前端共 89 项测试通过。
|
||||||
|
|
||||||
|
补充卸载时序修复:提供商或技能加载期间离开聊天页后,旧页面的初始化回调不再修改聊天选择,迟到错误也不再更新旧页面。两种加载延迟均通过先失败、修复后通过的回归测试,并验证返回页面后的默认模型和发送按钮状态;前端共 91 项测试通过。
|
||||||
|
|||||||
@@ -1,107 +1,107 @@
|
|||||||
# 模型提供商与模型发现开发说明
|
# 模型提供商、协议适配与模型路由开发说明
|
||||||
|
|
||||||
> 更新日期:2026-09-02。OpenAI、DeepSeek、Ollama 预设、模型自动发现、默认模型选择和开发阶段加密凭据存储均已实现并接入设置页。
|
> 更新日期:2026-09-04。阶段 E 实现记录。本地小模型的实际安装与多模态队列属于阶段 F;本阶段保留并测试可注入的本地后端接口。
|
||||||
|
|
||||||
## 1. 本次目标
|
## 1. 设置与凭据
|
||||||
|
|
||||||
本次完善设置页的模型提供商配置,不改变 Agent、Chat 和 Skill 对统一 Model Core 接口的依赖:
|
设置 → 模型提供商 → 新增 Provider 提供可搜索的 logo 预设网格,包含 DeepSeek、Kimi、阿里云百炼、智谱 GLM、火山方舟、硅基流动、百度千帆、腾讯混元、MiniMax、阶跃星辰,以及 OpenAI Chat / Responses、Anthropic 和 Ollama。图标打包到前端,使用时不请求第三方图片服务;来源和许可见前端 assets/providers 目录。
|
||||||
|
|
||||||
- 提供 OpenAI、DeepSeek 和 Ollama 配置预设;
|
预设返回 `preset_id`、`logo_id`、`name`、`provider_type`、`base_url`、`requires_credential`、`description` 和 `capabilities`。能力标签表示预设接入范围,不保证该账号的每个模型支持全部能力。厂商专用媒体协议、Coding Plan 和海外地域需要使用对应地址,不能仅凭厂商名称推断协议兼容。
|
||||||
- 保存 Provider 后自动获取该账号或服务当前可用的模型列表;
|
|
||||||
- 支持手动刷新模型列表和选择默认模型;
|
|
||||||
- 保留自定义 OpenAI-Compatible 服务入口;
|
|
||||||
- 不在 Vue、FastAPI 配置或仓库文件中保存、回显 API Key 明文。
|
|
||||||
|
|
||||||
## 2. 接口与实现
|
预设和自定义服务都可以直接输入 API Key。每个新配置分配独立 Credential ID,避免同厂商多账号相互覆盖。明文只留在密码输入框和专用请求中,提交、失败、切换预设及关闭时清空;密钥不进入 Pinia、localStorage、Provider 配置响应或模型路由。
|
||||||
|
|
||||||
### 2.1 Provider 预设
|
凭据继续使用独立的 `PUT /api/credentials/{credential_id}` 和 Fernet 开发存储。`plugin.*`、`mcp.*` 是保留命名空间。桌面端阶段仍需要把主密钥管理迁移到 Stronghold。保存密钥与保存 Provider 是两个请求,Provider 保存失败时可能留下未引用的加密凭据,可通过凭据删除接口清理。
|
||||||
|
|
||||||
新增接口:
|
Provider 配置和 Credential ID 写入 SQLite `provider_configs`,重启后恢复。Mock 为内置 Provider,不能编辑或删除。PATCH 已支持变更 `provider_type` 并重新创建 Adapter;Base URL 限制为不带用户信息、查询或 fragment 的 HTTP(S) 地址。
|
||||||
|
|
||||||
```http
|
## 2. 协议适配
|
||||||
GET /api/providers/presets
|
|
||||||
|
支持的协议是 OpenAI Chat Completions、OpenAI-Compatible、OpenAI Responses、Anthropic Messages 和 Ollama。Agent、Chat、Skill 仍只依赖内部 `ModelRequest` / `ModelEvent` / `ProviderTurn`,不直接解释厂商协议。
|
||||||
|
|
||||||
|
Adapter 负责消息及 Tool 历史转换、增量文本、可用的 reasoning delta、工具参数片段、usage、终止与统一错误。外部错误正文不原样返回;HTTP 鉴权、限流、超时、无效数据、流中断分别映射为内部错误。取消继续传播并关闭上游连接,不触发第二次本地推理。
|
||||||
|
|
||||||
|
`GET /api/providers/{provider_id}/models` 用于发现模型。模型列表不等于每个模型的能力承诺;部分厂商或代理不提供 `/models` 时,允许直接手动输入模型 ID。连接测试验证模型发现接口,不代表每一种媒体模型已完成真实推理验收。
|
||||||
|
|
||||||
|
## 3. 三类模型路由
|
||||||
|
|
||||||
|
接口:
|
||||||
|
|
||||||
|
| 方法 | 路径 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/model-routing` | 读取配置和本地后端状态 |
|
||||||
|
| PUT | `/api/model-routing` | 带版本更新三类模型绑定 |
|
||||||
|
| POST | `/api/models/embeddings` | 文本向量,返回来源和回退原因 |
|
||||||
|
| POST | `/api/media/transcriptions` | 附件转写作业 |
|
||||||
|
| GET | `/api/media/transcriptions/{job_id}` | 获取转写作业 |
|
||||||
|
| POST | `/api/media/speaker-matches` | 两个音频附件的声纹相似度 |
|
||||||
|
|
||||||
|
设置 → 索引与模型分别选择 Embedding、音频转文本和声纹匹配。三种绑定互相独立,可使用不同提供商、模型、密钥和 API 路径。
|
||||||
|
|
||||||
|
GET / PUT 响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"version": 1,
|
||||||
|
"embedding": {
|
||||||
|
"provider_id": "provider_example",
|
||||||
|
"model": "your-embedding-model",
|
||||||
|
"endpoint": "/embeddings",
|
||||||
|
"dimensions": null
|
||||||
|
},
|
||||||
|
"transcription": null,
|
||||||
|
"speaker_matching": null
|
||||||
|
},
|
||||||
|
"local_backends": [
|
||||||
|
{"capability": "embedding", "status": "placeholder", "message": "当前为 hash-v1 占位向量"},
|
||||||
|
{"capability": "transcription", "status": "not_installed", "message": "阶段 F 接入"},
|
||||||
|
{"capability": "speaker_matching", "status": "not_installed", "message": "阶段 F 接入"}
|
||||||
|
]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
预设由后端 `ProviderFactory` 提供,前端只消费名称、协议类型、Base URL 和是否需要凭据等配置元数据,不直接实现厂商协议。
|
PUT body 只提交 `config` 的内容。`version` 为读取时的版本,成功递增;并发更新返回 `MODEL_ROUTING_VERSION_CONFLICT`。绑定为空表示使用本地后端。删除仍被路由引用的 Provider 返回 `PROVIDER_IN_USE`,须先解除绑定。
|
||||||
|
|
||||||
当前预设:
|
本阶段三类远程路由使用 `openai_chat` / `openai_compatible` 的 Bearer HTTP 配置,endpoint 只能是该提供商下的路径。Responses、Anthropic 和 Ollama 原生协议不冒充上述媒体协议;Ollama 用户需要另建兼容 HTTP 配置才能用于当前远程 Embedding 接口。
|
||||||
|
|
||||||
| 提供商 | Provider Type | Base URL | 默认 Credential ID |
|
调用规则:无绑定 → 本地接口;有绑定 → API → 校验结果 → 失败或无效时调用本地接口。Provider 停用、密钥缺失、鉴权失败、限流、网络超时及无效结果均可回退;用户取消不会回退。附件不存在、大小非法等输入错误直接返回,不把用户输入错误当成模型故障。
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 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。
|
## 4. Embedding 与索引一致性
|
||||||
|
|
||||||
### 2.2 自动获取模型
|
请求使用 `model`、`input`、`encoding_format: float`;只有明确配置维度时才发送 `dimensions`。按最多 32 条分批请求,全部批次有效才使用 API 结果。校验返回数量、连续唯一 index、维度一致性、有限数值、非零范数,并 L2 归一化。维度可为 1–16384,不截断、补零或混用不同模型的向量。
|
||||||
|
|
||||||
模型列表继续使用既有接口:
|
返回 `vectors`、`source`、`model_id`、`dimensions`、`fallback_reason`。远程空间 ID 由完整 API URL、模型和实际维度生成;即使维度相同,不同模型的空间也不同。
|
||||||
|
|
||||||
```http
|
笔记索引始终保留现有 hash/sqlite-vec 本地基线,远程向量写入独立 `routed_block_vectors` 表。远程查询只搜索对应空间,并要求覆盖全部当前 Block。API 失败、索引缺失、不完整或损坏时使用完整本地索引。切换模型、URL、维度后应在设置中重建全部索引。旧空间与当前文本不会混合打分,删除笔记或重建索引会通过外键清理远程向量。
|
||||||
GET /api/providers/{provider_id}/models
|
|
||||||
```
|
|
||||||
|
|
||||||
设置页在以下时机调用该接口:
|
当前远程侧索引采用 SQLite JSON 向量和精确余弦扫描,复杂度 O(Block 数量 × 维度),适用于当前小型 Vault;后续大规模索引需替换为按空间隔离的 ANN。网络等待发生在数据库写事务之前,当前仍会增加保存或重建延迟,异步索引队列尚未接入。全量重建先在内存中准备全部向量,再使用一个 SQLite 事务更新元数据、FTS、本地与远程向量及任务关联;取消或失败只回滚索引事务,不再覆盖整库文件。准备阶段保留旧索引可查询,代价是内存同时容纳本次重建的向量。
|
||||||
|
|
||||||
- Provider 列表加载完成后,为所有已启用 Provider 自动刷新;
|
OpenAI Compatible 流中,工具名称可能分片返回。适配器在本轮输出结束后发送完整工具名及已缓冲参数,避免把名称片段当作工具 ID;文本与推理内容仍逐片发送。
|
||||||
- 新增或编辑 Provider 保存成功后自动刷新;
|
|
||||||
- 用户点击“刷新模型”时手动刷新;
|
|
||||||
- 打开已有 Provider 的编辑窗口时刷新可选模型。
|
|
||||||
|
|
||||||
前端按模型名称排序并按 `model_id` 去重。获取结果保存在 `providerStore.modelsByProvider`,加载状态和错误按 Provider 隔离,单个外部服务失败不会阻止其他服务展示。
|
无 API 时使用的 `HashEmbeddingProvider` 是确定性特征哈希占位实现,**不是已集成的小型语义模型**。真实本地 Embedding 可实现既有 `EmbeddingProvider` 接口注入。
|
||||||
|
|
||||||
获取成功后,Provider 卡片展示模型数量和默认模型下拉框。更换默认模型会调用 Provider PATCH 接口写回配置;编辑窗口仍允许手动输入模型 ID,以兼容未出现在列表中的代理模型或部署别名。
|
## 5. 音频与声纹边界
|
||||||
|
|
||||||
### 2.3 错误处理
|
转写默认请求 `/audio/transcriptions`,multipart 字段 `model`、可选 `language` 和 `file`,响应必须包含非空字符串 `text`。已有纯文本附件和 Host 旁路 `.txt` 导入保留,来源标记 `sidecar`,不伪称 ASR。转写作业新增 `source`、`fallback_reason`;回退失败的作业记录 `LOCAL_MODEL_NOT_INSTALLED` 等明确错误。作业目前同步执行、限量保存在内存中,不是持久化异步队列。
|
||||||
|
|
||||||
Provider Adapter 的错误在 FastAPI 路由转换为统一 API Error:
|
声纹匹配使用**本项目自定义 HTTP 契约**,默认 `/audio/speaker-matches`,multipart 字段 `model`、`file`、`reference_file`;响应为 `{"score": 0.85}`,score 必须为有限的 0–1 数值。公共入口只接受 `attachment_id` 和 `reference_attachment_id`,不接收任意文件路径。此接口用于一对一声纹比对,不等同于 pyannote 说话人分离,也不声称任意国内厂商原生支持该路径。
|
||||||
|
|
||||||
| Provider Error | HTTP 状态 |
|
媒体文件限制 1 字节至 25 MiB,API 响应限制 16 MiB,单次请求超时 30 秒。文件从后端受控附件目录读取,使用结束或取消时关闭句柄。
|
||||||
| --- | --- |
|
|
||||||
| `PROVIDER_AUTH_FAILED` | 401 |
|
|
||||||
| `MODEL_NOT_FOUND` | 404 |
|
|
||||||
| `PROVIDER_RATE_LIMITED` | 429 |
|
|
||||||
| `PROVIDER_TIMEOUT` | 504 |
|
|
||||||
| 其他 Provider 可用性错误 | 502 |
|
|
||||||
|
|
||||||
前端在对应 Provider 卡片内展示失败原因,并允许用户修正 Credential ID、Base URL 后重新获取。
|
`LocalSpeechBackend` 提供 `transcribe` 和 `match` 接口。阶段 E 默认 `PendingSpeechBackend` 明确报告未安装;阶段 F 接入 faster-whisper、pyannote.audio 及模型资源后替换。当前 `diarization=true` 明确返回失败作业 `DIARIZATION_NOT_IMPLEMENTED`,不会静默忽略。视频解码、TTS、视频生成及厂商专用异步媒体协议不在本次交付内。
|
||||||
|
|
||||||
## 3. 凭据边界
|
## 6. 官方协议依据与验证
|
||||||
|
|
||||||
设置页选择 OpenAI 或 DeepSeek 预设后展示密码类型的 API Key 输入框,不再要求用户理解 Credential ID。输入值只存在于表单的临时 `ref`,不会写入 Pinia 或 localStorage;请求完成、取消表单或失败后都会清空。
|
国内通用地址核对依据:[阿里云百炼兼容接口](https://help.aliyun.com/zh/model-studio/compatibility-of-openai-with-dashscope)、[百度千帆兼容接口](https://cloud.baidu.com/doc/qianfan/s/Hmh4suq26)、[腾讯混元兼容接口](https://cloud.tencent.com/document/product/1729/111007)、[MiniMax 文本接口](https://platform.minimaxi.com/docs/guides/text-generation)、[阶跃星辰通用与套餐地址区别](https://platform.stepfun.com/docs/zh/step-plan/overview)、[火山方舟 API](https://www.volcengine.com/docs/82379/1795150)、[智谱开放接口](https://docs.bigmodel.cn/api-reference/文件-api/文件列表)。模型 ID 以账号实际开通列表为准,不写死“最新模型”。
|
||||||
|
|
||||||
API Key 通过独立接口写入:
|
流式事件依据:[OpenAI Responses streaming](https://platform.openai.com/docs/api-reference/responses-streaming)、[Anthropic streaming](https://platform.claude.com/docs/en/build-with-claude/streaming)。音频请求依据:[SiliconFlow transcription](https://docs.siliconflow.com/en/api-reference/audio/create-audio-transcriptions)。
|
||||||
|
|
||||||
```http
|
自动化验证使用虚构凭据、本地附件、httpx.MockTransport 和可注入本地模型,覆盖流式 Tool/Usage/取消、错误映射、回退、索引空间隔离、版本冲突、重启恢复和界面凭据行为。没有使用真实 API Key 或向厂商发送推理请求。审阅修复并同步主分支后验证:后端全量 447 项、前端 76 项测试通过,Vue/TypeScript 类型检查和生产构建通过,浅色/深色预设页面与路由保存经过浏览器检查,git diff --check 通过。后端仅保留既有 Starlette 测试客户端弃用提示,前端保留既有大 bundle 提示。
|
||||||
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,任何读取接口均不返回明文。
|
```powershell
|
||||||
|
|
||||||
本地开发存储的主密钥与密文仍位于同一用户数据目录,因此它解决的是仓库泄漏、普通配置误提交和静态明文暴露,不等同于操作系统安全硬件或 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
|
cd backend
|
||||||
uv run pytest -q -p no:cacheprovider
|
uv run pytest -q -p no:cacheprovider
|
||||||
```
|
cd ../frontend
|
||||||
|
|
||||||
前端:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
pnpm test
|
pnpm test
|
||||||
pnpm build
|
pnpm build
|
||||||
```
|
```
|
||||||
|
|
||||||
自动化验证覆盖 Provider 预设、OpenAI-Compatible `/models` 请求与鉴权头、模型映射、前端自动刷新、排序去重及按 Provider 隔离错误。生产构建同时执行 Vue 和 TypeScript 类型检查。
|
|
||||||
|
|
||||||
当前完整回归基线:后端 136 项测试、前端 29 项测试通过,前端类型检查和生产构建通过。Provider 配置目前仍保存在内存 Registry,AI Core 重启后需要重新创建;凭据密文会保留。`plugin.*` 为 Plugin Secret 保留命名空间,Provider 配置、临时测试凭据和通用凭据 API 均拒绝该前缀。OpenAI Responses 与 Anthropic Messages Adapter 尚未实现,设置页正式预设不会使用这两种协议。
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
Lobe Icons — Copyright (c) 2023 LobeHub. MIT license; see LICENSE.
|
||||||
|
Source: https://github.com/lobehub/lobe-icons
|
||||||
|
Revision: 4aaf4ee1fb2678a7f989ea570f0f6ce14a9abf75
|
||||||
|
Source directory: packages/static-svg/icons/
|
||||||
|
Assets are bundled locally. Brand trademarks belong to their respective owners.
|
||||||
|
File mapping (local: upstream):
|
||||||
|
anthropic.svg: anthropic.svg
|
||||||
|
baidu.svg: baidu-color.svg
|
||||||
|
deepseek.svg: deepseek-color.svg
|
||||||
|
hunyuan.svg: hunyuan-color.svg
|
||||||
|
kimi.svg: kimi-color.svg
|
||||||
|
minimax.svg: minimax-color.svg
|
||||||
|
ollama.svg: ollama.svg
|
||||||
|
openai.svg: openai.svg
|
||||||
|
qwen.svg: qwen-color.svg
|
||||||
|
siliconflow.svg: siliconcloud-color.svg
|
||||||
|
stepfun.svg: stepfun-color.svg
|
||||||
|
volcengine.svg: volcengine-color.svg
|
||||||
|
zhipu.svg: zhipu-color.svg
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023 LobeHub
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
Lobe Icons — Copyright (c) 2023 LobeHub. MIT license; see LICENSE.
|
||||||
|
Source: https://github.com/lobehub/lobe-icons
|
||||||
|
Revision: 4aaf4ee1fb2678a7f989ea570f0f6ce14a9abf75
|
||||||
|
Source directory: packages/static-svg/icons/
|
||||||
|
Assets are bundled locally. Brand trademarks belong to their respective owners.
|
||||||
|
File mapping (local: upstream):
|
||||||
|
anthropic.svg: anthropic.svg
|
||||||
|
baidu.svg: baidu-color.svg
|
||||||
|
deepseek.svg: deepseek-color.svg
|
||||||
|
hunyuan.svg: hunyuan-color.svg
|
||||||
|
kimi.svg: kimi-color.svg
|
||||||
|
minimax.svg: minimax-color.svg
|
||||||
|
ollama.svg: ollama.svg
|
||||||
|
openai.svg: openai.svg
|
||||||
|
qwen.svg: qwen-color.svg
|
||||||
|
siliconflow.svg: siliconcloud-color.svg
|
||||||
|
stepfun.svg: stepfun-color.svg
|
||||||
|
volcengine.svg: volcengine-color.svg
|
||||||
|
zhipu.svg: zhipu-color.svg
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023 LobeHub
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 368 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Baidu</title><path d="M8.859 11.735c1.017-1.71 4.059-3.083 6.202.286 1.579 2.284 4.284 4.397 4.284 4.397s2.027 1.601.73 4.684c-1.24 2.956-5.64 1.607-6.005 1.49l-.024-.009s-1.746-.568-3.776-.112c-2.026.458-3.773.286-3.773.286l-.045-.001c-.328-.01-2.38-.187-3.001-2.968-.675-3.028 2.365-4.687 2.592-4.968.226-.288 1.802-1.37 2.816-3.085zm.986 1.738v2.032h-1.64s-1.64.138-2.213 2.014c-.2 1.252.177 1.99.242 2.148.067.157.596 1.073 1.927 1.342h3.078v-7.514l-1.394-.022zm3.588 2.191l-1.44.024v3.956s.064.985 1.44 1.344h3.541v-5.3h-1.528v3.979h-1.46s-.466-.068-.553-.447v-3.556zM9.82 16.715v3.06H8.58s-.863-.045-1.126-1.049c-.136-.445.02-.959.088-1.16.063-.203.353-.671.951-.85H9.82zm9.525-9.036c2.086 0 2.646 2.06 2.646 2.742 0 .688.284 3.597-2.309 3.655-2.595.057-2.704-1.77-2.704-3.08 0-1.374.277-3.317 2.367-3.317zM4.24 6.08c1.523-.135 2.645 1.55 2.762 2.513.07.625.393 3.486-1.975 4-2.364.515-3.244-2.249-2.984-3.544 0 0 .28-2.797 2.197-2.969zm8.847-1.483c.14-1.31 1.69-3.316 2.931-3.028 1.236.285 2.367 1.944 2.137 3.37-.224 1.428-1.345 3.313-3.095 3.082-1.748-.226-2.143-1.823-1.973-3.424zM9.425 1c1.307 0 2.364 1.519 2.364 3.398 0 1.879-1.057 3.4-2.364 3.4s-2.367-1.521-2.367-3.4C7.058 2.518 8.118 1 9.425 1z" fill="#2932E1" fill-rule="nonzero"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Hunyuan</title><circle cx="12" cy="12" fill="#0055E9" r="12"></circle><path d="M12 0c.518 0 1.028.033 1.528.096A6.188 6.188 0 0112.12 12.28l-.12.001c-2.99 0-5.242 2.179-5.554 5.11-.223 2.086.353 4.412 2.242 6.146C3.672 22.1 0 17.479 0 12 0 5.373 5.373 0 12 0z" fill="#A8DFF5"></path><path d="M5.286 5a2.438 2.438 0 01.682 3.38c-3.962 5.966-3.215 10.743 2.648 15.136C3.636 22.056 0 17.452 0 12c0-1.787.39-3.482 1.09-5.006.253-.435.525-.872.817-1.311A2.438 2.438 0 015.286 5z" fill="#0055E9"></path><path d="M12.98.04c.272.021.543.053.81.093.583.106 1.117.254 1.538.44 6.638 2.927 8.07 10.052 1.748 15.642a4.125 4.125 0 01-5.822-.358c-1.51-1.706-1.3-4.184.357-5.822.858-.848 3.108-1.223 4.045-2.441 1.257-1.634 2.122-6.009-2.523-7.506L12.98.039z" fill="#00BCFF"></path><path d="M13.528.096A6.187 6.187 0 0112 12.281a5.75 5.75 0 00-1.71.255c.147-.905.595-1.784 1.321-2.501.858-.848 3.108-1.223 4.045-2.441 1.27-1.651 2.14-6.104-2.676-7.554.184.014.367.033.548.056z" fill="#ECECEE"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z" fill="#1783FF"></path><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z" fill="#fff"></path></svg>
|
||||||
|
After Width: | Height: | Size: 773 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><defs><linearGradient id="lobe-icons-minimax-_R_0_" x1="0%" x2="100.182%" y1="50.057%" y2="50.057%"><stop offset="0%" stop-color="#E2167E"></stop><stop offset="100%" stop-color="#FE603C"></stop></linearGradient></defs><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="url(#lobe-icons-minimax-_R_0_)" fill-rule="nonzero"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Ollama</title><path d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z" fill="url(#lobe-icons-qwen-_R_0_)" fill-rule="nonzero"></path><defs><linearGradient id="lobe-icons-qwen-_R_0_" x1="0%" x2="100%" y1="0%" y2="0%"><stop offset="0%" stop-color="#6336E7" stop-opacity=".84"></stop><stop offset="100%" stop-color="#6F69F7" stop-opacity=".84"></stop></linearGradient></defs></svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>SiliconCloud</title><path clip-rule="evenodd" d="M22.956 6.521H12.522c-.577 0-1.044.468-1.044 1.044v3.13c0 .577-.466 1.044-1.043 1.044H1.044c-.577 0-1.044.467-1.044 1.044v4.174C0 17.533.467 18 1.044 18h10.434c.577 0 1.044-.467 1.044-1.043v-3.13c0-.578.466-1.044 1.043-1.044h9.391c.577 0 1.044-.467 1.044-1.044V7.565c0-.576-.467-1.044-1.044-1.044z" fill="#6E29F6" fill-rule="evenodd"></path></svg>
|
||||||
|
After Width: | Height: | Size: 520 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Stepfun</title><path d="M22.012 0h1.032v.927H24v.968h-.956V3.78h-1.032V1.896h-1.878v-.97h1.878V0zM2.6 12.371V1.87h.969v10.502h-.97zm10.423.66h10.95v.918h-6.208v9.579h-4.742V13.03zM5.629 3.333v12.356H0v4.51h10.386V8L20.859 8l-.003-4.668-15.227.001z" fill="url(#lobe-icons-stepfun-_R_0_)" fill-rule="evenodd"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-stepfun-_R_0_" x1="1.646" x2="18.342" y1="1.916" y2="22.091"><stop stop-color="#01A9FF"></stop><stop offset="1" stop-color="#0160FF"></stop></linearGradient></defs></svg>
|
||||||
|
After Width: | Height: | Size: 676 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Volcengine</title><path d="M19.44 10.153l-2.936 11.586a.215.215 0 00.214.261h5.87a.215.215 0 00.214-.261l-2.95-11.586a.214.214 0 00-.412 0zM3.28 12.778l-2.275 8.96A.214.214 0 001.22 22h4.532a.212.212 0 00.214-.165.214.214 0 000-.097l-2.276-8.96a.214.214 0 00-.41 0z" fill="#00E5E5"></path><path d="M7.29 5.359L3.148 21.738a.215.215 0 00.203.261h8.29a.214.214 0 00.215-.261L7.7 5.358a.214.214 0 00-.41 0z" fill="#006EFF"></path><path d="M14.44.15a.214.214 0 00-.41 0L8.366 21.739a.214.214 0 00.214.261H19.9a.216.216 0 00.171-.078.214.214 0 00.044-.183L14.439.15z" fill="#006EFF"></path><path d="M10.278 7.741L6.685 21.736a.214.214 0 00.214.264h7.17a.215.215 0 00.214-.264L10.688 7.741a.214.214 0 00-.41 0z" fill="#00E5E5"></path></svg>
|
||||||
|
After Width: | Height: | Size: 858 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>
|
||||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -39,11 +39,12 @@ const saveStatusColor = computed(() => {
|
|||||||
|
|
||||||
const indexStatusText = computed(() => {
|
const indexStatusText = computed(() => {
|
||||||
const s = settingsStore.indexStatus.status
|
const s = settingsStore.indexStatus.status
|
||||||
return s === 'idle' ? '索引就绪' : s === 'indexing' ? `索引中 (${settingsStore.indexStatus.pending_jobs})` : '索引错误'
|
return s === 'unknown' ? '索引状态未获取' : s === 'idle' ? '索引就绪' : s === 'indexing' ? `索引中 (${settingsStore.indexStatus.pending_jobs})` : '索引错误'
|
||||||
})
|
})
|
||||||
|
|
||||||
const aiCoreStatusText = computed(() => {
|
const aiCoreStatusText = computed(() => {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
|
unknown: 'AI Core 状态未获取',
|
||||||
starting: 'AI Core 启动中',
|
starting: 'AI Core 启动中',
|
||||||
running: 'AI Core 运行中',
|
running: 'AI Core 运行中',
|
||||||
stopped: 'AI Core 已停止',
|
stopped: 'AI Core 已停止',
|
||||||
|
|||||||
@@ -207,8 +207,8 @@ export interface PermissionRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenUsage {
|
export interface TokenUsage {
|
||||||
input_tokens: number
|
input_tokens?: number
|
||||||
output_tokens: number
|
output_tokens?: number
|
||||||
total_tokens: number
|
total_tokens: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,6 +416,39 @@ export interface ProviderPreset {
|
|||||||
base_url: string
|
base_url: string
|
||||||
default_credential_id?: string | null
|
default_credential_id?: string | null
|
||||||
requires_credential: boolean
|
requires_credential: boolean
|
||||||
|
logo_id?: string
|
||||||
|
description?: string
|
||||||
|
capabilities?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProviderUpdateRequest = Partial<Omit<ProviderConfig, 'provider_id' | 'credential_id' | 'base_url'>> & {
|
||||||
|
credential_id?: string | null
|
||||||
|
base_url?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoutingCapability = 'embedding' | 'transcription' | 'speaker_matching'
|
||||||
|
|
||||||
|
export interface ModelBinding {
|
||||||
|
provider_id: string
|
||||||
|
model: string
|
||||||
|
endpoint: string
|
||||||
|
dimensions?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelRoutingConfig {
|
||||||
|
version: number
|
||||||
|
embedding: ModelBinding | null
|
||||||
|
transcription: ModelBinding | null
|
||||||
|
speaker_matching: ModelBinding | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelRoutingResponse {
|
||||||
|
config: ModelRoutingConfig
|
||||||
|
local_backends: Array<{
|
||||||
|
capability: RoutingCapability
|
||||||
|
status: 'placeholder' | 'not_installed' | 'ready'
|
||||||
|
message: string
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Tasks ============
|
// ============ Tasks ============
|
||||||
@@ -429,11 +462,11 @@ export interface TaskItem {
|
|||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
status: TaskStatus
|
status: TaskStatus
|
||||||
priority: TaskPriority
|
priority?: TaskPriority
|
||||||
due_date?: string
|
due_date?: string
|
||||||
note_id?: string
|
note_id?: string
|
||||||
note_title?: string
|
note_title?: string
|
||||||
source: TaskSource
|
source?: TaskSource
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@@ -454,12 +487,12 @@ export interface ThemeConfig {
|
|||||||
// ============ Index ============
|
// ============ Index ============
|
||||||
|
|
||||||
export interface IndexStatus {
|
export interface IndexStatus {
|
||||||
status: 'idle' | 'indexing' | 'error'
|
status: 'unknown' | 'idle' | 'indexing' | 'error'
|
||||||
pending_jobs: number
|
pending_jobs: number
|
||||||
total_notes: number
|
total_notes: number | null
|
||||||
total_blocks: number
|
total_blocks: number | null
|
||||||
fts_enabled: boolean
|
fts_enabled?: boolean
|
||||||
vector_enabled: boolean
|
vector_enabled?: boolean
|
||||||
embedding_model?: string
|
embedding_model?: string
|
||||||
reranker_model?: string
|
reranker_model?: string
|
||||||
last_indexed_at?: string
|
last_indexed_at?: string
|
||||||
@@ -494,7 +527,7 @@ export type SaveStatus =
|
|||||||
| 'external_changed'
|
| 'external_changed'
|
||||||
| 'conflict'
|
| 'conflict'
|
||||||
|
|
||||||
export type AiCoreStatus = 'starting' | 'running' | 'stopped' | 'error'
|
export type AiCoreStatus = 'unknown' | 'starting' | 'running' | 'stopped' | 'error'
|
||||||
|
|
||||||
// ============ FastAPI wire contracts ============
|
// ============ FastAPI wire contracts ============
|
||||||
// UI view models above may contain presentation-only fields. Services must use
|
// UI view models above may contain presentation-only fields. Services must use
|
||||||
@@ -721,6 +754,9 @@ export interface ApiProviderPreset {
|
|||||||
base_url: string
|
base_url: string
|
||||||
default_credential_id?: string | null
|
default_credential_id?: string | null
|
||||||
requires_credential: boolean
|
requires_credential: boolean
|
||||||
|
logo_id?: string
|
||||||
|
description?: string
|
||||||
|
capabilities?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiModelInfo {
|
export interface ApiModelInfo {
|
||||||
@@ -741,6 +777,8 @@ export interface ApiTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiIndexStatus {
|
export interface ApiIndexStatus {
|
||||||
|
total_notes: number
|
||||||
|
total_blocks: number
|
||||||
status: 'idle' | 'queued' | 'running' | 'failed'
|
status: 'idle' | 'queued' | 'running' | 'failed'
|
||||||
pending_jobs: number
|
pending_jobs: number
|
||||||
active_job_id?: string | null
|
active_job_id?: string | null
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { useAgentStore } from '@/stores/agent'
|
|||||||
import { useProviderStore } from '@/stores/provider'
|
import { useProviderStore } from '@/stores/provider'
|
||||||
import { useSkillStore } from '@/stores/skill'
|
import { useSkillStore } from '@/stores/skill'
|
||||||
import type { AgentEvent } from '@/contracts'
|
import type { AgentEvent } from '@/contracts'
|
||||||
import { eventLabel, localizeDetails, permissionLabel, runStatusLabel, toolDescription, toolLabel } from './labels'
|
import { eventLabel, localizeDetails, permissionLabel, runStatusLabel, toolLabel } from './labels'
|
||||||
|
import ToolOption from './ToolOption.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -14,7 +15,7 @@ const providerStore = useProviderStore()
|
|||||||
const skillStore = useSkillStore()
|
const skillStore = useSkillStore()
|
||||||
const pageError = ref('')
|
const pageError = ref('')
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
input: '', provider_id: 'mock', model: 'mock-1', skill_id: '', max_steps: 10,
|
input: '', provider_id: '', model: '', skill_id: '', max_steps: 10,
|
||||||
tool_timeout_seconds: 30, run_timeout_seconds: 300, token_budget: 8000,
|
tool_timeout_seconds: 30, run_timeout_seconds: 300, token_budget: 8000,
|
||||||
allow_network: false, max_concurrent_tools: 1, allowed_tools: [] as string[],
|
allow_network: false, max_concurrent_tools: 1, allowed_tools: [] as string[],
|
||||||
})
|
})
|
||||||
@@ -25,7 +26,7 @@ const isNewRun = computed(() => !route.params.runId)
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
await Promise.all([providerStore.loadProviders(), skillStore.loadSkills(), agentStore.loadTools()])
|
await Promise.all([providerStore.loadProviders(), skillStore.loadSkills(), agentStore.loadTools()])
|
||||||
await providerStore.loadModels(form.provider_id)
|
form.provider_id = providerStore.defaultProviderId
|
||||||
} catch (error) { pageError.value = error instanceof Error ? error.message : '智能体配置加载失败' }
|
} catch (error) { pageError.value = error instanceof Error ? error.message : '智能体配置加载失败' }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -35,7 +36,10 @@ watch(() => route.params.runId, async (runId) => {
|
|||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
watch(() => form.provider_id, async (providerId) => {
|
watch(() => form.provider_id, async (providerId) => {
|
||||||
try { await providerStore.loadModels(providerId); form.model = models.value[0]?.model_id ?? '' } catch { /* page keeps current selection */ }
|
form.model = providerStore.providers.find(p => p.provider_id === providerId)?.default_model ?? ''
|
||||||
|
if (!providerId) return
|
||||||
|
try { await providerStore.loadModels(providerId) }
|
||||||
|
catch (error) { if (form.provider_id === providerId) pageError.value = error instanceof Error ? error.message : '模型列表加载失败,请手动填写模型 ID。' }
|
||||||
})
|
})
|
||||||
|
|
||||||
function toggleTool(name: string) {
|
function toggleTool(name: string) {
|
||||||
@@ -47,6 +51,7 @@ function toggleTool(name: string) {
|
|||||||
async function createRun() {
|
async function createRun() {
|
||||||
pageError.value = ''
|
pageError.value = ''
|
||||||
try {
|
try {
|
||||||
|
if (!form.provider_id || !form.model.trim()) throw new Error('请选择提供商并填写模型 ID。')
|
||||||
const run = await agentStore.createRun({
|
const run = await agentStore.createRun({
|
||||||
input: form.input, provider_id: form.provider_id, model: form.model,
|
input: form.input, provider_id: form.provider_id, model: form.model,
|
||||||
skill_id: form.skill_id || undefined, allowed_tools: form.allowed_tools,
|
skill_id: form.skill_id || undefined, allowed_tools: form.allowed_tools,
|
||||||
@@ -71,12 +76,12 @@ function eventText(event: AgentEvent) {
|
|||||||
<section class="feature-page agent-page">
|
<section class="feature-page agent-page">
|
||||||
<header class="feature-header"><div><h1>{{ isNewRun ? '创建智能体运行' : '智能体执行轨迹' }}</h1><p>配置执行边界,并实时查看模型、工具和权限事件。</p></div>
|
<header class="feature-header"><div><h1>{{ isNewRun ? '创建智能体运行' : '智能体执行轨迹' }}</h1><p>配置执行边界,并实时查看模型、工具和权限事件。</p></div>
|
||||||
<button v-if="!isNewRun" class="button-secondary" @click="router.push({ name: 'agent' })">新建运行</button></header>
|
<button v-if="!isNewRun" class="button-secondary" @click="router.push({ name: 'agent' })">新建运行</button></header>
|
||||||
<div v-if="pageError || agentStore.error" class="error-banner">{{ pageError || agentStore.error }}</div>
|
<div v-if="pageError || agentStore.error || providerStore.error" class="error-banner">{{ pageError || agentStore.error || providerStore.error }}</div>
|
||||||
<form v-if="isNewRun" class="panel run-form" @submit.prevent="createRun">
|
<form v-if="isNewRun" class="panel run-form" @submit.prevent="createRun">
|
||||||
<div class="field"><label>任务</label><textarea v-model="form.input" class="textarea" required placeholder="描述希望智能体完成的任务" /></div>
|
<div class="field"><label>任务</label><textarea v-model="form.input" class="textarea" required placeholder="描述希望智能体完成的任务" /></div>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="field"><label>模型提供商</label><select v-model="form.provider_id" class="select"><option v-for="p in providerStore.enabledProviders" :key="p.provider_id" :value="p.provider_id">{{ p.name }}</option></select></div>
|
<div class="field"><label>模型提供商</label><select v-model="form.provider_id" class="select"><option v-for="p in providerStore.enabledProviders" :key="p.provider_id" :value="p.provider_id">{{ p.name }}</option></select></div>
|
||||||
<div class="field"><label>模型</label><select v-model="form.model" class="select"><option v-for="m in models" :key="m.model_id" :value="m.model_id">{{ m.name }}</option></select></div>
|
<div class="field"><label>模型</label><input v-model="form.model" class="input" list="agent-models" placeholder="填写模型 ID" required /><datalist id="agent-models"><option v-for="m in models" :key="m.model_id" :value="m.model_id">{{ m.name }}</option></datalist></div>
|
||||||
<div class="field"><label>技能</label><select v-model="form.skill_id" class="select"><option value="">不使用技能</option><option v-for="s in skillStore.readySkills" :key="s.skill_id" :value="s.skill_id">{{ s.name }}</option></select></div>
|
<div class="field"><label>技能</label><select v-model="form.skill_id" class="select"><option value="">不使用技能</option><option v-for="s in skillStore.readySkills" :key="s.skill_id" :value="s.skill_id">{{ s.name }}</option></select></div>
|
||||||
<div class="field"><label>最大步骤</label><input v-model.number="form.max_steps" class="input" type="number" min="1" max="100" /></div>
|
<div class="field"><label>最大步骤</label><input v-model.number="form.max_steps" class="input" type="number" min="1" max="100" /></div>
|
||||||
<div class="field"><label>工具超时(秒)</label><input v-model.number="form.tool_timeout_seconds" class="input" type="number" min="1" /></div>
|
<div class="field"><label>工具超时(秒)</label><input v-model.number="form.tool_timeout_seconds" class="input" type="number" min="1" /></div>
|
||||||
@@ -84,9 +89,9 @@ function eventText(event: AgentEvent) {
|
|||||||
<div class="field"><label>令牌预算</label><input v-model.number="form.token_budget" class="input" type="number" min="1" /></div>
|
<div class="field"><label>令牌预算</label><input v-model.number="form.token_budget" class="input" type="number" min="1" /></div>
|
||||||
<div class="field"><label>最大并发工具</label><input v-model.number="form.max_concurrent_tools" class="input" type="number" min="1" /></div>
|
<div class="field"><label>最大并发工具</label><input v-model.number="form.max_concurrent_tools" class="input" type="number" min="1" /></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>允许使用的工具</label><div class="tool-grid"><label v-for="tool in agentStore.tools" :key="tool.name" class="tool-option"><input type="checkbox" :checked="form.allowed_tools.includes(tool.name)" @change="toggleTool(tool.name)" /><span><strong>{{ toolLabel(tool.name) }}</strong><code>{{ tool.name }}</code><small>{{ toolDescription(tool.name, tool.description) }}</small></span></label></div></div>
|
<div class="field"><label>允许使用的工具</label><div class="tool-grid"><ToolOption v-for="tool in agentStore.tools" :key="tool.name" :name="tool.name" :description="tool.description" :selected="form.allowed_tools.includes(tool.name)" @toggle="toggleTool" /></div></div>
|
||||||
<label class="network"><input v-model="form.allow_network" type="checkbox" /> 允许本次运行调用网络工具</label>
|
<label class="network"><input v-model="form.allow_network" type="checkbox" /> 允许本次运行调用网络工具</label>
|
||||||
<div class="inline-actions"><button class="button-primary" :disabled="agentStore.isCreating || !form.input.trim()">{{ agentStore.isCreating ? '创建中…' : '创建并运行' }}</button></div>
|
<div class="inline-actions"><button class="button-primary" :disabled="agentStore.isCreating || !form.input.trim() || !form.provider_id || !form.model.trim()">{{ agentStore.isCreating ? '创建中…' : '创建并运行' }}</button></div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div v-else class="trace-layout">
|
<div v-else class="trace-layout">
|
||||||
@@ -110,12 +115,7 @@ function eventText(event: AgentEvent) {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.agent-page > * { width: min(100%, 1080px); margin-inline: auto; }
|
.agent-page > * { width: min(100%, 1080px); margin-inline: auto; }
|
||||||
.run-form { display: grid; gap: var(--space-xl); }
|
.run-form { display: grid; gap: var(--space-xl); }
|
||||||
.tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--space-sm); }
|
.tool-grid { display: grid; align-items: start; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--space-sm); }
|
||||||
.tool-option { display: flex; gap: var(--space-sm); padding: var(--space-md); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-primary); cursor: pointer; transition: border-color var(--motion-fast), background-color var(--motion-fast), transform var(--motion-fast), box-shadow var(--motion-fast); }
|
|
||||||
.tool-option:hover { border-color: var(--color-accent-secondary); transform: translateY(-1px); box-shadow: var(--shadow-sm); }
|
|
||||||
.tool-option:has(input:checked) { border-color: var(--color-accent-primary); background: var(--color-accent-soft); box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent-primary) 10%, transparent); }
|
|
||||||
.tool-option small { display: block; color: var(--color-text-secondary); }
|
|
||||||
.tool-option code { display: block; margin: 2px 0; color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
|
|
||||||
.network { display: flex; gap: var(--space-sm); }
|
.network { display: flex; gap: var(--space-sm); }
|
||||||
.trace-layout { display: grid; gap: var(--space-lg); }
|
.trace-layout { display: grid; gap: var(--space-lg); }
|
||||||
.run-summary, .event-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); }
|
.run-summary, .event-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); }
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { expect, it } from 'vitest'
|
||||||
|
import ToolOption from './ToolOption.vue'
|
||||||
|
|
||||||
|
it('shows Chinese summaries, preserves raw metadata and emits the original tool ID', async () => {
|
||||||
|
const name = 'mcp.9ca7ee21603a.web_search'
|
||||||
|
const description = 'Search the web. query: string. ' + 'Full provider instructions. '.repeat(40)
|
||||||
|
const wrapper = mount(ToolOption, { props: { name, description, selected: false } })
|
||||||
|
expect(wrapper.get('strong').text()).toBe('网页搜索')
|
||||||
|
expect(wrapper.get('code').text()).toBe(name)
|
||||||
|
expect(wrapper.get('.tool-summary').text()).toContain('搜索关键词')
|
||||||
|
expect(wrapper.get('details').attributes('open')).toBeUndefined()
|
||||||
|
expect(wrapper.get('details p').element.textContent).toBe(description)
|
||||||
|
await wrapper.get('summary').trigger('click')
|
||||||
|
expect(wrapper.emitted('toggle')).toBeUndefined()
|
||||||
|
await wrapper.get('input').setValue(true)
|
||||||
|
expect(wrapper.emitted('toggle')).toEqual([[name]])
|
||||||
|
})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { toolDescription, toolLabel } from './labels'
|
||||||
|
|
||||||
|
const props = defineProps<{ name: string; description: string; selected: boolean }>()
|
||||||
|
const emit = defineEmits<{ toggle: [name: string] }>()
|
||||||
|
const summary = computed(() => toolDescription(props.name, props.description))
|
||||||
|
const showOriginal = computed(() => props.description.length > 0)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="tool-choice" :class="{ selected }">
|
||||||
|
<label class="tool-selection">
|
||||||
|
<input type="checkbox" :checked="selected" @change="emit('toggle', name)" />
|
||||||
|
<span class="tool-copy">
|
||||||
|
<strong>{{ toolLabel(name) }}</strong>
|
||||||
|
<code>{{ name }}</code>
|
||||||
|
<small class="tool-summary">{{ summary }}</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<details v-if="showOriginal" class="tool-original">
|
||||||
|
<summary>查看服务原文与参数</summary>
|
||||||
|
<p>{{ description }}</p>
|
||||||
|
</details>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tool-choice { min-width: 0; padding: var(--space-md); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-primary); }
|
||||||
|
.tool-choice.selected { border-color: var(--color-accent-primary); background: var(--color-accent-soft); }
|
||||||
|
.tool-selection { display: flex; align-items: flex-start; gap: var(--space-sm); cursor: pointer; }
|
||||||
|
.tool-selection input { flex-shrink: 0; margin-top: 4px; }
|
||||||
|
.tool-copy { min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.tool-copy strong, .tool-copy code, .tool-summary { display: block; }
|
||||||
|
.tool-copy code { margin: 3px 0; color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
|
||||||
|
.tool-summary { color: var(--color-text-secondary); line-height: 1.6; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; }
|
||||||
|
.tool-original { margin-top: var(--space-sm); font-size: var(--font-size-xs); }
|
||||||
|
.tool-original summary { cursor: pointer; color: var(--color-text-secondary); }
|
||||||
|
.tool-original p { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 240px; overflow: auto; margin-top: var(--space-sm); user-select: text; }
|
||||||
|
</style>
|
||||||
@@ -9,6 +9,21 @@ import {
|
|||||||
} from './labels'
|
} from './labels'
|
||||||
|
|
||||||
describe('智能体页面中文标签', () => {
|
describe('智能体页面中文标签', () => {
|
||||||
|
it('按 MCP 远程工具名匹配中文,不依赖服务器 ID', () => {
|
||||||
|
for (const server of ['9ca7ee21603a', 'another-server']) {
|
||||||
|
expect(toolLabel(`mcp.${server}.web_search`)).toBe('网页搜索')
|
||||||
|
expect(toolLabel(`mcp.${server}.understand_image`)).toBe('图像理解')
|
||||||
|
expect(toolDescription(`mcp.${server}.web_search`, 'Search the web')).toContain('搜索关键词')
|
||||||
|
}
|
||||||
|
expect(toolLabel('text.uppercase')).toBe('文本转大写')
|
||||||
|
expect(toolDescription('text.uppercase', 'Convert input text to uppercase.')).toContain('大写')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('保留服务端中文,未知工具不编造翻译或套用内置工具语义', () => {
|
||||||
|
expect(toolDescription('mcp.server.web_search', '仅搜索指定站点。')).toBe('仅搜索指定站点。')
|
||||||
|
expect(toolDescription('mcp.server.custom_action', 'Private action')).toContain('暂无中文说明')
|
||||||
|
expect(toolLabel('mcp.server.notes.delete')).toBe('MCP 工具 · notes.delete')
|
||||||
|
})
|
||||||
it('转换运行状态和事件名称', () => {
|
it('转换运行状态和事件名称', () => {
|
||||||
expect(runStatusLabel('waiting_permission')).toBe('等待授权')
|
expect(runStatusLabel('waiting_permission')).toBe('等待授权')
|
||||||
expect(eventLabel('ToolCall')).toBe('调用工具')
|
expect(eventLabel('ToolCall')).toBe('调用工具')
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const toolLabels: Record<string, string> = {
|
|||||||
'tasks.list': '列出任务',
|
'tasks.list': '列出任务',
|
||||||
'attachments.read': '读取附件',
|
'attachments.read': '读取附件',
|
||||||
'audio.transcribe': '音频转写',
|
'audio.transcribe': '音频转写',
|
||||||
|
'text.uppercase': '文本转大写',
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolDescriptions: Record<string, string> = {
|
const toolDescriptions: Record<string, string> = {
|
||||||
@@ -58,7 +59,25 @@ const toolDescriptions: Record<string, string> = {
|
|||||||
'tasks.update': '更新已有任务。',
|
'tasks.update': '更新已有任务。',
|
||||||
'tasks.list': '列出已持久化的任务。',
|
'tasks.list': '列出已持久化的任务。',
|
||||||
'attachments.read': '读取由宿主管理的 UTF-8 附件。',
|
'attachments.read': '读取由宿主管理的 UTF-8 附件。',
|
||||||
'audio.transcribe': '读取音频附件已有的宿主转写结果。',
|
'audio.transcribe': '将音频转写为文本,按模型路由使用 API 或本地后端。',
|
||||||
|
'text.uppercase': '将输入文本中的字母转换为大写。',
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCP IDs contain a server-specific namespace. Localize the remote tool name
|
||||||
|
// for presentation only; requests must keep using the complete original ID.
|
||||||
|
const mcpTools: Record<string, { label: string; description: string }> = {
|
||||||
|
web_search: {
|
||||||
|
label: '网页搜索',
|
||||||
|
description: '搜索实时或外部网页信息。输入搜索关键词;结果包含标题、链接、摘要等信息。时效性问题可在关键词中加入日期,完整参数以服务原文为准。',
|
||||||
|
},
|
||||||
|
understand_image: {
|
||||||
|
label: '图像理解',
|
||||||
|
description: '根据提示词分析图片、描述内容或提取信息。输入分析要求和图片地址或本地路径;支持的格式与路径规则请查看服务原文。',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function mcpName(name: string): string | undefined {
|
||||||
|
return /^mcp\.[^.]+\.(.+)$/.exec(name)?.[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
const permissionLabels: Record<string, string> = {
|
const permissionLabels: Record<string, string> = {
|
||||||
@@ -105,10 +124,17 @@ export function eventLabel(event: AgentEventType): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function toolLabel(name: string): string {
|
export function toolLabel(name: string): string {
|
||||||
|
const remote = mcpName(name)
|
||||||
|
if (remote) return mcpTools[remote]?.label ?? `MCP 工具 · ${remote}`
|
||||||
return toolLabels[name] ?? name
|
return toolLabels[name] ?? name
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toolDescription(name: string, fallback: string): string {
|
export function toolDescription(name: string, fallback: string): string {
|
||||||
|
const remote = mcpName(name)
|
||||||
|
if (remote) {
|
||||||
|
if (/\p{Script=Han}/u.test(fallback)) return fallback
|
||||||
|
return mcpTools[remote]?.description ?? '暂无中文说明,请展开查看服务原文。'
|
||||||
|
}
|
||||||
return toolDescriptions[name] ?? fallback
|
return toolDescriptions[name] ?? fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { beforeEach, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { useChatStore } from '@/stores/chat'
|
||||||
|
import { useProviderStore } from '@/stores/provider'
|
||||||
|
import { useSkillStore } from '@/stores/skill'
|
||||||
|
import ChatView from './ChatView.vue'
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||||
|
vi.mock('@/stores/editor', () => ({ useEditorStore: () => ({}) }))
|
||||||
|
vi.mock('@/stores/workspace', () => ({ useWorkspaceStore: () => ({}) }))
|
||||||
|
vi.mock('@/components/common/MarkdownContent.vue', () => ({ default: { template: '<div />' } }))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const providers = useProviderStore()
|
||||||
|
providers.providers = ['a', 'b'].map(id => ({
|
||||||
|
provider_id: id, provider_type: 'openai_compatible', name: id,
|
||||||
|
default_model: `${id}-default`, enabled: true, capabilities: { chat: true }, has_credential: false,
|
||||||
|
}))
|
||||||
|
providers.defaultProviderId = 'a'
|
||||||
|
vi.spyOn(providers, 'loadProviders').mockResolvedValue(undefined)
|
||||||
|
vi.spyOn(providers, 'loadModels').mockResolvedValue([])
|
||||||
|
vi.spyOn(useSkillStore(), 'loadSkills').mockResolvedValue(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves the selected provider and manual model after leaving and returning to chat', async () => {
|
||||||
|
const chat = useChatStore()
|
||||||
|
const first = mount(ChatView)
|
||||||
|
await flushPromises()
|
||||||
|
await first.get('select').setValue('b')
|
||||||
|
await first.get('input[list="chat-models"]').setValue('b-manual')
|
||||||
|
first.unmount()
|
||||||
|
const returned = mount(ChatView)
|
||||||
|
await flushPromises()
|
||||||
|
expect(chat.selectedProviderId).toBe('b')
|
||||||
|
expect(chat.selectedModel).toBe('b-manual')
|
||||||
|
expect(useProviderStore().loadModels).toHaveBeenLastCalledWith('b')
|
||||||
|
returned.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['missing', 'disabled', 'unselected'])('uses the default when the selected provider is %s', async state => {
|
||||||
|
const chat = useChatStore()
|
||||||
|
chat.selectedProviderId = state === 'unselected' ? '' : state === 'missing' ? 'deleted' : 'b'
|
||||||
|
chat.selectedModel = 'old-model'
|
||||||
|
if (state === 'disabled') useProviderStore().providers[1]!.enabled = false
|
||||||
|
const wrapper = mount(ChatView)
|
||||||
|
await flushPromises()
|
||||||
|
expect(chat.selectedProviderId).toBe('a')
|
||||||
|
expect(chat.selectedModel).toBe('a-default')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves the selection when provider discovery fails', async () => {
|
||||||
|
const chat = useChatStore()
|
||||||
|
chat.selectedProviderId = 'b'
|
||||||
|
chat.selectedModel = 'b-manual'
|
||||||
|
useProviderStore().error = 'offline'
|
||||||
|
const wrapper = mount(ChatView)
|
||||||
|
await flushPromises()
|
||||||
|
expect(chat.selectedProviderId).toBe('b')
|
||||||
|
expect(chat.selectedModel).toBe('b-manual')
|
||||||
|
expect(wrapper.get('.error-banner').text()).toBe('offline')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['providers', 'skills'])('ignores initialization after unmount while %s are loading', async source => {
|
||||||
|
const chat = useChatStore()
|
||||||
|
let finish!: () => void
|
||||||
|
const pending = new Promise<void>(resolve => { finish = resolve })
|
||||||
|
if (source === 'providers') vi.mocked(useProviderStore().loadProviders).mockReturnValueOnce(pending)
|
||||||
|
else vi.mocked(useSkillStore().loadSkills).mockReturnValueOnce(pending)
|
||||||
|
const first = mount(ChatView)
|
||||||
|
first.unmount()
|
||||||
|
finish()
|
||||||
|
await flushPromises()
|
||||||
|
expect(chat.selectedProviderId).toBe('')
|
||||||
|
expect(chat.selectedModel).toBe('')
|
||||||
|
expect(useProviderStore().loadModels).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
const returned = mount(ChatView)
|
||||||
|
await flushPromises()
|
||||||
|
expect(chat.selectedProviderId).toBe('a')
|
||||||
|
expect(chat.selectedModel).toBe('a-default')
|
||||||
|
await returned.get('textarea').setValue('hello')
|
||||||
|
expect(returned.get('button.button-primary').attributes('disabled')).toBeUndefined()
|
||||||
|
returned.unmount()
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import type { Citation } from '@/contracts'
|
import type { Citation } from '@/contracts'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chat'
|
||||||
@@ -16,26 +16,37 @@ const workspaceStore = useWorkspaceStore()
|
|||||||
const editorStore = useEditorStore()
|
const editorStore = useEditorStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const loadError = ref('')
|
const loadError = ref('')
|
||||||
|
let disposed = false
|
||||||
|
onBeforeUnmount(() => { disposed = true })
|
||||||
|
|
||||||
const availableModels = computed(() => providerStore.modelsByProvider[chatStore.selectedProviderId] ?? [])
|
const availableModels = computed(() => providerStore.modelsByProvider[chatStore.selectedProviderId] ?? [])
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
await Promise.all([providerStore.loadProviders(), skillStore.loadSkills()])
|
await Promise.all([providerStore.loadProviders(), skillStore.loadSkills()])
|
||||||
await providerStore.loadModels(chatStore.selectedProviderId)
|
if (disposed || providerStore.error) return
|
||||||
|
const selected = providerStore.enabledProviders.find(p => p.provider_id === chatStore.selectedProviderId)
|
||||||
|
if (!selected) {
|
||||||
|
chatStore.selectedProviderId = providerStore.defaultProviderId
|
||||||
|
} else {
|
||||||
|
await refreshModels(selected.provider_id)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
loadError.value = error instanceof Error ? error.message : '无法加载 AI 配置,当前展示本地数据。'
|
if (disposed) return
|
||||||
|
loadError.value = error instanceof Error ? error.message : '无法加载 AI 配置,请检查后端连接。'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => chatStore.selectedProviderId, async (providerId) => {
|
async function refreshModels(providerId: string) {
|
||||||
try {
|
loadError.value = ''
|
||||||
await providerStore.loadModels(providerId)
|
if (!providerId) return
|
||||||
const firstModel = providerStore.modelsByProvider[providerId]?.[0]
|
try { await providerStore.loadModels(providerId) }
|
||||||
if (firstModel) chatStore.selectedModel = firstModel.model_id
|
catch (error) { if (!disposed && chatStore.selectedProviderId === providerId) loadError.value = error instanceof Error ? error.message : '模型列表加载失败,请手动填写模型 ID。' }
|
||||||
} catch (error) {
|
|
||||||
loadError.value = error instanceof Error ? error.message : '模型列表加载失败'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(() => chatStore.selectedProviderId, async (providerId) => {
|
||||||
|
chatStore.selectedModel = providerStore.providers.find(p => p.provider_id === providerId)?.default_model ?? ''
|
||||||
|
await refreshModels(providerId)
|
||||||
})
|
})
|
||||||
|
|
||||||
function send() { void chatStore.sendMessage(chatStore.inputText) }
|
function send() { void chatStore.sendMessage(chatStore.inputText) }
|
||||||
@@ -54,17 +65,12 @@ async function openCitation(citation: Citation) {
|
|||||||
<div class="field compact"><label>Provider</label><select v-model="chatStore.selectedProviderId" class="select">
|
<div class="field compact"><label>Provider</label><select v-model="chatStore.selectedProviderId" class="select">
|
||||||
<option v-for="provider in providerStore.enabledProviders" :key="provider.provider_id" :value="provider.provider_id">{{ provider.name }}</option>
|
<option v-for="provider in providerStore.enabledProviders" :key="provider.provider_id" :value="provider.provider_id">{{ provider.name }}</option>
|
||||||
</select></div>
|
</select></div>
|
||||||
<div class="field compact"><label>Model</label><select v-model="chatStore.selectedModel" class="select">
|
<div class="field compact"><label>模型 ID</label><input v-model="chatStore.selectedModel" class="input" list="chat-models" placeholder="填写模型 ID" /><datalist id="chat-models"><option v-for="model in availableModels" :key="model.model_id" :value="model.model_id">{{ model.name }}</option></datalist></div>
|
||||||
<option v-for="model in availableModels" :key="model.model_id" :value="model.model_id">{{ model.name }}</option>
|
<span class="subtle">知识库问答与技能请使用智能体;普通聊天尚未接入这些能力。</span>
|
||||||
</select></div>
|
|
||||||
<div class="field compact"><label>Skill</label><select v-model="chatStore.selectedSkillId" class="select">
|
|
||||||
<option :value="null">不使用 Skill</option><option v-for="skill in skillStore.enabledSkills" :key="skill.skill_id" :value="skill.skill_id">{{ skill.name }}</option>
|
|
||||||
</select></div>
|
|
||||||
<label class="rag-toggle"><input v-model="chatStore.useRag" type="checkbox" /> 使用知识库</label>
|
|
||||||
</header>
|
</header>
|
||||||
<div v-if="loadError" class="error-banner chat-error">{{ loadError }}</div>
|
<div v-if="loadError || providerStore.error" class="error-banner chat-error">{{ loadError || providerStore.error }}</div>
|
||||||
<main class="message-timeline">
|
<main class="message-timeline">
|
||||||
<div v-if="!chatStore.messages.length" class="empty-state"><div><strong>开始一段知识对话</strong><p>可以直接提问,也可以打开 RAG 让模型基于当前 Vault 回答。</p></div></div>
|
<div v-if="!chatStore.messages.length" class="empty-state"><div><strong>开始一段知识对话</strong><p>请先配置模型提供商。聊天记录仅保留在本次页面会话中。</p></div></div>
|
||||||
<article v-for="message in chatStore.messages" :key="message.message_id" class="message" :class="message.role">
|
<article v-for="message in chatStore.messages" :key="message.message_id" class="message" :class="message.role">
|
||||||
<div class="avatar">{{ message.role === 'user' ? '你' : 'AI' }}</div>
|
<div class="avatar">{{ message.role === 'user' ? '你' : 'AI' }}</div>
|
||||||
<div class="message-body">
|
<div class="message-body">
|
||||||
@@ -78,7 +84,7 @@ async function openCitation(citation: Citation) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<time>{{ new Date(message.created_at).toLocaleTimeString() }}</time>
|
<time>{{ new Date(message.created_at).toLocaleTimeString() }}</time>
|
||||||
<small v-if="message.usage" class="usage">Token {{ message.usage.total_tokens }}(输入 {{ message.usage.input_tokens }} / 输出 {{ message.usage.output_tokens }})</small>
|
<small v-if="message.usage" class="usage">Token {{ message.usage.total_tokens }}<span v-if="message.usage.input_tokens !== undefined && message.usage.output_tokens !== undefined">(输入 {{ message.usage.input_tokens }} / 输出 {{ message.usage.output_tokens }})</span></small>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</main>
|
</main>
|
||||||
@@ -87,7 +93,7 @@ async function openCitation(citation: Citation) {
|
|||||||
@keydown.ctrl.enter.prevent="send" />
|
@keydown.ctrl.enter.prevent="send" />
|
||||||
<div class="composer-actions"><span class="subtle">回答可能包含错误,请核对 Citation。</span>
|
<div class="composer-actions"><span class="subtle">回答可能包含错误,请核对 Citation。</span>
|
||||||
<button v-if="chatStore.isStreaming" class="button-danger" @click="chatStore.stopGeneration">停止</button>
|
<button v-if="chatStore.isStreaming" class="button-danger" @click="chatStore.stopGeneration">停止</button>
|
||||||
<button v-else class="button-primary" :disabled="!chatStore.inputText.trim()" @click="send">发送</button>
|
<button v-else class="button-primary" :disabled="!chatStore.inputText.trim() || !chatStore.selectedProviderId || !chatStore.selectedModel.trim()" @click="send">发送</button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ async function uninstall(id: string, name: string) { if (!confirm(`卸载“${na
|
|||||||
<div v-if="pluginStore.selectedPlugin.dependent_skills?.length" class="notice-banner last-error">依赖此插件的 Skill:{{ pluginStore.selectedPlugin.dependent_skills.join('、') }}</div>
|
<div v-if="pluginStore.selectedPlugin.dependent_skills?.length" class="notice-banner last-error">依赖此插件的 Skill:{{ pluginStore.selectedPlugin.dependent_skills.join('、') }}</div>
|
||||||
<PluginMcpPanel :plugin="pluginStore.selectedPlugin" />
|
<PluginMcpPanel :plugin="pluginStore.selectedPlugin" />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="!pluginStore.plugins.length" class="empty-state"><div><strong>{{ pluginStore.isLoading ? '正在加载…' : pluginStore.error ? '加载失败' : '尚未安装' }}</strong><button class="button-secondary" @click="pluginStore.loadPlugins">重新加载</button></div></div>
|
||||||
<div v-else class="feature-grid"><article v-for="plugin in pluginStore.plugins" :key="plugin.plugin_id" class="item-card extension-card" @click="pluginStore.selectPlugin(plugin.plugin_id)"><div class="extension-title"><AppIcon :icon="Connection" :size="22" /><div><strong>{{ plugin.name }}</strong><p>v{{ plugin.version }}</p></div><span class="badge" :class="{ success: plugin.status === 'ready', error: plugin.status === 'error', warning: plugin.status === 'permission_required' }">{{ plugin.status }}</span></div><p class="muted">{{ plugin.description }}</p><p class="subtle">{{ plugin.permissions.length }} 项权限 · {{ plugin.contributions.length }} 项 Contribution</p></article></div>
|
<div v-else class="feature-grid"><article v-for="plugin in pluginStore.plugins" :key="plugin.plugin_id" class="item-card extension-card" @click="pluginStore.selectPlugin(plugin.plugin_id)"><div class="extension-title"><AppIcon :icon="Connection" :size="22" /><div><strong>{{ plugin.name }}</strong><p>v{{ plugin.version }}</p></div><span class="badge" :class="{ success: plugin.status === 'ready', error: plugin.status === 'error', warning: plugin.status === 'permission_required' }">{{ plugin.status }}</span></div><p class="muted">{{ plugin.description }}</p><p class="subtle">{{ plugin.permissions.length }} 项权限 · {{ plugin.contributions.length }} 项 Contribution</p></article></div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { ModelRoutingResponse, ProviderConfig } from '@/contracts'
|
||||||
|
import { ApiErrorClass } from '@/services/apiClient'
|
||||||
|
import * as service from '@/services/modelRoutingService'
|
||||||
|
import { listProviders } from '@/services/providerService'
|
||||||
|
import ModelRoutingSettings from './ModelRoutingSettings.vue'
|
||||||
|
|
||||||
|
vi.mock('@/services/modelRoutingService', () => ({ getModelRouting: vi.fn(), saveModelRouting: vi.fn() }))
|
||||||
|
vi.mock('@/services/providerService', () => ({ listProviders: vi.fn() }))
|
||||||
|
const providers: ProviderConfig[] = [
|
||||||
|
{ provider_id: 'p1', provider_type: 'openai_compatible', name: 'Custom API', enabled: true, default_model: 'chat-model', capabilities: {}, has_credential: true },
|
||||||
|
{ provider_id: 'p2', provider_type: 'openai_chat', name: 'OpenAI', enabled: true, default_model: '', capabilities: {}, has_credential: true },
|
||||||
|
{ provider_id: 'responses', provider_type: 'openai_responses', name: 'Responses', enabled: true, default_model: '', capabilities: {}, has_credential: true },
|
||||||
|
{ provider_id: 'anthropic', provider_type: 'anthropic_messages', name: 'Anthropic', enabled: true, default_model: '', capabilities: {}, has_credential: true },
|
||||||
|
{ provider_id: 'ollama', provider_type: 'ollama', name: 'Ollama', enabled: true, default_model: '', capabilities: {}, has_credential: false },
|
||||||
|
{ provider_id: 'disabled', provider_type: 'openai_chat', name: 'Disabled', enabled: false, default_model: '', capabilities: {}, has_credential: true },
|
||||||
|
]
|
||||||
|
const initial: ModelRoutingResponse = { config: { version: 3, embedding: null, transcription: null, speaker_matching: null }, local_backends: [
|
||||||
|
{ capability: 'embedding', status: 'placeholder', message: 'hash fallback' },
|
||||||
|
{ capability: 'transcription', status: 'not_installed', message: 'ASR not installed' },
|
||||||
|
{ capability: 'speaker_matching', status: 'not_installed', message: 'speaker not installed' },
|
||||||
|
] }
|
||||||
|
const wrappers: ReturnType<typeof mount>[] = []
|
||||||
|
async function render() {
|
||||||
|
const wrapper = mount(ModelRoutingSettings)
|
||||||
|
wrappers.push(wrapper)
|
||||||
|
await flushPromises()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks()
|
||||||
|
vi.mocked(service.getModelRouting).mockResolvedValue(structuredClone(initial))
|
||||||
|
vi.mocked(listProviders).mockResolvedValue(providers)
|
||||||
|
vi.mocked(service.saveModelRouting).mockImplementation(async config => ({ ...initial, config: { ...config, version: config.version + 1 } }))
|
||||||
|
})
|
||||||
|
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) })
|
||||||
|
|
||||||
|
describe('ModelRoutingSettings', () => {
|
||||||
|
it('loads local selections honestly, explains index rebuilds, and disables incompatible providers', async () => {
|
||||||
|
const wrapper = await render()
|
||||||
|
expect(wrapper.findAll('select').map(select => (select.element as HTMLSelectElement).value)).toEqual(['', '', ''])
|
||||||
|
expect(wrapper.text()).toContain('当前为占位实现')
|
||||||
|
expect(wrapper.text()).toContain('真实本地 ASR 尚未接入')
|
||||||
|
expect(wrapper.text()).toContain('真实本地说话人匹配尚未接入')
|
||||||
|
expect(wrapper.text()).toContain('重建全部')
|
||||||
|
expect(wrapper.text()).toContain('重建完成前继续使用本地检索')
|
||||||
|
expect(wrapper.text()).toContain('不是 OpenAI 标准接口')
|
||||||
|
for (const id of ['responses', 'anthropic', 'ollama', 'disabled']) expect(wrapper.get(`option[value="${id}"]`).attributes()).toHaveProperty('disabled')
|
||||||
|
expect(wrapper.get('option[value="p1"]').attributes()).not.toHaveProperty('disabled')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves all three independent bindings with expected version and remote dimensions', async () => {
|
||||||
|
const wrapper = await render()
|
||||||
|
for (const capability of ['embedding', 'transcription', 'speaker_matching']) {
|
||||||
|
const card = wrapper.get(`[data-capability="${capability}"]`)
|
||||||
|
await card.get('select').setValue('p1')
|
||||||
|
await card.get('[data-field="model"]').setValue(`${capability}-model`)
|
||||||
|
}
|
||||||
|
await wrapper.get('[data-field="dimensions"]').setValue('3072')
|
||||||
|
await wrapper.get('form').trigger('submit')
|
||||||
|
await flushPromises()
|
||||||
|
expect(service.saveModelRouting).toHaveBeenCalledWith({ version: 3,
|
||||||
|
embedding: { provider_id: 'p1', model: 'embedding-model', endpoint: '/embeddings', dimensions: 3072 },
|
||||||
|
transcription: { provider_id: 'p1', model: 'transcription-model', endpoint: '/audio/transcriptions' },
|
||||||
|
speaker_matching: { provider_id: 'p1', model: 'speaker_matching-model', endpoint: '/audio/speaker-matches' },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).toContain('配置版本 4')
|
||||||
|
expect(wrapper.text()).toContain('模型路由已保存')
|
||||||
|
await wrapper.get('[data-capability="embedding"] select').setValue('')
|
||||||
|
await wrapper.get('form').trigger('submit')
|
||||||
|
await flushPromises()
|
||||||
|
expect(service.saveModelRouting).toHaveBeenLastCalledWith(expect.objectContaining({ version: 4, embedding: null }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports omitted dimensions and clears stale models/endpoints when switching providers', async () => {
|
||||||
|
const wrapper = await render()
|
||||||
|
const card = wrapper.get('[data-capability="embedding"]')
|
||||||
|
await card.get('select').setValue('p1')
|
||||||
|
await card.get('[data-field="model"]').setValue('custom-embedding')
|
||||||
|
await card.get('[data-field="endpoint"]').setValue('/custom/embeddings')
|
||||||
|
await wrapper.get('form').trigger('submit')
|
||||||
|
await flushPromises()
|
||||||
|
expect(service.saveModelRouting).toHaveBeenCalledWith(expect.objectContaining({ embedding: { provider_id: 'p1', model: 'custom-embedding', endpoint: '/custom/embeddings', dimensions: null } }))
|
||||||
|
await card.get('select').setValue('p2')
|
||||||
|
expect((card.get('[data-field="model"]').element as HTMLInputElement).value).toBe('')
|
||||||
|
expect((card.get('[data-field="endpoint"]').element as HTMLInputElement).value).toBe('/embeddings')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a loading state and does not offer a default local configuration after load failure', async () => {
|
||||||
|
let fail!: (error: Error) => void
|
||||||
|
vi.mocked(service.getModelRouting).mockReturnValueOnce(new Promise((_, reject) => { fail = reject }))
|
||||||
|
const wrapper = await render()
|
||||||
|
expect(wrapper.text()).toContain('正在加载模型路由')
|
||||||
|
expect(wrapper.find('form').exists()).toBe(false)
|
||||||
|
fail(new Error('offline'))
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.text()).toContain('加载失败:offline')
|
||||||
|
expect(wrapper.find('form').exists()).toBe(false)
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('form').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps unsaved input on save failure and retries without inventing a new version', async () => {
|
||||||
|
const wrapper = await render()
|
||||||
|
const card = wrapper.get('[data-capability="transcription"]')
|
||||||
|
await card.get('select').setValue('p1')
|
||||||
|
await card.get('[data-field="model"]').setValue('asr-model')
|
||||||
|
vi.mocked(service.saveModelRouting).mockRejectedValueOnce(new Error('disk full'))
|
||||||
|
await wrapper.get('form').trigger('submit')
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.text()).toContain('保存失败:disk full')
|
||||||
|
expect((card.get('[data-field="model"]').element as HTMLInputElement).value).toBe('asr-model')
|
||||||
|
await wrapper.get('form').trigger('submit')
|
||||||
|
await flushPromises()
|
||||||
|
expect(service.saveModelRouting).toHaveBeenLastCalledWith(expect.objectContaining({ version: 3 }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks overwrite after a conflict until explicitly reloading the latest configuration', async () => {
|
||||||
|
const wrapper = await render()
|
||||||
|
vi.mocked(service.saveModelRouting).mockRejectedValueOnce(new ApiErrorClass('MODEL_ROUTING_VERSION_CONFLICT', 'stale'))
|
||||||
|
await wrapper.get('form').trigger('submit')
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.text()).toContain('配置版本冲突')
|
||||||
|
expect(wrapper.get('button[type="submit"]').attributes()).toHaveProperty('disabled')
|
||||||
|
await wrapper.get('form').trigger('submit')
|
||||||
|
expect(service.saveModelRouting).toHaveBeenCalledTimes(1)
|
||||||
|
vi.mocked(service.getModelRouting).mockResolvedValueOnce({ ...initial, config: { ...initial.config, version: 8 } })
|
||||||
|
await wrapper.findAll('button').find(button => button.text().includes('放弃当前输入'))!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.get('form').trigger('submit')
|
||||||
|
await flushPromises()
|
||||||
|
expect(service.saveModelRouting).toHaveBeenLastCalledWith(expect.objectContaining({ version: 8 }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prevents invalid dimensions, endpoints, and missing provider bindings from being saved', async () => {
|
||||||
|
vi.mocked(service.getModelRouting).mockResolvedValueOnce({ ...initial, config: { ...initial.config, embedding: { provider_id: 'missing', model: 'old-model', endpoint: '/embeddings' } } })
|
||||||
|
const wrapper = await render()
|
||||||
|
expect(wrapper.text()).toContain('原提供商已不可用')
|
||||||
|
await wrapper.get('form').trigger('submit')
|
||||||
|
expect(service.saveModelRouting).not.toHaveBeenCalled()
|
||||||
|
const card = wrapper.get('[data-capability="embedding"]')
|
||||||
|
await card.get('select').setValue('p1')
|
||||||
|
await card.get('[data-field="model"]').setValue('embedding-model')
|
||||||
|
await card.get('[data-field="dimensions"]').setValue('1.5')
|
||||||
|
await wrapper.get('form').trigger('submit')
|
||||||
|
expect(wrapper.text()).toContain('嵌入维度必须为 1–16384 的整数')
|
||||||
|
await card.get('[data-field="dimensions"]').setValue('16385')
|
||||||
|
await wrapper.get('form').trigger('submit')
|
||||||
|
expect(service.saveModelRouting).not.toHaveBeenCalled()
|
||||||
|
expect(card.get('[data-field="dimensions"]').attributes('max')).toBe('16384')
|
||||||
|
await card.get('[data-field="dimensions"]').setValue('')
|
||||||
|
await card.get('[data-field="endpoint"]').setValue('https://example.test/embeddings')
|
||||||
|
await wrapper.get('form').trigger('submit')
|
||||||
|
expect(wrapper.text()).toContain('Endpoint 必须是以 / 开头的相对路径')
|
||||||
|
expect(service.saveModelRouting).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('labels injected ready local backends accurately', async () => {
|
||||||
|
vi.mocked(service.getModelRouting).mockResolvedValueOnce({ ...initial, local_backends: [{ capability: 'transcription', status: 'ready', message: 'Local ASR ready' }] })
|
||||||
|
const wrapper = await render()
|
||||||
|
const card = wrapper.get('[data-capability="transcription"]')
|
||||||
|
expect(card.get('option[value=""]').text()).toBe('本地 · 已就绪')
|
||||||
|
expect(card.text()).toContain('本地后端已就绪')
|
||||||
|
expect(card.text()).toContain('Local ASR ready')
|
||||||
|
expect(card.text()).not.toContain('真实本地 ASR 尚未接入')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||||
|
import type { ModelBinding, ModelRoutingConfig, ModelRoutingResponse, ProviderConfig, RoutingCapability } from '@/contracts'
|
||||||
|
import { getModelRouting, saveModelRouting } from '@/services/modelRoutingService'
|
||||||
|
import { listProviders } from '@/services/providerService'
|
||||||
|
import { ApiErrorClass } from '@/services/apiClient'
|
||||||
|
|
||||||
|
const capabilities: Array<{ id: RoutingCapability; name: string; endpoint: string; placeholder: string; local: string }> = [
|
||||||
|
{ id: 'embedding', name: '向量嵌入 · Embedding', endpoint: '/embeddings', placeholder: '例如 text-embedding-3-small', local: '当前为占位实现,尚未接入真实本地嵌入模型。' },
|
||||||
|
{ id: 'transcription', name: '语音转写 · Transcription', endpoint: '/audio/transcriptions', placeholder: '输入转写模型 ID', local: '真实本地 ASR 尚未接入,等待阶段 F;当前无法进行本地语音识别。' },
|
||||||
|
{ id: 'speaker_matching', name: '说话人匹配 · Speaker matching', endpoint: '/audio/speaker-matches', placeholder: '输入说话人匹配模型 ID', local: '真实本地说话人匹配尚未接入,等待阶段 F;当前无法进行本地声纹匹配。' },
|
||||||
|
]
|
||||||
|
type Draft = { provider_id: string; model: string; endpoint: string; dimensions: string | number }
|
||||||
|
const drafts = reactive(Object.fromEntries(capabilities.map(item => [item.id, { provider_id: '', model: '', endpoint: item.endpoint, dimensions: '' }])) as Record<RoutingCapability, Draft>)
|
||||||
|
const providers = ref<ProviderConfig[]>([])
|
||||||
|
const response = ref<ModelRoutingResponse | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const saved = ref(false)
|
||||||
|
const conflict = ref(false)
|
||||||
|
let active = true
|
||||||
|
const eligible = (provider: ProviderConfig) => provider.enabled && ['openai_chat', 'openai_compatible'].includes(provider.provider_type)
|
||||||
|
const available = computed(() => providers.value.filter(eligible))
|
||||||
|
const unavailable = computed(() => providers.value.filter(provider => !eligible(provider)))
|
||||||
|
const localBackend = (capability: RoutingCapability) => response.value?.local_backends.find(item => item.capability === capability)
|
||||||
|
const localLabel = (capability: RoutingCapability) => {
|
||||||
|
const status = localBackend(capability)?.status
|
||||||
|
return status === 'ready' ? '已就绪' : status === 'placeholder' ? '占位实现' : '尚未接入'
|
||||||
|
}
|
||||||
|
const protocols = [
|
||||||
|
{ id: 'openai_chat', label: 'OpenAI Chat' }, { id: 'openai_compatible', label: 'OpenAI Compatible' },
|
||||||
|
{ id: 'openai_responses', label: 'Responses' }, { id: 'anthropic_messages', label: 'Anthropic' }, { id: 'ollama', label: 'Ollama' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function applyResponse(result: ModelRoutingResponse) {
|
||||||
|
response.value = result
|
||||||
|
for (const item of capabilities) {
|
||||||
|
const binding = result.config[item.id]
|
||||||
|
Object.assign(drafts[item.id], { provider_id: binding?.provider_id ?? '', model: binding?.model ?? '', endpoint: binding?.endpoint ?? item.endpoint, dimensions: binding?.dimensions?.toString() ?? '' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (loading.value || saving.value) return
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
saved.value = false
|
||||||
|
try {
|
||||||
|
const [routing, items] = await Promise.all([getModelRouting(), listProviders()])
|
||||||
|
if (!active) return
|
||||||
|
providers.value = items
|
||||||
|
applyResponse(routing)
|
||||||
|
conflict.value = false
|
||||||
|
} catch (reason) {
|
||||||
|
if (active) error.value = `加载失败:${reason instanceof Error ? reason.message : '无法读取模型路由或提供商'}`
|
||||||
|
} finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
onBeforeUnmount(() => { active = false })
|
||||||
|
|
||||||
|
function changeProvider(capability: RoutingCapability) {
|
||||||
|
const draft = drafts[capability]
|
||||||
|
draft.model = ''
|
||||||
|
draft.dimensions = ''
|
||||||
|
draft.endpoint = capabilities.find(item => item.id === capability)!.endpoint
|
||||||
|
saved.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindingFor(capability: RoutingCapability): ModelBinding | null {
|
||||||
|
const draft = drafts[capability]
|
||||||
|
if (!draft.provider_id) return null
|
||||||
|
if (!available.value.some(provider => provider.provider_id === draft.provider_id)) throw new Error('请选择已启用且协议可用的提供商,或切换到本地。')
|
||||||
|
if (!draft.model.trim()) throw new Error('请填写所选 API 的模型 ID。')
|
||||||
|
if (!/^\/[A-Za-z0-9_/-]+$/.test(draft.endpoint) || draft.endpoint.startsWith('//')) throw new Error('Endpoint 必须是以 / 开头的相对路径,只能包含字母、数字、下划线、连字符和 /。')
|
||||||
|
const binding: ModelBinding = { provider_id: draft.provider_id, model: draft.model.trim(), endpoint: draft.endpoint }
|
||||||
|
if (capability === 'embedding') {
|
||||||
|
const dimension = String(draft.dimensions).trim()
|
||||||
|
if (dimension && (!/^\d+$/.test(dimension) || !Number.isSafeInteger(Number(dimension)) || Number(dimension) < 1 || Number(dimension) > 16384)) throw new Error('嵌入维度必须为 1–16384 的整数,或留空使用 API 默认值。')
|
||||||
|
binding.dimensions = dimension ? Number(dimension) : null
|
||||||
|
}
|
||||||
|
return binding
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!response.value || loading.value || saving.value || conflict.value) return
|
||||||
|
saving.value = true
|
||||||
|
error.value = ''
|
||||||
|
saved.value = false
|
||||||
|
try {
|
||||||
|
const config: ModelRoutingConfig = {
|
||||||
|
version: response.value.config.version,
|
||||||
|
embedding: bindingFor('embedding'), transcription: bindingFor('transcription'), speaker_matching: bindingFor('speaker_matching'),
|
||||||
|
}
|
||||||
|
const result = await saveModelRouting(config)
|
||||||
|
if (active) { applyResponse(result); saved.value = true }
|
||||||
|
} catch (reason) {
|
||||||
|
if (!active) return
|
||||||
|
conflict.value = reason instanceof ApiErrorClass && /CONFLICT|VERSION|HTTP_409/i.test(reason.code)
|
||||||
|
error.value = conflict.value
|
||||||
|
? '配置版本冲突:其他窗口已修改路由。当前输入尚未保存,请重新加载最新配置后再编辑。'
|
||||||
|
: `保存失败:${reason instanceof Error ? reason.message : '请重试'}`
|
||||||
|
} finally { saving.value = false }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="routing-settings" aria-labelledby="routing-title" :aria-busy="loading || saving">
|
||||||
|
<div><h2 id="routing-title">能力模型路由</h2><p class="subtle">向量嵌入、语音转写和说话人匹配分别选择提供商与模型,独立于默认聊天模型。API Key 在「模型提供商」中管理。</p></div>
|
||||||
|
<p class="subtle">未选择提供商即使用本地路径。API 请求失败、配置不可用或响应无效时,服务端会回退到当前本地处理;本地占位不代表真实模型已接入。</p>
|
||||||
|
<p v-if="loading" role="status">正在加载模型路由…</p>
|
||||||
|
<div v-if="error" class="error-banner" role="alert">{{ error }}</div>
|
||||||
|
<div class="inline-actions"><button type="button" class="button-secondary" :disabled="loading || saving" @click="load">{{ conflict ? '放弃当前输入并加载最新配置' : response ? '重新加载(放弃未保存更改)' : '重试加载' }}</button><span v-if="response" class="subtle">配置版本 {{ response.config.version }}</span></div>
|
||||||
|
<form v-if="response" @submit.prevent="save" @input="saved = false" @change="saved = false">
|
||||||
|
<fieldset :disabled="loading || saving || conflict">
|
||||||
|
<article v-for="capability in capabilities" :key="capability.id" class="routing-card" :data-capability="capability.id">
|
||||||
|
<h3>{{ capability.name }}</h3>
|
||||||
|
<p v-if="capability.id === 'embedding'" class="embedding-notice">更换模型或接口后,请重建全部索引。重建完成前继续使用本地检索。</p>
|
||||||
|
<div class="protocols" aria-label="协议可用性">
|
||||||
|
<span v-for="protocol in protocols" :key="protocol.id" class="badge" :class="{ 'protocol-unavailable': !['openai_chat', 'openai_compatible'].includes(protocol.id) }">{{ protocol.label }}{{ ['openai_chat', 'openai_compatible'].includes(protocol.id) ? ' · 可用' : ' · 不可用' }}</span>
|
||||||
|
</div>
|
||||||
|
<label class="field"><span>处理方式 / 提供商</span><select v-model="drafts[capability.id].provider_id" class="select" data-field="provider" @change="changeProvider(capability.id)">
|
||||||
|
<option value="">本地 · {{ localLabel(capability.id) }}</option>
|
||||||
|
<option v-for="provider in available" :key="provider.provider_id" :value="provider.provider_id">{{ provider.name }} · {{ provider.provider_type }}</option>
|
||||||
|
<option v-for="provider in unavailable" :key="provider.provider_id" :value="provider.provider_id" disabled>{{ provider.name }} · {{ provider.enabled ? '协议不可用' : '未启用' }}</option>
|
||||||
|
<option v-if="drafts[capability.id].provider_id && !providers.some(provider => provider.provider_id === drafts[capability.id].provider_id)" :value="drafts[capability.id].provider_id" disabled>原提供商已不可用 · {{ drafts[capability.id].provider_id }}</option>
|
||||||
|
</select></label>
|
||||||
|
<div v-if="drafts[capability.id].provider_id" class="routing-fields">
|
||||||
|
<label class="field"><span>模型 ID</span><input v-model="drafts[capability.id].model" class="input" data-field="model" :placeholder="capability.placeholder" maxlength="256" required /></label>
|
||||||
|
<label class="field"><span>Endpoint(相对 Base URL)</span><input v-model="drafts[capability.id].endpoint" class="input" data-field="endpoint" :placeholder="capability.endpoint" maxlength="256" required /></label>
|
||||||
|
<label v-if="capability.id === 'embedding'" class="field"><span>向量维度(可选)</span><input v-model="drafts.embedding.dimensions" class="input" data-field="dimensions" type="number" min="1" max="16384" step="1" placeholder="留空使用 API 默认维度" /><small class="subtle">填写模型支持的 1–16384 整数维度,或留空使用 API 默认值。</small></label>
|
||||||
|
</div>
|
||||||
|
<p v-if="capability.id === 'speaker_matching'" class="subtle">说话人匹配使用本应用自定义 HTTP multipart 契约。该端点不是 OpenAI 标准接口;服务需实现对应的说话人匹配请求和响应。</p>
|
||||||
|
<div class="local-status" :class="{ selected: !drafts[capability.id].provider_id }">
|
||||||
|
<strong>{{ drafts[capability.id].provider_id ? '本地回退状态' : '当前本地状态' }}</strong>
|
||||||
|
<p>{{ localBackend(capability.id)?.status === 'ready' ? '本地后端已就绪。' : capability.local }}</p>
|
||||||
|
<p v-for="backend in response.local_backends.filter(item => item.capability === capability.id)" :key="backend.capability" class="subtle"><span class="badge">{{ backend.status === 'ready' ? '已就绪' : backend.status === 'placeholder' ? '占位实现' : '未安装 / 未接入' }}</span> {{ backend.message }}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</fieldset>
|
||||||
|
<div class="inline-actions"><button type="submit" class="button-primary" :disabled="loading || saving || conflict">{{ saving ? '保存中…' : '保存模型路由' }}</button><span v-if="saved" role="status">模型路由已保存。</span></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.routing-settings, form, fieldset { display: grid; gap: var(--space-lg); }
|
||||||
|
.routing-settings { border-top: 1px solid var(--color-border-default); padding-top: var(--space-xl); margin-top: var(--space-md); }
|
||||||
|
fieldset { min-width: 0; padding: 0; margin: 0; border: 0; }
|
||||||
|
.routing-card { display: grid; gap: var(--space-md); padding: var(--space-lg); border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); background: var(--color-surface-primary); }
|
||||||
|
.routing-card h3 { margin: 0; }
|
||||||
|
.protocols { display: flex; flex-wrap: wrap; gap: var(--space-xs); }
|
||||||
|
.protocol-unavailable { opacity: .65; }
|
||||||
|
.embedding-notice { padding: var(--space-md); border-radius: var(--radius-md); background: var(--color-accent-soft); color: var(--color-text-primary); }
|
||||||
|
.routing-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-md); }
|
||||||
|
.local-status { display: grid; gap: var(--space-xs); padding: var(--space-md); background: var(--color-background-secondary); border-radius: var(--radius-md); }
|
||||||
|
.local-status.selected { border-left: 3px solid var(--color-accent-primary); }
|
||||||
|
@media (max-width: 700px) { .routing-fields { grid-template-columns: 1fr; } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { ProviderConfig, ProviderPreset } from '@/contracts'
|
||||||
|
import * as service from '@/services/providerService'
|
||||||
|
import ProviderForm from './ProviderForm.vue'
|
||||||
|
import ProviderPresetSelector from './ProviderPresetSelector.vue'
|
||||||
|
|
||||||
|
vi.mock('@/services/providerService', () => ({ listProviderPresets: vi.fn(), getCredentialStatus: vi.fn(), putCredential: vi.fn(), createProvider: vi.fn(), updateProvider: vi.fn() }))
|
||||||
|
const presets: ProviderPreset[] = [
|
||||||
|
{ preset_id: 'deepseek', name: 'DeepSeek', provider_type: 'openai_compatible', base_url: 'https://deepseek.example.test', default_credential_id: 'shared-deepseek', requires_credential: true, logo_id: 'deepseek' },
|
||||||
|
{ preset_id: 'qwen', name: '通义千问', provider_type: 'openai_compatible', base_url: 'https://qwen.example.test', default_credential_id: 'shared-qwen', requires_credential: true, logo_id: 'qwen' },
|
||||||
|
]
|
||||||
|
const existing: ProviderConfig = { provider_id: 'p1', provider_type: 'openai_compatible', name: 'DeepSeek', base_url: presets[0].base_url, default_model: 'old-model', enabled: true, credential_id: 'old-shared-key', has_credential: true, capabilities: {} }
|
||||||
|
const wrappers: ReturnType<typeof mount>[] = []
|
||||||
|
async function render(provider?: ProviderConfig) {
|
||||||
|
const wrapper = mount(ProviderForm, { props: { provider, models: [{ model_id: 'old-model', name: 'Old', capabilities: {} }] } })
|
||||||
|
wrappers.push(wrapper)
|
||||||
|
await flushPromises()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks()
|
||||||
|
vi.mocked(service.listProviderPresets).mockResolvedValue(presets)
|
||||||
|
vi.mocked(service.getCredentialStatus).mockResolvedValue(true)
|
||||||
|
vi.mocked(service.putCredential).mockResolvedValue()
|
||||||
|
vi.mocked(service.createProvider).mockResolvedValue(existing)
|
||||||
|
vi.mocked(service.updateProvider).mockResolvedValue(existing)
|
||||||
|
})
|
||||||
|
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) })
|
||||||
|
|
||||||
|
describe('ProviderForm', () => {
|
||||||
|
it('filters compact preset chips and resolves bundled logos', async () => {
|
||||||
|
const wrapper = await render()
|
||||||
|
await wrapper.get('#provider-search').setValue('通义')
|
||||||
|
expect(wrapper.find('[data-preset="deepseek"]').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('[data-preset="qwen"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.get('[data-preset="qwen"] img').attributes('src')).not.toMatch(/^https?:/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears the secret, old model and credential on preset and custom selection', async () => {
|
||||||
|
const wrapper = await render(existing)
|
||||||
|
await wrapper.get('input[type="password"]').setValue('draft-secret')
|
||||||
|
await wrapper.get('[data-preset="qwen"]').trigger('click')
|
||||||
|
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
|
||||||
|
expect((wrapper.get('[data-field="model"]').element as HTMLInputElement).value).toBe('')
|
||||||
|
expect(wrapper.findAll('datalist option')).toHaveLength(0)
|
||||||
|
await wrapper.get('form').trigger('submit')
|
||||||
|
expect(service.updateProvider).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.text()).toContain('请输入 API Key')
|
||||||
|
await wrapper.get('input[type="password"]').setValue('new-secret')
|
||||||
|
wrapper.getComponent(ProviderPresetSelector).vm.$emit('update:modelValue', '')
|
||||||
|
await flushPromises()
|
||||||
|
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allocates different credential IDs for two new providers using the same preset', async () => {
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const wrapper = await render()
|
||||||
|
await wrapper.get('[data-preset="deepseek"]').trigger('click')
|
||||||
|
await wrapper.get('input[type="password"]').setValue(`test-key-${i}`)
|
||||||
|
await wrapper.get('form').trigger('submit')
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
const ids = vi.mocked(service.putCredential).mock.calls.map(call => call[0])
|
||||||
|
expect(ids).toHaveLength(2)
|
||||||
|
expect(new Set(ids).size).toBe(2)
|
||||||
|
ids.forEach(id => expect(id).toMatch(/^provider-key-[0-9a-f-]{36}$/))
|
||||||
|
vi.mocked(service.createProvider).mock.calls.forEach(([data], index) => {
|
||||||
|
expect(data.credential_id).toBe(ids[index])
|
||||||
|
expect(JSON.stringify(data)).not.toContain('test-key')
|
||||||
|
expect(JSON.stringify(data)).not.toContain('shared-deepseek')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts a custom API key and clears it after a failed credential save', async () => {
|
||||||
|
const wrapper = await render()
|
||||||
|
await wrapper.get('[data-field="name"]').setValue('Custom')
|
||||||
|
await wrapper.get('[data-field="base-url"]').setValue('https://custom.example.test/v1')
|
||||||
|
await wrapper.get('input[type="password"]').setValue('custom-test-key')
|
||||||
|
vi.mocked(service.putCredential).mockRejectedValueOnce(new Error('credential store unavailable'))
|
||||||
|
await wrapper.get('form').trigger('submit')
|
||||||
|
await flushPromises()
|
||||||
|
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
|
||||||
|
expect(wrapper.text()).toContain('credential store unavailable')
|
||||||
|
expect(service.createProvider).not.toHaveBeenCalled()
|
||||||
|
await wrapper.get('input[type="password"]').setValue('custom-test-key')
|
||||||
|
await wrapper.get('form').trigger('submit')
|
||||||
|
await flushPromises()
|
||||||
|
expect(service.createProvider).toHaveBeenCalledWith(expect.objectContaining({ name: 'Custom', credential_id: expect.stringMatching(/^provider-key-/) }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves its own existing key when untouched and rotates shared legacy references when replacing a key', async () => {
|
||||||
|
const untouched = await render(existing)
|
||||||
|
await untouched.get('form').trigger('submit')
|
||||||
|
await flushPromises()
|
||||||
|
expect(service.updateProvider).toHaveBeenLastCalledWith('p1', expect.objectContaining({ credential_id: 'old-shared-key', default_model: 'old-model' }))
|
||||||
|
const rotated = await render(existing)
|
||||||
|
await rotated.get('input[type="password"]').setValue('replacement-test-key')
|
||||||
|
await rotated.get('form').trigger('submit')
|
||||||
|
await flushPromises()
|
||||||
|
expect(service.putCredential).toHaveBeenCalledWith(expect.stringMatching(/^provider-key-/), 'replacement-test-key')
|
||||||
|
expect(service.updateProvider).toHaveBeenLastCalledWith('p1', expect.objectContaining({ credential_id: vi.mocked(service.putCredential).mock.calls[0][0] }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists edited protocols and unlinks the previous credential and model', async () => {
|
||||||
|
const wrapper = await render(existing)
|
||||||
|
await wrapper.get('[data-field="protocol"]').setValue('openai_responses')
|
||||||
|
await wrapper.get('form').trigger('submit')
|
||||||
|
await flushPromises()
|
||||||
|
expect(service.updateProvider).toHaveBeenCalledWith('p1', expect.objectContaining({ provider_type: 'openai_responses', default_model: '', credential_id: null }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not let a late credential status reuse a key after switching presets', async () => {
|
||||||
|
let resolveStatus!: (configured: boolean) => void
|
||||||
|
vi.mocked(service.getCredentialStatus).mockReturnValue(new Promise(resolve => { resolveStatus = resolve }))
|
||||||
|
const wrapper = await render(existing)
|
||||||
|
await wrapper.get('[data-preset="qwen"]').trigger('click')
|
||||||
|
resolveStatus(true)
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.get('form').trigger('submit')
|
||||||
|
expect(service.updateProvider).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.text()).toContain('请输入 API Key')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears secrets on close and unmount, and stops a pending credential save from creating a provider', async () => {
|
||||||
|
let finish!: () => void
|
||||||
|
vi.mocked(service.putCredential).mockReturnValue(new Promise(resolve => { finish = resolve }))
|
||||||
|
const wrapper = await render()
|
||||||
|
await wrapper.get('[data-preset="deepseek"]').trigger('click')
|
||||||
|
await wrapper.get('input[type="password"]').setValue('pending-test-key')
|
||||||
|
await wrapper.get('form').trigger('submit')
|
||||||
|
await wrapper.get('[aria-label="关闭提供商表单"]').trigger('click')
|
||||||
|
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
|
||||||
|
wrapper.unmount()
|
||||||
|
finish()
|
||||||
|
await flushPromises()
|
||||||
|
expect(service.createProvider).not.toHaveBeenCalled()
|
||||||
|
const reopened = await render()
|
||||||
|
expect((reopened.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears a secret after provider save failure and keeps only the successfully saved reference for retry', async () => {
|
||||||
|
const wrapper = await render()
|
||||||
|
await wrapper.get('[data-preset="deepseek"]').trigger('click')
|
||||||
|
await wrapper.get('input[type="password"]').setValue('retry-test-key')
|
||||||
|
vi.mocked(service.createProvider).mockRejectedValueOnce(new Error('provider save failed'))
|
||||||
|
await wrapper.get('form').trigger('submit')
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.text()).toContain('provider save failed')
|
||||||
|
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
|
||||||
|
await wrapper.get('form').trigger('submit')
|
||||||
|
await flushPromises()
|
||||||
|
expect(service.putCredential).toHaveBeenCalledTimes(1)
|
||||||
|
expect(service.createProvider).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||||
|
import type { ModelInfo, ProviderConfig, ProviderPreset, ProviderType } from '@/contracts'
|
||||||
|
import * as service from '@/services/providerService'
|
||||||
|
import ProviderPresetSelector from './ProviderPresetSelector.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ provider?: ProviderConfig; models?: ModelInfo[] }>()
|
||||||
|
const emit = defineEmits<{ close: []; saved: [provider: ProviderConfig] }>()
|
||||||
|
const newCredentialId = () => `provider-key-${crypto.randomUUID()}`
|
||||||
|
const form = reactive({
|
||||||
|
preset_id: '', provider_type: props.provider?.provider_type ?? 'openai_compatible' as ProviderType,
|
||||||
|
name: props.provider?.name ?? '', base_url: props.provider?.base_url ?? '',
|
||||||
|
default_model: props.provider?.default_model ?? '', enabled: props.provider?.enabled ?? true,
|
||||||
|
})
|
||||||
|
const credentialId = ref(props.provider?.credential_id || newCredentialId())
|
||||||
|
const apiKey = ref('')
|
||||||
|
const configured = ref(false)
|
||||||
|
const credentialLoading = ref(false)
|
||||||
|
const credentialError = ref('')
|
||||||
|
const presets = ref<ProviderPreset[]>([])
|
||||||
|
const presetsLoading = ref(false)
|
||||||
|
const presetsError = ref('')
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const contextChanged = ref(false)
|
||||||
|
const dialog = ref<HTMLElement>()
|
||||||
|
const previousFocus = document.activeElement as HTMLElement | null
|
||||||
|
let active = true
|
||||||
|
let credentialGeneration = 0
|
||||||
|
const selectedPreset = computed(() => presets.value.find(preset => preset.preset_id === form.preset_id))
|
||||||
|
const modelOptions = computed(() => contextChanged.value ? [] : props.models ?? [])
|
||||||
|
|
||||||
|
async function loadPresets() {
|
||||||
|
presetsLoading.value = true
|
||||||
|
presetsError.value = ''
|
||||||
|
try {
|
||||||
|
presets.value = await service.listProviderPresets()
|
||||||
|
if (!contextChanged.value) form.preset_id = presets.value.find(preset => preset.provider_type === props.provider?.provider_type && preset.base_url === props.provider?.base_url)?.preset_id ?? ''
|
||||||
|
} catch { presetsError.value = '预设加载失败,请重试,或填写自定义服务。' }
|
||||||
|
finally { presetsLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
void loadPresets()
|
||||||
|
if (props.provider?.credential_id) {
|
||||||
|
const generation = credentialGeneration
|
||||||
|
credentialLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await service.getCredentialStatus(credentialId.value)
|
||||||
|
if (active && generation === credentialGeneration) configured.value = result
|
||||||
|
} catch {
|
||||||
|
if (active && generation === credentialGeneration) credentialError.value = '无法检查已保存的凭据。可输入新密钥,或关闭后重试。'
|
||||||
|
} finally {
|
||||||
|
if (generation === credentialGeneration) credentialLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
if (active) dialog.value?.querySelector<HTMLInputElement>('input')?.focus()
|
||||||
|
})
|
||||||
|
|
||||||
|
function detachCredential() {
|
||||||
|
credentialGeneration++
|
||||||
|
apiKey.value = ''
|
||||||
|
credentialId.value = newCredentialId()
|
||||||
|
configured.value = false
|
||||||
|
credentialLoading.value = false
|
||||||
|
credentialError.value = ''
|
||||||
|
form.default_model = ''
|
||||||
|
contextChanged.value = true
|
||||||
|
error.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPreset(id: string) {
|
||||||
|
if (form.preset_id === id) return
|
||||||
|
detachCredential()
|
||||||
|
form.preset_id = id
|
||||||
|
const preset = presets.value.find(item => item.preset_id === id)
|
||||||
|
Object.assign(form, { provider_type: preset?.provider_type ?? 'openai_compatible', name: preset?.name ?? '', base_url: preset?.base_url ?? '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeConnection() {
|
||||||
|
form.preset_id = ''
|
||||||
|
detachCredential()
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
active = false
|
||||||
|
apiKey.value = ''
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
active = false
|
||||||
|
apiKey.value = ''
|
||||||
|
previousFocus?.focus()
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') { event.preventDefault(); close() }
|
||||||
|
if (event.key !== 'Tab') return
|
||||||
|
const elements = Array.from(dialog.value?.querySelectorAll<HTMLElement>('button, input, select, [tabindex="0"]') ?? []).filter(element => !element.matches(':disabled'))
|
||||||
|
const first = elements[0], last = elements[elements.length - 1]
|
||||||
|
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() }
|
||||||
|
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus() }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (saving.value || credentialLoading.value || !active) return
|
||||||
|
error.value = ''
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (!form.name.trim() || !form.base_url.trim()) throw new Error('请填写名称和 Base URL。')
|
||||||
|
if (selectedPreset.value?.requires_credential && !apiKey.value.trim() && !configured.value) throw new Error('请输入 API Key。密钥将由后端加密保存。')
|
||||||
|
// Snapshot before awaiting: closing/unmounting must never create a provider with a changed draft.
|
||||||
|
const data = { provider_type: form.provider_type, name: form.name.trim(), base_url: form.base_url.trim() || undefined, default_model: form.default_model.trim(), enabled: form.enabled, capabilities: {}, has_credential: false }
|
||||||
|
if (apiKey.value.trim()) {
|
||||||
|
// Rotate even an existing reference: older installations may share preset credential IDs.
|
||||||
|
const nextId = newCredentialId()
|
||||||
|
const request = service.putCredential(nextId, apiKey.value.trim())
|
||||||
|
apiKey.value = ''
|
||||||
|
await request
|
||||||
|
if (!active) return
|
||||||
|
credentialId.value = nextId
|
||||||
|
configured.value = true
|
||||||
|
}
|
||||||
|
const reference = configured.value ? credentialId.value : undefined
|
||||||
|
// A failed status check must not silently unlink the provider's existing credential.
|
||||||
|
if (credentialError.value && !reference) throw new Error(credentialError.value)
|
||||||
|
const saved = props.provider
|
||||||
|
? await service.updateProvider(props.provider.provider_id, { ...data, credential_id: reference ?? null })
|
||||||
|
: await service.createProvider({ ...data, credential_id: reference })
|
||||||
|
if (active) { emit('saved', saved); close() }
|
||||||
|
} catch (reason) {
|
||||||
|
if (active) error.value = reason instanceof Error ? reason.message : 'Provider 保存失败,请重试。'
|
||||||
|
} finally { apiKey.value = ''; saving.value = false }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="modal-backdrop provider-backdrop" @click.self="close" @keydown="handleKeydown">
|
||||||
|
<div ref="dialog" class="modal provider-modal" role="dialog" aria-modal="true" aria-labelledby="provider-form-title" :aria-busy="saving">
|
||||||
|
<div class="form-heading"><h2 id="provider-form-title">{{ provider ? '编辑 Provider' : '新增 Provider' }}</h2><button type="button" class="button-secondary" aria-label="关闭提供商表单" @click="close">关闭</button></div>
|
||||||
|
<p v-if="presetsLoading" class="subtle" role="status">正在加载提供商预设…</p>
|
||||||
|
<div v-if="presetsError" class="error-banner" role="alert">{{ presetsError }} <button type="button" class="button-secondary" :disabled="presetsLoading || saving" @click="loadPresets">重试</button></div>
|
||||||
|
<form @submit.prevent="save">
|
||||||
|
<fieldset :disabled="saving">
|
||||||
|
<ProviderPresetSelector :presets="presets" :model-value="form.preset_id" @update:model-value="applyPreset" />
|
||||||
|
<p v-if="selectedPreset?.description" class="subtle">{{ selectedPreset.description }}</p>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field"><span>接入协议</span><select v-model="form.provider_type" class="select" data-field="protocol" @change="changeConnection"><option value="openai_compatible">OpenAI Compatible</option><option value="openai_chat">OpenAI Chat</option><option value="openai_responses">OpenAI Responses</option><option value="anthropic_messages">Anthropic Messages</option><option value="ollama">Ollama</option></select></label>
|
||||||
|
<label class="field"><span>名称</span><input v-model="form.name" class="input" data-field="name" required /></label>
|
||||||
|
<label class="field wide"><span>Base URL</span><input v-model="form.base_url" class="input" data-field="base-url" placeholder="https://api.example.com/v1" required @change="changeConnection" /></label>
|
||||||
|
<label class="field wide"><span>API Key</span><input v-model="apiKey" class="input" type="password" autocomplete="new-password" spellcheck="false" :placeholder="configured ? '已配置,留空表示不修改' : '请输入 API Key(无鉴权服务可留空)'" /><small class="subtle">密钥由本地 AI Core 加密保存;提供商配置仅保存独立的凭据引用。</small></label>
|
||||||
|
<p v-if="credentialLoading" class="subtle wide" role="status">正在检查凭据状态…</p>
|
||||||
|
<p v-if="credentialError" class="error-text wide" role="alert">{{ credentialError }}</p>
|
||||||
|
<label class="field wide"><span>默认聊天模型</span><input v-model="form.default_model" class="input" data-field="model" list="provider-model-options" placeholder="输入模型 ID,或保存后获取模型列表" /><datalist id="provider-model-options"><option v-for="model in modelOptions" :key="model.model_id" :value="model.model_id">{{ model.name }}</option></datalist></label>
|
||||||
|
</div>
|
||||||
|
<label class="inline-actions"><input v-model="form.enabled" type="checkbox" /> 启用</label>
|
||||||
|
</fieldset>
|
||||||
|
<div v-if="error" class="error-banner" role="alert">{{ error }}</div>
|
||||||
|
<div class="inline-actions form-footer"><button class="button-primary" type="submit" :disabled="saving || credentialLoading">{{ saving ? '保存中…' : '保存提供商' }}</button><button type="button" class="button-secondary" @click="close">取消</button></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.provider-modal { width: min(820px, 100%); max-height: 90dvh; }
|
||||||
|
.form-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); margin-bottom: var(--space-md); }
|
||||||
|
.form-heading h2 { margin: 0; }
|
||||||
|
fieldset { display: grid; gap: var(--space-md); border: 0; padding: 0; margin: 0; min-width: 0; }
|
||||||
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-md); }
|
||||||
|
.wide { grid-column: 1 / -1; }
|
||||||
|
.error-text { color: var(--color-error); }
|
||||||
|
.form-footer { padding-top: var(--space-sm); }
|
||||||
|
@media (max-width: 600px) { .provider-backdrop { padding: 12px; }.provider-modal { padding: var(--space-lg); max-height: 94dvh; }.form-grid { grid-template-columns: 1fr; } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ logoId?: string }>()
|
||||||
|
const assets = import.meta.glob<string>('../../assets/providers/*.svg', { eager: true, query: '?url', import: 'default' })
|
||||||
|
const source = computed(() => {
|
||||||
|
const id = props.logoId === 'openai-responses' ? 'openai' : props.logoId
|
||||||
|
return assets[`../../assets/providers/${id}.svg`]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="provider-logo" :class="{ 'dark-logo': logoId === 'kimi' }" aria-hidden="true">
|
||||||
|
<img v-if="source" :src="source" alt="" width="22" height="22" />
|
||||||
|
<span v-else class="custom-logo">+</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.provider-logo { display: inline-flex; flex: 0 0 28px; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 7px; background: #fff; color: #252b36; }
|
||||||
|
img { display: block; object-fit: contain; }
|
||||||
|
.dark-logo { background: #111; }
|
||||||
|
.custom-logo { font-size: 23px; line-height: 1; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import type { ProviderPreset } from '@/contracts'
|
||||||
|
import ProviderLogo from './ProviderLogo.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ presets: ProviderPreset[]; modelValue: string }>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||||
|
const search = ref('')
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const query = search.value.trim().toLocaleLowerCase()
|
||||||
|
return props.presets.filter(preset => [preset.name, preset.preset_id, preset.description, preset.base_url]
|
||||||
|
.some(value => value?.toLocaleLowerCase().includes(query)))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="preset-selector">
|
||||||
|
<label class="field" for="provider-search"><span>提供商预设</span><input id="provider-search" v-model="search" class="input" type="search" placeholder="搜索提供商,例如 通义千问 / DeepSeek" /></label>
|
||||||
|
<div class="preset-grid" role="group" aria-label="提供商预设">
|
||||||
|
<button type="button" class="preset-chip" :class="{ selected: !modelValue }" :aria-pressed="!modelValue" @click="emit('update:modelValue', '')"><ProviderLogo /><span>自定义</span></button>
|
||||||
|
<button v-for="preset in filtered" :key="preset.preset_id" type="button" class="preset-chip" :class="{ selected: modelValue === preset.preset_id }" :aria-pressed="modelValue === preset.preset_id" :title="preset.description || preset.name" :data-preset="preset.preset_id" @click="emit('update:modelValue', preset.preset_id)">
|
||||||
|
<ProviderLogo :logo-id="preset.logo_id || preset.preset_id" /><span>{{ preset.name }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="search && !filtered.length" class="subtle" role="status">没有匹配的预设,可以使用自定义服务。</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.preset-selector { display: grid; gap: var(--space-sm); }
|
||||||
|
.preset-grid { display: flex; flex-wrap: wrap; gap: 8px; max-height: 220px; overflow-y: auto; padding: 3px; }
|
||||||
|
.preset-chip { display: inline-flex; align-items: center; gap: 7px; padding: 6px 10px; border: 1px solid var(--color-border-default); border-radius: 11px; background: var(--color-surface-primary); color: var(--color-text-primary); cursor: pointer; font: inherit; font-size: 13px; }
|
||||||
|
.preset-chip:hover { background: var(--color-background-hover); }
|
||||||
|
.preset-chip.selected { border-color: #377cf6; background: color-mix(in srgb, #377cf6 12%, var(--color-surface-primary)); color: #377cf6; box-shadow: 0 0 0 1px #377cf6; }
|
||||||
|
.preset-chip:focus-visible { outline: 2px solid var(--color-border-focus); outline-offset: 2px; }
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import type { ProviderConfig, ProviderType } from '@/contracts'
|
import type { ProviderConfig } from '@/contracts'
|
||||||
|
import ProviderForm from './ProviderForm.vue'
|
||||||
|
import ProviderLogo from './ProviderLogo.vue'
|
||||||
|
import ModelRoutingSettings from './ModelRoutingSettings.vue'
|
||||||
import { useProviderStore } from '@/stores/provider'
|
import { useProviderStore } from '@/stores/provider'
|
||||||
import { useSettingsStore } from '@/stores/settings'
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
@@ -15,14 +18,9 @@ const settingsStore = useSettingsStore()
|
|||||||
const providerStore = useProviderStore()
|
const providerStore = useProviderStore()
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
const showProviderForm = ref(false)
|
const showProviderForm = ref(false)
|
||||||
const editingProviderId = ref<string | null>(null)
|
const editingProvider = ref<ProviderConfig>()
|
||||||
const providerAction = ref('')
|
const providerAction = ref('')
|
||||||
const testResults = ref<Record<string, string>>({})
|
const testResults = ref<Record<string, string>>({})
|
||||||
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(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([providerStore.loadProviders(), providerStore.loadPresets(), settingsStore.loadDiagnostics()])
|
await Promise.all([providerStore.loadProviders(), providerStore.loadPresets(), settingsStore.loadDiagnostics()])
|
||||||
await providerStore.refreshEnabledModels()
|
await providerStore.refreshEnabledModels()
|
||||||
@@ -36,54 +34,15 @@ function presetIdFor(provider?: ProviderConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openProvider(provider?: ProviderConfig) {
|
function openProvider(provider?: ProviderConfig) {
|
||||||
editingProviderId.value = provider?.provider_id ?? null
|
editingProvider.value = provider
|
||||||
const presetId = presetIdFor(provider)
|
providerAction.value = ''
|
||||||
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
|
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)
|
if (provider) void providerStore.loadModels(provider.provider_id).catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyProviderPreset() {
|
async function providerSaved(provider: ProviderConfig) {
|
||||||
const preset = providerStore.presets.find((item) => item.preset_id === providerForm.preset_id)
|
await providerStore.loadProviders()
|
||||||
if (!preset) return
|
if (provider.enabled) void providerStore.loadModels(provider.provider_id).catch(() => undefined)
|
||||||
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 (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 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 : '删除失败' } }
|
||||||
@@ -107,14 +66,15 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
|
|||||||
|
|
||||||
<div v-else-if="activeSection === 'providers'" class="settings-section">
|
<div v-else-if="activeSection === 'providers'" class="settings-section">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div><h2>模型提供商</h2><p class="subtle">支持 OpenAI、DeepSeek、Ollama 和自定义兼容服务。</p></div>
|
<div><h2>模型提供商</h2><p class="subtle">选择国内外提供商预设,或配置自定义 API 与独立密钥。</p></div>
|
||||||
<button class="button-primary" @click="openProvider()">新增 Provider</button>
|
<button class="button-primary" @click="openProvider()">新增 Provider</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="providerStore.error || providerAction" class="error-banner">{{ providerStore.error || providerAction }}</div>
|
<div v-if="providerStore.error || providerAction" class="error-banner">{{ providerStore.error || providerAction }}</div>
|
||||||
|
<p v-if="!providerStore.providers.length" class="subtle">{{ providerStore.isLoading ? '正在加载提供商…' : '尚无可用提供商,请添加真实 API 或本地 Ollama 配置。' }}</p>
|
||||||
<div class="provider-list">
|
<div class="provider-list">
|
||||||
<article v-for="provider in providerStore.providers" :key="provider.provider_id" class="item-card provider-card">
|
<article v-for="provider in providerStore.providers" :key="provider.provider_id" class="item-card provider-card">
|
||||||
<div class="provider-main">
|
<div class="provider-main">
|
||||||
<div class="inline-actions"><strong>{{ provider.name }}</strong><span class="badge" :class="{ success: provider.enabled }">{{ provider.provider_type }}</span></div>
|
<div class="inline-actions"><ProviderLogo :logo-id="providerStore.presets.find(preset => preset.preset_id === presetIdFor(provider))?.logo_id || presetIdFor(provider)" /><strong>{{ provider.name }}</strong><span class="badge" :class="{ success: provider.enabled }">{{ provider.provider_type }}</span></div>
|
||||||
<p class="subtle">{{ provider.base_url || '本地内置' }} · 默认模型 {{ provider.default_model || '未设置' }}</p>
|
<p class="subtle">{{ provider.base_url || '本地内置' }} · 默认模型 {{ provider.default_model || '未设置' }}</p>
|
||||||
<div class="tag-list"><span v-for="(_, capability) in provider.capabilities" :key="capability" class="badge">{{ capability }}</span></div>
|
<div class="tag-list"><span v-for="(_, capability) in provider.capabilities" :key="capability" class="badge">{{ capability }}</span></div>
|
||||||
<div v-if="providerStore.modelsByProvider[provider.provider_id]?.length" class="model-picker">
|
<div v-if="providerStore.modelsByProvider[provider.provider_id]?.length" class="model-picker">
|
||||||
@@ -132,48 +92,19 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
|
|||||||
<button class="button-secondary" :disabled="providerStore.modelLoadingByProvider[provider.provider_id]" @click="refreshModels(provider)">{{ providerStore.modelLoadingByProvider[provider.provider_id] ? '获取中…' : '刷新模型' }}</button>
|
<button class="button-secondary" :disabled="providerStore.modelLoadingByProvider[provider.provider_id]" @click="refreshModels(provider)">{{ providerStore.modelLoadingByProvider[provider.provider_id] ? '获取中…' : '刷新模型' }}</button>
|
||||||
<button class="button-secondary" @click="testProvider(provider)">测试</button>
|
<button class="button-secondary" @click="testProvider(provider)">测试</button>
|
||||||
<button class="button-secondary" @click="openProvider(provider)">编辑</button>
|
<button class="button-secondary" @click="openProvider(provider)">编辑</button>
|
||||||
<button class="button-danger" :disabled="provider.provider_id === 'mock'" @click="removeProvider(provider)">删除</button>
|
<button class="button-danger" @click="removeProvider(provider)">删除</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="activeSection === 'index'" class="panel settings-section"><h2>索引与模型</h2><div class="index-summary"><div><span class="badge" :class="{ success: settingsStore.indexStatus.status === 'idle', error: settingsStore.indexStatus.status === 'error' }">{{ settingsStore.indexStatus.status }}</span><p>待处理任务 {{ settingsStore.indexStatus.pending_jobs }}</p></div><div><strong>{{ settingsStore.indexStatus.total_notes }}</strong><small>笔记</small></div><div><strong>{{ settingsStore.indexStatus.total_blocks }}</strong><small>Block</small></div></div><div v-if="settingsStore.indexStatus.error" class="error-banner">{{ settingsStore.indexStatus.error }}</div><div class="inline-actions"><button class="button-primary" @click="settingsStore.rebuildIndex('full')">重建全部</button><button class="button-secondary" @click="settingsStore.rebuildIndex('fts')">重建文本索引</button><button class="button-secondary" @click="settingsStore.rebuildIndex('vector')">重建向量索引</button></div></div>
|
<div v-else-if="activeSection === 'index'" class="panel settings-section"><h2>索引与模型</h2><div class="index-summary"><div><span class="badge" :class="{ success: settingsStore.indexStatus.status === 'idle', error: settingsStore.indexStatus.status === 'error' }">{{ settingsStore.indexStatus.status }}</span><p>待处理任务 {{ settingsStore.indexStatus.pending_jobs }}</p></div><div><strong>{{ settingsStore.indexStatus.total_notes ?? '未获取' }}</strong><small>笔记</small></div><div><strong>{{ settingsStore.indexStatus.total_blocks ?? '未获取' }}</strong><small>Block</small></div></div><div v-if="settingsStore.indexStatus.error" class="error-banner">{{ settingsStore.indexStatus.error }}</div><div class="inline-actions"><button class="button-primary" @click="settingsStore.rebuildIndex('full')">重建全部</button><span class="subtle">当前后端支持全量重建。</span></div><ModelRoutingSettings /></div>
|
||||||
|
|
||||||
<div v-else-if="activeSection === 'permissions'" class="panel settings-section"><h2>权限策略</h2><p class="muted section-description">高影响能力默认需要确认。未知权限由后端拒绝。</p><div class="permission-list"><div v-for="(policy, permission) in settingsStore.permissionPolicy" :key="permission" class="setting-row"><span><strong>{{ permission }}</strong></span><select :value="policy" class="select short" @change="settingsStore.setPermission(String(permission), ($event.target as HTMLSelectElement).value as 'allow' | 'confirm' | 'deny')"><option value="allow">允许</option><option value="confirm">每次确认</option><option value="deny">拒绝</option></select></div></div></div>
|
<div v-else-if="activeSection === 'permissions'" class="panel settings-section"><h2>权限策略</h2><p class="muted section-description">以下为后端当前生效的权限策略;全局策略编辑尚未开放,运行时按实际权限请求确认。</p><p v-if="!Object.keys(settingsStore.permissionPolicy).length" class="subtle">尚未获取权限策略,请检查后端连接并重新检测。</p><div class="permission-list"><div v-for="(policy, permission) in settingsStore.permissionPolicy" :key="permission" class="setting-row"><span><strong>{{ permission }}</strong></span><span>{{ policy === 'allow' ? '允许' : policy === 'confirm' ? '每次确认' : '拒绝' }}</span></div></div></div>
|
||||||
|
|
||||||
<div v-else class="panel settings-section"><h2>AI Core 诊断</h2><div v-if="settingsStore.diagnosticsError" class="error-banner">{{ settingsStore.diagnosticsError }}</div><div class="diagnostic-grid"><div class="item-card"><span class="badge" :class="{ success: settingsStore.aiCoreStatus === 'running', error: settingsStore.aiCoreStatus === 'error' }">{{ settingsStore.aiCoreStatus }}</span><h3>Sidecar 状态</h3><p class="subtle">AI Core 不可用时,Markdown 编辑仍可继续使用。</p></div><div class="item-card"><strong>{{ settingsStore.aiCoreAddress }}</strong><h3>开发 API 地址</h3><p class="subtle">正式桌面环境由 Sidecar Manager 动态提供。</p></div></div><div class="inline-actions diagnostic-actions"><button class="button-primary" @click="settingsStore.loadDiagnostics">重新检测</button><button class="button-secondary" @click="settingsStore.restartAiCore">重启 AI Core</button></div></div>
|
<div v-else class="panel settings-section"><h2>AI Core 诊断</h2><div v-if="settingsStore.diagnosticsError" class="error-banner">{{ settingsStore.diagnosticsError }}</div><div class="diagnostic-grid"><div class="item-card"><span class="badge" :class="{ success: settingsStore.aiCoreStatus === 'running', error: settingsStore.aiCoreStatus === 'error' }">{{ settingsStore.aiCoreStatus }}</span><h3>AI Core 连接状态</h3><p class="subtle">AI Core 不可用时,Markdown 编辑仍可继续使用。</p></div><div class="item-card"><strong>{{ settingsStore.aiCoreAddress }}</strong><h3>开发 API 地址</h3><p class="subtle">正式桌面环境由 Sidecar Manager 动态提供。</p></div></div><div class="inline-actions diagnostic-actions"><button class="button-primary" @click="settingsStore.loadDiagnostics">重新检测</button><span class="subtle">当前 Web 端不支持重启后端进程,请在运行后端的终端中操作。</span></div></div>
|
||||||
|
|
||||||
<div v-if="showProviderForm" class="modal-backdrop" @click.self="closeProvider">
|
<ProviderForm v-if="showProviderForm" :provider="editingProvider" :models="editingProvider ? providerStore.modelsByProvider[editingProvider.provider_id] : []" @close="showProviderForm = false" @saved="providerSaved" />
|
||||||
<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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ async function uninstall(skillId: string, name: string) {
|
|||||||
<div class="detail-grid"><div><h3>工具</h3><div class="tag-list"><span v-for="tool in skillStore.selectedSkill.tools" :key="tool" class="badge info">{{ tool }}</span></div></div><div><h3>权限</h3><div class="tag-list"><span v-for="permission in skillStore.selectedSkill.permissions" :key="permission" class="badge warning">{{ permission }}</span></div></div><div><h3>检索配置</h3><pre>{{ JSON.stringify(skillStore.selectedSkill.retrieval_config, null, 2) }}</pre></div><div><h3>模型能力</h3><div class="tag-list"><span v-for="cap in skillStore.selectedSkill.model_requirements?.capabilities" :key="cap" class="badge">{{ cap }}</span></div></div></div>
|
<div class="detail-grid"><div><h3>工具</h3><div class="tag-list"><span v-for="tool in skillStore.selectedSkill.tools" :key="tool" class="badge info">{{ tool }}</span></div></div><div><h3>权限</h3><div class="tag-list"><span v-for="permission in skillStore.selectedSkill.permissions" :key="permission" class="badge warning">{{ permission }}</span></div></div><div><h3>检索配置</h3><pre>{{ JSON.stringify(skillStore.selectedSkill.retrieval_config, null, 2) }}</pre></div><div><h3>模型能力</h3><div class="tag-list"><span v-for="cap in skillStore.selectedSkill.model_requirements?.capabilities" :key="cap" class="badge">{{ cap }}</span></div></div></div>
|
||||||
<div v-if="skillStore.selectedSkill.missing_dependencies?.length" class="error-banner dependencies">缺失依赖:{{ skillStore.selectedSkill.missing_dependencies.join('、') }}</div>
|
<div v-if="skillStore.selectedSkill.missing_dependencies?.length" class="error-banner dependencies">缺失依赖:{{ skillStore.selectedSkill.missing_dependencies.join('、') }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="!skillStore.skills.length" class="empty-state"><div><strong>{{ skillStore.isLoading ? '正在加载…' : skillStore.error ? '加载失败' : '尚未安装' }}</strong><button class="button-secondary" @click="skillStore.loadSkills">重新加载</button></div></div>
|
||||||
<div v-else class="feature-grid"><article v-for="skill in skillStore.skills" :key="skill.skill_id" class="item-card extension-card" @click="skillStore.selectSkill(skill.skill_id)"><div class="extension-title"><AppIcon :icon="Lightning" :size="22" /><div><strong>{{ skill.name }}</strong><p>v{{ skill.version }}</p></div><span class="badge" :class="{ success: skill.status === 'ready', warning: skill.status === 'dependency_missing' }">{{ skill.status }}</span></div><p class="muted">{{ skill.description }}</p><div class="tag-list"><span v-for="permission in skill.permissions.slice(0, 3)" :key="permission" class="badge">{{ permission }}</span></div></article></div>
|
<div v-else class="feature-grid"><article v-for="skill in skillStore.skills" :key="skill.skill_id" class="item-card extension-card" @click="skillStore.selectSkill(skill.skill_id)"><div class="extension-title"><AppIcon :icon="Lightning" :size="22" /><div><strong>{{ skill.name }}</strong><p>v{{ skill.version }}</p></div><span class="badge" :class="{ success: skill.status === 'ready', warning: skill.status === 'dependency_missing' }">{{ skill.status }}</span></div><p class="muted">{{ skill.description }}</p><div class="tag-list"><span v-for="permission in skill.permissions.slice(0, 3)" :key="permission" class="badge">{{ permission }}</span></div></article></div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -23,13 +23,13 @@ onMounted(async () => {
|
|||||||
await openVault(lastVaultPath)
|
await openVault(lastVaultPath)
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
// Mock 阶段保存的旧路径可能与当前后端 Vault 不同,清除后让用户重新选择。
|
// 历史保存的旧路径可能与当前后端 Vault 不同,清除后让用户重新选择。
|
||||||
localStorage.removeItem('last-vault-path')
|
localStorage.removeItem('last-vault-path')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
{
|
||||||
aiCoreStatus.value = settingsStore.aiCoreStatus === 'running' ? 'running' : 'stopped'
|
aiCoreStatus.value = settingsStore.aiCoreStatus === 'running' ? 'running' : 'stopped'
|
||||||
}, 800)
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function openVault(path: string) {
|
async function openVault(path: string) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import apiClient from './apiClient'
|
import apiClient from './apiClient'
|
||||||
import { SseClient } from './sseClient'
|
import { SseClient } from './sseClient'
|
||||||
import type { AgentRun, AgentEvent, AgentTraceResponse, ApiAgentRun, OperationResponse, PageMeta, ToolDefinition, PermissionRequest } from '@/contracts'
|
import type { AgentRun, AgentEvent, AgentTraceResponse, ApiAgentRun, OperationResponse, PageMeta, ToolDefinition } from '@/contracts'
|
||||||
|
|
||||||
function toAgentRun(run: ApiAgentRun): AgentRun {
|
function toAgentRun(run: ApiAgentRun): AgentRun {
|
||||||
// API 的 token_usage 是累计值,UI 模型预留了输入/输出拆分字段。
|
// API 的 token_usage 是累计值,UI 模型预留了输入/输出拆分字段。
|
||||||
@@ -10,8 +10,6 @@ function toAgentRun(run: ApiAgentRun): AgentRun {
|
|||||||
current_step: run.current_step,
|
current_step: run.current_step,
|
||||||
max_steps: run.max_steps,
|
max_steps: run.max_steps,
|
||||||
token_usage: {
|
token_usage: {
|
||||||
input_tokens: 0,
|
|
||||||
output_tokens: 0,
|
|
||||||
total_tokens: run.token_usage,
|
total_tokens: run.token_usage,
|
||||||
},
|
},
|
||||||
started_at: run.created_at,
|
started_at: run.created_at,
|
||||||
@@ -107,207 +105,3 @@ export async function respondToPermission(
|
|||||||
decision,
|
decision,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mockTools: ToolDefinition[] = [
|
|
||||||
{
|
|
||||||
name: 'notes.search',
|
|
||||||
description: '搜索笔记,支持关键词和语义检索',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
query: { type: 'string', description: '搜索关键词' },
|
|
||||||
limit: { type: 'number', description: '返回结果数量' },
|
|
||||||
},
|
|
||||||
required: ['query'],
|
|
||||||
},
|
|
||||||
source: 'builtin',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'notes.read',
|
|
||||||
description: '读取指定笔记的完整内容',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
note_id: { type: 'string' },
|
|
||||||
},
|
|
||||||
required: ['note_id'],
|
|
||||||
},
|
|
||||||
source: 'builtin',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'notes.create',
|
|
||||||
description: '创建新笔记',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
title: { type: 'string' },
|
|
||||||
content: { type: 'string' },
|
|
||||||
folder_path: { type: 'string' },
|
|
||||||
},
|
|
||||||
required: ['title', 'content'],
|
|
||||||
},
|
|
||||||
source: 'builtin',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'rag.search',
|
|
||||||
description: '基于 RAG 的语义检索,返回相关知识片段',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
query: { type: 'string' },
|
|
||||||
top_k: { type: 'number' },
|
|
||||||
},
|
|
||||||
required: ['query'],
|
|
||||||
},
|
|
||||||
source: 'builtin',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'tasks.create',
|
|
||||||
description: '创建任务',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
title: { type: 'string' },
|
|
||||||
description: { type: 'string' },
|
|
||||||
priority: { type: 'string', enum: ['low', 'medium', 'high'] },
|
|
||||||
},
|
|
||||||
required: ['title'],
|
|
||||||
},
|
|
||||||
source: 'builtin',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'system.echo',
|
|
||||||
description: '回显输入内容(测试用)',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
text: { type: 'string' },
|
|
||||||
},
|
|
||||||
required: ['text'],
|
|
||||||
},
|
|
||||||
source: 'builtin',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'math.add',
|
|
||||||
description: '两数相加(测试用)',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
a: { type: 'number' },
|
|
||||||
b: { type: 'number' },
|
|
||||||
},
|
|
||||||
required: ['a', 'b'],
|
|
||||||
},
|
|
||||||
source: 'builtin',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const mockAgentRuns: AgentRun[] = [
|
|
||||||
{
|
|
||||||
run_id: 'run-1',
|
|
||||||
status: 'completed',
|
|
||||||
current_step: 3,
|
|
||||||
max_steps: 10,
|
|
||||||
token_usage: { input_tokens: 2340, output_tokens: 890, total_tokens: 3230 },
|
|
||||||
started_at: '2026-08-25T11:00:00Z',
|
|
||||||
completed_at: '2026-08-25T11:02:30Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
run_id: 'run-2',
|
|
||||||
status: 'running',
|
|
||||||
current_step: 2,
|
|
||||||
max_steps: 10,
|
|
||||||
token_usage: { input_tokens: 1500, output_tokens: 420, total_tokens: 1920 },
|
|
||||||
started_at: '2026-08-26T09:30:00Z',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const mockAgentEvents: AgentEvent[] = [
|
|
||||||
{
|
|
||||||
event: 'RunStarted',
|
|
||||||
sequence: 1,
|
|
||||||
run_id: 'run-1',
|
|
||||||
data: { task: '帮我整理红黑树的核心知识点' },
|
|
||||||
timestamp: '2026-08-25T11:00:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
event: 'ThinkingDelta',
|
|
||||||
sequence: 2,
|
|
||||||
run_id: 'run-1',
|
|
||||||
data: { text: '我需要先搜索笔记中关于红黑树的内容...' },
|
|
||||||
timestamp: '2026-08-25T11:00:01Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
event: 'ToolCall',
|
|
||||||
sequence: 3,
|
|
||||||
run_id: 'run-1',
|
|
||||||
data: {
|
|
||||||
tool_call_id: 'tc-1',
|
|
||||||
name: 'notes.search',
|
|
||||||
parameters: { query: '红黑树 插入 删除', limit: 5 },
|
|
||||||
status: 'running',
|
|
||||||
},
|
|
||||||
timestamp: '2026-08-25T11:00:02Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
event: 'ToolResult',
|
|
||||||
sequence: 4,
|
|
||||||
run_id: 'run-1',
|
|
||||||
data: {
|
|
||||||
tool_call_id: 'tc-1',
|
|
||||||
name: 'notes.search',
|
|
||||||
status: 'completed',
|
|
||||||
result: '找到 5 条相关结果,包括红黑树性质、插入操作、删除操作等...',
|
|
||||||
duration_ms: 320,
|
|
||||||
},
|
|
||||||
timestamp: '2026-08-25T11:00:02Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
event: 'Citation',
|
|
||||||
sequence: 5,
|
|
||||||
run_id: 'run-1',
|
|
||||||
data: {
|
|
||||||
note_id: 'n-rbt',
|
|
||||||
block_id: 'b1',
|
|
||||||
heading_path: '数据结构 / 红黑树 / 性质',
|
|
||||||
},
|
|
||||||
timestamp: '2026-08-25T11:00:03Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
event: 'ThinkingDelta',
|
|
||||||
sequence: 6,
|
|
||||||
run_id: 'run-1',
|
|
||||||
data: { text: '搜索结果很全面,让我整理一下结构...' },
|
|
||||||
timestamp: '2026-08-25T11:00:03Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
event: 'TextDelta',
|
|
||||||
sequence: 7,
|
|
||||||
run_id: 'run-1',
|
|
||||||
data: { text: '## 红黑树核心知识点整理\n\n### 1. 基本性质\n红黑树是一种自平衡二叉搜索树,每个节点带有颜色属性...' },
|
|
||||||
timestamp: '2026-08-25T11:00:04Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
event: 'Usage',
|
|
||||||
sequence: 8,
|
|
||||||
run_id: 'run-1',
|
|
||||||
data: { input_tokens: 2340, output_tokens: 890, total_tokens: 3230 },
|
|
||||||
timestamp: '2026-08-25T11:02:30Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
event: 'RunCompleted',
|
|
||||||
sequence: 9,
|
|
||||||
run_id: 'run-1',
|
|
||||||
data: { message: 'Task completed successfully' },
|
|
||||||
timestamp: '2026-08-25T11:02:30Z',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const mockPermissionRequest: PermissionRequest = {
|
|
||||||
request_id: 'perm-1',
|
|
||||||
run_id: 'run-2',
|
|
||||||
tool_name: 'notes.create',
|
|
||||||
permission: 'notes.write',
|
|
||||||
parameters: { title: '红黑树知识点总结', folder_path: '/数据结构' },
|
|
||||||
impact: '将在你的知识库中创建一篇新笔记',
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { SseClient } from './sseClient'
|
import { SseClient } from './sseClient'
|
||||||
import type { Conversation, ChatMessage, ModelEvent } from '@/contracts'
|
import type { ModelEvent } from '@/contracts'
|
||||||
|
|
||||||
export interface ChatRequest {
|
export interface ChatRequest {
|
||||||
provider_id: string
|
provider_id: string
|
||||||
@@ -46,87 +46,3 @@ export function streamChat(
|
|||||||
client.connect().catch(() => {})
|
client.connect().catch(() => {})
|
||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mockConversations: Conversation[] = [
|
|
||||||
{
|
|
||||||
conversation_id: 'conv-1',
|
|
||||||
title: '关于红黑树的讨论',
|
|
||||||
created_at: '2026-08-25T10:00:00Z',
|
|
||||||
updated_at: '2026-08-25T10:30:00Z',
|
|
||||||
message_count: 6,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
conversation_id: 'conv-2',
|
|
||||||
title: '死锁避免算法',
|
|
||||||
created_at: '2026-08-24T14:00:00Z',
|
|
||||||
updated_at: '2026-08-24T15:20:00Z',
|
|
||||||
message_count: 4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
conversation_id: 'conv-3',
|
|
||||||
title: 'TCP三次握手',
|
|
||||||
created_at: '2026-08-22T09:00:00Z',
|
|
||||||
updated_at: '2026-08-22T09:15:00Z',
|
|
||||||
message_count: 3,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const mockMessages: Record<string, ChatMessage[]> = {
|
|
||||||
'conv-1': [
|
|
||||||
{
|
|
||||||
message_id: 'msg-1',
|
|
||||||
conversation_id: 'conv-1',
|
|
||||||
role: 'user',
|
|
||||||
content: '红黑树的插入操作有哪些情况需要调整?',
|
|
||||||
created_at: '2026-08-25T10:00:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message_id: 'msg-2',
|
|
||||||
conversation_id: 'conv-1',
|
|
||||||
role: 'assistant',
|
|
||||||
content:
|
|
||||||
'红黑树插入后可能违反红黑性质,需要通过旋转和变色来修复。主要有以下几种情况:\n\n**情况1:叔叔节点是红色**\n- 将父节点和叔叔节点设为黑色\n- 将祖父节点设为红色\n- 当前节点上移到祖父节点,继续向上调整\n\n**情况2:叔叔节点是黑色,且当前节点是右孩子**\n- 以父节点为支点左旋\n- 将当前节点转换为左孩子,进入情况3\n\n**情况3:叔叔节点是黑色,且当前节点是左孩子**\n- 以祖父节点为支点右旋\n- 将父节点设为黑色,祖父节点设为红色\n\n以上三种情况需要从插入节点开始向上递归处理,直到根节点。',
|
|
||||||
created_at: '2026-08-25T10:01:00Z',
|
|
||||||
citations: [
|
|
||||||
{
|
|
||||||
note_id: 'n-rbt',
|
|
||||||
block_id: 'b1',
|
|
||||||
file_path: '/数据结构/红黑树.md',
|
|
||||||
heading_path: '数据结构 / 红黑树 / 插入操作',
|
|
||||||
content: '插入后可能破坏红黑性质,需要通过变色和旋转来修复...',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
note_id: 'n-rbt',
|
|
||||||
block_id: 'b2',
|
|
||||||
file_path: '/数据结构/红黑树.md',
|
|
||||||
heading_path: '数据结构 / 红黑树 / 五种情况',
|
|
||||||
content: 'Case 1: 叔叔为红色,直接变色...',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message_id: 'msg-3',
|
|
||||||
conversation_id: 'conv-1',
|
|
||||||
role: 'user',
|
|
||||||
content: '能对比一下 AVL 树和红黑树的性能差异吗?',
|
|
||||||
created_at: '2026-08-25T10:10:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message_id: 'msg-4',
|
|
||||||
conversation_id: 'conv-1',
|
|
||||||
role: 'assistant',
|
|
||||||
content:
|
|
||||||
'AVL 树和红黑树都是平衡二叉搜索树,但它们的平衡策略和性能特点有所不同:\n\n| 特性 | AVL 树 | 红黑树 |\n|------|--------|--------|\n| 严格平衡 | 高度差 ≤ 1 | 黑色高度相同 |\n| 查找速度 | 更快(严格平衡) | 略慢但仍为 O(log n) |\n| 插入/删除 | 旋转更多 | 旋转更少(颜色调整为主) |\n| 存储开销 | 每个节点存高度(int) | 每个节点存颜色(1 bit) |\n| 适用场景 | 读多写少 | 读写均衡或写较多 |\n\n**总结:**\n- 如果应用以查询为主,很少修改,AVL 树可能更优\n- 如果插入删除频繁,红黑树更合适,重平衡开销更低\n- 红黑树在工业界应用更广泛(C++ STL 的 map/set、Java 的 TreeMap 等)',
|
|
||||||
created_at: '2026-08-25T10:11:00Z',
|
|
||||||
citations: [
|
|
||||||
{
|
|
||||||
note_id: 'n-rbt',
|
|
||||||
block_id: 'b3',
|
|
||||||
file_path: '/数据结构/红黑树.md',
|
|
||||||
heading_path: '数据结构 / 红黑树 / 与AVL树对比',
|
|
||||||
content: '红黑树相比AVL树,牺牲了部分平衡性以换取更少的旋转操作...',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export * as skillService from './skillService'
|
|||||||
export * as pluginService from './pluginService'
|
export * as pluginService from './pluginService'
|
||||||
export * as mcpServerService from './mcpServerService'
|
export * as mcpServerService from './mcpServerService'
|
||||||
export * as providerService from './providerService'
|
export * as providerService from './providerService'
|
||||||
|
export * as modelRoutingService from './modelRoutingService'
|
||||||
export * as taskService from './taskService'
|
export * as taskService from './taskService'
|
||||||
export * as indexService from './indexService'
|
export * as indexService from './indexService'
|
||||||
export * as systemService from './systemService'
|
export * as systemService from './systemService'
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ function toIndexStatus(status: ApiIndexStatus): IndexStatus {
|
|||||||
return {
|
return {
|
||||||
status: status.status === 'idle' ? 'idle' : status.status === 'failed' ? 'error' : 'indexing',
|
status: status.status === 'idle' ? 'idle' : status.status === 'failed' ? 'error' : 'indexing',
|
||||||
pending_jobs: status.pending_jobs,
|
pending_jobs: status.pending_jobs,
|
||||||
total_notes: 0,
|
total_notes: status.total_notes ?? null,
|
||||||
total_blocks: 0,
|
total_blocks: status.total_blocks ?? null,
|
||||||
fts_enabled: true,
|
|
||||||
vector_enabled: true,
|
|
||||||
last_indexed_at: status.last_completed_at ?? undefined,
|
last_indexed_at: status.last_completed_at ?? undefined,
|
||||||
error: status.error_message ?? undefined,
|
error: status.error_message ?? undefined,
|
||||||
}
|
}
|
||||||
@@ -26,15 +24,3 @@ export async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full'): P
|
|||||||
export async function getIndexJob(jobId: string): Promise<ApiIndexJob> {
|
export async function getIndexJob(jobId: string): Promise<ApiIndexJob> {
|
||||||
return apiClient.get(`/api/index/jobs/${jobId}`)
|
return apiClient.get(`/api/index/jobs/${jobId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mockIndexStatus: IndexStatus = {
|
|
||||||
status: 'idle',
|
|
||||||
pending_jobs: 0,
|
|
||||||
total_notes: 42,
|
|
||||||
total_blocks: 318,
|
|
||||||
fts_enabled: true,
|
|
||||||
vector_enabled: true,
|
|
||||||
embedding_model: 'bge-m3',
|
|
||||||
reranker_model: 'bge-reranker-base',
|
|
||||||
last_indexed_at: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { getModelRouting, saveModelRouting } from './modelRoutingService'
|
||||||
|
|
||||||
|
const config = { version: 7, embedding: { provider_id: 'p1', model: 'embedding', endpoint: '/embeddings', dimensions: 1024 }, transcription: null, speaker_matching: null }
|
||||||
|
const result = { config, local_backends: [{ capability: 'embedding', status: 'placeholder', message: 'hash' }] }
|
||||||
|
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
|
||||||
|
beforeEach(() => { vi.stubGlobal('fetch', vi.fn()) })
|
||||||
|
afterEach(() => { vi.unstubAllGlobals() })
|
||||||
|
|
||||||
|
describe('model routing service', () => {
|
||||||
|
it('round-trips versioned routing without an extra config wrapper', async () => {
|
||||||
|
vi.mocked(fetch).mockImplementation(async () => json(result))
|
||||||
|
expect(await getModelRouting()).toEqual(result)
|
||||||
|
expect(await saveModelRouting(config)).toEqual(result)
|
||||||
|
expect(fetch).toHaveBeenNthCalledWith(1, '/api/model-routing', expect.objectContaining({ method: 'GET' }))
|
||||||
|
expect(fetch).toHaveBeenNthCalledWith(2, '/api/model-routing', expect.objectContaining({ method: 'PUT', body: JSON.stringify(config) }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('surfaces load, save and version conflict errors instead of returning local defaults', async () => {
|
||||||
|
vi.mocked(fetch).mockRejectedValueOnce(new Error('offline'))
|
||||||
|
.mockResolvedValueOnce(json({ error: { code: 'MODEL_ROUTING_VERSION_CONFLICT', message: 'conflict' } }, 409))
|
||||||
|
.mockResolvedValueOnce(json({ error: { code: 'SAVE_FAILED', message: 'disk full' } }, 500))
|
||||||
|
await expect(getModelRouting()).rejects.toMatchObject({ code: 'NETWORK_ERROR' })
|
||||||
|
await expect(saveModelRouting(config)).rejects.toMatchObject({ code: 'MODEL_ROUTING_VERSION_CONFLICT' })
|
||||||
|
await expect(saveModelRouting(config)).rejects.toMatchObject({ code: 'SAVE_FAILED' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { ModelRoutingConfig, ModelRoutingResponse } from '@/contracts'
|
||||||
|
import apiClient from './apiClient'
|
||||||
|
|
||||||
|
export function getModelRouting(): Promise<ModelRoutingResponse> {
|
||||||
|
return apiClient.get('/api/model-routing')
|
||||||
|
}
|
||||||
|
|
||||||
|
// version is the last version read from the server (optimistic concurrency).
|
||||||
|
export function saveModelRouting(config: ModelRoutingConfig): Promise<ModelRoutingResponse> {
|
||||||
|
return apiClient.put('/api/model-routing', config)
|
||||||
|
}
|
||||||
@@ -126,92 +126,3 @@ export async function deletePluginSecret(pluginId: string, key: string): Promise
|
|||||||
export async function uninstallPlugin(pluginId: string): Promise<OperationResponse> {
|
export async function uninstallPlugin(pluginId: string): Promise<OperationResponse> {
|
||||||
return apiClient.delete(`/api/plugins/${pluginId}`)
|
return apiClient.delete(`/api/plugins/${pluginId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mockPlugins: Plugin[] = [
|
|
||||||
{
|
|
||||||
plugin_id: 'github-integration',
|
|
||||||
name: 'GitHub 集成',
|
|
||||||
version: '1.3.2',
|
|
||||||
description: '接入 GitHub API,支持搜索 Issue、查看 PR 和管理仓库',
|
|
||||||
icon: '',
|
|
||||||
author: 'NotesAgent 团队',
|
|
||||||
status: 'ready',
|
|
||||||
enabled: true,
|
|
||||||
permissions: ['notes.read', 'network.request'],
|
|
||||||
contributions: [
|
|
||||||
{ type: 'tool', id: 'github.search_issues', name: '搜索 Issue', description: '搜索 GitHub 仓库中的 Issue' },
|
|
||||||
{ type: 'tool', id: 'github.get_pr', name: '获取 PR 详情', description: '获取 Pull Request 的详细信息' },
|
|
||||||
{ type: 'command', id: 'github.open_repo', name: '打开仓库', description: '在浏览器中打开对应 GitHub 仓库' },
|
|
||||||
],
|
|
||||||
backend_type: 'mcp',
|
|
||||||
transport: 'stdio',
|
|
||||||
dependent_skills: ['research-assistant'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
plugin_id: 'translator',
|
|
||||||
name: '翻译助手',
|
|
||||||
version: '1.0.0',
|
|
||||||
description: '提供多语言翻译能力,支持文档批量翻译',
|
|
||||||
icon: '',
|
|
||||||
author: '社区贡献',
|
|
||||||
status: 'ready',
|
|
||||||
enabled: false,
|
|
||||||
permissions: ['notes.read', 'notes.write', 'network.request'],
|
|
||||||
contributions: [
|
|
||||||
{ type: 'tool', id: 'translator.translate', name: '翻译文本', description: '翻译指定文本到目标语言' },
|
|
||||||
{ type: 'command', id: 'translator.translate_note', name: '翻译当前笔记', description: '翻译当前打开的笔记' },
|
|
||||||
{ type: 'settings_section', id: 'translator.settings', name: '翻译设置', description: '配置翻译服务和默认语言' },
|
|
||||||
],
|
|
||||||
backend_type: 'mcp',
|
|
||||||
transport: 'stdio',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
plugin_id: 'kanban',
|
|
||||||
name: '看板视图',
|
|
||||||
version: '0.8.0',
|
|
||||||
description: '为任务提供看板视图,支持拖拽排序和多维度筛选',
|
|
||||||
icon: '',
|
|
||||||
author: '社区贡献',
|
|
||||||
status: 'installed',
|
|
||||||
enabled: false,
|
|
||||||
permissions: ['tasks.read', 'tasks.write'],
|
|
||||||
contributions: [
|
|
||||||
{ type: 'sidebar_panel', id: 'kanban.panel', name: '任务看板', description: '以看板方式查看和管理任务' },
|
|
||||||
],
|
|
||||||
backend_type: 'internal_rpc',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
plugin_id: 'pdf-importer',
|
|
||||||
name: 'PDF 导入',
|
|
||||||
version: '2.1.0',
|
|
||||||
description: '导入 PDF 文档,提取文本和目录结构生成笔记',
|
|
||||||
icon: '',
|
|
||||||
author: 'NotesAgent 团队',
|
|
||||||
status: 'error',
|
|
||||||
enabled: false,
|
|
||||||
permissions: ['notes.write', 'attachments.read'],
|
|
||||||
contributions: [
|
|
||||||
{ type: 'importer', id: 'pdf.import', name: 'PDF 导入器', description: '从 PDF 文件导入内容' },
|
|
||||||
],
|
|
||||||
backend_type: 'mcp',
|
|
||||||
transport: 'stdio',
|
|
||||||
last_error: 'PDF 解析库初始化失败,请检查 Python 依赖',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
plugin_id: 'calendar',
|
|
||||||
name: '日历同步',
|
|
||||||
version: '0.5.0',
|
|
||||||
description: '同步日历事件,自动生成相关笔记和任务提醒',
|
|
||||||
icon: '',
|
|
||||||
author: '社区贡献',
|
|
||||||
status: 'dependency_missing',
|
|
||||||
enabled: false,
|
|
||||||
permissions: ['tasks.read', 'tasks.write', 'network.request'],
|
|
||||||
contributions: [
|
|
||||||
{ type: 'tool', id: 'calendar.events', name: '日历事件', description: '获取日历事件列表' },
|
|
||||||
{ type: 'sidebar_panel', id: 'calendar.widget', name: '日历小部件', description: '侧边栏日历视图' },
|
|
||||||
],
|
|
||||||
backend_type: 'mcp',
|
|
||||||
transport: 'http',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createProvider, listProviderPresets, putCredential, updateProvider } from './providerService'
|
||||||
|
|
||||||
|
const provider = { provider_id: 'provider-1', provider_type: 'openai_chat', name: 'Custom', capabilities: [], enabled: true }
|
||||||
|
const json = (body: unknown) => new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' } })
|
||||||
|
beforeEach(() => { vi.stubGlobal('fetch', vi.fn()) })
|
||||||
|
afterEach(() => { vi.unstubAllGlobals() })
|
||||||
|
|
||||||
|
describe('provider wire contracts', () => {
|
||||||
|
it('retains preset logos, descriptions and capabilities', async () => {
|
||||||
|
const preset = { preset_id: 'qwen', logo_id: 'qwen', description: '通义千问', capabilities: ['chat', 'embedding'] }
|
||||||
|
vi.mocked(fetch).mockResolvedValue(json({ items: [preset] }))
|
||||||
|
expect(await listProviderPresets()).toEqual([preset])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists protocol edits and explicit credential unlinking', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValue(json(provider))
|
||||||
|
await updateProvider('provider-1', { provider_type: 'openai_responses', default_model: '', credential_id: null })
|
||||||
|
expect(fetch).toHaveBeenCalledWith('/api/providers/provider-1', expect.objectContaining({ method: 'PATCH' }))
|
||||||
|
expect(JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body))).toEqual({ provider_type: 'openai_responses', default_model: '', credential_id: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends secrets only to credentials and a reference to provider configuration', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(json({ configured: true })).mockResolvedValueOnce(json(provider))
|
||||||
|
await putCredential('provider-key-test', 'test-only-key')
|
||||||
|
await createProvider({ name: 'Custom', provider_type: 'openai_compatible', default_model: '', enabled: true, credential_id: 'provider-key-test', has_credential: true, capabilities: {} })
|
||||||
|
const calls = vi.mocked(fetch).mock.calls
|
||||||
|
expect(calls[0][0]).toBe('/api/credentials/provider-key-test')
|
||||||
|
expect(JSON.parse(String(calls[0][1]?.body))).toEqual({ api_key: 'test-only-key' })
|
||||||
|
expect(JSON.parse(String(calls[1][1]?.body))).toMatchObject({ credential_id: 'provider-key-test' })
|
||||||
|
expect(calls[1][1]?.body).not.toContain('test-only-key')
|
||||||
|
expect(calls[1][1]?.body).not.toContain('has_credential')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import apiClient from './apiClient'
|
import apiClient from './apiClient'
|
||||||
import type { ApiModelInfo, ApiProviderConfig, ApiProviderPreset, ModelCapability, ModelInfo, OperationResponse, ProviderConfig, ProviderPreset } from '@/contracts'
|
import type { ApiModelInfo, ApiProviderConfig, ApiProviderPreset, ModelCapability, ModelInfo, OperationResponse, ProviderConfig, ProviderPreset, ProviderUpdateRequest } from '@/contracts'
|
||||||
|
|
||||||
function capabilityMap(capabilities: string[]): Partial<ModelCapability> {
|
function capabilityMap(capabilities: string[]): Partial<ModelCapability> {
|
||||||
return Object.fromEntries(capabilities.map((capability) => [capability, true])) as Partial<ModelCapability>
|
return Object.fromEntries(capabilities.map((capability) => [capability, true])) as Partial<ModelCapability>
|
||||||
@@ -15,7 +15,7 @@ function toProvider(provider: ApiProviderConfig): ProviderConfig {
|
|||||||
enabled: provider.enabled,
|
enabled: provider.enabled,
|
||||||
capabilities: capabilityMap(provider.capabilities),
|
capabilities: capabilityMap(provider.capabilities),
|
||||||
credential_id: provider.credential_id ?? undefined,
|
credential_id: provider.credential_id ?? undefined,
|
||||||
has_credential: Boolean(provider.credential_id) || provider.provider_type === 'mock',
|
has_credential: Boolean(provider.credential_id),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ function toModel(model: ApiModelInfo): ModelInfo {
|
|||||||
|
|
||||||
export async function listProviders(): Promise<ProviderConfig[]> {
|
export async function listProviders(): Promise<ProviderConfig[]> {
|
||||||
const response = await apiClient.get<{ items: ApiProviderConfig[] }>('/api/providers')
|
const response = await apiClient.get<{ items: ApiProviderConfig[] }>('/api/providers')
|
||||||
return response.items.map(toProvider)
|
return response.items.filter(provider => provider.provider_type !== 'mock').map(toProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProvider(providerId: string): Promise<ProviderConfig> {
|
export async function getProvider(providerId: string): Promise<ProviderConfig> {
|
||||||
@@ -61,8 +61,9 @@ export async function putCredential(credentialId: string, apiKey: string): Promi
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProvider(providerId: string, data: Partial<ProviderConfig>): Promise<ProviderConfig> {
|
export async function updateProvider(providerId: string, data: ProviderUpdateRequest): Promise<ProviderConfig> {
|
||||||
const response = await apiClient.patch<ApiProviderConfig>(`/api/providers/${providerId}`, {
|
const response = await apiClient.patch<ApiProviderConfig>(`/api/providers/${providerId}`, {
|
||||||
|
provider_type: data.provider_type,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
base_url: data.base_url,
|
base_url: data.base_url,
|
||||||
default_model: data.default_model,
|
default_model: data.default_model,
|
||||||
@@ -96,102 +97,3 @@ export async function testProvider(providerId: string): Promise<TestResult> {
|
|||||||
return { success: false, error_code: e.code || 'TEST_FAILED', error_message: e.message }
|
return { success: false, error_code: e.code || 'TEST_FAILED', error_message: e.message }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mockProviders: ProviderConfig[] = [
|
|
||||||
{
|
|
||||||
provider_id: 'mock',
|
|
||||||
provider_type: 'mock',
|
|
||||||
name: 'Mock Provider (测试)',
|
|
||||||
default_model: 'mock-1',
|
|
||||||
enabled: true,
|
|
||||||
has_credential: true,
|
|
||||||
capabilities: {
|
|
||||||
chat: true,
|
|
||||||
tool_calling: true,
|
|
||||||
streaming: true,
|
|
||||||
vision: false,
|
|
||||||
reasoning: false,
|
|
||||||
structured_output: true,
|
|
||||||
embedding: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider_id: 'openai-compat-1',
|
|
||||||
provider_type: 'openai_compatible',
|
|
||||||
name: 'OpenAI 兼容服务',
|
|
||||||
base_url: 'https://api.openai.com/v1',
|
|
||||||
default_model: 'gpt-4o-mini',
|
|
||||||
enabled: true,
|
|
||||||
has_credential: true,
|
|
||||||
capabilities: {
|
|
||||||
chat: true,
|
|
||||||
tool_calling: true,
|
|
||||||
streaming: true,
|
|
||||||
vision: true,
|
|
||||||
reasoning: false,
|
|
||||||
structured_output: true,
|
|
||||||
embedding: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider_id: 'ollama-local',
|
|
||||||
provider_type: 'ollama',
|
|
||||||
name: 'Ollama (本地)',
|
|
||||||
base_url: 'http://127.0.0.1:11434',
|
|
||||||
default_model: 'qwen2.5:7b',
|
|
||||||
enabled: false,
|
|
||||||
has_credential: false,
|
|
||||||
capabilities: {
|
|
||||||
chat: true,
|
|
||||||
tool_calling: false,
|
|
||||||
streaming: true,
|
|
||||||
vision: false,
|
|
||||||
reasoning: false,
|
|
||||||
structured_output: false,
|
|
||||||
embedding: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const mockModels: Record<string, ModelInfo[]> = {
|
|
||||||
mock: [
|
|
||||||
{
|
|
||||||
model_id: 'mock-1',
|
|
||||||
name: 'Mock Model v1',
|
|
||||||
capabilities: { chat: true, tool_calling: true, streaming: true, structured_output: true },
|
|
||||||
context_window: 8192,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'openai-compat-1': [
|
|
||||||
{
|
|
||||||
model_id: 'gpt-4o-mini',
|
|
||||||
name: 'GPT-4o Mini',
|
|
||||||
capabilities: { chat: true, tool_calling: true, streaming: true, vision: true, structured_output: true },
|
|
||||||
context_window: 128000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model_id: 'gpt-4o',
|
|
||||||
name: 'GPT-4o',
|
|
||||||
capabilities: { chat: true, tool_calling: true, streaming: true, vision: true, structured_output: true, reasoning: true },
|
|
||||||
context_window: 128000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model_id: 'text-embedding-3-small',
|
|
||||||
name: 'Text Embedding 3 Small',
|
|
||||||
capabilities: { embedding: true },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'ollama-local': [
|
|
||||||
{
|
|
||||||
model_id: 'qwen2.5:7b',
|
|
||||||
name: 'Qwen 2.5 7B',
|
|
||||||
capabilities: { chat: true, streaming: true },
|
|
||||||
context_window: 32768,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model_id: 'bge-m3',
|
|
||||||
name: 'BGE M3',
|
|
||||||
capabilities: { embedding: true },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -35,69 +35,3 @@ export async function search(request: SearchRequest): Promise<{
|
|||||||
mode: response.mode,
|
mode: response.mode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchMock(
|
|
||||||
query: string,
|
|
||||||
mode: 'fts' | 'vector' | 'hybrid' = 'hybrid'
|
|
||||||
): Promise<{
|
|
||||||
results: SearchResult[]
|
|
||||||
total: number
|
|
||||||
mode: 'fts' | 'vector' | 'hybrid'
|
|
||||||
}> {
|
|
||||||
await new Promise((r) => setTimeout(r, 300))
|
|
||||||
if (!query.trim()) return { results: [], total: 0, mode }
|
|
||||||
const results: SearchResult[] = [
|
|
||||||
{
|
|
||||||
block_id: 'b1',
|
|
||||||
note_id: 'n-rbt',
|
|
||||||
note_title: '红黑树',
|
|
||||||
file_path: '/数据结构/红黑树.md',
|
|
||||||
heading_path: '数据结构 / 红黑树 / 插入操作',
|
|
||||||
snippet: '插入后可能破坏红黑性质,需要通过变色和旋转来修复...',
|
|
||||||
score: 0.95,
|
|
||||||
match_type: 'hybrid',
|
|
||||||
tags: ['数据结构', '树'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
block_id: 'b2',
|
|
||||||
note_id: 'n-rbt',
|
|
||||||
note_title: '红黑树',
|
|
||||||
file_path: '/数据结构/红黑树.md',
|
|
||||||
heading_path: '数据结构 / 红黑树 / 性质',
|
|
||||||
snippet: '红黑树是一种自平衡二叉搜索树,每个节点带有颜色属性(红或黑)...',
|
|
||||||
score: 0.87,
|
|
||||||
match_type: 'fts',
|
|
||||||
tags: ['数据结构'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
block_id: 'b3',
|
|
||||||
note_id: 'n-bst',
|
|
||||||
note_title: '二叉搜索树',
|
|
||||||
file_path: '/数据结构/二叉搜索树.md',
|
|
||||||
heading_path: '数据结构 / 二叉搜索树 / 基本操作',
|
|
||||||
snippet: '二叉搜索树的插入需要先找到合适的位置,再添加新节点...',
|
|
||||||
score: 0.72,
|
|
||||||
match_type: 'vector',
|
|
||||||
tags: ['数据结构', '树'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
block_id: 'b4',
|
|
||||||
note_id: 'n-deadlock',
|
|
||||||
note_title: '死锁',
|
|
||||||
file_path: '/操作系统/死锁.md',
|
|
||||||
heading_path: '操作系统 / 死锁 / 必要条件',
|
|
||||||
snippet: '死锁的四个必要条件:互斥、占有并等待、不可抢占、循环等待...',
|
|
||||||
score: 0.45,
|
|
||||||
match_type: 'vector',
|
|
||||||
tags: ['操作系统'],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const filtered = results.filter(
|
|
||||||
(r) =>
|
|
||||||
r.note_title.includes(query) ||
|
|
||||||
r.snippet.includes(query) ||
|
|
||||||
r.heading_path.includes(query) ||
|
|
||||||
query.length > 1
|
|
||||||
)
|
|
||||||
return { results: filtered, total: filtered.length, mode }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -42,77 +42,3 @@ export async function disableSkill(skillId: string): Promise<Skill> {
|
|||||||
export async function uninstallSkill(skillId: string): Promise<OperationResponse> {
|
export async function uninstallSkill(skillId: string): Promise<OperationResponse> {
|
||||||
return apiClient.delete(`/api/skills/${skillId}`)
|
return apiClient.delete(`/api/skills/${skillId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mockSkills: Skill[] = [
|
|
||||||
{
|
|
||||||
skill_id: 'exam-review',
|
|
||||||
name: '期末复习助手',
|
|
||||||
version: '1.0.0',
|
|
||||||
description: '根据课程笔记生成复习要点和练习题,帮助高效备考',
|
|
||||||
icon: '',
|
|
||||||
author: 'NotesAgent 团队',
|
|
||||||
permissions: ['notes.search', 'notes.read', 'tasks.create'],
|
|
||||||
tools: ['notes.search', 'notes.read', 'tasks.create'],
|
|
||||||
retrieval_config: { top_k: 10, rerank: true, citation: true },
|
|
||||||
model_requirements: { capabilities: ['chat', 'tool_calling'] },
|
|
||||||
status: 'ready',
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
skill_id: 'meeting-summary',
|
|
||||||
name: '会议纪要生成',
|
|
||||||
version: '1.1.0',
|
|
||||||
description: '从音频或文本中提取会议要点、行动项和待办任务',
|
|
||||||
icon: '',
|
|
||||||
author: 'NotesAgent 团队',
|
|
||||||
permissions: ['notes.search', 'notes.write', 'tasks.write', 'attachments.read'],
|
|
||||||
tools: ['notes.search', 'notes.create', 'tasks.create', 'attachments.read'],
|
|
||||||
retrieval_config: { top_k: 5, rerank: false, citation: true },
|
|
||||||
model_requirements: { capabilities: ['chat', 'tool_calling', 'structured_output'] },
|
|
||||||
status: 'ready',
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
skill_id: 'code-explainer',
|
|
||||||
name: '代码解读助手',
|
|
||||||
version: '0.9.0',
|
|
||||||
description: '分析代码片段,解释功能、复杂度和优化建议',
|
|
||||||
icon: '',
|
|
||||||
author: '社区贡献',
|
|
||||||
permissions: ['notes.search', 'notes.read'],
|
|
||||||
tools: ['notes.search', 'notes.read', 'rag.search'],
|
|
||||||
retrieval_config: { top_k: 8, rerank: true, citation: true },
|
|
||||||
model_requirements: { capabilities: ['chat', 'tool_calling'] },
|
|
||||||
status: 'installed',
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
skill_id: 'research-assistant',
|
|
||||||
name: '文献研究助手',
|
|
||||||
version: '1.2.0',
|
|
||||||
description: '自动整理文献笔记,生成研究综述和引用关系图',
|
|
||||||
icon: '',
|
|
||||||
author: '社区贡献',
|
|
||||||
permissions: ['notes.search', 'notes.read', 'notes.write'],
|
|
||||||
tools: ['notes.search', 'notes.read', 'notes.create', 'rag.search'],
|
|
||||||
retrieval_config: { top_k: 15, rerank: true, citation: true },
|
|
||||||
model_requirements: { capabilities: ['chat', 'tool_calling', 'reasoning'] },
|
|
||||||
status: 'dependency_missing',
|
|
||||||
enabled: false,
|
|
||||||
missing_dependencies: ['文献引用插件', '知识图谱插件'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
skill_id: 'language-tutor',
|
|
||||||
name: '语言学习助手',
|
|
||||||
version: '0.5.0',
|
|
||||||
description: '基于你的学习笔记生成语言练习和记忆卡片',
|
|
||||||
icon: '',
|
|
||||||
author: '社区贡献',
|
|
||||||
permissions: ['notes.search', 'notes.read', 'tasks.create'],
|
|
||||||
tools: ['notes.search', 'notes.read', 'tasks.create'],
|
|
||||||
retrieval_config: { top_k: 6, rerank: false, citation: false },
|
|
||||||
model_requirements: { capabilities: ['chat'] },
|
|
||||||
status: 'ready',
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -1,23 +1,14 @@
|
|||||||
import apiClient from './apiClient'
|
import apiClient from './apiClient'
|
||||||
import type { SystemStatus } from '@/contracts'
|
import type { SystemStatus } from '@/contracts'
|
||||||
|
|
||||||
export async function healthCheck(): Promise<{ status: string }> {
|
export function healthCheck(): Promise<{ status: string }> {
|
||||||
try {
|
return apiClient.get('/health')
|
||||||
return await apiClient.get<{ status: string }>('/health')
|
|
||||||
} catch {
|
|
||||||
return { status: 'unavailable' }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStatus(): Promise<SystemStatus> {
|
export function getStatus(): Promise<SystemStatus> {
|
||||||
try {
|
return apiClient.get('/api/status')
|
||||||
return await apiClient.get<SystemStatus>('/api/status')
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
status: 'ok',
|
|
||||||
name: 'notes-agent',
|
|
||||||
version: '0.1.0',
|
|
||||||
environment: import.meta.env.DEV ? 'development' : 'production',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPermissionPolicy(): Promise<Record<string, 'allow' | 'confirm' | 'deny'>> {
|
||||||
|
return apiClient.get('/api/permissions/policy')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import apiClient from './apiClient'
|
import apiClient from './apiClient'
|
||||||
import type { ApiTask, OperationResponse, PageMeta, TaskItem, TaskStatus, TaskPriority } from '@/contracts'
|
import type { ApiTask, OperationResponse, PageMeta, TaskItem, TaskStatus } from '@/contracts'
|
||||||
|
|
||||||
function toTask(task: ApiTask): TaskItem {
|
function toTask(task: ApiTask): TaskItem {
|
||||||
return {
|
return {
|
||||||
@@ -7,10 +7,8 @@ function toTask(task: ApiTask): TaskItem {
|
|||||||
title: task.title,
|
title: task.title,
|
||||||
description: task.description,
|
description: task.description,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
priority: 'medium',
|
|
||||||
due_date: task.due_at ?? undefined,
|
due_date: task.due_at ?? undefined,
|
||||||
note_id: task.note_id ?? undefined,
|
note_id: task.note_id ?? undefined,
|
||||||
source: 'user',
|
|
||||||
created_at: task.created_at,
|
created_at: task.created_at,
|
||||||
updated_at: task.updated_at,
|
updated_at: task.updated_at,
|
||||||
}
|
}
|
||||||
@@ -60,67 +58,3 @@ export async function updateTask(
|
|||||||
export async function deleteTask(taskId: string): Promise<OperationResponse> {
|
export async function deleteTask(taskId: string): Promise<OperationResponse> {
|
||||||
return apiClient.delete(`/api/tasks/${taskId}`)
|
return apiClient.delete(`/api/tasks/${taskId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mockTasks: TaskItem[] = [
|
|
||||||
{
|
|
||||||
task_id: 't-1',
|
|
||||||
title: '完成红黑树章节复习',
|
|
||||||
description: '整理插入、删除操作的所有情况,准备期末复习',
|
|
||||||
status: 'todo',
|
|
||||||
priority: 'high',
|
|
||||||
due_date: '2026-08-30T23:59:00Z',
|
|
||||||
note_id: 'n-rbt',
|
|
||||||
note_title: '红黑树',
|
|
||||||
source: 'user',
|
|
||||||
created_at: '2026-08-20T10:00:00Z',
|
|
||||||
updated_at: '2026-08-25T14:30:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
task_id: 't-2',
|
|
||||||
title: '理解死锁的银行家算法',
|
|
||||||
description: '推导银行家算法的安全性检查过程',
|
|
||||||
status: 'in_progress',
|
|
||||||
priority: 'medium',
|
|
||||||
note_id: 'n-deadlock',
|
|
||||||
note_title: '死锁',
|
|
||||||
source: 'agent',
|
|
||||||
created_at: '2026-08-22T09:00:00Z',
|
|
||||||
updated_at: '2026-08-24T16:00:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
task_id: 't-3',
|
|
||||||
title: 'TCP 三次握手与四次挥手',
|
|
||||||
description: '',
|
|
||||||
status: 'done',
|
|
||||||
priority: 'high',
|
|
||||||
note_id: 'n-tcp',
|
|
||||||
note_title: 'TCP_IP',
|
|
||||||
source: 'user',
|
|
||||||
created_at: '2026-08-15T08:00:00Z',
|
|
||||||
updated_at: '2026-08-18T20:00:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
task_id: 't-4',
|
|
||||||
title: 'HTTP 状态码整理',
|
|
||||||
description: '整理常见 HTTP 状态码及含义',
|
|
||||||
status: 'todo',
|
|
||||||
priority: 'low',
|
|
||||||
note_id: 'n-http',
|
|
||||||
note_title: 'HTTP协议',
|
|
||||||
source: 'note',
|
|
||||||
created_at: '2026-08-10T10:00:00Z',
|
|
||||||
updated_at: '2026-08-10T10:00:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
task_id: 't-5',
|
|
||||||
title: '链表操作实现练习',
|
|
||||||
description: '实现单链表和双向链表的基本操作',
|
|
||||||
status: 'todo',
|
|
||||||
priority: 'medium',
|
|
||||||
note_id: 'n-slist',
|
|
||||||
note_title: '单链表',
|
|
||||||
source: 'agent',
|
|
||||||
created_at: '2026-08-23T11:00:00Z',
|
|
||||||
updated_at: '2026-08-23T11:00:00Z',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { AgentRun, AgentEvent, ToolDefinition, PermissionRequest, ToolCall } from '@/contracts'
|
import type { AgentRun, AgentEvent, ToolDefinition, PermissionRequest, ToolCall } from '@/contracts'
|
||||||
import { mockAgentRuns, mockAgentEvents, mockTools, mockPermissionRequest } from '@/services/agentService'
|
|
||||||
import * as agentService from '@/services/agentService'
|
import * as agentService from '@/services/agentService'
|
||||||
import type { SseClient } from '@/services/sseClient'
|
import type { SseClient } from '@/services/sseClient'
|
||||||
|
|
||||||
export const useAgentStore = defineStore('agent', () => {
|
export const useAgentStore = defineStore('agent', () => {
|
||||||
const runs = ref<AgentRun[]>(mockAgentRuns)
|
const runs = ref<AgentRun[]>([])
|
||||||
const activeRunId = ref<string | null>('run-1')
|
const activeRunId = ref<string | null>(null)
|
||||||
const events = ref<AgentEvent[]>(mockAgentEvents.filter((e) => e.run_id === 'run-1'))
|
const events = ref<AgentEvent[]>([])
|
||||||
const tools = ref<ToolDefinition[]>(mockTools)
|
const tools = ref<ToolDefinition[]>([])
|
||||||
const isCreating = ref(false)
|
const isCreating = ref(false)
|
||||||
const isRunning = ref(false)
|
const isRunning = ref(false)
|
||||||
const permissionRequest = ref<PermissionRequest | null>(null)
|
const permissionRequest = ref<PermissionRequest | null>(null)
|
||||||
const toolCalls = ref<ToolCall[]>([])
|
const toolCalls = ref<ToolCall[]>([])
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
let eventStream: SseClient | null = null
|
let eventStream: SseClient | null = null
|
||||||
|
let selectionVersion = 0
|
||||||
|
|
||||||
const activeRun = computed(() =>
|
const activeRun = computed(() =>
|
||||||
runs.value.find((r) => r.run_id === activeRunId.value) || null
|
runs.value.find((r) => r.run_id === activeRunId.value) || null
|
||||||
@@ -40,9 +40,15 @@ export const useAgentStore = defineStore('agent', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadRun(runId: string) {
|
async function loadRun(runId: string) {
|
||||||
|
const version = ++selectionVersion
|
||||||
eventStream?.cancel()
|
eventStream?.cancel()
|
||||||
activeRunId.value = runId
|
activeRunId.value = runId
|
||||||
|
events.value = []
|
||||||
|
toolCalls.value = []
|
||||||
|
permissionRequest.value = null
|
||||||
|
isRunning.value = false
|
||||||
const run = await agentService.getAgentRun(runId)
|
const run = await agentService.getAgentRun(runId)
|
||||||
|
if (version !== selectionVersion) return
|
||||||
const existingIndex = runs.value.findIndex((item) => item.run_id === runId)
|
const existingIndex = runs.value.findIndex((item) => item.run_id === runId)
|
||||||
if (existingIndex >= 0) runs.value[existingIndex] = run
|
if (existingIndex >= 0) runs.value[existingIndex] = run
|
||||||
else runs.value.unshift(run)
|
else runs.value.unshift(run)
|
||||||
@@ -106,9 +112,9 @@ export const useAgentStore = defineStore('agent', () => {
|
|||||||
isRunning.value = true
|
isRunning.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
eventStream = agentService.streamAgentEvents(runId, {
|
eventStream = agentService.streamAgentEvents(runId, {
|
||||||
onEvent: processEvent,
|
onEvent(event) { if (activeRunId.value === runId) processEvent(event) },
|
||||||
onError(streamError) { error.value = streamError.message; isRunning.value = false },
|
onError(streamError) { if (activeRunId.value === runId) { error.value = streamError.message; isRunning.value = false } },
|
||||||
onDone() { isRunning.value = false; eventStream = null },
|
onDone() { if (activeRunId.value === runId) { isRunning.value = false; eventStream = null } },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +122,7 @@ export const useAgentStore = defineStore('agent', () => {
|
|||||||
isCreating.value = true
|
isCreating.value = true
|
||||||
try {
|
try {
|
||||||
const run = await agentService.createAgentRun(request)
|
const run = await agentService.createAgentRun(request)
|
||||||
|
selectionVersion++
|
||||||
runs.value.unshift(run)
|
runs.value.unshift(run)
|
||||||
activeRunId.value = run.run_id
|
activeRunId.value = run.run_id
|
||||||
events.value = []
|
events.value = []
|
||||||
@@ -143,10 +150,6 @@ export const useAgentStore = defineStore('agent', () => {
|
|||||||
permissionRequest.value = null
|
permissionRequest.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function showPermissionDemo() {
|
|
||||||
permissionRequest.value = mockPermissionRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
runs,
|
runs,
|
||||||
activeRunId,
|
activeRunId,
|
||||||
@@ -166,6 +169,5 @@ export const useAgentStore = defineStore('agent', () => {
|
|||||||
createRun,
|
createRun,
|
||||||
cancelRun,
|
cancelRun,
|
||||||
respondPermission,
|
respondPermission,
|
||||||
showPermissionDemo,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { beforeEach, expect, it, vi } from 'vitest'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { useChatStore } from './chat'
|
||||||
|
import { streamChat } from '@/services/chatService'
|
||||||
|
import type { SseClient } from '@/services/sseClient'
|
||||||
|
|
||||||
|
vi.mock('@/services/chatService', () => ({ streamChat: vi.fn() }))
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.mocked(streamChat).mockReset().mockReturnValue({ cancel: vi.fn() } as unknown as SseClient)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends real user history, applies streaming changes, and restores it when switching conversations', async () => {
|
||||||
|
const store = useChatStore()
|
||||||
|
store.selectedProviderId = 'real'
|
||||||
|
store.selectedModel = 'configured-model'
|
||||||
|
await store.sendMessage('user input')
|
||||||
|
const [request, handlers] = vi.mocked(streamChat).mock.calls[0]!
|
||||||
|
expect(request.messages).toEqual([{ role: 'user', content: 'user input' }])
|
||||||
|
handlers.onEvent?.({ event: 'TextDelta', sequence: 0, timestamp: '', data: { text: 'real response' } })
|
||||||
|
expect(store.messages[1]?.content).toBe('real response')
|
||||||
|
handlers.onDone?.()
|
||||||
|
const id = store.activeConversationId!
|
||||||
|
store.createNewConversation()
|
||||||
|
expect(store.messages).toEqual([])
|
||||||
|
await store.setActiveConversation(id)
|
||||||
|
expect(store.messages.map(m => m.content)).toEqual(['user input', 'real response'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not send without a provider and ignores late callbacks from a cancelled conversation', async () => {
|
||||||
|
const store = useChatStore()
|
||||||
|
await store.sendMessage('no provider')
|
||||||
|
expect(streamChat).not.toHaveBeenCalled()
|
||||||
|
store.selectedProviderId = 'real'
|
||||||
|
store.selectedModel = 'configured-model'
|
||||||
|
await store.sendMessage('first')
|
||||||
|
const old = vi.mocked(streamChat).mock.calls[0]![1]
|
||||||
|
store.createNewConversation()
|
||||||
|
await store.sendMessage('second')
|
||||||
|
old.onDone?.()
|
||||||
|
expect(store.isStreaming).toBe(true)
|
||||||
|
expect(store.messages[0]?.content).toBe('second')
|
||||||
|
})
|
||||||
@@ -1,22 +1,24 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, reactive } from 'vue'
|
||||||
import type { ChatMessage, Conversation } from '@/contracts'
|
import type { ChatMessage, Conversation } from '@/contracts'
|
||||||
import { mockConversations, mockMessages, streamChat } from '@/services/chatService'
|
import { streamChat } from '@/services/chatService'
|
||||||
import type { SseClient } from '@/services/sseClient'
|
import type { SseClient } from '@/services/sseClient'
|
||||||
|
|
||||||
export const useChatStore = defineStore('chat', () => {
|
export const useChatStore = defineStore('chat', () => {
|
||||||
const conversations = ref<Conversation[]>(mockConversations)
|
const conversations = ref<Conversation[]>([])
|
||||||
const activeConversationId = ref<string | null>('conv-1')
|
const activeConversationId = ref<string | null>(null)
|
||||||
const messages = ref<ChatMessage[]>(mockMessages['conv-1'] || [])
|
const messages = ref<ChatMessage[]>([])
|
||||||
const isStreaming = ref(false)
|
const isStreaming = ref(false)
|
||||||
const inputText = ref('')
|
const inputText = ref('')
|
||||||
const useRag = ref(true)
|
const useRag = ref(false)
|
||||||
const selectedSkillId = ref<string | null>(null)
|
const selectedSkillId = ref<string | null>(null)
|
||||||
const selectedProviderId = ref('mock')
|
const selectedProviderId = ref('')
|
||||||
const selectedModel = ref('mock-1')
|
const selectedModel = ref('')
|
||||||
let sseClient: SseClient | null = null
|
let sseClient: SseClient | null = null
|
||||||
|
let streamVersion = 0
|
||||||
|
|
||||||
// TODO(chat): 会话持久化接口完成后移除 mockConversations/mockMessages 数据源。
|
// User-created conversations live in this browser session; no fabricated history.
|
||||||
|
const history = reactive<Record<string, ChatMessage[]>>({})
|
||||||
|
|
||||||
const activeConversation = computed(() =>
|
const activeConversation = computed(() =>
|
||||||
conversations.value.find((c) => c.conversation_id === activeConversationId.value) || null
|
conversations.value.find((c) => c.conversation_id === activeConversationId.value) || null
|
||||||
@@ -27,13 +29,14 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
async function setActiveConversation(id: string) {
|
async function setActiveConversation(id: string) {
|
||||||
|
stopGeneration()
|
||||||
activeConversationId.value = id
|
activeConversationId.value = id
|
||||||
messages.value = mockMessages[id] || []
|
messages.value = history[id] ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMessage(text: string) {
|
async function sendMessage(text: string) {
|
||||||
if (!text.trim() || isStreaming.value) return
|
if (!text.trim() || isStreaming.value || !selectedProviderId.value || !selectedModel.value) return
|
||||||
const conversationId = activeConversationId.value || `conv-${Date.now()}`
|
const conversationId = activeConversationId.value || crypto.randomUUID()
|
||||||
|
|
||||||
if (!activeConversationId.value) {
|
if (!activeConversationId.value) {
|
||||||
const newConv: Conversation = {
|
const newConv: Conversation = {
|
||||||
@@ -47,8 +50,10 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
activeConversationId.value = conversationId
|
activeConversationId.value = conversationId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
history[conversationId] = messages.value
|
||||||
|
const conversationMessages = messages.value
|
||||||
const userMsg: ChatMessage = {
|
const userMsg: ChatMessage = {
|
||||||
message_id: `msg-${Date.now()}`,
|
message_id: crypto.randomUUID(),
|
||||||
conversation_id: conversationId,
|
conversation_id: conversationId,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: text,
|
content: text,
|
||||||
@@ -57,29 +62,34 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
messages.value.push(userMsg)
|
messages.value.push(userMsg)
|
||||||
inputText.value = ''
|
inputText.value = ''
|
||||||
isStreaming.value = true
|
isStreaming.value = true
|
||||||
|
const conversation = conversations.value.find(c => c.conversation_id === conversationId)
|
||||||
|
if (conversation) { conversation.updated_at = new Date().toISOString(); conversation.message_count = messages.value.length }
|
||||||
|
|
||||||
// 先插入占位消息,随后将 SSE 增量原位合并,避免每个 token 重建消息列表。
|
// 先插入占位消息,随后将 SSE 增量原位合并,避免每个 token 重建消息列表。
|
||||||
const aiMsg: ChatMessage = {
|
const aiMsg = reactive<ChatMessage>({
|
||||||
message_id: `msg-${Date.now() + 1}`,
|
message_id: crypto.randomUUID(),
|
||||||
conversation_id: conversationId,
|
conversation_id: conversationId,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: '',
|
content: '',
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
citations: [],
|
citations: [],
|
||||||
tool_calls: [],
|
tool_calls: [],
|
||||||
}
|
})
|
||||||
messages.value.push(aiMsg)
|
messages.value.push(aiMsg)
|
||||||
|
|
||||||
|
const version = ++streamVersion
|
||||||
|
const argumentBuffers = new Map<string, string>()
|
||||||
sseClient = streamChat({
|
sseClient = streamChat({
|
||||||
provider_id: selectedProviderId.value,
|
provider_id: selectedProviderId.value,
|
||||||
model: selectedModel.value,
|
model: selectedModel.value,
|
||||||
conversation_id: conversationId,
|
conversation_id: conversationId,
|
||||||
use_rag: useRag.value,
|
use_rag: useRag.value,
|
||||||
messages: messages.value
|
messages: messages.value
|
||||||
.filter((message) => message !== aiMsg)
|
.filter((message) => message.message_id !== aiMsg.message_id)
|
||||||
.map((message) => ({ role: message.role, content: message.content })),
|
.map((message) => ({ role: message.role, content: message.content })),
|
||||||
}, {
|
}, {
|
||||||
onEvent(event) {
|
onEvent(event) {
|
||||||
|
if (version !== streamVersion) return
|
||||||
if (event.event === 'TextDelta') aiMsg.content += String(event.data.text ?? '')
|
if (event.event === 'TextDelta') aiMsg.content += String(event.data.text ?? '')
|
||||||
if (event.event === 'ThinkingDelta') aiMsg.thinking = `${aiMsg.thinking ?? ''}${String(event.data.text ?? '')}`
|
if (event.event === 'ThinkingDelta') aiMsg.thinking = `${aiMsg.thinking ?? ''}${String(event.data.text ?? '')}`
|
||||||
if (event.event === 'ToolCallStart') {
|
if (event.event === 'ToolCallStart') {
|
||||||
@@ -92,6 +102,11 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
if (event.event === 'ToolCallDelta') {
|
if (event.event === 'ToolCallDelta') {
|
||||||
const call = aiMsg.tool_calls?.find((item) => item.tool_call_id === event.data.tool_call_id)
|
const call = aiMsg.tool_calls?.find((item) => item.tool_call_id === event.data.tool_call_id)
|
||||||
|
if (call && typeof event.data.arguments_delta === 'string') {
|
||||||
|
const buffer = (argumentBuffers.get(call.tool_call_id) ?? '') + event.data.arguments_delta
|
||||||
|
argumentBuffers.set(call.tool_call_id, buffer)
|
||||||
|
try { call.parameters = JSON.parse(buffer) } catch { /* incomplete JSON fragment */ }
|
||||||
|
}
|
||||||
if (call && event.data.arguments && typeof event.data.arguments === 'object') {
|
if (call && event.data.arguments && typeof event.data.arguments === 'object') {
|
||||||
Object.assign(call.parameters, event.data.arguments)
|
Object.assign(call.parameters, event.data.arguments)
|
||||||
}
|
}
|
||||||
@@ -116,14 +131,16 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
if (event.event === 'Error') aiMsg.content += `\n\n生成失败:${String(event.data.message ?? '未知错误')}`
|
if (event.event === 'Error') aiMsg.content += `\n\n生成失败:${String(event.data.message ?? '未知错误')}`
|
||||||
},
|
},
|
||||||
onError(error) {
|
onError(error) {
|
||||||
|
if (version !== streamVersion) return
|
||||||
aiMsg.content += `\n\n连接失败:${error.message}`
|
aiMsg.content += `\n\n连接失败:${error.message}`
|
||||||
isStreaming.value = false
|
isStreaming.value = false
|
||||||
sseClient = null
|
sseClient = null
|
||||||
},
|
},
|
||||||
onDone() {
|
onDone() {
|
||||||
|
if (version !== streamVersion) return
|
||||||
const conversation = conversations.value.find((item) => item.conversation_id === conversationId)
|
const conversation = conversations.value.find((item) => item.conversation_id === conversationId)
|
||||||
if (conversation) {
|
if (conversation) {
|
||||||
conversation.message_count = messages.value.length
|
conversation.message_count = conversationMessages.length
|
||||||
conversation.updated_at = new Date().toISOString()
|
conversation.updated_at = new Date().toISOString()
|
||||||
}
|
}
|
||||||
isStreaming.value = false
|
isStreaming.value = false
|
||||||
@@ -133,6 +150,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stopGeneration() {
|
function stopGeneration() {
|
||||||
|
streamVersion++
|
||||||
if (sseClient) {
|
if (sseClient) {
|
||||||
sseClient.cancel()
|
sseClient.cancel()
|
||||||
sseClient = null
|
sseClient = null
|
||||||
@@ -141,8 +159,9 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createNewConversation() {
|
function createNewConversation() {
|
||||||
|
stopGeneration()
|
||||||
const newConv: Conversation = {
|
const newConv: Conversation = {
|
||||||
conversation_id: `conv-${Date.now()}`,
|
conversation_id: crypto.randomUUID(),
|
||||||
title: '新对话',
|
title: '新对话',
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
@@ -150,16 +169,19 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
conversations.value.unshift(newConv)
|
conversations.value.unshift(newConv)
|
||||||
activeConversationId.value = newConv.conversation_id
|
activeConversationId.value = newConv.conversation_id
|
||||||
messages.value = []
|
history[newConv.conversation_id] = []
|
||||||
|
messages.value = history[newConv.conversation_id]
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteConversation(id: string) {
|
function deleteConversation(id: string) {
|
||||||
|
if (activeConversationId.value === id) stopGeneration()
|
||||||
|
delete history[id]
|
||||||
const idx = conversations.value.findIndex((c) => c.conversation_id === id)
|
const idx = conversations.value.findIndex((c) => c.conversation_id === id)
|
||||||
if (idx > -1) {
|
if (idx > -1) {
|
||||||
conversations.value.splice(idx, 1)
|
conversations.value.splice(idx, 1)
|
||||||
if (activeConversationId.value === id) {
|
if (activeConversationId.value === id) {
|
||||||
activeConversationId.value = conversations.value[0]?.conversation_id || null
|
activeConversationId.value = conversations.value[0]?.conversation_id || null
|
||||||
messages.value = conversations.value[0] ? mockMessages[conversations.value[0].conversation_id] || [] : []
|
messages.value = conversations.value[0] ? history[conversations.value[0].conversation_id] || [] : []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { useAgentStore } from './agent'
|
||||||
|
import { useChatStore } from './chat'
|
||||||
|
import { useTaskStore } from './task'
|
||||||
|
import { usePluginStore } from './plugin'
|
||||||
|
import { useSkillStore } from './skill'
|
||||||
|
import { useProviderStore } from './provider'
|
||||||
|
import { useSettingsStore } from './settings'
|
||||||
|
import { listProviders } from '@/services/providerService'
|
||||||
|
import { getStatus } from '@/services/systemService'
|
||||||
|
|
||||||
|
beforeEach(() => { setActivePinia(createPinia()); localStorage.clear() })
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
|
describe('runtime data sources', () => {
|
||||||
|
it('starts with no fabricated domain records or healthy diagnostics', () => {
|
||||||
|
expect(useAgentStore().runs).toEqual([])
|
||||||
|
expect(useAgentStore().events).toEqual([])
|
||||||
|
expect(useAgentStore().tools).toEqual([])
|
||||||
|
expect(useAgentStore().permissionRequest).toBeNull()
|
||||||
|
expect(useChatStore().conversations).toEqual([])
|
||||||
|
expect(useChatStore().messages).toEqual([])
|
||||||
|
expect(useTaskStore().tasks).toEqual([])
|
||||||
|
expect(usePluginStore().plugins).toEqual([])
|
||||||
|
expect(useSkillStore().skills).toEqual([])
|
||||||
|
expect(useProviderStore().providers).toEqual([])
|
||||||
|
expect(useProviderStore().defaultProviderId).toBe('')
|
||||||
|
expect(useSettingsStore().aiCoreStatus).toBe('unknown')
|
||||||
|
expect(useSettingsStore().indexStatus.total_notes).toBeNull()
|
||||||
|
expect(useSettingsStore().permissionPolicy).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps initial collections empty and exposes errors when the API is offline', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')))
|
||||||
|
const stores = [useTaskStore(), usePluginStore(), useSkillStore(), useProviderStore()] as const
|
||||||
|
await Promise.all([stores[0].loadTasks(), stores[1].loadPlugins(), stores[2].loadSkills(), stores[3].loadProviders()])
|
||||||
|
expect(stores.every(store => store.error)).toBe(true)
|
||||||
|
await useSettingsStore().loadDiagnostics()
|
||||||
|
expect(useSettingsStore().aiCoreStatus).toBe('error')
|
||||||
|
expect(useSettingsStore().indexStatus.total_blocks).toBeNull()
|
||||||
|
expect(useSettingsStore().diagnosticsError).toBeTruthy()
|
||||||
|
await expect(getStatus()).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders backend counts and effective permissions and excludes the test provider', async () => {
|
||||||
|
const data: Record<string, unknown> = {
|
||||||
|
'/health': { status: 'ok' }, '/api/status': { version: '9.2.1' },
|
||||||
|
'/api/index/status': { status: 'idle', pending_jobs: 0, total_notes: 7, total_blocks: 19 },
|
||||||
|
'/api/permissions/policy': { 'attachments.read': 'allow' },
|
||||||
|
'/api/providers': { items: [
|
||||||
|
{ provider_id: 'mock', provider_type: 'mock', capabilities: [] },
|
||||||
|
{ provider_id: 'real', name: 'Real', provider_type: 'ollama', capabilities: [], enabled: true, default_model: 'installed-model' },
|
||||||
|
] },
|
||||||
|
}
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async (url: string) => new Response(JSON.stringify(data[url]), { status: 200, headers: { "content-type": "application/json" } })))
|
||||||
|
expect((await listProviders()).map(p => p.provider_id)).toEqual(['real'])
|
||||||
|
await useProviderStore().loadProviders()
|
||||||
|
expect(useProviderStore().defaultProviderId).toBe('real')
|
||||||
|
await useSettingsStore().loadDiagnostics()
|
||||||
|
expect(useSettingsStore().indexStatus.total_notes).toBe(7)
|
||||||
|
expect(useSettingsStore().indexStatus.total_blocks).toBe(19)
|
||||||
|
expect(useSettingsStore().aiCoreVersion).toBe('9.2.1')
|
||||||
|
expect(useSettingsStore().permissionPolicy).toEqual({ 'attachments.read': 'allow' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,7 +4,7 @@ import type { Plugin } from '@/contracts'
|
|||||||
import * as pluginService from '@/services/pluginService'
|
import * as pluginService from '@/services/pluginService'
|
||||||
|
|
||||||
export const usePluginStore = defineStore('plugin', () => {
|
export const usePluginStore = defineStore('plugin', () => {
|
||||||
const plugins = ref<Plugin[]>(pluginService.mockPlugins)
|
const plugins = ref<Plugin[]>([])
|
||||||
const selectedPluginId = ref<string | null>(null)
|
const selectedPluginId = ref<string | null>(null)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { createPinia, setActivePinia } from 'pinia'
|
|||||||
import type { ProviderConfig, ProviderPreset } from '@/contracts'
|
import type { ProviderConfig, ProviderPreset } from '@/contracts'
|
||||||
|
|
||||||
vi.mock('@/services/providerService', () => ({
|
vi.mock('@/services/providerService', () => ({
|
||||||
mockProviders: [],
|
|
||||||
mockModels: {},
|
|
||||||
listProviders: vi.fn(),
|
listProviders: vi.fn(),
|
||||||
listProviderPresets: vi.fn(),
|
listProviderPresets: vi.fn(),
|
||||||
getCredentialStatus: vi.fn(),
|
getCredentialStatus: vi.fn(),
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { ProviderConfig, ModelInfo, ProviderPreset } from '@/contracts'
|
import type { ProviderConfig, ModelInfo, ProviderPreset } from '@/contracts'
|
||||||
import { createProvider, deleteProvider as deleteProviderRequest, getCredentialStatus, listModels, listProviderPresets, listProviders, mockProviders, mockModels, putCredential, testProvider as testProviderRequest, updateProvider as updateProviderRequest } from '@/services/providerService'
|
import { createProvider, deleteProvider as deleteProviderRequest, getCredentialStatus, listModels, listProviderPresets, listProviders, putCredential, testProvider as testProviderRequest, updateProvider as updateProviderRequest } from '@/services/providerService'
|
||||||
import { ApiErrorClass } from '@/services/apiClient'
|
import { ApiErrorClass } from '@/services/apiClient'
|
||||||
|
|
||||||
export const useProviderStore = defineStore('provider', () => {
|
export const useProviderStore = defineStore('provider', () => {
|
||||||
const providers = ref<ProviderConfig[]>(mockProviders)
|
const providers = ref<ProviderConfig[]>([])
|
||||||
const presets = ref<ProviderPreset[]>([])
|
const presets = ref<ProviderPreset[]>([])
|
||||||
const modelsByProvider = ref<Record<string, ModelInfo[]>>(mockModels)
|
const modelsByProvider = ref<Record<string, ModelInfo[]>>({})
|
||||||
const modelLoadingByProvider = ref<Record<string, boolean>>({})
|
const modelLoadingByProvider = ref<Record<string, boolean>>({})
|
||||||
const modelErrorsByProvider = ref<Record<string, string>>({})
|
const modelErrorsByProvider = ref<Record<string, string>>({})
|
||||||
const credentialConfiguredById = ref<Record<string, boolean>>({})
|
const credentialConfiguredById = ref<Record<string, boolean>>({})
|
||||||
const defaultProviderId = ref('mock')
|
const defaultProviderId = ref('')
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
@@ -24,6 +24,9 @@ export const useProviderStore = defineStore('provider', () => {
|
|||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
providers.value = await listProviders()
|
providers.value = await listProviders()
|
||||||
|
if (!enabledProviders.value.some(p => p.provider_id === defaultProviderId.value)) {
|
||||||
|
defaultProviderId.value = enabledProviders.value[0]?.provider_id ?? ''
|
||||||
|
}
|
||||||
error.value = null
|
error.value = null
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
error.value = reason instanceof Error ? reason.message : 'Provider 加载失败'
|
error.value = reason instanceof Error ? reason.message : 'Provider 加载失败'
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import type { AiCoreStatus, IndexStatus } from '@/contracts'
|
import type { AiCoreStatus, IndexStatus } from '@/contracts'
|
||||||
import { mockIndexStatus } from '@/services/indexService'
|
import { resolveApiUrl } from '@/services/apiClient'
|
||||||
|
import packageInfo from '../../package.json'
|
||||||
import * as indexService from '@/services/indexService'
|
import * as indexService from '@/services/indexService'
|
||||||
import * as systemService from '@/services/systemService'
|
import * as systemService from '@/services/systemService'
|
||||||
|
|
||||||
@@ -14,8 +15,8 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
const restoreLastVault = ref(saved.restoreLastVault !== false)
|
const restoreLastVault = ref(saved.restoreLastVault !== false)
|
||||||
const autoSaveInterval = ref(typeof saved.autoSaveInterval === 'number' ? saved.autoSaveInterval : 1500)
|
const autoSaveInterval = ref(typeof saved.autoSaveInterval === 'number' ? saved.autoSaveInterval : 1500)
|
||||||
const language = ref<'zh-CN' | 'en'>(saved.language === 'en' ? 'en' : 'zh-CN')
|
const language = ref<'zh-CN' | 'en'>(saved.language === 'en' ? 'en' : 'zh-CN')
|
||||||
const appVersion = ref('0.1.0')
|
const appVersion = ref(packageInfo.version)
|
||||||
const aiCoreVersion = ref('0.1.0')
|
const aiCoreVersion = ref('未获取')
|
||||||
|
|
||||||
// Editor
|
// Editor
|
||||||
const defaultEditorMode = ref<'wysiwyg' | 'source'>(saved.defaultEditorMode === 'source' ? 'source' : 'wysiwyg')
|
const defaultEditorMode = ref<'wysiwyg' | 'source'>(saved.defaultEditorMode === 'source' ? 'source' : 'wysiwyg')
|
||||||
@@ -23,24 +24,15 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
const spellCheck = ref(saved.spellCheck === true)
|
const spellCheck = ref(saved.spellCheck === true)
|
||||||
|
|
||||||
// AI Core
|
// AI Core
|
||||||
const aiCoreStatus = ref<AiCoreStatus>('running')
|
const aiCoreStatus = ref<AiCoreStatus>('unknown')
|
||||||
const aiCoreAddress = ref('http://127.0.0.1:8000')
|
const aiCoreAddress = ref(resolveApiUrl('/api') || '/api')
|
||||||
|
|
||||||
// Index
|
// Index
|
||||||
const indexStatus = ref<IndexStatus>(mockIndexStatus)
|
const emptyIndex = (): IndexStatus => ({ status: 'unknown', pending_jobs: 0, total_notes: null, total_blocks: null })
|
||||||
|
const indexStatus = ref<IndexStatus>(emptyIndex())
|
||||||
|
|
||||||
// Permissions
|
// Permissions
|
||||||
const permissionPolicy = ref<Record<string, 'allow' | 'confirm' | 'deny'>>({
|
const permissionPolicy = ref<Record<string, 'allow' | 'confirm' | 'deny'>>({})
|
||||||
'notes.read': 'allow',
|
|
||||||
'notes.search': 'allow',
|
|
||||||
'notes.write': 'confirm',
|
|
||||||
'notes.delete': 'confirm',
|
|
||||||
'tasks.read': 'allow',
|
|
||||||
'tasks.write': 'confirm',
|
|
||||||
'attachments.read': 'confirm',
|
|
||||||
'network.request': 'confirm',
|
|
||||||
'secrets.use': 'confirm',
|
|
||||||
})
|
|
||||||
const diagnosticsError = ref<string | null>(null)
|
const diagnosticsError = ref<string | null>(null)
|
||||||
|
|
||||||
watch(() => ({
|
watch(() => ({
|
||||||
@@ -50,18 +42,15 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
}), (value) => localStorage.setItem('app-settings', JSON.stringify(value)), { deep: true })
|
}), (value) => localStorage.setItem('app-settings', JSON.stringify(value)), { deep: true })
|
||||||
|
|
||||||
async function loadDiagnostics() {
|
async function loadDiagnostics() {
|
||||||
try {
|
const results = await Promise.allSettled([
|
||||||
const [health, status, index] = await Promise.all([
|
systemService.healthCheck(), systemService.getStatus(), indexService.getIndexStatus(), systemService.getPermissionPolicy(),
|
||||||
systemService.healthCheck(), systemService.getStatus(), indexService.getIndexStatus(),
|
|
||||||
])
|
])
|
||||||
aiCoreStatus.value = health.status === 'ok' ? 'running' : 'error'
|
const [health, status, index, policy] = results
|
||||||
aiCoreVersion.value = status.version
|
aiCoreStatus.value = health.status === 'fulfilled' && health.value.status === 'ok' ? 'running' : 'error'
|
||||||
indexStatus.value = index
|
aiCoreVersion.value = status.status === 'fulfilled' ? status.value.version : '未获取'
|
||||||
diagnosticsError.value = null
|
indexStatus.value = index.status === 'fulfilled' ? index.value : emptyIndex()
|
||||||
} catch (reason) {
|
permissionPolicy.value = policy.status === 'fulfilled' ? policy.value : {}
|
||||||
aiCoreStatus.value = 'error'
|
diagnosticsError.value = results.filter(item => item.status === 'rejected').map(item => item.reason instanceof Error ? item.reason.message : '后端请求失败').join(';') || null
|
||||||
diagnosticsError.value = reason instanceof Error ? reason.message : '诊断信息加载失败'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAutoSaveInterval(ms: number) {
|
function setAutoSaveInterval(ms: number) {
|
||||||
@@ -72,21 +61,6 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
defaultEditorMode.value = mode
|
defaultEditorMode.value = mode
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPermission(permission: string, policy: 'allow' | 'confirm' | 'deny') {
|
|
||||||
permissionPolicy.value[permission] = policy
|
|
||||||
}
|
|
||||||
|
|
||||||
function setAiCoreStatus(status: AiCoreStatus) {
|
|
||||||
aiCoreStatus.value = status
|
|
||||||
}
|
|
||||||
|
|
||||||
async function restartAiCore(): Promise<boolean> {
|
|
||||||
aiCoreStatus.value = 'starting'
|
|
||||||
await new Promise((r) => setTimeout(r, 1500))
|
|
||||||
aiCoreStatus.value = 'running'
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full') {
|
async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full') {
|
||||||
indexStatus.value.status = 'indexing'
|
indexStatus.value.status = 'indexing'
|
||||||
try {
|
try {
|
||||||
@@ -115,9 +89,6 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
loadDiagnostics,
|
loadDiagnostics,
|
||||||
setAutoSaveInterval,
|
setAutoSaveInterval,
|
||||||
setDefaultEditorMode,
|
setDefaultEditorMode,
|
||||||
setPermission,
|
|
||||||
setAiCoreStatus,
|
|
||||||
restartAiCore,
|
|
||||||
rebuildIndex,
|
rebuildIndex,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { Skill } from '@/contracts'
|
|||||||
import * as skillService from '@/services/skillService'
|
import * as skillService from '@/services/skillService'
|
||||||
|
|
||||||
export const useSkillStore = defineStore('skill', () => {
|
export const useSkillStore = defineStore('skill', () => {
|
||||||
const skills = ref<Skill[]>(skillService.mockSkills)
|
const skills = ref<Skill[]>([])
|
||||||
const selectedSkillId = ref<string | null>(null)
|
const selectedSkillId = ref<string | null>(null)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { TaskItem, TaskStatus, TaskPriority, TaskSource } from '@/contracts'
|
import type { TaskItem, TaskStatus, TaskPriority, TaskSource } from '@/contracts'
|
||||||
import { createTask as createTaskRequest, deleteTask as deleteTaskRequest, listTasks, mockTasks, updateTask as updateTaskRequest } from '@/services/taskService'
|
import { createTask as createTaskRequest, deleteTask as deleteTaskRequest, listTasks, updateTask as updateTaskRequest } from '@/services/taskService'
|
||||||
|
|
||||||
export const useTaskStore = defineStore('task', () => {
|
export const useTaskStore = defineStore('task', () => {
|
||||||
const tasks = ref<TaskItem[]>(mockTasks)
|
const tasks = ref<TaskItem[]>([])
|
||||||
const filterStatus = ref<TaskStatus | 'all'>('all')
|
const filterStatus = ref<TaskStatus | 'all'>('all')
|
||||||
const filterPriority = ref<TaskPriority | 'all'>('all')
|
const filterPriority = ref<TaskPriority | 'all'>('all')
|
||||||
const filterSource = ref<TaskSource | 'all'>('all')
|
const filterSource = ref<TaskSource | 'all'>('all')
|
||||||
@@ -47,7 +47,7 @@ export const useTaskStore = defineStore('task', () => {
|
|||||||
const task = tasks.value.find((t) => t.task_id === taskId)
|
const task = tasks.value.find((t) => t.task_id === taskId)
|
||||||
if (task) {
|
if (task) {
|
||||||
const updated = await updateTaskRequest(taskId, data)
|
const updated = await updateTaskRequest(taskId, data)
|
||||||
Object.assign(task, updated, data)
|
Object.assign(task, updated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||