Skip to content

Commit 0ab8abf

Browse files
authored
feat: added basic workspace file browsing api (gptme#530)
1 parent 76b5548 commit 0ab8abf

File tree

2 files changed

+217
-1
lines changed

2 files changed

+217
-1
lines changed

gptme/server/api.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,11 +361,13 @@ def create_app(cors_origin: str | None = None) -> flask.Flask:
361361
app = flask.Flask(__name__, static_folder=static_path)
362362
app.register_blueprint(api)
363363

364-
# Register v2 API
364+
# Register v2 API and workspace API
365365
# noreorder
366366
from .api_v2 import v2_api # fmt: skip
367+
from .workspace_api import workspace_api # fmt: skip
367368

368369
app.register_blueprint(v2_api)
370+
app.register_blueprint(workspace_api)
369371

370372
if cors_origin:
371373
# Only allow credentials if a specific origin is set (not '*')

gptme/server/workspace_api.py

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
"""
2+
Workspace API endpoints for browsing files in conversation workspaces.
3+
"""
4+
5+
import logging
6+
import mimetypes
7+
from dataclasses import dataclass
8+
from datetime import datetime
9+
from pathlib import Path
10+
from typing import Literal, TypedDict
11+
12+
import flask
13+
from flask import request
14+
15+
from ..logmanager import LogManager
16+
17+
logger = logging.getLogger(__name__)
18+
19+
workspace_api = flask.Blueprint("workspace_api", __name__)
20+
21+
22+
class FileType(TypedDict):
23+
"""File metadata type."""
24+
25+
name: str
26+
path: str
27+
type: Literal["file", "directory"]
28+
size: int
29+
modified: str
30+
mime_type: str | None
31+
32+
33+
@dataclass
34+
class WorkspaceFile:
35+
"""Represents a file or directory in the workspace."""
36+
37+
path: Path
38+
workspace: Path
39+
40+
@property
41+
def is_dir(self) -> bool:
42+
return self.path.is_dir()
43+
44+
@property
45+
def is_hidden(self) -> bool:
46+
"""Check if file/directory is hidden."""
47+
return self.path.name.startswith(".")
48+
49+
@property
50+
def relative_path(self) -> str:
51+
"""Get path relative to workspace."""
52+
return str(self.path.relative_to(self.workspace))
53+
54+
@property
55+
def mime_type(self) -> str | None:
56+
"""Get MIME type of file."""
57+
if self.is_dir:
58+
return None
59+
return mimetypes.guess_type(self.path)[0]
60+
61+
def to_dict(self) -> FileType:
62+
"""Convert to dictionary representation."""
63+
stat = self.path.stat()
64+
return {
65+
"name": self.path.name,
66+
"path": self.relative_path,
67+
"type": "directory" if self.is_dir else "file",
68+
"size": stat.st_size,
69+
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
70+
"mime_type": self.mime_type,
71+
}
72+
73+
74+
def safe_workspace_path(workspace: Path, path: str | None = None) -> Path:
75+
"""
76+
Safely resolve a path within a workspace.
77+
78+
Args:
79+
workspace: Base workspace path
80+
path: Optional path relative to workspace
81+
82+
Returns:
83+
Resolved absolute path, guaranteed to be within workspace
84+
85+
Raises:
86+
ValueError: If path would escape workspace
87+
"""
88+
workspace = workspace.resolve()
89+
if not path:
90+
return workspace
91+
92+
# Resolve the full path
93+
full_path = (workspace / path).resolve()
94+
95+
# Check if path is within workspace
96+
if not full_path.is_relative_to(workspace):
97+
raise ValueError("Path escapes workspace")
98+
99+
return full_path
100+
101+
102+
def list_directory(
103+
path: Path, workspace: Path, show_hidden: bool = False
104+
) -> list[FileType]:
105+
"""
106+
List contents of a directory.
107+
108+
Args:
109+
path: Directory path to list
110+
workspace: Base workspace path
111+
show_hidden: Whether to include hidden files
112+
113+
Returns:
114+
List of file metadata
115+
"""
116+
if not path.is_dir():
117+
raise ValueError("Path is not a directory")
118+
119+
files = []
120+
for item in path.iterdir():
121+
wfile = WorkspaceFile(item, workspace)
122+
if not show_hidden and wfile.is_hidden:
123+
continue
124+
files.append(wfile.to_dict())
125+
126+
return sorted(files, key=lambda f: (f["type"] == "file", f["name"].lower()))
127+
128+
129+
@workspace_api.route("/api/v2/conversations/<string:conversation_id>/workspace")
130+
@workspace_api.route(
131+
"/api/v2/conversations/<string:conversation_id>/workspace/<path:subpath>"
132+
)
133+
def browse_workspace(conversation_id: str, subpath: str | None = None):
134+
"""
135+
List contents of a conversation's workspace directory.
136+
137+
Args:
138+
conversation_id: ID of the conversation
139+
subpath: Optional path within workspace
140+
"""
141+
try:
142+
# Load the conversation to get its workspace
143+
manager = LogManager.load(conversation_id, lock=False)
144+
workspace = manager.workspace
145+
146+
if not workspace.is_dir():
147+
return flask.jsonify({"error": "Workspace not found"}), 404
148+
149+
path = safe_workspace_path(workspace, subpath)
150+
show_hidden = request.args.get("show_hidden", "").lower() == "true"
151+
152+
if path.is_file():
153+
# Return single file metadata
154+
return flask.jsonify(WorkspaceFile(path, workspace).to_dict())
155+
else:
156+
# Return directory listing
157+
return flask.jsonify(list_directory(path, workspace, show_hidden))
158+
159+
except ValueError as e:
160+
return flask.jsonify({"error": str(e)}), 400
161+
except Exception as e:
162+
logger.exception("Error browsing workspace")
163+
return flask.jsonify({"error": str(e)}), 500
164+
165+
166+
@workspace_api.route(
167+
"/api/v2/conversations/<string:conversation_id>/workspace/<path:filepath>/preview"
168+
)
169+
def preview_file(conversation_id: str, filepath: str):
170+
"""
171+
Get a preview of a file in the conversation's workspace.
172+
173+
Currently supports:
174+
- Text files (returned as-is)
175+
- Images (returned as-is)
176+
- Binary files (returns metadata only)
177+
178+
Args:
179+
conversation_id: ID of the conversation
180+
filepath: Path to file within workspace
181+
"""
182+
try:
183+
# Load the conversation to get its workspace
184+
manager = LogManager.load(conversation_id, lock=False)
185+
workspace = manager.workspace
186+
187+
if not workspace.is_dir():
188+
return flask.jsonify({"error": "Workspace not found"}), 404
189+
190+
path = safe_workspace_path(workspace, filepath)
191+
if not path.is_file():
192+
return flask.jsonify({"error": "File not found"}), 404
193+
194+
wfile = WorkspaceFile(path, workspace)
195+
mime_type = wfile.mime_type
196+
197+
# Handle different file types
198+
if mime_type and mime_type.startswith("text/"):
199+
# Text files
200+
with open(path) as f:
201+
content = f.read()
202+
return flask.jsonify({"type": "text", "content": content})
203+
elif mime_type and mime_type.startswith("image/"):
204+
# Images
205+
return flask.send_file(path, mimetype=mime_type)
206+
else:
207+
# Binary files - just return metadata
208+
return flask.jsonify({"type": "binary", "metadata": wfile.to_dict()})
209+
210+
except ValueError as e:
211+
return flask.jsonify({"error": str(e)}), 400
212+
except Exception as e:
213+
logger.exception("Error previewing file")
214+
return flask.jsonify({"error": str(e)}), 500

0 commit comments

Comments
 (0)