diff --git a/README.md b/README.md index 692fc47..21fcce9 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,8 @@ API=http://127.0.0.1:1934/memory-system | `POST` | `/sessions/{session_id}/extract` | 立即触发 OpenViking extract | 需要 | | `GET/POST` | `/sessions/{session_id}/context` | 查询 OpenViking 会话上下文,并用同一 query 搜索 EverOS 记忆 | 需要 | | `GET/POST` | `/openviking/tasks/{task_id}` | 查询 OpenViking 后台任务状态 | 需要 | +| `POST` | `/resources` | 上传本地文件或远程 URL 到 OpenViking resources | 需要 | +| `DELETE` | `/resources` | 删除 OpenViking resource URI | 需要 | | `POST` | `/search` | 同时搜索 OpenViking 和 EverOS 记忆 | 需要 | | `GET/POST` | `/users/{user_id}/profile` | 查询 EverOS profile,并补充 OpenViking 用户记忆搜索结果 | 需要 | @@ -355,6 +357,90 @@ curl -sS -X POST "$API/openviking/tasks/${TASK_ID}" \ }' ``` +### `POST /resources` + +上传文件资源到 OpenViking。网关只调用 OpenViking,不写 EverOS。 + +如果 `path` 是本地路径,文件必须能被 Memory System API 服务进程读取。网关会先调用 OpenViking `/api/v1/resources/temp_upload` 上传临时文件,取返回的 `temp_file_id`,再调用 `/api/v1/resources` 添加资源。 + +如果 `path` 是 `http://` 或 `https://` URL,网关会直接调用 OpenViking `/api/v1/resources`,并把 URL 作为 `path` 传给 OpenViking。 + +请求体: + +| 参数 | 类型 | 必需 | 说明 | +|---|---|---:|---| +| `user_id` | string | 是 | 用户 ID | +| `user_key` | string | 是 | `/users` 返回的 user key | +| `path` | string | 是 | 本地文件路径,或 `http://` / `https://` URL | +| `to` | string | 是 | 目标 OpenViking resource URI | +| `reason` | string/null | 否 | 上传原因,透传给 OpenViking | +| `wait` | bool | 否 | 是否等待处理完成,默认 `true` | +| `directly_upload_media` | bool | 否 | 是否直接上传媒体,默认 `true` | + +本地文件示例: + +```bash +curl -sS -X POST "$API/resources" \ + -H "Content-Type: application/json" \ + -d '{ + "user_id": "userA", + "user_key": "'"$USER_KEY"'", + "path": "/home/tom/memory-gateway/tests/大语言模型应用.pdf", + "to": "viking://resources/userA/files/大语言模型应用.pdf", + "reason": "userA 上传的文件", + "wait": true, + "directly_upload_media": true + }' +``` + +远程 URL 示例: + +```bash +curl -sS -X POST "$API/resources" \ + -H "Content-Type: application/json" \ + -d '{ + "user_id": "userA", + "user_key": "'"$USER_KEY"'", + "path": "https://example.com/images/photo.png", + "to": "viking://resources/userA/images/photo.png", + "reason": "userA 上传的远程图片", + "wait": true, + "directly_upload_media": true + }' +``` + +返回中的 `resource` 是 OpenViking `/api/v1/resources` 的原始响应,`backends.openviking` 保留调用状态和错误信息。 + +### `DELETE /resources` + +删除 OpenViking resource URI。网关会调用 OpenViking: + +```http +DELETE /api/v1/fs?uri=&recursive= +X-API-Key: +``` + +Query 参数: + +| 参数 | 类型 | 必需 | 说明 | +|---|---|---:|---| +| `user_id` | string | 是 | 用户 ID | +| `user_key` | string | 是 | `/users` 返回的 user key | +| `uri` | string | 是 | 要删除的 OpenViking URI | +| `recursive` | bool | 否 | 是否递归删除,默认 `true` | + +示例: + +```bash +curl -sS -X DELETE -G "$API/resources" \ + --data-urlencode "user_id=userA" \ + --data-urlencode "user_key=$USER_KEY" \ + --data-urlencode "uri=viking://resources/userA/files/大语言模型应用.pdf" \ + --data-urlencode "recursive=true" +``` + +返回中的 `resource` 是 OpenViking `/api/v1/fs` 删除接口的原始响应。 + ### `POST /search` 同时查询 OpenViking 和 EverOS,并合并结果。 diff --git a/memory_system_api/api.py b/memory_system_api/api.py index 68021b7..aaa5188 100644 --- a/memory_system_api/api.py +++ b/memory_system_api/api.py @@ -7,6 +7,7 @@ from .auth import verify_api_key from .schemas import ( MessageIngestRequest, ProfileRequest, + ResourceUploadRequest, SearchRequest, SessionContextRequest, SessionUserRequest, @@ -54,6 +55,31 @@ async def ingest_messages( raise user_auth_error(exc) from exc +@router.post("/resources") +async def upload_resource( + request: ResourceUploadRequest, + service: MemorySystemService = Depends(get_service), +): + try: + return await service.upload_resource(request) + except PermissionError as exc: + raise user_auth_error(exc) from exc + + +@router.delete("/resources") +async def delete_resource( + user_id: str = Query(min_length=1), + user_key: str = Query(min_length=1), + uri: str = Query(min_length=1), + recursive: bool = Query(default=True), + service: MemorySystemService = Depends(get_service), +): + try: + return await service.delete_resource(user_id, user_key, uri, recursive=recursive) + except PermissionError as exc: + raise user_auth_error(exc) from exc + + @router.post("/sessions/{session_id}/commit") async def commit_session( session_id: str, diff --git a/memory_system_api/clients.py b/memory_system_api/clients.py index 00d2a7d..de66a36 100644 --- a/memory_system_api/clients.py +++ b/memory_system_api/clients.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timezone +from pathlib import Path from typing import Any import httpx @@ -136,6 +137,59 @@ class OpenVikingMemorySystemClient: response.raise_for_status() return response.json() + async def upload_temp_file(self, credential: OpenVikingCredential | str, path: str | Path) -> dict[str, Any]: + file_path = Path(path) + async with self._credential_client(credential, json_content_type=False) as client: + with file_path.open("rb") as file_obj: + response = await client.post( + "/api/v1/resources/temp_upload", + files={"file": (file_path.name, file_obj)}, + ) + response.raise_for_status() + return response.json() + + async def add_resource( + self, + credential: OpenVikingCredential | str, + *, + to: str, + reason: str | None = None, + wait: bool = True, + directly_upload_media: bool = True, + path: str | None = None, + temp_file_id: str | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "to": to, + "wait": wait, + "directly_upload_media": directly_upload_media, + } + if reason is not None: + payload["reason"] = reason + if temp_file_id is not None: + payload["temp_file_id"] = temp_file_id + else: + payload["path"] = path + + async with self._credential_client(credential) as client: + response = await client.post("/api/v1/resources", json=payload) + response.raise_for_status() + return response.json() + + async def delete_resource( + self, + credential: OpenVikingCredential | str, + uri: str, + recursive: bool = True, + ) -> dict[str, Any]: + async with self._credential_client(credential) as client: + response = await client.delete( + "/api/v1/fs", + params={"uri": uri, "recursive": str(recursive).lower()}, + ) + response.raise_for_status() + return response.json() + async def find(self, credential: OpenVikingCredential | str, query: str, limit: int) -> dict[str, Any]: user_id = credential.user_id if isinstance(credential, OpenVikingCredential) else None target_uri = f"viking://user/{user_id}/memories/" if user_id else "viking://user/memories/" @@ -198,11 +252,15 @@ class OpenVikingMemorySystemClient: response.raise_for_status() return response.json() - def _credential_client(self, credential: OpenVikingCredential | str) -> httpx.AsyncClient: + def _credential_client( + self, + credential: OpenVikingCredential | str, + json_content_type: bool = True, + ) -> httpx.AsyncClient: if isinstance(credential, str): - return self._client(credential) + return self._client(credential, json_content_type=json_content_type) if credential.user_key_auth: - return self._client(credential.api_key) + return self._client(credential.api_key, json_content_type=json_content_type) headers = {} if credential.account_id: headers["X-OpenViking-Account"] = credential.account_id @@ -210,10 +268,17 @@ class OpenVikingMemorySystemClient: headers["X-OpenViking-User"] = credential.user_id if credential.agent_id: headers["X-OpenViking-Agent"] = credential.agent_id - return self._client(credential.api_key, headers) + return self._client(credential.api_key, headers, json_content_type=json_content_type) - def _client(self, api_key: str, extra_headers: dict[str, str] | None = None) -> httpx.AsyncClient: - headers = {"Content-Type": "application/json", "X-API-Key": api_key} + def _client( + self, + api_key: str, + extra_headers: dict[str, str] | None = None, + json_content_type: bool = True, + ) -> httpx.AsyncClient: + headers = {"X-API-Key": api_key} + if json_content_type: + headers["Content-Type"] = "application/json" if extra_headers: headers.update(extra_headers) return httpx.AsyncClient( diff --git a/memory_system_api/schemas.py b/memory_system_api/schemas.py index c04dc64..308b005 100644 --- a/memory_system_api/schemas.py +++ b/memory_system_api/schemas.py @@ -54,6 +54,16 @@ class ProfileRequest(BaseModel): level: int = Field(default=2, ge=0) +class ResourceUploadRequest(BaseModel): + user_id: str = Field(min_length=1) + user_key: str = Field(min_length=1) + path: str = Field(min_length=1) + to: str = Field(min_length=1) + reason: str | None = None + wait: bool = True + directly_upload_media: bool = True + + class BackendStatus(BaseModel): status: OperationStatus result: Any = None @@ -104,3 +114,9 @@ class ProfileResponse(BaseModel): profile: Any = None items: list[dict[str, Any]] = Field(default_factory=list) backends: dict[str, BackendStatus] + + +class ResourceMutationResponse(BaseModel): + status: OperationStatus + resource: Any = None + backends: dict[str, BackendStatus] diff --git a/memory_system_api/service.py b/memory_system_api/service.py index 41fad29..b0b1aff 100644 --- a/memory_system_api/service.py +++ b/memory_system_api/service.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from typing import Any, Awaitable, Callable +from urllib.parse import urlparse from .clients import EverOSMemorySystemClient, OpenVikingMemorySystemClient from .schemas import ( @@ -13,6 +14,8 @@ from .schemas import ( MessageIngestRequest, MessageIngestResponse, ProfileResponse, + ResourceMutationResponse, + ResourceUploadRequest, SearchRequest, SearchResponse, SessionContextRequest, @@ -30,6 +33,49 @@ class MemorySystemService: account = backends["openviking"].result if backends["openviking"].status == "success" else None return AccountResponse(status=self._aggregate_status(backends), account=account, backends=backends) + async def upload_resource(self, request: ResourceUploadRequest) -> ResourceMutationResponse: + credential = self.openviking.credential_for_user(request.user_id, request.user_key) + + async def upload_openviking() -> dict[str, Any]: + if self._is_remote_url(request.path): + return await self.openviking.add_resource( + credential, + path=request.path, + to=request.to, + reason=request.reason, + wait=request.wait, + directly_upload_media=request.directly_upload_media, + ) + + temp_upload = await self.openviking.upload_temp_file(credential, request.path) + temp_file_id = self._temp_file_id_from_result(temp_upload) + return await self.openviking.add_resource( + credential, + temp_file_id=temp_file_id, + to=request.to, + reason=request.reason, + wait=request.wait, + directly_upload_media=request.directly_upload_media, + ) + + backends = {"openviking": await self._capture(upload_openviking)} + resource = backends["openviking"].result if backends["openviking"].status == "success" else None + return ResourceMutationResponse(status=self._aggregate_status(backends), resource=resource, backends=backends) + + async def delete_resource( + self, + user_id: str, + user_key: str, + uri: str, + recursive: bool = True, + ) -> ResourceMutationResponse: + credential = self.openviking.credential_for_user(user_id, user_key) + backends = { + "openviking": await self._capture(lambda: self.openviking.delete_resource(credential, uri, recursive)), + } + resource = backends["openviking"].result if backends["openviking"].status == "success" else None + return ResourceMutationResponse(status=self._aggregate_status(backends), resource=resource, backends=backends) + async def ingest_messages(self, request: MessageIngestRequest) -> MessageIngestResponse: messages = self._messages_from_request(request) if not messages: @@ -204,6 +250,16 @@ class MemorySystemService: messages.append({"role": "assistant", "content": request.assistant_message}) return messages + def _is_remote_url(self, path: str) -> bool: + return urlparse(path).scheme in {"http", "https"} + + def _temp_file_id_from_result(self, result: Any) -> str: + data = result.get("result") if isinstance(result, dict) and isinstance(result.get("result"), dict) else result + temp_file_id = data.get("temp_file_id") if isinstance(data, dict) else None + if not temp_file_id: + raise ValueError("OpenViking temp upload response missing temp_file_id") + return str(temp_file_id) + async def _run_backends(self, **calls: Callable[[], Awaitable[Any]]) -> dict[str, BackendStatus]: names = list(calls) results = await asyncio.gather(*(self._capture(calls[name]) for name in names)) diff --git a/tests/test_memory_system_clients.py b/tests/test_memory_system_clients.py index ce9baa1..fd6b84a 100644 --- a/tests/test_memory_system_clients.py +++ b/tests/test_memory_system_clients.py @@ -61,14 +61,21 @@ class FakeAsyncClient: async def __aexit__(self, exc_type, exc, tb): return False - async def post(self, path: str, json: dict | None = None) -> FakeResponse: - self.calls.append(("post", self.api_key, self.headers, path, json)) + async def post(self, path: str, json: dict | None = None, files: dict | None = None) -> FakeResponse: + if files and "file" in files: + uploaded = files["file"] + files = {"file": uploaded[0] if isinstance(uploaded, tuple) else uploaded} + self.calls.append(("post", self.api_key, self.headers, path, json, files)) return self.responses.pop(0) async def get(self, path: str) -> FakeResponse: self.calls.append(("get", self.api_key, self.headers, path, None)) return self.responses.pop(0) + async def delete(self, path: str, params: dict | None = None) -> FakeResponse: + self.calls.append(("delete", self.api_key, self.headers, path, params)) + return self.responses.pop(0) + def test_openviking_rejects_unknown_user_credentials(): store = FakeStore() @@ -126,7 +133,7 @@ def test_openviking_create_user_creates_isolated_admin_account(): }, ), ] - client._client = lambda api_key, extra_headers=None: FakeAsyncClient( # type: ignore[method-assign] + client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign] calls, responses, api_key, @@ -152,6 +159,7 @@ def test_openviking_create_user_creates_isolated_admin_account(): {}, "/api/v1/admin/accounts", {"account_id": "userA_account", "admin_user_id": "userA"}, + None, ), ] @@ -175,7 +183,7 @@ def test_openviking_create_user_creates_account_even_when_admin_workspace_exists }, ) ] - client._client = lambda api_key, extra_headers=None: FakeAsyncClient( # type: ignore[method-assign] + client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign] calls, responses, api_key, @@ -201,6 +209,7 @@ def test_openviking_create_user_creates_account_even_when_admin_workspace_exists {}, "/api/v1/admin/accounts", {"account_id": "userB_account", "admin_user_id": "userB"}, + None, ) ] @@ -210,7 +219,7 @@ def test_openviking_user_key_auth_is_used_for_session_create(): client.root_key = "root-key" calls = [] responses = [FakeResponse(200, {"status": "ok", "result": {"session_id": "sess-2"}})] - client._client = lambda api_key, extra_headers=None: FakeAsyncClient( # type: ignore[method-assign] + client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign] calls, responses, api_key, @@ -229,6 +238,7 @@ def test_openviking_user_key_auth_is_used_for_session_create(): {}, "/api/v1/sessions", {"session_id": "sess-2"}, + None, ) ] @@ -237,7 +247,7 @@ def test_openviking_find_uses_current_identity_memory_scope(): client = OpenVikingMemorySystemClient(store=FakeStore()) calls = [] responses = [FakeResponse(200, {"status": "ok", "result": {"memories": []}})] - client._client = lambda api_key, extra_headers=None: FakeAsyncClient( # type: ignore[method-assign] + client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign] calls, responses, api_key, @@ -255,6 +265,7 @@ def test_openviking_find_uses_current_identity_memory_scope(): {}, "/api/v1/search/find", {"query": "咖啡", "target_uri": "viking://user/tom/memories/", "limit": 5}, + None, ) ] @@ -263,7 +274,7 @@ def test_openviking_search_uses_fixed_user_memory_target_with_level_and_score_th client = OpenVikingMemorySystemClient(store=FakeStore()) calls = [] responses = [FakeResponse(200, {"status": "ok", "result": {"memories": []}})] - client._client = lambda api_key, extra_headers=None: FakeAsyncClient( # type: ignore[method-assign] + client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign] calls, responses, api_key, @@ -287,6 +298,7 @@ def test_openviking_search_uses_fixed_user_memory_target_with_level_and_score_th "level": 3, "score_threshold": 0.7, }, + None, ) ] @@ -295,7 +307,7 @@ def test_openviking_search_accepts_custom_target_uri(): client = OpenVikingMemorySystemClient(store=FakeStore()) calls = [] responses = [FakeResponse(200, {"status": "ok", "result": {"memories": []}})] - client._client = lambda api_key, extra_headers=None: FakeAsyncClient( # type: ignore[method-assign] + client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign] calls, responses, api_key, @@ -326,6 +338,7 @@ def test_openviking_search_accepts_custom_target_uri(): "level": 3, "score_threshold": 0.7, }, + None, ) ] @@ -334,7 +347,7 @@ def test_openviking_profile_search_uses_user_memory_target_and_level(): client = OpenVikingMemorySystemClient(store=FakeStore()) calls = [] responses = [FakeResponse(200, {"status": "ok", "result": {"memories": []}})] - client._client = lambda api_key, extra_headers=None: FakeAsyncClient( # type: ignore[method-assign] + client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign] calls, responses, api_key, @@ -357,6 +370,7 @@ def test_openviking_profile_search_uses_user_memory_target_and_level(): "level": 2, "target_uri": "viking://user/memories", }, + None, ) ] @@ -376,7 +390,7 @@ def test_openviking_get_session_context_uses_user_key_auth(): }, ) ] - client._client = lambda api_key, extra_headers=None: FakeAsyncClient( # type: ignore[method-assign] + client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign] calls, responses, api_key, @@ -420,7 +434,7 @@ def test_openviking_commit_keeps_no_recent_live_messages(): }, ) ] - client._client = lambda api_key, extra_headers=None: FakeAsyncClient( # type: ignore[method-assign] + client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign] calls, responses, api_key, @@ -446,6 +460,115 @@ def test_openviking_commit_keeps_no_recent_live_messages(): {}, "/api/v1/sessions/sess-1/commit", {"keep_recent_count": 0}, + None, + ) + ] + + +def test_openviking_upload_temp_file_posts_multipart(tmp_path): + path = tmp_path / "report.pdf" + path.write_bytes(b"pdf-bytes") + client = OpenVikingMemorySystemClient(store=FakeStore()) + calls = [] + responses = [ + FakeResponse( + 200, + {"status": "ok", "result": {"temp_file_id": "upload_report.pdf"}}, + ) + ] + client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign] + calls, + responses, + api_key, + extra_headers or {}, + ) + credential = client.user_credential("tom-key", "tom") + + result = asyncio.run(client.upload_temp_file(credential, path)) + + assert result == {"status": "ok", "result": {"temp_file_id": "upload_report.pdf"}} + assert calls == [ + ( + "post", + "tom-key", + {}, + "/api/v1/resources/temp_upload", + None, + {"file": "report.pdf"}, + ) + ] + + +def test_openviking_add_resource_posts_url_payload(): + client = OpenVikingMemorySystemClient(store=FakeStore()) + calls = [] + responses = [FakeResponse(200, {"status": "ok", "result": {"uri": "viking://resources/tom/images/photo.png"}})] + client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign] + calls, + responses, + api_key, + extra_headers or {}, + ) + credential = client.user_credential("tom-key", "tom") + + result = asyncio.run( + client.add_resource( + credential, + path="https://example.com/photo.png", + to="viking://resources/tom/images/photo.png", + reason="上传远程图片", + wait=True, + directly_upload_media=True, + ) + ) + + assert result == {"status": "ok", "result": {"uri": "viking://resources/tom/images/photo.png"}} + assert calls == [ + ( + "post", + "tom-key", + {}, + "/api/v1/resources", + { + "path": "https://example.com/photo.png", + "to": "viking://resources/tom/images/photo.png", + "reason": "上传远程图片", + "wait": True, + "directly_upload_media": True, + }, + None, + ) + ] + + +def test_openviking_delete_resource_sends_uri_and_recursive_flag(): + client = OpenVikingMemorySystemClient(store=FakeStore()) + calls = [] + responses = [FakeResponse(200, {"status": "ok", "result": {"estimated_deleted_count": 4}})] + client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign] + calls, + responses, + api_key, + extra_headers or {}, + ) + credential = client.user_credential("tom-key", "tom") + + result = asyncio.run( + client.delete_resource( + credential, + uri="viking://resources/tom/files/report.pdf", + recursive=True, + ) + ) + + assert result == {"status": "ok", "result": {"estimated_deleted_count": 4}} + assert calls == [ + ( + "delete", + "tom-key", + {}, + "/api/v1/fs", + {"uri": "viking://resources/tom/files/report.pdf", "recursive": "true"}, ) ] diff --git a/tests/test_memory_system_server.py b/tests/test_memory_system_server.py index 1f39a42..533505b 100644 --- a/tests/test_memory_system_server.py +++ b/tests/test_memory_system_server.py @@ -13,6 +13,7 @@ def test_memory_system_server_exposes_routes(): } assert {"GET", "POST"} <= context_methods assert "/memory-system/search" in paths + assert "/memory-system/resources" in paths assert "/memory-system/users/{user_id}/profile" in paths task_methods = { method @@ -28,6 +29,13 @@ def test_memory_system_server_exposes_routes(): } assert {"GET", "POST"} <= task_methods assert {"GET", "POST"} <= profile_methods + resource_methods = { + method + for route in app.routes + if getattr(route, "path", "") == "/memory-system/resources" + for method in getattr(route, "methods", set()) + } + assert {"DELETE", "POST"} <= resource_methods def test_memory_system_messages_does_not_require_account_key_header(): diff --git a/tests/test_memory_system_service.py b/tests/test_memory_system_service.py index 7c15dc5..7471490 100644 --- a/tests/test_memory_system_service.py +++ b/tests/test_memory_system_service.py @@ -1,6 +1,6 @@ import asyncio -from memory_system_api.schemas import MessageIngestRequest, SearchRequest, SessionContextRequest +from memory_system_api.schemas import MessageIngestRequest, ResourceUploadRequest, SearchRequest, SessionContextRequest from memory_system_api.service import MemorySystemService @@ -89,6 +89,37 @@ class FakeOpenViking: self.calls.append(("commit_session", user_key, session_id)) return {"status": "ok", "result": {"task_id": "task-1", "archive_uri": "archive-1"}} + async def upload_temp_file(self, user_key: str, path) -> dict: + self.calls.append(("upload_temp_file", user_key, str(path))) + return {"status": "ok", "result": {"temp_file_id": "upload_report.pdf"}} + + async def add_resource( + self, + user_key: str, + *, + to: str, + reason: str | None, + wait: bool, + directly_upload_media: bool, + path: str | None = None, + temp_file_id: str | None = None, + ) -> dict: + self.calls.append(( + "add_resource", + user_key, + path, + temp_file_id, + to, + reason, + wait, + directly_upload_media, + )) + return {"status": "ok", "result": {"uri": to}} + + async def delete_resource(self, user_key: str, uri: str, recursive: bool = True) -> dict: + self.calls.append(("delete_resource", user_key, uri, recursive)) + return {"status": "ok", "result": {"uri": uri, "estimated_deleted_count": 4}} + class FakeEverOS: def __init__(self, fail_on_append: bool = False): @@ -218,6 +249,91 @@ def test_create_user_delegates_to_openviking_only(): assert everos.calls == [] +def test_upload_resource_with_url_delegates_directly_to_openviking_add_resource(): + openviking = FakeOpenViking() + service = MemorySystemService(openviking=openviking, everos=FakeEverOS()) + + response = asyncio.run(service.upload_resource(ResourceUploadRequest( + user_id="tom", + user_key="tom-key", + path="https://example.com/images/photo.png", + to="viking://resources/tom/images/photo.png", + reason="上传远程图片", + ))) + + assert response.status == "success" + assert response.resource == {"status": "ok", "result": {"uri": "viking://resources/tom/images/photo.png"}} + assert openviking.calls == [ + ("credential_for_user", "tom", "tom-key", None), + ( + "add_resource", + "key-tom", + "https://example.com/images/photo.png", + None, + "viking://resources/tom/images/photo.png", + "上传远程图片", + True, + True, + ), + ] + + +def test_upload_resource_with_local_path_uploads_temp_file_first(tmp_path): + path = tmp_path / "report.pdf" + path.write_bytes(b"pdf-bytes") + openviking = FakeOpenViking() + service = MemorySystemService(openviking=openviking, everos=FakeEverOS()) + + response = asyncio.run(service.upload_resource(ResourceUploadRequest( + user_id="tom", + user_key="tom-key", + path=str(path), + to="viking://resources/tom/files/report.pdf", + reason="上传本地文件", + ))) + + assert response.status == "success" + assert response.resource == {"status": "ok", "result": {"uri": "viking://resources/tom/files/report.pdf"}} + assert openviking.calls == [ + ("credential_for_user", "tom", "tom-key", None), + ("upload_temp_file", "key-tom", str(path)), + ( + "add_resource", + "key-tom", + None, + "upload_report.pdf", + "viking://resources/tom/files/report.pdf", + "上传本地文件", + True, + True, + ), + ] + + +def test_delete_resource_delegates_to_openviking_only(): + openviking = FakeOpenViking() + everos = FakeEverOS() + service = MemorySystemService(openviking=openviking, everos=everos) + + response = asyncio.run(service.delete_resource( + user_id="tom", + user_key="tom-key", + uri="viking://resources/tom/files/report.pdf", + recursive=True, + )) + + assert response.status == "success" + assert response.resource == { + "status": "ok", + "result": {"uri": "viking://resources/tom/files/report.pdf", "estimated_deleted_count": 4}, + } + assert openviking.calls == [ + ("credential_for_user", "tom", "tom-key", None), + ("delete_resource", "key-tom", "viking://resources/tom/files/report.pdf", True), + ] + assert everos.calls == [] + + def test_search_removes_vectors_from_items_and_backend_results(): service = MemorySystemService(openviking=FakeOpenViking(), everos=FakeEverOSWithVector())