Paramjit Singh commited on
Commit
55c39bc
·
unverified ·
2 Parent(s): bd035a4886fdec

Merge pull request #192 from Jiya3177/feat/share-answer-165

Browse files
backend/app/models.py CHANGED
@@ -78,3 +78,15 @@ class ChatMessage(Base):
78
  # Relationships
79
  user = relationship("User", back_populates="messages")
80
  document = relationship("Document", back_populates="messages")
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  # Relationships
79
  user = relationship("User", back_populates="messages")
80
  document = relationship("Document", back_populates="messages")
81
+ shared_message = relationship("SharedMessage", back_populates="message", uselist=False, cascade="all, delete-orphan")
82
+
83
+
84
+ class SharedMessage(Base):
85
+ __tablename__ = "shared_messages"
86
+
87
+ id = Column(String, primary_key=True, default=generate_uuid)
88
+ message_id = Column(String, ForeignKey("chat_messages.id"), nullable=False, unique=True, index=True)
89
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
90
+
91
+ # Relationships
92
+ message = relationship("ChatMessage", back_populates="shared_message")
backend/app/routes/chat.py CHANGED
@@ -17,38 +17,81 @@ from reportlab.lib.units import inch
17
  from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
18
  from sqlalchemy.orm import Session
19
 
 
20
  from app.database import get_db
21
- from app.models import User, ChatMessage, Document
22
  from app.metrics import record_query_response_time
23
- from app.schemas import ChatRequest, ChatResponse, ChatMessageResponse, ChatHistoryResponse, SourceChunk
24
- from app.auth import get_current_user
25
  from app.rate_limit import limiter
 
 
 
 
 
 
 
 
 
26
 
27
  logger = logging.getLogger(__name__)
28
 
29
  router = APIRouter(prefix="/chat", tags=["Chat"])
30
 
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  def generate_answer(question: str, user_id: str, document_id: Optional[str] = None):
33
- """Import the RAG agent lazily so route tests can patch this boundary."""
34
  from app.rag.agent import generate_answer as _generate_answer
35
 
36
- return _generate_answer(
37
- question=question,
38
- user_id=user_id,
39
- document_id=document_id,
40
- )
41
 
42
 
43
  def generate_answer_stream(question: str, user_id: str, document_id: Optional[str] = None):
44
- """Import the streaming RAG agent lazily so route tests can patch this boundary."""
45
  from app.rag.agent import generate_answer_stream as _generate_answer_stream
46
 
47
- return _generate_answer_stream(
48
- question=question,
49
- user_id=user_id,
50
- document_id=document_id,
51
- )
52
 
53
 
54
  @router.post("/ask", response_model=ChatResponse)
