Jiya3177 commited on
Commit
3ef9fca
·
1 Parent(s): 3a10c69

feat: add shareable answer links

Browse files
backend/app/models.py CHANGED
@@ -62,3 +62,15 @@ class ChatMessage(Base):
62
  # Relationships
63
  user = relationship("User", back_populates="messages")
64
  document = relationship("Document", back_populates="messages")
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  # Relationships
63
  user = relationship("User", back_populates="messages")
64
  document = relationship("Document", back_populates="messages")
65
+ shared_message = relationship("SharedMessage", back_populates="message", uselist=False, cascade="all, delete-orphan")
66
+
67
+
68
+ class SharedMessage(Base):
69
+ __tablename__ = "shared_messages"
70
+
71
+ id = Column(String, primary_key=True, default=generate_uuid)
72
+ message_id = Column(String, ForeignKey("chat_messages.id"), nullable=False, unique=True, index=True)
73
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
74
+
75
+ # Relationships
76
+ message = relationship("ChatMessage", back_populates="shared_message")
backend/app/routes/chat.py CHANGED
@@ -17,8 +17,16 @@ from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
17
  from sqlalchemy.orm import Session
18
 
19
  from app.database import get_db
20
- from app.models import User, ChatMessage, Document
21
- from app.schemas import ChatRequest, ChatResponse, ChatMessageResponse, ChatHistoryResponse, SourceChunk
 
 
 
 
 
 
 
 
22
  from app.auth import get_current_user
23
  from app.rag.agent import generate_answer, generate_answer_stream
24
  from app.rate_limit import limiter
@@ -28,6 +36,53 @@ logger = logging.getLogger(__name__)
28
  router = APIRouter(prefix="/chat", tags=["Chat"])
29
 
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  @router.post("/ask", response_model=ChatResponse)
32
  @limiter.limit("10/minute")
