From 1643112bf51fb004461f90e0af1dd5403eb25124 Mon Sep 17 00:00:00 2001 From: LittleChenLiya <64821731+LittleChenLiya@users.noreply.github.com> Date: Thu, 28 May 2026 19:53:48 +0800 Subject: [PATCH 1/6] feat: add public answer sharing --- backend/app/gateway/app.py | 4 + backend/app/gateway/auth_middleware.py | 11 +- backend/app/gateway/routers/__init__.py | 4 +- backend/app/gateway/routers/shares.py | 158 ++++++++++++++++++ backend/tests/test_auth_middleware.py | 36 +++- backend/tests/test_shares_router.py | 117 +++++++++++++ frontend/src/app/share/[share_id]/page.tsx | 101 +++++++++++ .../workspace/messages/message-list.tsx | 87 +++++++++- frontend/src/core/threads/api.ts | 33 +++- frontend/src/core/threads/types.ts | 10 ++ frontend/tests/unit/core/threads/api.test.ts | 33 ++++ 11 files changed, 583 insertions(+), 11 deletions(-) create mode 100644 backend/app/gateway/routers/shares.py create mode 100644 backend/tests/test_shares_router.py create mode 100644 frontend/src/app/share/[share_id]/page.tsx diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index 8baecb3631..9572e28841 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -21,6 +21,7 @@ memory, models, runs, + shares, skills, suggestions, thread_runs, @@ -351,6 +352,9 @@ def create_app() -> FastAPI: # Thread cleanup API is mounted at /api/threads/{thread_id} app.include_router(threads.router) + # Public conversation shares are mounted at /api/shares + app.include_router(shares.router) + # Agents API is mounted at /api/agents app.include_router(agents.router) diff --git a/backend/app/gateway/auth_middleware.py b/backend/app/gateway/auth_middleware.py index 6b64522643..2865f1bfe5 100644 --- a/backend/app/gateway/auth_middleware.py +++ b/backend/app/gateway/auth_middleware.py @@ -49,6 +49,15 @@ def _is_public(path: str) -> bool: return any(path.startswith(prefix) for prefix in _PUBLIC_PATH_PREFIXES) +def _is_public_request(method: str, path: str) -> bool: + """Return True for routes that are intentionally anonymous.""" + if _is_public(path): + return True + stripped = path.rstrip("/") + parts = stripped.split("/") + return method == "GET" and len(parts) == 4 and parts[:3] == ["", "api", "shares"] and bool(parts[3]) + + class AuthMiddleware(BaseHTTPMiddleware): """Strict auth gate: reject requests without a valid session. @@ -73,7 +82,7 @@ def __init__(self, app: ASGIApp) -> None: super().__init__(app) async def dispatch(self, request: Request, call_next: Callable) -> Response: - if _is_public(request.url.path): + if _is_public_request(request.method, request.url.path): return await call_next(request) internal_user = None diff --git a/backend/app/gateway/routers/__init__.py b/backend/app/gateway/routers/__init__.py index c5f67a396b..2dbb38047f 100644 --- a/backend/app/gateway/routers/__init__.py +++ b/backend/app/gateway/routers/__init__.py @@ -1,3 +1,3 @@ -from . import artifacts, assistants_compat, mcp, models, skills, suggestions, thread_runs, threads, uploads +from . import artifacts, assistants_compat, mcp, models, shares, skills, suggestions, thread_runs, threads, uploads -__all__ = ["artifacts", "assistants_compat", "mcp", "models", "skills", "suggestions", "threads", "thread_runs", "uploads"] +__all__ = ["artifacts", "assistants_compat", "mcp", "models", "shares", "skills", "suggestions", "threads", "thread_runs", "uploads"] diff --git a/backend/app/gateway/routers/shares.py b/backend/app/gateway/routers/shares.py new file mode 100644 index 0000000000..c871508312 --- /dev/null +++ b/backend/app/gateway/routers/shares.py @@ -0,0 +1,158 @@ +"""Public conversation share endpoints.""" + +from __future__ import annotations + +import logging +import secrets +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field + +from app.gateway.authz import require_permission +from app.gateway.deps import get_checkpointer, get_store +from app.gateway.utils import sanitize_log_param +from deerflow.runtime import serialize_channel_values +from deerflow.utils.time import now_iso + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/shares", tags=["shares"]) + +_SHARES_NS = ("shares",) +_SHARE_ID_BYTES = 16 + + +class ShareCreateRequest(BaseModel): + """Request body for creating a public share snapshot.""" + + message_ids: list[str] = Field( + min_length=1, + description="Message IDs to include in the public share.", + ) + title: str | None = Field(default=None, max_length=256, description="Optional share title") + + +class ShareCreateResponse(BaseModel): + share_id: str + title: str | None = None + created_at: str + + +class ShareResponse(BaseModel): + share_id: str + title: str | None = None + messages: list[dict[str, Any]] = Field(default_factory=list) + created_at: str + + +def _extract_message_id(message: dict[str, Any]) -> str | None: + message_id = message.get("id") + return message_id if isinstance(message_id, str) and message_id else None + + +def _has_displayable_content(message: dict[str, Any]) -> bool: + content = message.get("content") + if isinstance(content, str): + return bool(content.strip()) + if isinstance(content, list): + return len(content) > 0 + return content is not None + + +def _is_shareable_message(message: dict[str, Any]) -> bool: + message_type = message.get("type") + if message_type == "human": + return _has_displayable_content(message) + if message_type == "ai": + return _has_displayable_content(message) and not message.get("tool_calls") and not message.get("invalid_tool_calls") + return False + + +async def _put_unique_share(store, value: dict[str, Any]) -> str: + for _ in range(4): + share_id = secrets.token_urlsafe(_SHARE_ID_BYTES) + if await store.aget(_SHARES_NS, share_id) is None: + await store.aput(_SHARES_NS, share_id, value) + return share_id + raise HTTPException(status_code=500, detail="Failed to create share") + + +@router.post("/threads/{thread_id}", response_model=ShareCreateResponse) +@require_permission("threads", "read", owner_check=True, require_existing=True) +async def create_thread_share(thread_id: str, body: ShareCreateRequest, request: Request) -> ShareCreateResponse: + """Create a public immutable snapshot from an owned thread.""" + store = get_store(request) + if store is None: + raise HTTPException(status_code=503, detail="Store not available") + + checkpointer = get_checkpointer(request) + config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}} + try: + checkpoint_tuple = await checkpointer.aget_tuple(config) + except Exception: + logger.exception("Failed to get state for share source thread %s", sanitize_log_param(thread_id)) + raise HTTPException(status_code=500, detail="Failed to create share") + + if checkpoint_tuple is None: + raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found") + + checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {} + channel_values = checkpoint.get("channel_values", {}) or {} + serialized_values = serialize_channel_values(channel_values) + all_messages = serialized_values.get("messages", []) + if not isinstance(all_messages, list) or not all_messages: + raise HTTPException(status_code=400, detail="Thread has no messages to share") + + requested_ids = [message_id for message_id in body.message_ids if message_id] + if not requested_ids: + raise HTTPException(status_code=400, detail="No message IDs selected") + + requested_id_set = set(requested_ids) + selected_messages = [message for message in all_messages if isinstance(message, dict) and _extract_message_id(message) in requested_id_set] + selected_id_set = {message_id for message in selected_messages if (message_id := _extract_message_id(message)) is not None} + missing_ids = [message_id for message_id in requested_ids if message_id not in selected_id_set] + if missing_ids: + raise HTTPException(status_code=400, detail=f"Message IDs not found: {', '.join(missing_ids)}") + + non_shareable_ids = [message_id for message in selected_messages if (message_id := _extract_message_id(message)) is not None and not _is_shareable_message(message)] + if non_shareable_ids: + raise HTTPException(status_code=400, detail=f"Message IDs are not shareable: {', '.join(non_shareable_ids)}") + + created_at = now_iso() + title = body.title or serialized_values.get("title") + if not isinstance(title, str): + title = None + + share_id = await _put_unique_share( + store, + { + "title": title, + "messages": selected_messages, + "created_at": created_at, + }, + ) + return ShareCreateResponse(share_id=share_id, title=title, created_at=created_at) + + +@router.get("/{share_id}", response_model=ShareResponse) +async def get_share(share_id: str, request: Request) -> ShareResponse: + """Read a public share snapshot without requiring authentication.""" + store = get_store(request) + if store is None: + raise HTTPException(status_code=503, detail="Store not available") + + item = await store.aget(_SHARES_NS, share_id) + if item is None: + raise HTTPException(status_code=404, detail="Share not found") + + value = item.value or {} + messages = value.get("messages", []) + if not isinstance(messages, list): + messages = [] + title = value.get("title") + return ShareResponse( + share_id=share_id, + title=title if isinstance(title, str) else None, + messages=messages, + created_at=value.get("created_at", ""), + ) diff --git a/backend/tests/test_auth_middleware.py b/backend/tests/test_auth_middleware.py index 726786ac9c..b99ef1f6b3 100644 --- a/backend/tests/test_auth_middleware.py +++ b/backend/tests/test_auth_middleware.py @@ -3,7 +3,7 @@ import pytest from starlette.testclient import TestClient -from app.gateway.auth_middleware import AuthMiddleware, _is_public +from app.gateway.auth_middleware import AuthMiddleware, _is_public, _is_public_request # ── _is_public unit tests ───────────────────────────────────────────────── @@ -27,6 +27,13 @@ def test_public_paths(path: str): assert _is_public(path) is True +def test_public_share_read_request(): + assert _is_public_request("GET", "/api/shares/share-1") is True + assert _is_public_request("POST", "/api/shares/threads/thread-1") is False + assert _is_public_request("GET", "/api/shares/threads/thread-1") is False + assert _is_public_request("GET", "/api/shares-anything") is False + + @pytest.mark.parametrize( "path", [ @@ -129,6 +136,18 @@ async def stream(): async def future(): return {"ok": True} + @app.get("/api/shares/share-1") + async def share_get(): + return {"ok": True} + + @app.post("/api/shares/threads/abc") + async def share_create(): + return {"ok": True} + + @app.get("/api/shares-anything") + async def shares_prefix_lookalike(): + return {"ok": True} + return app @@ -148,6 +167,21 @@ def test_public_auth_path_no_cookie(client): assert res.status_code == 200 +def test_public_share_path_no_cookie(client): + res = client.get("/api/shares/share-1") + assert res.status_code == 200 + + +def test_share_create_no_cookie_returns_401(client): + res = client.post("/api/shares/threads/abc") + assert res.status_code == 401 + + +def test_share_prefix_lookalike_no_cookie_returns_401(client): + res = client.get("/api/shares-anything") + assert res.status_code == 401 + + def test_protected_auth_path_no_cookie(client): """/auth/me requires cookie even though it's under /api/v1/auth/.""" res = client.get("/api/v1/auth/me") diff --git a/backend/tests/test_shares_router.py b/backend/tests/test_shares_router.py new file mode 100644 index 0000000000..a04a4692f0 --- /dev/null +++ b/backend/tests/test_shares_router.py @@ -0,0 +1,117 @@ +import asyncio +from types import SimpleNamespace + +from _router_auth_helpers import make_authed_test_app +from fastapi.testclient import TestClient +from langchain_core.messages import AIMessage, HumanMessage +from langgraph.store.memory import InMemoryStore + +from app.gateway.routers import shares +from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore + + +class _FakeCheckpointer: + def __init__(self) -> None: + self.checkpoints: dict[str, dict] = {} + + async def aget_tuple(self, config: dict): + thread_id = config["configurable"]["thread_id"] + checkpoint = self.checkpoints.get(thread_id) + if checkpoint is None: + return None + return SimpleNamespace(checkpoint=checkpoint) + + +def _build_share_app(*, owner_check_passes: bool = True) -> tuple[TestClient, InMemoryStore, _FakeCheckpointer]: + app = make_authed_test_app(owner_check_passes=owner_check_passes) + store = InMemoryStore() + checkpointer = _FakeCheckpointer() + app.state.store = store + app.state.checkpointer = checkpointer + app.include_router(shares.router) + return TestClient(app), store, checkpointer + + +def _seed_thread(store: InMemoryStore, checkpointer: _FakeCheckpointer, thread_id: str) -> None: + async def _seed() -> None: + await MemoryThreadMetaStore(store).create(thread_id, metadata={}) + checkpointer.checkpoints[thread_id] = { + "channel_values": { + "title": "Share source", + "messages": [ + HumanMessage(content="Question", id="human-1"), + AIMessage(content="Answer", id="ai-1"), + AIMessage(content="", id="tool-call-1", tool_calls=[{"name": "search", "args": {}, "id": "call-1"}]), + HumanMessage(content="Follow-up", id="human-2"), + ], + }, + } + + asyncio.run(_seed()) + + +def test_create_share_snapshots_selected_messages_and_public_read() -> None: + client, store, checkpointer = _build_share_app() + _seed_thread(store, checkpointer, "thread-share") + + response = client.post( + "/api/shares/threads/thread-share", + json={"message_ids": ["human-1", "ai-1"]}, + ) + + assert response.status_code == 200, response.text + share_id = response.json()["share_id"] + + public_response = client.get(f"/api/shares/{share_id}") + assert public_response.status_code == 200, public_response.text + body = public_response.json() + assert body["title"] == "Share source" + assert [message["id"] for message in body["messages"]] == ["human-1", "ai-1"] + assert [message["content"] for message in body["messages"]] == ["Question", "Answer"] + + +def test_create_share_rejects_unknown_message_id() -> None: + client, store, checkpointer = _build_share_app() + _seed_thread(store, checkpointer, "thread-share") + + response = client.post( + "/api/shares/threads/thread-share", + json={"message_ids": ["missing-message"]}, + ) + + assert response.status_code == 400 + assert "missing-message" in response.json()["detail"] + + +def test_create_share_requires_selected_message_ids() -> None: + client, store, checkpointer = _build_share_app() + _seed_thread(store, checkpointer, "thread-share") + + response = client.post("/api/shares/threads/thread-share", json={}) + + assert response.status_code == 422 + + +def test_create_share_rejects_non_shareable_message_id() -> None: + client, store, checkpointer = _build_share_app() + _seed_thread(store, checkpointer, "thread-share") + + response = client.post( + "/api/shares/threads/thread-share", + json={"message_ids": ["tool-call-1"]}, + ) + + assert response.status_code == 400 + assert "not shareable" in response.json()["detail"] + + +def test_create_share_requires_thread_access() -> None: + client, store, checkpointer = _build_share_app(owner_check_passes=False) + _seed_thread(store, checkpointer, "thread-share") + + response = client.post( + "/api/shares/threads/thread-share", + json={"message_ids": ["human-1", "ai-1"]}, + ) + + assert response.status_code == 404 diff --git a/frontend/src/app/share/[share_id]/page.tsx b/frontend/src/app/share/[share_id]/page.tsx new file mode 100644 index 0000000000..77914d3198 --- /dev/null +++ b/frontend/src/app/share/[share_id]/page.tsx @@ -0,0 +1,101 @@ +"use client"; + +import type { BaseStream } from "@langchain/langgraph-sdk/react"; +import { useParams } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; + +import { SidebarProvider } from "@/components/ui/sidebar"; +import { ArtifactsProvider } from "@/components/workspace/artifacts"; +import { MessageList } from "@/components/workspace/messages"; +import { getBackendBaseURL } from "@/core/config"; +import { SubtasksProvider } from "@/core/tasks/context"; +import type { AgentThreadState, ThreadShareResponse } from "@/core/threads"; + +export default function SharePage() { + const { share_id: shareId } = useParams<{ share_id: string }>(); + const [share, setShare] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function loadShare() { + try { + const response = await fetch( + `${getBackendBaseURL()}/api/shares/${encodeURIComponent(shareId)}`, + ); + if (!response.ok) { + throw new Error("Share not found"); + } + const data = (await response.json()) as ThreadShareResponse; + if (!cancelled) { + setShare(data); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to load share"); + } + } + } + + void loadShare(); + return () => { + cancelled = true; + }; + }, [shareId]); + + const thread = useMemo( + () => + ({ + messages: share?.messages ?? [], + values: { + title: share?.title ?? "", + messages: share?.messages ?? [], + artifacts: [], + }, + isLoading: false, + isThreadLoading: share === null && error === null, + }) as unknown as BaseStream, + [error, share], + ); + + return ( + + + +
+
+
+
+
+ DeerFlow +
+ {share?.title && ( +

+ {share.title} +

+ )} +
+
+
+ {error ? ( +
+ {error} +
+ ) : ( +
+ +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx index ca8672a3a6..bc84abf398 100644 --- a/frontend/src/components/workspace/messages/message-list.tsx +++ b/frontend/src/components/workspace/messages/message-list.tsx @@ -1,7 +1,8 @@ import type { Message } from "@langchain/langgraph-sdk"; import type { BaseStream } from "@langchain/langgraph-sdk/react"; -import { ChevronUpIcon, Loader2Icon } from "lucide-react"; +import { ChevronUpIcon, Loader2Icon, Share2Icon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef } from "react"; +import { toast } from "sonner"; import { Conversation, @@ -31,11 +32,13 @@ import type { Subtask } from "@/core/tasks"; import { useUpdateSubtask } from "@/core/tasks/context"; import { parseSubtaskResult } from "@/core/tasks/subtask-result"; import type { AgentThreadState } from "@/core/threads"; +import { createThreadShare } from "@/core/threads/api"; import { cn } from "@/lib/utils"; import { ArtifactFileList } from "../artifacts/artifact-file-list"; import { CopyButton } from "../copy-button"; import { StreamingIndicator } from "../streaming-indicator"; +import { Tooltip } from "../tooltip"; import { MarkdownContent } from "./markdown-content"; import { MessageGroup } from "./message-group"; @@ -51,6 +54,25 @@ export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 24; const LOAD_MORE_HISTORY_THROTTLE_MS = 1200; +function findPreviousHumanMessages( + groups: ReturnType, + groupIndex: number, +) { + for (let index = groupIndex - 1; index >= 0; index -= 1) { + const group = groups[index]; + if (!group) { + continue; + } + if (group.type === "human") { + return group.messages; + } + if (group.type === "assistant") { + return null; + } + } + return null; +} + function LoadMoreHistoryIndicator({ isLoading, hasMore, @@ -165,6 +187,7 @@ export function MessageList({ hasMoreHistory, loadMoreHistory, isHistoryLoading, + enableSharing = true, }: { className?: string; threadId: string; @@ -174,6 +197,7 @@ export function MessageList({ hasMoreHistory?: boolean; loadMoreHistory?: () => void; isHistoryLoading?: boolean; + enableSharing?: boolean; }) { const { t } = useI18n(); const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); @@ -196,21 +220,69 @@ export function MessageList({ [messages, thread.getMessagesMetadata, thread.isLoading], ); - const renderAssistantCopyButton = useCallback( - (messages: Message[], isStreaming: boolean) => { + const renderAssistantActions = useCallback( + ( + messages: Message[], + isStreaming: boolean, + previousMessages: Message[], + ) => { const clipboardData = getAssistantTurnCopyData(messages, { isStreaming }); if (!clipboardData) { return null; } + const shareMessageIds = [...previousMessages, ...messages] + .map((message) => message.id) + .filter((id): id is string => typeof id === "string" && id.length > 0); + return ( -
+
+ {enableSharing && ( + + + + )}
); }, - [], + [ + t.clipboard.failedToCopyToClipboard, + t.clipboard.linkCopied, + t.common.share, + t.conversation.noMessages, + enableSharing, + thread.values.title, + threadId, + ], ); const renderTokenUsage = useCallback( @@ -275,6 +347,8 @@ export function MessageList({ /> {groupedMessages.map((group, groupIndex) => { const turnUsageMessages = turnUsageMessagesByGroupIndex[groupIndex]; + const previousMessages = + findPreviousHumanMessages(groupedMessages, groupIndex) ?? []; if (group.type === "human" || group.type === "assistant") { return ( @@ -301,12 +375,13 @@ export function MessageList({ turnUsageMessages, })} {group.type === "assistant" && - renderAssistantCopyButton( + renderAssistantActions( group.messages, isAssistantMessageGroupStreaming( group.messages, streamingMessages, ), + previousMessages, )}
); diff --git a/frontend/src/core/threads/api.ts b/frontend/src/core/threads/api.ts index 1d1feb40f7..eb24b1468f 100644 --- a/frontend/src/core/threads/api.ts +++ b/frontend/src/core/threads/api.ts @@ -1,7 +1,10 @@ import { fetch as fetchWithAuth } from "@/core/api/fetcher"; import { getBackendBaseURL } from "@/core/config"; -import type { ThreadTokenUsageResponse } from "./types"; +import type { + ThreadShareCreateResponse, + ThreadTokenUsageResponse, +} from "./types"; export async function fetchThreadTokenUsage( threadId: string, @@ -22,3 +25,31 @@ export async function fetchThreadTokenUsage( return (await response.json()) as ThreadTokenUsageResponse; } + +export async function createThreadShare({ + threadId, + messageIds, + title, +}: { + threadId: string; + messageIds: string[]; + title?: string; +}): Promise { + const response = await fetchWithAuth( + `${getBackendBaseURL()}/api/shares/threads/${encodeURIComponent(threadId)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message_ids: messageIds, + title, + }), + }, + ); + + if (!response.ok) { + throw new Error("Failed to create share."); + } + + return (await response.json()) as ThreadShareCreateResponse; +} diff --git a/frontend/src/core/threads/types.ts b/frontend/src/core/threads/types.ts index dafb073494..1a783c0e1a 100644 --- a/frontend/src/core/threads/types.ts +++ b/frontend/src/core/threads/types.ts @@ -45,3 +45,13 @@ export interface ThreadTokenUsageResponse { middleware: number; }; } + +export interface ThreadShareCreateResponse { + share_id: string; + title?: string | null; + created_at: string; +} + +export interface ThreadShareResponse extends ThreadShareCreateResponse { + messages: Message[]; +} diff --git a/frontend/tests/unit/core/threads/api.test.ts b/frontend/tests/unit/core/threads/api.test.ts index 4d1268694b..52e59a292d 100644 --- a/frontend/tests/unit/core/threads/api.test.ts +++ b/frontend/tests/unit/core/threads/api.test.ts @@ -53,3 +53,36 @@ test("fetchThreadTokenUsage returns null for unavailable token usage", async () await expect(fetchThreadTokenUsage("thread-1")).resolves.toBeNull(); }); + +test("createThreadShare posts selected message ids", async () => { + fetchWithAuth.mockResolvedValue({ + ok: true, + json: async () => ({ + share_id: "share-1", + title: "Shared answer", + created_at: "2026-05-28T00:00:00+00:00", + }), + }); + + const { createThreadShare } = await import("@/core/threads/api"); + + await expect( + createThreadShare({ + threadId: "thread-1", + messageIds: ["human-1", "ai-1"], + title: "Shared answer", + }), + ).resolves.toMatchObject({ share_id: "share-1" }); + + expect(fetchWithAuth).toHaveBeenCalledWith( + expect.stringContaining("/api/shares/threads/thread-1"), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message_ids: ["human-1", "ai-1"], + title: "Shared answer", + }), + }, + ); +}); From a68dc5b3f02a7656ae1c44ed27ed7db38999189a Mon Sep 17 00:00:00 2001 From: LittleChenLiya <64821731+LittleChenLiya@users.noreply.github.com> Date: Thu, 28 May 2026 20:20:29 +0800 Subject: [PATCH 2/6] fix: address share review feedback --- backend/app/gateway/auth_middleware.py | 2 +- backend/app/gateway/routers/shares.py | 3 ++ backend/tests/test_auth_middleware.py | 10 ++++ backend/tests/test_shares_router.py | 13 +++++ frontend/src/app/share/[share_id]/page.tsx | 1 + .../workspace/messages/message-list.tsx | 53 ++++++++++--------- frontend/src/core/threads/api.ts | 14 ++++- frontend/tests/unit/core/threads/api.test.ts | 17 ++++++ 8 files changed, 87 insertions(+), 26 deletions(-) diff --git a/backend/app/gateway/auth_middleware.py b/backend/app/gateway/auth_middleware.py index 2865f1bfe5..03f0c512ab 100644 --- a/backend/app/gateway/auth_middleware.py +++ b/backend/app/gateway/auth_middleware.py @@ -55,7 +55,7 @@ def _is_public_request(method: str, path: str) -> bool: return True stripped = path.rstrip("/") parts = stripped.split("/") - return method == "GET" and len(parts) == 4 and parts[:3] == ["", "api", "shares"] and bool(parts[3]) + return method == "GET" and len(parts) == 4 and parts[:3] == ["", "api", "shares"] and bool(parts[3]) and parts[3] != "threads" class AuthMiddleware(BaseHTTPMiddleware): diff --git a/backend/app/gateway/routers/shares.py b/backend/app/gateway/routers/shares.py index c871508312..c6f232f540 100644 --- a/backend/app/gateway/routers/shares.py +++ b/backend/app/gateway/routers/shares.py @@ -86,6 +86,9 @@ async def create_thread_share(thread_id: str, body: ShareCreateRequest, request: raise HTTPException(status_code=503, detail="Store not available") checkpointer = get_checkpointer(request) + if checkpointer is None: + raise HTTPException(status_code=503, detail="Checkpointer not available") + config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}} try: checkpoint_tuple = await checkpointer.aget_tuple(config) diff --git a/backend/tests/test_auth_middleware.py b/backend/tests/test_auth_middleware.py index b99ef1f6b3..d84bb77564 100644 --- a/backend/tests/test_auth_middleware.py +++ b/backend/tests/test_auth_middleware.py @@ -29,6 +29,7 @@ def test_public_paths(path: str): def test_public_share_read_request(): assert _is_public_request("GET", "/api/shares/share-1") is True + assert _is_public_request("GET", "/api/shares/threads") is False assert _is_public_request("POST", "/api/shares/threads/thread-1") is False assert _is_public_request("GET", "/api/shares/threads/thread-1") is False assert _is_public_request("GET", "/api/shares-anything") is False @@ -140,6 +141,10 @@ async def future(): async def share_get(): return {"ok": True} + @app.get("/api/shares/threads") + async def share_threads_reserved(): + return {"ok": True} + @app.post("/api/shares/threads/abc") async def share_create(): return {"ok": True} @@ -177,6 +182,11 @@ def test_share_create_no_cookie_returns_401(client): assert res.status_code == 401 +def test_share_threads_reserved_no_cookie_returns_401(client): + res = client.get("/api/shares/threads") + assert res.status_code == 401 + + def test_share_prefix_lookalike_no_cookie_returns_401(client): res = client.get("/api/shares-anything") assert res.status_code == 401 diff --git a/backend/tests/test_shares_router.py b/backend/tests/test_shares_router.py index a04a4692f0..6892008cee 100644 --- a/backend/tests/test_shares_router.py +++ b/backend/tests/test_shares_router.py @@ -115,3 +115,16 @@ def test_create_share_requires_thread_access() -> None: ) assert response.status_code == 404 + + +def test_create_share_returns_503_without_checkpointer() -> None: + client, store, _checkpointer = _build_share_app() + _seed_thread(store, _checkpointer, "thread-share") + client.app.state.checkpointer = None + + response = client.post( + "/api/shares/threads/thread-share", + json={"message_ids": ["human-1", "ai-1"]}, + ) + + assert response.status_code == 503 diff --git a/frontend/src/app/share/[share_id]/page.tsx b/frontend/src/app/share/[share_id]/page.tsx index 77914d3198..6d865a6a86 100644 --- a/frontend/src/app/share/[share_id]/page.tsx +++ b/frontend/src/app/share/[share_id]/page.tsx @@ -55,6 +55,7 @@ export default function SharePage() { }, isLoading: false, isThreadLoading: share === null && error === null, + getMessagesMetadata: () => [], }) as unknown as BaseStream, [error, share], ); diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx index bc84abf398..51d922eedc 100644 --- a/frontend/src/components/workspace/messages/message-list.tsx +++ b/frontend/src/components/workspace/messages/message-list.tsx @@ -54,25 +54,6 @@ export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 24; const LOAD_MORE_HISTORY_THROTTLE_MS = 1200; -function findPreviousHumanMessages( - groups: ReturnType, - groupIndex: number, -) { - for (let index = groupIndex - 1; index >= 0; index -= 1) { - const group = groups[index]; - if (!group) { - continue; - } - if (group.type === "human") { - return group.messages; - } - if (group.type === "assistant") { - return null; - } - } - return null; -} - function LoadMoreHistoryIndicator({ isLoading, hasMore, @@ -203,9 +184,24 @@ export function MessageList({ const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const updateSubtask = useUpdateSubtask(); const messages = thread.messages; - const groupedMessages = getMessageGroups(messages); + const groupedMessages = useMemo(() => getMessageGroups(messages), [messages]); const turnUsageMessagesByGroupIndex = getAssistantTurnUsageMessages(groupedMessages); + const previousHumanMessagesByGroupIndex = useMemo(() => { + const previousByIndex: Message[][] = []; + let previousHumanMessages: Message[] | null = null; + + groupedMessages.forEach((group, index) => { + previousByIndex[index] = previousHumanMessages ?? []; + if (group.type === "human") { + previousHumanMessages = group.messages; + } else if (group.type === "assistant") { + previousHumanMessages = null; + } + }); + + return previousByIndex; + }, [groupedMessages]); const tokenDebugSteps = useMemo( () => buildTokenDebugSteps(messages, t), [messages, t], @@ -260,10 +256,19 @@ export function MessageList({ `/share/${share.share_id}`, window.location.origin, ).toString(); - await navigator.clipboard.writeText(shareUrl); + try { + await navigator.clipboard.writeText(shareUrl); + } catch { + toast.error(t.clipboard.failedToCopyToClipboard); + return; + } toast.success(t.clipboard.linkCopied); - } catch { - toast.error(t.clipboard.failedToCopyToClipboard); + } catch (err) { + toast.error( + err instanceof Error + ? err.message + : "Failed to create share.", + ); } }} > @@ -348,7 +353,7 @@ export function MessageList({ {groupedMessages.map((group, groupIndex) => { const turnUsageMessages = turnUsageMessagesByGroupIndex[groupIndex]; const previousMessages = - findPreviousHumanMessages(groupedMessages, groupIndex) ?? []; + previousHumanMessagesByGroupIndex[groupIndex] ?? []; if (group.type === "human" || group.type === "assistant") { return ( diff --git a/frontend/src/core/threads/api.ts b/frontend/src/core/threads/api.ts index eb24b1468f..de392acfe6 100644 --- a/frontend/src/core/threads/api.ts +++ b/frontend/src/core/threads/api.ts @@ -6,6 +6,18 @@ import type { ThreadTokenUsageResponse, } from "./types"; +async function readErrorDetail(response: Response, fallback: string) { + try { + const body = (await response.json()) as { detail?: unknown }; + if (typeof body.detail === "string" && body.detail) { + return body.detail; + } + } catch { + // Ignore malformed error bodies and keep the stable fallback message. + } + return `${fallback} (${response.status})`; +} + export async function fetchThreadTokenUsage( threadId: string, ): Promise { @@ -48,7 +60,7 @@ export async function createThreadShare({ ); if (!response.ok) { - throw new Error("Failed to create share."); + throw new Error(await readErrorDetail(response, "Failed to create share.")); } return (await response.json()) as ThreadShareCreateResponse; diff --git a/frontend/tests/unit/core/threads/api.test.ts b/frontend/tests/unit/core/threads/api.test.ts index 52e59a292d..c67bd1d140 100644 --- a/frontend/tests/unit/core/threads/api.test.ts +++ b/frontend/tests/unit/core/threads/api.test.ts @@ -86,3 +86,20 @@ test("createThreadShare posts selected message ids", async () => { }, ); }); + +test("createThreadShare rejects with backend error detail", async () => { + fetchWithAuth.mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ detail: "Message IDs not found: missing-message" }), + }); + + const { createThreadShare } = await import("@/core/threads/api"); + + await expect( + createThreadShare({ + threadId: "thread-1", + messageIds: ["missing-message"], + }), + ).rejects.toThrow("Message IDs not found: missing-message"); +}); From 2febe3f6fcf95e02ccb65ee7c21cb677e4099784 Mon Sep 17 00:00:00 2001 From: LittleChenLiya <64821731+LittleChenLiya@users.noreply.github.com> Date: Thu, 28 May 2026 21:40:04 +0800 Subject: [PATCH 3/6] fix: harden public share snapshots --- backend/app/gateway/routers/shares.py | 106 ++++++++++++++++-- backend/tests/test_shares_router.py | 102 ++++++++++++++++- frontend/src/app/share/[share_id]/page.tsx | 2 +- .../workspace/messages/message-list.tsx | 8 +- 4 files changed, 201 insertions(+), 17 deletions(-) diff --git a/backend/app/gateway/routers/shares.py b/backend/app/gateway/routers/shares.py index c6f232f540..f8174c4b72 100644 --- a/backend/app/gateway/routers/shares.py +++ b/backend/app/gateway/routers/shares.py @@ -4,6 +4,7 @@ import logging import secrets +from datetime import UTC, datetime, timedelta from typing import Any from fastapi import APIRouter, HTTPException, Request @@ -20,6 +21,8 @@ _SHARES_NS = ("shares",) _SHARE_ID_BYTES = 16 +_SHARE_RETENTION = timedelta(days=30) +_SHARE_TTL_MINUTES = _SHARE_RETENTION.total_seconds() / 60 class ShareCreateRequest(BaseModel): @@ -45,6 +48,25 @@ class ShareResponse(BaseModel): created_at: str +def _parse_iso_datetime(value: Any) -> datetime | None: + if not isinstance(value, str): + return None + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC) + + +def _is_expired_share(value: dict[str, Any], *, now: datetime | None = None) -> bool: + expires_at = _parse_iso_datetime(value.get("expires_at")) + if expires_at is None: + return False + return expires_at <= (now or datetime.now(UTC)) + + def _extract_message_id(message: dict[str, Any]) -> str | None: message_id = message.get("id") return message_id if isinstance(message_id, str) and message_id else None @@ -64,19 +86,50 @@ def _is_shareable_message(message: dict[str, Any]) -> bool: if message_type == "human": return _has_displayable_content(message) if message_type == "ai": - return _has_displayable_content(message) and not message.get("tool_calls") and not message.get("invalid_tool_calls") + has_tool_metadata = bool(message.get("tool_calls") or message.get("invalid_tool_calls")) + return _has_displayable_content(message) and not has_tool_metadata return False +def _to_public_message(message: dict[str, Any]) -> dict[str, Any]: + """Keep only fields needed to render a public read-only message.""" + public_message: dict[str, Any] = { + "type": message.get("type"), + "content": message.get("content"), + } + message_id = _extract_message_id(message) + if message_id is not None: + public_message["id"] = message_id + return public_message + + async def _put_unique_share(store, value: dict[str, Any]) -> str: + await _delete_expired_shares(store) + ttl = _SHARE_TTL_MINUTES if getattr(store, "supports_ttl", False) else None for _ in range(4): share_id = secrets.token_urlsafe(_SHARE_ID_BYTES) if await store.aget(_SHARES_NS, share_id) is None: - await store.aput(_SHARES_NS, share_id, value) + if ttl is None: + await store.aput(_SHARES_NS, share_id, value) + else: + await store.aput(_SHARES_NS, share_id, value, ttl=ttl) return share_id raise HTTPException(status_code=500, detail="Failed to create share") +async def _delete_expired_shares(store) -> None: + if getattr(store, "supports_ttl", False): + return + try: + items = await store.asearch(_SHARES_NS, limit=100, refresh_ttl=False) + now = datetime.now(UTC) + for item in items: + if _is_expired_share(item.value or {}, now=now): + await store.adelete(tuple(item.namespace), item.key) + except Exception: + logger.debug("Failed to cleanup expired share snapshots", exc_info=True) + + @router.post("/threads/{thread_id}", response_model=ShareCreateResponse) @require_permission("threads", "read", owner_check=True, require_existing=True) async def create_thread_share(thread_id: str, body: ShareCreateRequest, request: Request) -> ShareCreateResponse: @@ -93,7 +146,10 @@ async def create_thread_share(thread_id: str, body: ShareCreateRequest, request: try: checkpoint_tuple = await checkpointer.aget_tuple(config) except Exception: - logger.exception("Failed to get state for share source thread %s", sanitize_log_param(thread_id)) + logger.exception( + "Failed to get state for share source thread %s", + sanitize_log_param(thread_id), + ) raise HTTPException(status_code=500, detail="Failed to create share") if checkpoint_tuple is None: @@ -111,17 +167,36 @@ async def create_thread_share(thread_id: str, body: ShareCreateRequest, request: raise HTTPException(status_code=400, detail="No message IDs selected") requested_id_set = set(requested_ids) - selected_messages = [message for message in all_messages if isinstance(message, dict) and _extract_message_id(message) in requested_id_set] - selected_id_set = {message_id for message in selected_messages if (message_id := _extract_message_id(message)) is not None} + selected_messages: list[dict[str, Any]] = [] + selected_id_set: set[str] = set() + for message in all_messages: + if not isinstance(message, dict): + continue + message_id = _extract_message_id(message) + if message_id in requested_id_set: + selected_messages.append(message) + selected_id_set.add(message_id) + missing_ids = [message_id for message_id in requested_ids if message_id not in selected_id_set] if missing_ids: - raise HTTPException(status_code=400, detail=f"Message IDs not found: {', '.join(missing_ids)}") - - non_shareable_ids = [message_id for message in selected_messages if (message_id := _extract_message_id(message)) is not None and not _is_shareable_message(message)] + raise HTTPException( + status_code=400, + detail=f"Message IDs not found: {', '.join(missing_ids)}", + ) + + non_shareable_ids: list[str] = [] + for message in selected_messages: + message_id = _extract_message_id(message) + if message_id is not None and not _is_shareable_message(message): + non_shareable_ids.append(message_id) if non_shareable_ids: - raise HTTPException(status_code=400, detail=f"Message IDs are not shareable: {', '.join(non_shareable_ids)}") + raise HTTPException( + status_code=400, + detail=f"Message IDs are not shareable: {', '.join(non_shareable_ids)}", + ) created_at = now_iso() + expires_at = (datetime.now(UTC) + _SHARE_RETENTION).isoformat() title = body.title or serialized_values.get("title") if not isinstance(title, str): title = None @@ -130,8 +205,9 @@ async def create_thread_share(thread_id: str, body: ShareCreateRequest, request: store, { "title": title, - "messages": selected_messages, + "messages": [_to_public_message(message) for message in selected_messages], "created_at": created_at, + "expires_at": expires_at, }, ) return ShareCreateResponse(share_id=share_id, title=title, created_at=created_at) @@ -149,13 +225,21 @@ async def get_share(share_id: str, request: Request) -> ShareResponse: raise HTTPException(status_code=404, detail="Share not found") value = item.value or {} + if _is_expired_share(value): + await store.adelete(_SHARES_NS, share_id) + raise HTTPException(status_code=404, detail="Share not found") + messages = value.get("messages", []) if not isinstance(messages, list): messages = [] + public_messages: list[dict[str, Any]] = [] + for message in messages: + if isinstance(message, dict) and _is_shareable_message(message): + public_messages.append(_to_public_message(message)) title = value.get("title") return ShareResponse( share_id=share_id, title=title if isinstance(title, str) else None, - messages=messages, + messages=public_messages, created_at=value.get("created_at", ""), ) diff --git a/backend/tests/test_shares_router.py b/backend/tests/test_shares_router.py index 6892008cee..f68fb1bfe7 100644 --- a/backend/tests/test_shares_router.py +++ b/backend/tests/test_shares_router.py @@ -10,6 +10,18 @@ from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore +class _ShareTestStore(InMemoryStore): + def __init__(self, *, supports_ttl: bool = True) -> None: + super().__init__() + self.supports_ttl = supports_ttl + self.put_ttls: list[float | None] = [] + + async def aput(self, *args, **kwargs): # type: ignore[no-untyped-def] + if args and args[0] == shares._SHARES_NS: + self.put_ttls.append(kwargs.get("ttl")) + await super().aput(*args, **kwargs) + + class _FakeCheckpointer: def __init__(self) -> None: self.checkpoints: dict[str, dict] = {} @@ -22,9 +34,13 @@ async def aget_tuple(self, config: dict): return SimpleNamespace(checkpoint=checkpoint) -def _build_share_app(*, owner_check_passes: bool = True) -> tuple[TestClient, InMemoryStore, _FakeCheckpointer]: +def _build_share_app( + *, + owner_check_passes: bool = True, + store_supports_ttl: bool = True, +) -> tuple[TestClient, _ShareTestStore, _FakeCheckpointer]: app = make_authed_test_app(owner_check_passes=owner_check_passes) - store = InMemoryStore() + store = _ShareTestStore(supports_ttl=store_supports_ttl) checkpointer = _FakeCheckpointer() app.state.store = store app.state.checkpointer = checkpointer @@ -40,8 +56,17 @@ async def _seed() -> None: "title": "Share source", "messages": [ HumanMessage(content="Question", id="human-1"), - AIMessage(content="Answer", id="ai-1"), - AIMessage(content="", id="tool-call-1", tool_calls=[{"name": "search", "args": {}, "id": "call-1"}]), + AIMessage( + content="Answer", + id="ai-1", + additional_kwargs={"private": "not public"}, + response_metadata={"model": "hidden"}, + ), + AIMessage( + content="", + id="tool-call-1", + tool_calls=[{"name": "search", "args": {}, "id": "call-1"}], + ), HumanMessage(content="Follow-up", id="human-2"), ], }, @@ -68,6 +93,10 @@ def test_create_share_snapshots_selected_messages_and_public_read() -> None: assert body["title"] == "Share source" assert [message["id"] for message in body["messages"]] == ["human-1", "ai-1"] assert [message["content"] for message in body["messages"]] == ["Question", "Answer"] + assert body["messages"][1] == {"type": "ai", "content": "Answer", "id": "ai-1"} + assert "response_metadata" not in body["messages"][1] + assert "additional_kwargs" not in body["messages"][1] + assert store.put_ttls == [shares._SHARE_TTL_MINUTES] def test_create_share_rejects_unknown_message_id() -> None: @@ -128,3 +157,68 @@ def test_create_share_returns_503_without_checkpointer() -> None: ) assert response.status_code == 503 + + +def test_get_share_deletes_expired_snapshot_when_ttl_is_unavailable() -> None: + client, store, checkpointer = _build_share_app(store_supports_ttl=False) + _seed_thread(store, checkpointer, "thread-share") + + response = client.post( + "/api/shares/threads/thread-share", + json={"message_ids": ["human-1", "ai-1"]}, + ) + assert response.status_code == 200, response.text + share_id = response.json()["share_id"] + + async def _expire_share() -> None: + item = await store.aget(shares._SHARES_NS, share_id) + assert item is not None + value = dict(item.value) + value["expires_at"] = "2000-01-01T00:00:00+00:00" + await store.aput(shares._SHARES_NS, share_id, value) + + asyncio.run(_expire_share()) + + public_response = client.get(f"/api/shares/{share_id}") + assert public_response.status_code == 404 + + async def _assert_deleted() -> None: + assert await store.aget(shares._SHARES_NS, share_id) is None + + asyncio.run(_assert_deleted()) + + +def test_get_share_normalizes_stored_messages() -> None: + client, store, _checkpointer = _build_share_app() + + async def _seed_share() -> None: + await store.aput( + shares._SHARES_NS, + "share-with-metadata", + { + "title": "Legacy share", + "created_at": "2026-05-28T00:00:00+00:00", + "messages": [ + { + "id": "ai-1", + "type": "ai", + "content": "Answer", + "response_metadata": {"model": "hidden"}, + "additional_kwargs": {"private": "not public"}, + }, + { + "id": "tool-call-1", + "type": "ai", + "content": "", + "tool_calls": [{"name": "search", "args": {}, "id": "call-1"}], + }, + ], + }, + ) + + asyncio.run(_seed_share()) + + response = client.get("/api/shares/share-with-metadata") + + assert response.status_code == 200, response.text + assert response.json()["messages"] == [{"type": "ai", "content": "Answer", "id": "ai-1"}] diff --git a/frontend/src/app/share/[share_id]/page.tsx b/frontend/src/app/share/[share_id]/page.tsx index 6d865a6a86..e1a42ee8a7 100644 --- a/frontend/src/app/share/[share_id]/page.tsx +++ b/frontend/src/app/share/[share_id]/page.tsx @@ -87,7 +87,7 @@ export default function SharePage() {
typeof id === "string" && id.length > 0); return ( -
+
{enableSharing && (