@@ -456,6 +499,23 @@ def _save_message(
456
  db.commit()
457
 
458
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
459
  def _format_markdown(doc, messages) -> str:
460
  """Format chat history as a Markdown document.
461
 
 
17
  from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
18
  from sqlalchemy.orm import Session
19
 
20
+ from app.auth import get_current_user
21
  from app.database import get_db
 
22
  from app.metrics import record_query_response_time
23
+ from app.models import User, ChatMessage, Document, SharedMessage
 
24
  from app.rate_limit import limiter
25
+ from app.schemas import (
26
+ ChatRequest,
27
+ ChatResponse,
28
+ ChatMessageResponse,
29
+ ChatHistoryResponse,
30
+ ShareAnswerResponse,
31
+ ShareLinkResponse,
32
+ SourceChunk,
33
+ )
34
 
35
  logger = logging.getLogger(__name__)
36
 
37
  router = APIRouter(prefix="/chat", tags=["Chat"])
38
 
39
 
40
+ @router.get("/share/{message_id}", response_model=ShareAnswerResponse)
41
+ def get_shared_answer(
42
+ message_id: str,
43
+ db: Session = Depends(get_db),
44
+ ):
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
+ message = db.query(ChatMessage).filter(
63
+ ChatMessage.id == message_id,
64
+ ChatMessage.user_id == user.id,
65
+ ).first()
66
+
67
+ if not message:
68
+ raise HTTPException(status_code=404, detail="Message not found")
69
+
70
+ if message.role != "assistant":
71
+ raise HTTPException(status_code=400, detail="Only assistant messages can be shared")
72
+
73
+ shared_message = db.query(SharedMessage).filter(SharedMessage.message_id == message.id).first()
74
+ if not shared_message:
75
+ shared_message = SharedMessage(message_id=message.id)
76
+ db.add(shared_message)
77
+ db.commit()
78
+
79
+ return ShareLinkResponse(
80
+ message_id=message.id,
81
+ share_url=f"/share?message_id={message.id}",
82
+ )
83
+
84
+
85
  def generate_answer(question: str, user_id: str, document_id: Optional[str] = None):
 
86
  from app.rag.agent import generate_answer as _generate_answer
87
 
88
+ return _generate_answer(question=question, user_id=user_id, document_id=document_id)
 
 
 
 
89
 
90
 
91
  def generate_answer_stream(question: str, user_id: str, document_id: Optional[str] = None):
 
92
  from app.rag.agent import generate_answer_stream as _generate_answer_stream
93
 
94
+ return _generate_answer_stream(question=question, user_id=user_id, document_id=document_id)
 
 
 
 
95
 
96
 
97
  @router.post("/ask", response_model=ChatResponse)
 
499
  db.commit()
500
 
501
 
502
+ def _share_answer_response(message: ChatMessage) -> ShareAnswerResponse:
503
+ """Format a shared assistant message with only safe public fields."""
504
+ sources = []
505
+ if message.sources_json:
506
+ try:
507
+ sources = [SourceChunk(**item) for item in json.loads(message.sources_json)]
508
+ except Exception:
509
+ sources = []
510
+
511
+ return ShareAnswerResponse(
512
+ id=message.id,
513
+ content=message.content,
514
+ created_at=message.created_at,
515
+ sources=sources,
516
+ )
517
+
518
+
519
  def _format_markdown(doc, messages) -> str:
520
  """Format chat history as a Markdown document.
521
 
backend/app/schemas.py CHANGED
@@ -58,6 +58,19 @@ class HFTokenUpdate(BaseModel):
58
  hf_token: str
59
 
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  class UserResponse(BaseModel):
62
  id: str
63
  username: str
@@ -160,21 +173,16 @@ class ChatHistoryResponse(BaseModel):
160
  document_id: Optional[str] = None
161
 
162
 
163
- # ── ApiKeys ─────────────────────────────────────────────
164
-
165
- class ApiKeyResponse(BaseModel):
166
  id: str
167
- key_prefix: str
 
168
  created_at: datetime
169
- last_used: Optional[datetime] = None
170
-
171
- class Config:
172
- from_attributes = True
173
 
174
 
175
- class ApiKeyCreateResponse(BaseModel):
176
- key: str
177
- api_key: ApiKeyResponse
178
 
179
 
180
  # Rebuild models for forward references
 
58
  hf_token: str
59
 
60
 
61
+ class ApiKeyResponse(BaseModel):
62
+ id: str
63
+ key_preview: str
64
+ created_at: datetime
65
+
66
+ class Config:
67
+ from_attributes = True
68
+
69
+
70
+ class ApiKeyCreateResponse(ApiKeyResponse):
71
+ raw_key: str
72
+
73
+
74
  class UserResponse(BaseModel):
75
  id: str
76
  username: str
 
173
  document_id: Optional[str] = None
174
 
175
 
176
+ class ShareAnswerResponse(BaseModel):
 
 
177
  id: str
178
+ content: str
179
+ sources: List[SourceChunk] = []
180
  created_at: datetime
 
 
 
 
181
 
182
 
183
+ class ShareLinkResponse(BaseModel):
184
+ message_id: str
185
+ share_url: str
186
 
187
 
188
  # Rebuild models for forward references
backend/tests/conftest.py CHANGED
@@ -16,7 +16,7 @@ BACKEND_DIR = ROOT / "backend"
16
  if str(BACKEND_DIR) not in sys.path:
17
  sys.path.insert(0, str(BACKEND_DIR))
18
 
19
- os.environ["SECRET_KEY"] = "test-secret-key"
20
  os.environ["DATABASE_URL"] = "sqlite:///./test_bootstrap.db"
21
  os.environ["DEBUG"] = "false"
22
  os.environ["HF_TOKEN"] = "test-hf-token"
@@ -84,7 +84,7 @@ sys.modules.setdefault("slowapi.util", slowapi_util)
84
  from app.auth import create_access_token, create_refresh_token, hash_password
85
  from app.database import Base, get_db
86
  from app.main import app
87
- from app.models import Document, User
88
 
89
 
90
  @pytest.fixture()
@@ -141,6 +141,19 @@ def user(db_session):
141
  return instance
142
 
143
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  @pytest.fixture()
145
  def auth_headers(user):
146
  token = create_access_token(user.id)
@@ -182,3 +195,43 @@ def pending_document(db_session, user):
182
  db_session.commit()
183
  db_session.refresh(instance)
184
  return instance
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  if str(BACKEND_DIR) not in sys.path:
17
  sys.path.insert(0, str(BACKEND_DIR))
18
 
19
+ os.environ["SECRET_KEY"] = "test-secret-key-that-is-long-enough"
20
  os.environ["DATABASE_URL"] = "sqlite:///./test_bootstrap.db"
21
  os.environ["DEBUG"] = "false"
22
  os.environ["HF_TOKEN"] = "test-hf-token"
 
84
  from app.auth import create_access_token, create_refresh_token, hash_password
85
  from app.database import Base, get_db
86
  from app.main import app
87
+ from app.models import ChatMessage, Document, User
88
 
89
 
90
  @pytest.fixture()
 
141
  return instance
142
 
143
 
144
+ @pytest.fixture()
145
+ def other_user(db_session):
146
+ instance = User(
147
+ username="other",
148
+ email="other@example.com",
149
+ hashed_password=hash_password("password123"),
150
+ )
151
+ db_session.add(instance)
152
+ db_session.commit()
153
+ db_session.refresh(instance)
154
+ return instance
155
+
156
+
157
  @pytest.fixture()
158
  def auth_headers(user):
159
  token = create_access_token(user.id)
 
195
  db_session.commit()
196
  db_session.refresh(instance)
197
  return instance
198
+
199
+
200
+ @pytest.fixture()
201
+ def assistant_message(db_session, user):
202
+ instance = ChatMessage(
203
+ user_id=user.id,
204
+ role="assistant",
205
+ content="Shared assistant answer",
206
+ sources_json='[{"text":"Source text","filename":"file.txt","page":1,"score":0.9,"confidence":95.0}]',
207
+ )
208
+ db_session.add(instance)
209
+ db_session.commit()
210
+ db_session.refresh(instance)
211
+ return instance
212
+
213
+
214
+ @pytest.fixture()
215
+ def user_message(db_session, user):
216
+ instance = ChatMessage(
217
+ user_id=user.id,
218
+ role="user",
219
+ content="Private user prompt",
220
+ )
221
+ db_session.add(instance)
222
+ db_session.commit()
223
+ db_session.refresh(instance)
224
+ return instance
225
+
226
+
227
+ @pytest.fixture()
228
+ def other_user_assistant_message(db_session, other_user):
229
+ instance = ChatMessage(
230
+ user_id=other_user.id,
231
+ role="assistant",
232
+ content="Other user's answer",
233
+ )
234
+ db_session.add(instance)
235
+ db_session.commit()
236
+ db_session.refresh(instance)
237
+ 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]}