33
  def ask_question(
@@ -425,6 +480,23 @@ def _save_message(
425
  db.commit()
426
 
427
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  def _format_markdown(doc, messages) -> str:
429
  """Format chat history as a Markdown document.
430
 
 
17
  from sqlalchemy.orm import Session
18
 
19
  from app.database import get_db
20
+ from app.models import User, ChatMessage, Document, SharedMessage
21
+ from app.schemas import (
22
+ ChatRequest,
23
+ ChatResponse,
24
+ ChatMessageResponse,
25
+ ChatHistoryResponse,
26
+ ShareAnswerResponse,
27
+ ShareLinkResponse,
28
+ SourceChunk,
29
+ )
30
  from app.auth import get_current_user
31
  from app.rag.agent import generate_answer, generate_answer_stream
32
  from app.rate_limit import limiter
 
36
  router = APIRouter(prefix="/chat", tags=["Chat"])
37
 
38
 
39
+ @router.get("/share/{message_id}", response_model=ShareAnswerResponse)
40
+ def get_shared_answer(
41
+ message_id: str,
42
+ db: Session = Depends(get_db),
43
+ ):
44
+ """Fetch a single assistant answer for public sharing."""
45
+ message = db.query(ChatMessage).filter(
46
+ ChatMessage.id == message_id,
47
+ ChatMessage.role == "assistant",
48
+ ).first()
49
+
50
+ if not message or not db.query(SharedMessage).filter(SharedMessage.message_id == message.id).first():
51
+ raise HTTPException(status_code=404, detail="Shared answer not found")
52
+
53
+ return _share_answer_response(message)
54
+
55
+
56
+ @router.post("/share/{message_id}", response_model=ShareLinkResponse)
57
+ def create_share_link(
58
+ message_id: str,
59
+ user: User = Depends(get_current_user),
60
+ db: Session = Depends(get_db),
61
+ ):
62
+ """Create a public share URL for a user's assistant answer."""
63
+ message = db.query(ChatMessage).filter(
64
+ ChatMessage.id == message_id,
65
+ ChatMessage.user_id == user.id,
66
+ ).first()
67
+
68
+ if not message:
69
+ raise HTTPException(status_code=404, detail="Message not found")
70
+
71
+ if message.role != "assistant":
72
+ raise HTTPException(status_code=400, detail="Only assistant messages can be shared")
73
+
74
+ shared_message = db.query(SharedMessage).filter(SharedMessage.message_id == message.id).first()
75
+ if not shared_message:
76
+ shared_message = SharedMessage(message_id=message.id)
77
+ db.add(shared_message)
78
+ db.commit()
79
+
80
+ return ShareLinkResponse(
81
+ message_id=message.id,
82
+ share_url=f"/share?message_id={message.id}",
83
+ )
84
+
85
+
86
  @router.post("/ask", response_model=ChatResponse)
87
  @limiter.limit("10/minute")
88
  def ask_question(
 
480
  db.commit()
481
 
482
 
483
+ def _share_answer_response(message: ChatMessage) -> ShareAnswerResponse:
484
+ """Format a shared assistant message with only safe public fields."""
485
+ sources = []
486
+ if message.sources_json:
487
+ try:
488
+ sources = [SourceChunk(**item) for item in json.loads(message.sources_json)]
489
+ except Exception:
490
+ sources = []
491
+
492
+ return ShareAnswerResponse(
493
+ id=message.id,
494
+ content=message.content,
495
+ created_at=message.created_at,
496
+ sources=sources,
497
+ )
498
+
499
+
500
  def _format_markdown(doc, messages) -> str:
501
  """Format chat history as a Markdown document.
502
 
backend/app/schemas.py CHANGED
@@ -136,5 +136,17 @@ class ChatHistoryResponse(BaseModel):
136
  document_id: Optional[str] = None
137
 
138
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  # Rebuild models for forward references
140
  TokenResponse.model_rebuild()
 
136
  document_id: Optional[str] = None
137
 
138
 
139
+ class ShareAnswerResponse(BaseModel):
140
+ id: str
141
+ content: str
142
+ sources: List[SourceChunk] = []
143
+ created_at: datetime
144
+
145
+
146
+ class ShareLinkResponse(BaseModel):
147
+ message_id: str
148
+ share_url: str
149
+
150
+
151
  # Rebuild models for forward references
152
  TokenResponse.model_rebuild()
backend/tests/conftest.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import types
4
+ from contextlib import asynccontextmanager
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+ from fastapi.testclient import TestClient
9
+ from sqlalchemy import create_engine
10
+ from sqlalchemy.orm import sessionmaker
11
+
12
+ ROOT = Path(__file__).resolve().parents[2]
13
+ BACKEND_DIR = ROOT / "backend"
14
+
15
+ if str(BACKEND_DIR) not in sys.path:
16
+ sys.path.insert(0, str(BACKEND_DIR))
17
+
18
+ os.environ.setdefault("SECRET_KEY", "test-secret-key-that-is-long-enough")
19
+ os.environ.setdefault("DATABASE_URL", "sqlite:///./test_share_bootstrap.db")
20
+ os.environ.setdefault("HF_TOKEN", "test-hf-token")
21
+ os.environ.setdefault("UPLOAD_DIR", str(ROOT / "backend" / "test_uploads"))
22
+ os.environ.setdefault("CHROMA_PERSIST_DIR", str(ROOT / "backend" / "test_chroma"))
23
+
24
+ fake_embeddings = types.ModuleType("app.rag.embeddings")
25
+ fake_embeddings.get_embedding_model = lambda: object()
26
+ fake_embeddings.embed_query = lambda query: [0.0]
27
+ fake_embeddings.embed_texts = lambda texts: [[0.0] for _ in texts]
28
+ sys.modules.setdefault("app.rag.embeddings", fake_embeddings)
29
+
30
+
31
+ class _FakeChromaClient:
32
+ def heartbeat(self):
33
+ return "ok"
34
+
35
+
36
+ fake_vectorstore = types.ModuleType("app.rag.vectorstore")
37
+ fake_vectorstore.get_chroma_client = lambda: _FakeChromaClient()
38
+ fake_vectorstore.store_chunks = lambda chunks, document_id, filename, user_id: len(chunks)
39
+ fake_vectorstore.delete_document_chunks = lambda document_id, user_id: None
40
+ fake_vectorstore.query_chunks = lambda query_embedding, user_id, document_id=None, top_k=10: []
41
+ sys.modules.setdefault("app.rag.vectorstore", fake_vectorstore)
42
+
43
+ slowapi_module = types.ModuleType("slowapi")
44
+ slowapi_errors = types.ModuleType("slowapi.errors")
45
+ slowapi_middleware = types.ModuleType("slowapi.middleware")
46
+ slowapi_util = types.ModuleType("slowapi.util")
47
+
48
+
49
+ class RateLimitExceeded(Exception):
50
+ pass
51
+
52
+
53
+ class SlowAPIMiddleware:
54
+ def __init__(self, app, *args, **kwargs):
55
+ self.app = app
56
+
57
+ async def __call__(self, scope, receive, send):
58
+ await self.app(scope, receive, send)
59
+
60
+
61
+ class Limiter:
62
+ def __init__(self, key_func=None, *args, **kwargs):
63
+ self.key_func = key_func
64
+
65
+ def limit(self, _value):
66
+ def decorator(fn):
67
+ return fn
68
+ return decorator
69
+
70
+
71
+ slowapi_errors.RateLimitExceeded = RateLimitExceeded
72
+ slowapi_middleware.SlowAPIMiddleware = SlowAPIMiddleware
73
+ slowapi_util.get_remote_address = lambda request: "127.0.0.1"
74
+ slowapi_module.Limiter = Limiter
75
+
76
+ sys.modules.setdefault("slowapi", slowapi_module)
77
+ sys.modules.setdefault("slowapi.errors", slowapi_errors)
78
+ sys.modules.setdefault("slowapi.middleware", slowapi_middleware)
79
+ sys.modules.setdefault("slowapi.util", slowapi_util)
80
+
81
+ from app.auth import create_access_token, hash_password
82
+ from app.database import Base, get_db
83
+ from app.main import app
84
+ from app.models import ChatMessage, User
85
+
86
+
87
+ @pytest.fixture()
88
+ def db_session(tmp_path):
89
+ db_file = tmp_path / "test.db"
90
+ engine = create_engine(
91
+ f"sqlite:///{db_file}",
92
+ connect_args={"check_same_thread": False},
93
+ )
94
+ TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
95
+ Base.metadata.create_all(bind=engine)
96
+
97
+ session = TestingSessionLocal()
98
+ try:
99
+ yield session
100
+ finally:
101
+ session.close()
102
+ Base.metadata.drop_all(bind=engine)
103
+ engine.dispose()
104
+
105
+
106
+ @pytest.fixture()
107
+ def client(db_session, monkeypatch):
108
+ def override_get_db():
109
+ try:
110
+ yield db_session
111
+ finally:
112
+ pass
113
+
114
+ @asynccontextmanager
115
+ async def no_lifespan(_app):
116
+ yield
117
+
118
+ monkeypatch.setattr("app.database.SessionLocal", lambda: db_session)
119
+ app.dependency_overrides[get_db] = override_get_db
120
+ app.router.lifespan_context = no_lifespan
121
+
122
+ with TestClient(app) as test_client:
123
+ yield test_client
124
+
125
+ app.dependency_overrides.clear()
126
+
127
+
128
+ @pytest.fixture()
129
+ def user(db_session):
130
+ instance = User(
131
+ username="tester",
132
+ email="tester@example.com",
133
+ hashed_password=hash_password("password123"),
134
+ )
135
+ db_session.add(instance)
136
+ db_session.commit()
137
+ db_session.refresh(instance)
138
+ return instance
139
+
140
+
141
+ @pytest.fixture()
142
+ def other_user(db_session):
143
+ instance = User(
144
+ username="other",
145
+ email="other@example.com",
146
+ hashed_password=hash_password("password123"),
147
+ )
148
+ db_session.add(instance)
149
+ db_session.commit()
150
+ db_session.refresh(instance)
151
+ return instance
152
+
153
+
154
+ @pytest.fixture()
155
+ def auth_headers(user):
156
+ token = create_access_token(user.id)
157
+ return {"Authorization": f"Bearer {token}"}
158
+
159
+
160
+ @pytest.fixture()
161
+ def assistant_message(db_session, user):
162
+ instance = ChatMessage(
163
+ user_id=user.id,
164
+ role="assistant",
165
+ content="Shared assistant answer",
166
+ sources_json='[{"text":"Source text","filename":"file.txt","page":1,"score":0.9,"confidence":95.0}]',
167
+ )
168
+ db_session.add(instance)
169
+ db_session.commit()
170
+ db_session.refresh(instance)
171
+ return instance
172
+
173
+
174
+ @pytest.fixture()
175
+ def user_message(db_session, user):
176
+ instance = ChatMessage(
177
+ user_id=user.id,
178
+ role="user",
179
+ content="Private user prompt",
180
+ )
181
+ db_session.add(instance)
182
+ db_session.commit()
183
+ db_session.refresh(instance)
184
+ return instance
185
+
186
+
187
+ @pytest.fixture()
188
+ def other_user_assistant_message(db_session, other_user):
189
+ instance = ChatMessage(
190
+ user_id=other_user.id,
191
+ role="assistant",
192
+ content="Other user's answer",
193
+ )
194
+ db_session.add(instance)
195
+ db_session.commit()
196
+ db_session.refresh(instance)
197
+ return instance
backend/tests/test_share.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def test_share_link_creation_success(client, auth_headers, assistant_message):
2
+ response = client.post(
3
+ f"/api/v1/chat/share/{assistant_message.id}",
4
+ headers=auth_headers,
5
+ )
6
+
7
+ assert response.status_code == 200
8
+ payload = response.json()
9
+ assert payload["message_id"] == assistant_message.id
10
+ assert payload["share_url"] == f"/share?message_id={assistant_message.id}"
11
+
12
+
13
+ def test_share_link_unauthorized_for_other_users_message(client, auth_headers, other_user_assistant_message):
14
+ response = client.post(
15
+ f"/api/v1/chat/share/{other_user_assistant_message.id}",
16
+ headers=auth_headers,
17
+ )
18
+
19
+ assert response.status_code == 404
20
+ assert response.json()["detail"] == "Message not found"
21
+
22
+
23
+ def test_cannot_share_user_message(client, auth_headers, user_message):
24
+ response = client.post(
25
+ f"/api/v1/chat/share/{user_message.id}",
26
+ headers=auth_headers,
27
+ )
28
+
29
+ assert response.status_code == 400
30
+ assert response.json()["detail"] == "Only assistant messages can be shared"
31
+
32
+
33
+ def test_public_fetch_fails_before_share(client, assistant_message):
34
+ response = client.get(f"/api/v1/chat/share/{assistant_message.id}")
35
+
36
+ assert response.status_code == 404
37
+ assert response.json()["detail"] == "Shared answer not found"
38
+
39
+
40
+ def test_public_fetch_shared_answer_success_after_share(client, auth_headers, assistant_message):
41
+ share_response = client.post(
42
+ f"/api/v1/chat/share/{assistant_message.id}",
43
+ headers=auth_headers,
44
+ )
45
+ assert share_response.status_code == 200
46
+
47
+ response = client.get(f"/api/v1/chat/share/{assistant_message.id}")
48
+
49
+ assert response.status_code == 200
50
+ payload = response.json()
51
+ assert payload["id"] == assistant_message.id
52
+ assert payload["content"] == "Shared assistant answer"
53
+ assert len(payload["sources"]) == 1
54
+ assert payload["sources"][0]["filename"] == "file.txt"
55
+
56
+
57
+ def test_missing_message_returns_404(client):
58
+ response = client.get("/api/v1/chat/share/missing-message-id")
59
+
60
+ assert response.status_code == 404
61
+ assert response.json()["detail"] == "Shared answer not found"
frontend/src/app/share/page.tsx ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { Suspense, useEffect, useState } from "react";
4
+ import { useSearchParams } from "next/navigation";
5
+ import ReactMarkdown, { type Components } from "react-markdown";
6
+ import rehypeHighlight from "rehype-highlight";
7
+ import remarkGfm from "remark-gfm";
8
+ import { Brain } from "lucide-react";
9
+ import { api } from "@/lib/api";
10
+
11
+ interface SharedSource {
12
+ text: string;
13
+ filename: string;
14
+ page: number;
15
+ score: number;
16
+ confidence: number;
17
+ }
18
+
19
+ interface SharedAnswer {
20
+ id: string;
21
+ content: string;
22
+ created_at: string;
23
+ sources: SharedSource[];
24
+ }
25
+
26
+ const markdownComponents: Components = {
27
+ table: ({ children }) => (
28
+ <div className="my-3 overflow-x-auto rounded-lg border border-border/70">
29
+ <table className="min-w-full border-collapse text-left text-sm">
30
+ {children}
31
+ </table>
32
+ </div>
33
+ ),
34
+ thead: ({ children }) => (
35
+ <thead className="bg-muted/60 text-foreground">{children}</thead>
36
+ ),
37
+ th: ({ children }) => (
38
+ <th className="border-b border-border/70 px-3 py-2 font-semibold">
39
+ {children}
40
+ </th>
41
+ ),
42
+ td: ({ children }) => (
43
+ <td className="border-b border-border/50 px-3 py-2 align-top">
44
+ {children}
45
+ </td>
46
+ ),
47
+ pre: ({ children }) => (
48
+ <pre className="not-prose my-3 overflow-x-auto rounded-lg border border-border/70 bg-zinc-950 p-3 text-sm text-zinc-100">
49
+ {children}
50
+ </pre>
51
+ ),
52
+ code: ({ className, children, ...props }) => {
53
+ const language = /language-(\w+)/.exec(className ?? "")?.[1];
54
+
55
+ return (
56
+ <code className={className} data-language={language} {...props}>
57
+ {children}
58
+ </code>
59
+ );
60
+ },
61
+ };
62
+
63
+ function ShareAnswerContent() {
64
+ const searchParams = useSearchParams();
65
+ const messageId = searchParams.get("message_id");
66
+ const missingMessageId = !messageId;
67
+ const [answer, setAnswer] = useState<SharedAnswer | null>(null);
68
+ const [error, setError] = useState("");
69
+ const loading = !error && !answer && !missingMessageId;
70
+
71
+ useEffect(() => {
72
+ if (missingMessageId) {
73
+ return;
74
+ }
75
+
76
+ let cancelled = false;
77
+
78
+ void api
79
+ .get<SharedAnswer>(`/api/v1/chat/share/${messageId}`)
80
+ .then((data) => {
81
+ if (cancelled) return;
82
+ setAnswer(data);
83
+ setError("");
84
+ })
85
+ .catch((err: unknown) => {
86
+ if (cancelled) return;
87
+ setAnswer(null);
88
+ setError(err instanceof Error ? err.message : "Shared answer not found");
89
+ });
90
+
91
+ return () => {
92
+ cancelled = true;
93
+ };
94
+ }, [messageId, missingMessageId]);
95
+
96
+ if (missingMessageId) {
97
+ return (
98
+ <div className="min-h-screen flex items-center justify-center px-4">
99
+ <div className="rounded-xl border border-border/50 bg-card/80 px-6 py-5 text-center">
100
+ <p className="text-lg font-semibold mb-1">Shared answer unavailable</p>
101
+ <p className="text-sm text-muted-foreground">This shared answer could not be found.</p>
102
+ </div>
103
+ </div>
104
+ );
105
+ }
106
+
107
+ if (loading) {
108
+ return (
109
+ <div className="min-h-screen flex items-center justify-center px-4">
110
+ <div className="text-sm text-muted-foreground">Loading shared answer...</div>
111
+ </div>
112
+ );
113
+ }
114
+
115
+ if (error || !answer) {
116
+ return (
117
+ <div className="min-h-screen flex items-center justify-center px-4">
118
+ <div className="rounded-xl border border-border/50 bg-card/80 px-6 py-5 text-center">
119
+ <p className="text-lg font-semibold mb-1">Shared answer unavailable</p>
120
+ <p className="text-sm text-muted-foreground">{error || "This shared answer could not be found."}</p>
121
+ </div>
122
+ </div>
123
+ );
124
+ }
125
+
126
+ return (
127
+ <div className="min-h-screen px-4 py-10 bg-background text-foreground">
128
+ <div className="max-w-3xl mx-auto">
129
+ <div className="rounded-2xl border border-border/50 bg-card/80 backdrop-blur-sm p-6">
130
+ <div className="flex items-center gap-3 mb-5">
131
+ <div className="w-10 h-10 rounded-xl bg-primary/15 flex items-center justify-center">
132
+ <Brain className="w-5 h-5 text-primary" />
133
+ </div>
134
+ <div>
135
+ <h1 className="text-xl font-semibold">Shared AI Answer</h1>
136
+ <p className="text-sm text-muted-foreground">
137
+ {new Date(answer.created_at).toLocaleString()}
138
+ </p>
139
+ </div>
140
+ </div>
141
+
142
+ <div className="prose-chat text-sm">
143
+ <ReactMarkdown
144
+ remarkPlugins={[remarkGfm]}
145
+ rehypePlugins={[rehypeHighlight]}
146
+ components={markdownComponents}
147
+ >
148
+ {answer.content}
149
+ </ReactMarkdown>
150
+ </div>
151
+
152
+ {answer.sources.length > 0 && (
153
+ <div className="mt-6 border-t border-border/50 pt-4">
154
+ <h2 className="text-sm font-semibold mb-3">Sources</h2>
155
+ <div className="space-y-2">
156
+ {answer.sources.map((source, index) => (
157
+ <div
158
+ key={`${answer.id}-${index}`}
159
+ className="rounded-lg border border-border/50 bg-background/60 p-3"
160
+ >
161
+ <p className="text-xs font-medium mb-1">
162
+ {source.filename} • Page {source.page}
163
+ </p>
164
+ <p className="text-xs text-muted-foreground">{source.text}</p>
165
+ </div>
166
+ ))}
167
+ </div>
168
+ </div>
169
+ )}
170
+ </div>
171
+ </div>
172
+ </div>
173
+ );
174
+ }
175
+
176
+ export default function ShareAnswerPage() {
177
+ return (
178
+ <Suspense
179
+ fallback={
180
+ <div className="min-h-screen flex items-center justify-center px-4">
181
+ <div className="text-sm text-muted-foreground">Loading shared answer...</div>
182
+ </div>
183
+ }
184
+ >
185
+ <ShareAnswerContent />
186
+ </Suspense>
187
+ );
188
+ }
frontend/src/components/chat/MessageBubble.tsx CHANGED
@@ -5,7 +5,8 @@ import ReactMarkdown, { type Components } from "react-markdown";
5
  import rehypeHighlight from "rehype-highlight";
6
  import remarkGfm from "remark-gfm";
7
  import type { ChatMsg } from "@/store/chat-store";
8
- import { Brain, User, Copy, Check } from "lucide-react";
 
9
  import { Button } from "@/components/ui/button";
10
 
11
  interface Props {
@@ -52,7 +53,10 @@ const markdownComponents: Components = {
52
  export default function MessageBubble({ message }: Props) {
53
  const isUser = message.role === "user";
54
  const [copied, setCopied] = useState(false);
 
 
55
  const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
 
56
 
57
  const handleCopy = async () => {
58
  if (!message.content) return;
@@ -66,6 +70,31 @@ export default function MessageBubble({ message }: Props) {
66
  }
67
  };
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  return (
70
  <div
71
  className={`flex gap-3 py-3 animate-fade-in-up ${isUser ? "justify-end" : "justify-start"}`}
@@ -88,26 +117,50 @@ export default function MessageBubble({ message }: Props) {
88
  ) : (
89
  <>
90
  {message.content && (
91
- <Button
92
- type="button"
93
- variant="ghost"
94
- size="icon-xs"
95
- className={`absolute top-2 right-2 text-muted-foreground hover:text-foreground transition-opacity ${
96
- copied
97
- ? "opacity-100"
98
- : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto"
99
- }`}
100
- onClick={handleCopy}
101
- aria-label={copied ? "Copied" : "Copy response"}
102
- >
103
- {copied ? (
104
- <Check className="w-3.5 h-3.5 text-emerald-400" />
105
- ) : (
106
- <Copy className="w-3.5 h-3.5" />
 
 
 
 
 
 
107
  )}
108
- </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  )}
110
- <div className={`prose-chat text-sm ${message.content ? "pr-7" : ""}`}>
111
  {message.content ? (
112
  <ReactMarkdown
113
  remarkPlugins={[remarkGfm]}
 
5
  import rehypeHighlight from "rehype-highlight";
6
  import remarkGfm from "remark-gfm";
7
  import type { ChatMsg } from "@/store/chat-store";
8
+ import { api } from "@/lib/api";
9
+ import { Brain, User, Copy, Check, Share2, Link2, X } from "lucide-react";
10
  import { Button } from "@/components/ui/button";
11
 
12
  interface Props {
 
53
  export default function MessageBubble({ message }: Props) {
54
  const isUser = message.role === "user";
55
  const [copied, setCopied] = useState(false);
56
+ const [shared, setShared] = useState(false);
57
+ const [shareFailed, setShareFailed] = useState(false);
58
  const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
59
+ const sharedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
60
 
61
  const handleCopy = async () => {
62
  if (!message.content) return;
 
70
  }
71
  };
72
 
73
+ const handleShare = async () => {
74
+ if (!message.content || message.isStreaming) return;
75
+
76
+ try {
77
+ const data = await api.post<{ message_id: string; share_url: string }>(
78
+ `/api/v1/chat/share/${message.id}`
79
+ );
80
+ await navigator.clipboard.writeText(`${window.location.origin}${data.share_url}`);
81
+ setShared(true);
82
+ setShareFailed(false);
83
+ if (sharedTimeoutRef.current) clearTimeout(sharedTimeoutRef.current);
84
+ sharedTimeoutRef.current = setTimeout(() => {
85
+ setShared(false);
86
+ setShareFailed(false);
87
+ }, 2000);
88
+ } catch {
89
+ setShareFailed(true);
90
+ setShared(false);
91
+ if (sharedTimeoutRef.current) clearTimeout(sharedTimeoutRef.current);
92
+ sharedTimeoutRef.current = setTimeout(() => {
93
+ setShareFailed(false);
94
+ }, 2000);
95
+ }
96
+ };
97
+
98
  return (
99
  <div
100
  className={`flex gap-3 py-3 animate-fade-in-up ${isUser ? "justify-end" : "justify-start"}`}
 
117
  ) : (
118
  <>
119
  {message.content && (
120
+ <>
121
+ {!message.isStreaming && (
122
+ <Button
123
+ type="button"
124
+ variant="ghost"
125
+ size="icon-xs"
126
+ className={`absolute top-2 right-2 text-muted-foreground hover:text-foreground transition-opacity ${
127
+ shared || shareFailed
128
+ ? "opacity-100"
129
+ : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto"
130
+ }`}
131
+ onClick={handleShare}
132
+ aria-label={shared ? "Link copied" : shareFailed ? "Share failed" : "Share response"}
133
+ >
134
+ {shared ? (
135
+ <Link2 className="w-3.5 h-3.5 text-emerald-400" />
136
+ ) : shareFailed ? (
137
+ <X className="w-3.5 h-3.5 text-destructive" />
138
+ ) : (
139
+ <Share2 className="w-3.5 h-3.5" />
140
+ )}
141
+ </Button>
142
  )}
143
+ <Button
144
+ type="button"
145
+ variant="ghost"
146
+ size="icon-xs"
147
+ className={`absolute top-2 right-9 text-muted-foreground hover:text-foreground transition-opacity ${
148
+ copied
149
+ ? "opacity-100"
150
+ : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto"
151
+ }`}
152
+ onClick={handleCopy}
153
+ aria-label={copied ? "Copied" : "Copy response"}
154
+ >
155
+ {copied ? (
156
+ <Check className="w-3.5 h-3.5 text-emerald-400" />
157
+ ) : (
158
+ <Copy className="w-3.5 h-3.5" />
159
+ )}
160
+ </Button>
161
+ </>
162
  )}
163
+ <div className={`prose-chat text-sm ${message.content ? "pr-14" : ""}`}>
164
  {message.content ? (
165
  <ReactMarkdown
166
  remarkPlugins={[remarkGfm]}