Skip to content

Commit 92c6e55

Browse files
authored
feat: implement file attachment system with session management and event handling (#44)
* feat: implement file attachment system with session management and event handling * refactor: remove unused asyncio import and streamline session ID generation with secure random string
1 parent 08e0e4d commit 92c6e55

File tree

7 files changed

+456
-33
lines changed

7 files changed

+456
-33
lines changed

backend/application/chat/utilities/file_utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,5 +455,6 @@ def build_files_manifest(session_context: Dict[str, Any]) -> Optional[Dict[str,
455455
f"{file_list}\n\n"
456456
"(You can ask to open or analyze any of these by name. "
457457
"Large contents are not fully in this prompt unless user or tools provided excerpts.)"
458+
"The user may refer to these files in their requests as session files or attachments."
458459
)
459460
}
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import base64
2+
import uuid
3+
import pytest
4+
5+
from application.chat.service import ChatService
6+
from modules.file_storage.manager import FileManager
7+
from modules.file_storage.mock_s3_client import MockS3StorageClient
8+
9+
10+
class FakeLLM:
11+
async def call_plain(self, model_name, messages, temperature=0.7):
12+
return "ok"
13+
14+
async def call_with_tools(self, model_name, messages, tools_schema, tool_choice="auto", temperature=0.7):
15+
from interfaces.llm import LLMResponse
16+
return LLMResponse(content="ok", tool_calls=None, model_used=model_name)
17+
18+
async def call_with_rag(self, model_name, messages, data_sources, user_email, temperature=0.7):
19+
return "ok"
20+
21+
async def call_with_rag_and_tools(self, model_name, messages, data_sources, tools_schema, user_email, tool_choice="auto", temperature=0.7):
22+
from interfaces.llm import LLMResponse
23+
return LLMResponse(content="ok", tool_calls=None, model_used=model_name)
24+
25+
26+
@pytest.fixture
27+
def file_manager():
28+
# Use in-process mock S3 for deterministic tests
29+
return FileManager(s3_client=MockS3StorageClient())
30+
31+
32+
@pytest.fixture
33+
def chat_service(file_manager):
34+
# Minimal ChatService wiring for file/session operations
35+
return ChatService(llm=FakeLLM(), file_manager=file_manager)
36+
37+
38+
@pytest.mark.asyncio
39+
async def test_handle_attach_file_success_creates_session_and_emits_update(chat_service, file_manager):
40+
user_email = "[email protected]"
41+
session_id = uuid.uuid4()
42+
43+
# Seed a file into the mock storage for this user
44+
filename = "report.txt"
45+
content_b64 = base64.b64encode(b"hello world").decode()
46+
upload_meta = await file_manager.s3_client.upload_file(
47+
user_email=user_email,
48+
filename=filename,
49+
content_base64=content_b64,
50+
content_type="text/plain",
51+
tags={"source": "user"},
52+
source_type="user",
53+
)
54+
s3_key = upload_meta["key"]
55+
56+
updates = []
57+
58+
async def capture_update(msg):
59+
updates.append(msg)
60+
61+
# Act: attach the file to a brand new session (auto-creates session)
62+
resp = await chat_service.handle_attach_file(
63+
session_id=session_id,
64+
s3_key=s3_key,
65+
user_email=user_email,
66+
update_callback=capture_update,
67+
)
68+
69+
# Assert: success response and files_update emitted
70+
assert resp.get("type") == "file_attach"
71+
assert resp.get("success") is True
72+
assert resp.get("filename") == filename
73+
74+
assert any(
75+
u.get("type") == "intermediate_update" and u.get("update_type") == "files_update"
76+
for u in updates
77+
), "Expected a files_update intermediate update to be emitted"
78+
79+
# Session context should include the file by filename
80+
session = chat_service.sessions.get(session_id)
81+
assert session is not None
82+
assert filename in session.context.get("files", {})
83+
assert session.context["files"][filename]["key"] == s3_key
84+
85+
86+
@pytest.mark.asyncio
87+
async def test_handle_attach_file_not_found_returns_error(chat_service):
88+
user_email = "[email protected]"
89+
session_id = uuid.uuid4()
90+
91+
# Non-existent S3 key for the same user
92+
bad_key = f"users/{user_email}/uploads/does_not_exist_12345.txt"
93+
resp = await chat_service.handle_attach_file(
94+
session_id=session_id,
95+
s3_key=bad_key,
96+
user_email=user_email,
97+
update_callback=None,
98+
)
99+
100+
assert resp.get("type") == "file_attach"
101+
assert resp.get("success") is False
102+
assert "File not found" in resp.get("error", "")
103+
104+
105+
@pytest.mark.asyncio
106+
async def test_handle_attach_file_unauthorized_other_user_key(chat_service, file_manager):
107+
# Upload under user1
108+
owner_email = "[email protected]"
109+
other_email = "[email protected]"
110+
session_id = uuid.uuid4()
111+
112+
filename = "secret.pdf"
113+
content_b64 = base64.b64encode(b"top-secret").decode()
114+
upload_meta = await file_manager.s3_client.upload_file(
115+
user_email=owner_email,
116+
filename=filename,
117+
content_base64=content_b64,
118+
content_type="application/pdf",
119+
tags={"source": "user"},
120+
source_type="user",
121+
)
122+
s3_key = upload_meta["key"]
123+
124+
# Attempt to attach with a different user should fail
125+
resp = await chat_service.handle_attach_file(
126+
session_id=session_id,
127+
s3_key=s3_key,
128+
user_email=other_email,
129+
update_callback=None,
130+
)
131+
132+
assert resp.get("type") == "file_attach"
133+
assert resp.get("success") is False
134+
assert "Access denied" in resp.get("error", "")
135+
136+
137+
@pytest.mark.asyncio
138+
async def test_handle_reset_session_reinitializes(chat_service):
139+
user_email = "[email protected]"
140+
session_id = uuid.uuid4()
141+
142+
# Create a session first
143+
await chat_service.create_session(session_id, user_email)
144+
assert chat_service.sessions.get(session_id) is not None
145+
146+
# Reset the session
147+
resp = await chat_service.handle_reset_session(session_id=session_id, user_email=user_email)
148+
149+
assert resp.get("type") == "session_reset"
150+
# After reset, a fresh active session should exist for the same id
151+
new_session = chat_service.sessions.get(session_id)
152+
assert new_session is not None
153+
assert new_session.active is True
154+
155+
156+
@pytest.mark.asyncio
157+
async def test_handle_download_file_success_after_attach(chat_service, file_manager):
158+
user_email = "[email protected]"
159+
session_id = uuid.uuid4()
160+
161+
# Upload and then attach to session
162+
filename = "notes.md"
163+
content_bytes = b"### Title\nSome content."
164+
content_b64 = base64.b64encode(content_bytes).decode()
165+
upload_meta = await file_manager.s3_client.upload_file(
166+
user_email=user_email,
167+
filename=filename,
168+
content_base64=content_b64,
169+
content_type="text/markdown",
170+
tags={"source": "user"},
171+
source_type="user",
172+
)
173+
s3_key = upload_meta["key"]
174+
175+
await chat_service.handle_attach_file(
176+
session_id=session_id,
177+
s3_key=s3_key,
178+
user_email=user_email,
179+
update_callback=None,
180+
)
181+
182+
# Act: download by filename (from session context)
183+
resp = await chat_service.handle_download_file(
184+
session_id=session_id,
185+
filename=filename,
186+
user_email=user_email,
187+
)
188+
189+
assert resp.get("type") is not None
190+
# content_base64 should match uploaded content
191+
returned_b64 = resp.get("content_base64")
192+
assert isinstance(returned_b64, str) and len(returned_b64) > 0
193+
assert base64.b64decode(returned_b64) == content_bytes
194+
195+
196+
@pytest.mark.asyncio
197+
async def test_handle_download_file_not_in_session_returns_error(chat_service):
198+
user_email = "[email protected]"
199+
session_id = uuid.uuid4()
200+
filename = "missing.txt"
201+
202+
# No attach performed; should error that file isn't in session
203+
resp = await chat_service.handle_download_file(
204+
session_id=session_id,
205+
filename=filename,
206+
user_email=user_email,
207+
)
208+
209+
assert resp.get("error") == "Session or file manager not available" or resp.get("error") == "File not found in session"

frontend/src/components/AllFilesView.jsx

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { useChat } from '../contexts/ChatContext'
1717
import { useWS } from '../contexts/WSContext'
1818

1919
const AllFilesView = () => {
20-
const { token, user: userEmail } = useChat()
20+
const { token, user: userEmail, ensureSession, addSystemEvent, addPendingFileEvent, attachments } = useChat()
2121
const { sendMessage } = useWS()
2222
const [allFiles, setAllFiles] = useState([])
2323
const [filteredFiles, setFilteredFiles] = useState([])
@@ -211,17 +211,36 @@ const AllFilesView = () => {
211211
}
212212
}
213213

214-
const handleLoadToSession = async (file) => {
214+
const handleAddToSession = async (file) => {
215215
try {
216+
// Check if file is already attached
217+
if (attachments.has(file.key)) {
218+
addSystemEvent('file-attached', `'${file.filename}' is already in this session.`)
219+
return
220+
}
221+
222+
// Ensure session exists
223+
await ensureSession()
224+
225+
// Add "attaching" system event and track it as pending
226+
const eventId = addSystemEvent('file-attaching', `Adding '${file.filename}' to this session...`, {
227+
fileId: file.key,
228+
fileName: file.filename,
229+
source: 'library'
230+
})
231+
232+
// Track this as a pending file event
233+
addPendingFileEvent(file.key, eventId)
234+
235+
// Send attach_file message (WebSocket handler will resolve the pending event)
216236
sendMessage({
217237
type: 'attach_file',
218238
s3_key: file.key,
219239
user: userEmail
220240
})
221-
showNotification(`File "${file.filename}" loaded to current session`, 'success')
222241
} catch (error) {
223-
console.error('Error loading file to session:', error)
224-
showNotification('Failed to load file to session', 'error')
242+
console.error('Error adding file to session:', error)
243+
addSystemEvent('file-attach-error', `Failed to add '${file.filename}' to session: ${error.message}`)
225244
}
226245
}
227246

@@ -380,9 +399,9 @@ const AllFilesView = () => {
380399
{/* Action Buttons */}
381400
<div className="flex items-center gap-2 flex-shrink-0">
382401
<button
383-
onClick={() => handleLoadToSession(file)}
402+
onClick={() => handleAddToSession(file)}
384403
className="p-2 rounded-lg bg-purple-600 hover:bg-purple-700 text-white transition-colors"
385-
title="Load to current session"
404+
title="Add to session"
386405
>
387406
<ArrowUpToLine className="w-4 h-4" />
388407
</button>

frontend/src/components/Message.jsx

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ hljs.registerLanguage('sh', bash)
5050
const processFileReferences = (content) => {
5151
return content.replace(
5252
/@file\s+([^\s]+)/g,
53-
'<span class="inline-flex items-center px-2 py-1 rounded-md bg-green-900/30 border border-green-500/30 text-green-400 text-sm font-medium">📎 @file $1</span>'
53+
'<span class="inline-flex items-center px-2 py-1 rounded-md bg-green-900/30 border border-green-500/30 text-green-400 text-sm font-medium">@file $1</span>'
5454
)
5555
}
5656

@@ -629,24 +629,24 @@ const renderContent = () => {
629629
</div>
630630
)}
631631

632-
{/* Result Section */}
633-
{message.result && (
634-
<div className="mb-2">
635-
<div className={`border-l-4 pl-4 ${
636-
message.status === 'failed' ? 'border-red-500' : 'border-green-500'
637-
}`}>
638-
<button
639-
onClick={() => setToolOutputCollapsed(!toolOutputCollapsed)}
640-
className={`w-full text-left text-sm font-semibold mb-2 flex items-center gap-2 transition-colors ${
641-
message.status === 'failed'
642-
? 'text-red-400 hover:text-red-300'
643-
: 'text-green-400 hover:text-green-300'
644-
}`}
645-
>
646-
<span className={`transform transition-transform duration-200 ${toolOutputCollapsed ? 'rotate-0' : 'rotate-90'}`}>
647-
648-
</span>
649-
{message.status === 'failed' ? 'Error Details' : 'Output Result'} {toolOutputCollapsed ? '(click to expand)' : ''}
632+
{/* Result Section */}
633+
{message.result && (
634+
<div className="mb-2">
635+
<div className={`border-l-4 pl-4 ${
636+
message.status === 'failed' ? 'border-red-500' : 'border-green-500'
637+
}`}>
638+
<button
639+
onClick={() => setToolOutputCollapsed(!toolOutputCollapsed)}
640+
className={`w-full text-left text-sm font-semibold mb-2 flex items-center gap-2 transition-colors ${
641+
message.status === 'failed'
642+
? 'text-red-400 hover:text-red-300'
643+
: 'text-green-400 hover:text-green-300'
644+
}`}
645+
>
646+
<span className={`transform transition-transform duration-200 ${toolOutputCollapsed ? 'rotate-0' : 'rotate-90'}`}>
647+
648+
</span>
649+
{message.status === 'failed' ? 'Error Details' : 'Output Result'} {toolOutputCollapsed ? '(click to expand)' : ''}
650650
</button>
651651

652652
{!toolOutputCollapsed && (
@@ -768,6 +768,31 @@ const renderContent = () => {
768768
}
769769

770770
if (isUser || isSystem) {
771+
// Handle file attachment system events
772+
if (message.type === 'system' && message.subtype) {
773+
switch (message.subtype) {
774+
case 'file-attaching':
775+
return (
776+
<div className="text-blue-300 italic">
777+
{message.text}
778+
</div>
779+
)
780+
case 'file-attached':
781+
return (
782+
<div className="text-green-300">
783+
{message.text}
784+
</div>
785+
)
786+
case 'file-attach-error':
787+
return (
788+
<div className="text-red-300">
789+
{message.text}
790+
</div>
791+
)
792+
default:
793+
return <div className="text-gray-200">{message.content}</div>
794+
}
795+
}
771796
return <div className="text-gray-200">{message.content}</div>
772797
}
773798

0 commit comments

Comments
 (0)