Stream Jupyter workspace file downloads
This commit is contained in:
+65
-6
@@ -83,6 +83,9 @@ FEISHU_SPREADSHEET_MAX_CELL_CHARS = 5000
|
|||||||
FEISHU_SPREADSHEET_WRITE_BATCH_ROWS = 500
|
FEISHU_SPREADSHEET_WRITE_BATCH_ROWS = 500
|
||||||
FEISHU_ONLINE_DOCS_FILENAME = 'online-docs.json'
|
FEISHU_ONLINE_DOCS_FILENAME = 'online-docs.json'
|
||||||
JUPYTER_WORKSPACE_FILENAME = 'jupyter_workspace.json'
|
JUPYTER_WORKSPACE_FILENAME = 'jupyter_workspace.json'
|
||||||
|
JUPYTER_STREAM_CHUNK_BYTES = 1024 * 1024
|
||||||
|
FEISHU_REMOTE_DOC_MAX_BYTES = 50 * 1024 * 1024
|
||||||
|
FEISHU_REMOTE_SHEET_MAX_BYTES = 100 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -1096,11 +1099,20 @@ def create_app(state: AgentState) -> FastAPI:
|
|||||||
if runtime is None:
|
if runtime is None:
|
||||||
raise HTTPException(status_code=404, detail='Jupyter runtime is not connected')
|
raise HTTPException(status_code=404, detail='Jupyter runtime is not connected')
|
||||||
try:
|
try:
|
||||||
data, filename = runtime.read_file_bytes(path)
|
response, filename = runtime.open_file_stream(path)
|
||||||
except JupyterRuntimeError as exc:
|
except JupyterRuntimeError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
return Response(
|
|
||||||
data,
|
def stream_chunks() -> Any:
|
||||||
|
try:
|
||||||
|
for chunk in response.iter_content(chunk_size=JUPYTER_STREAM_CHUNK_BYTES):
|
||||||
|
if chunk:
|
||||||
|
yield chunk
|
||||||
|
finally:
|
||||||
|
response.close()
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
stream_chunks(),
|
||||||
media_type=_content_type_for_filename(filename),
|
media_type=_content_type_for_filename(filename),
|
||||||
headers={
|
headers={
|
||||||
'content-disposition': (
|
'content-disposition': (
|
||||||
@@ -3050,9 +3062,27 @@ def _materialize_jupyter_file_for_feishu(
|
|||||||
if runtime is None:
|
if runtime is None:
|
||||||
raise HTTPException(status_code=404, detail='Jupyter runtime is not connected')
|
raise HTTPException(status_code=404, detail='Jupyter runtime is not connected')
|
||||||
try:
|
try:
|
||||||
data, filename = runtime.read_file_bytes(remote_path)
|
info = runtime.file_info(remote_path)
|
||||||
|
filename = _safe_uploaded_filename(str(info.get('name') or 'remote-file'))
|
||||||
|
suffix = Path(filename).suffix.lower()
|
||||||
|
max_bytes = (
|
||||||
|
FEISHU_REMOTE_SHEET_MAX_BYTES
|
||||||
|
if suffix in FEISHU_SPREADSHEET_SUFFIXES
|
||||||
|
else FEISHU_REMOTE_DOC_MAX_BYTES
|
||||||
|
)
|
||||||
|
size = info.get('size')
|
||||||
|
if isinstance(size, int) and size > max_bytes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=413,
|
||||||
|
detail=(
|
||||||
|
f'远端文件过大,当前在线文档转换上限为 '
|
||||||
|
f'{_format_bytes(max_bytes)},请先在 Jupyter 侧裁剪或抽样后再转换。'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response, stream_filename = runtime.open_file_stream(remote_path)
|
||||||
except JupyterRuntimeError as exc:
|
except JupyterRuntimeError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
filename = filename or _safe_uploaded_filename(stream_filename)
|
||||||
target_dir = (
|
target_dir = (
|
||||||
state.account_paths(account_id)['base']
|
state.account_paths(account_id)['base']
|
||||||
/ 'integrations'
|
/ 'integrations'
|
||||||
@@ -3061,8 +3091,27 @@ def _materialize_jupyter_file_for_feishu(
|
|||||||
/ session_id
|
/ session_id
|
||||||
)
|
)
|
||||||
target_dir.mkdir(parents=True, exist_ok=True)
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
target = target_dir / _safe_uploaded_filename(filename)
|
target = target_dir / filename
|
||||||
target.write_bytes(data)
|
written = 0
|
||||||
|
try:
|
||||||
|
with target.open('wb') as handle:
|
||||||
|
for chunk in response.iter_content(chunk_size=JUPYTER_STREAM_CHUNK_BYTES):
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
written += len(chunk)
|
||||||
|
if written > max_bytes:
|
||||||
|
handle.close()
|
||||||
|
target.unlink(missing_ok=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=413,
|
||||||
|
detail=(
|
||||||
|
f'远端文件过大,当前在线文档转换上限为 '
|
||||||
|
f'{_format_bytes(max_bytes)},请先在 Jupyter 侧裁剪或抽样后再转换。'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
handle.write(chunk)
|
||||||
|
finally:
|
||||||
|
response.close()
|
||||||
return target
|
return target
|
||||||
|
|
||||||
|
|
||||||
@@ -3080,6 +3129,16 @@ def _safe_uploaded_filename(filename: str) -> str:
|
|||||||
return cleaned[:180] or 'remote-file'
|
return cleaned[:180] or 'remote-file'
|
||||||
|
|
||||||
|
|
||||||
|
def _format_bytes(value: int) -> str:
|
||||||
|
if value >= 1024 * 1024 * 1024:
|
||||||
|
return f'{value / (1024 * 1024 * 1024):.1f}GB'
|
||||||
|
if value >= 1024 * 1024:
|
||||||
|
return f'{value / (1024 * 1024):.0f}MB'
|
||||||
|
if value >= 1024:
|
||||||
|
return f'{value / 1024:.0f}KB'
|
||||||
|
return f'{value}B'
|
||||||
|
|
||||||
|
|
||||||
def _resolve_account_file(state: AgentState, account_id: str | None, raw_path: str) -> Path:
|
def _resolve_account_file(state: AgentState, account_id: str | None, raw_path: str) -> Path:
|
||||||
path = Path(raw_path).expanduser().resolve()
|
path = Path(raw_path).expanduser().resolve()
|
||||||
account_base = state.account_paths(account_id)['base'].resolve()
|
account_base = state.account_paths(account_id)['base'].resolve()
|
||||||
|
|||||||
@@ -282,6 +282,7 @@ type SessionFile = {
|
|||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
kind: "input" | "output";
|
kind: "input" | "output";
|
||||||
|
source?: "local" | "jupyter";
|
||||||
size: number;
|
size: number;
|
||||||
modified_at: string;
|
modified_at: string;
|
||||||
download_url: string;
|
download_url: string;
|
||||||
@@ -593,6 +594,7 @@ function SessionFileRow({ file }: { file: SessionFile }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-muted-foreground text-xs">
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{file.source === "jupyter" ? "JUPYTER · " : ""}
|
||||||
{fileExtension(file.name).toUpperCase() || "FILE"} ·{" "}
|
{fileExtension(file.name).toUpperCase() || "FILE"} ·{" "}
|
||||||
{formatBytes(file.size)}
|
{formatBytes(file.size)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -647,6 +647,50 @@ print(json.dumps(entries, ensure_ascii=False))
|
|||||||
name = str(payload.get('name') or PurePosixPath(target).name or 'remote-file')
|
name = str(payload.get('name') or PurePosixPath(target).name or 'remote-file')
|
||||||
return data, name
|
return data, name
|
||||||
|
|
||||||
|
def file_info(self, path: str) -> dict[str, Any]:
|
||||||
|
target = self.resolve_workspace_path(path)
|
||||||
|
api_path = api_path_for_absolute_path(target)
|
||||||
|
url = f'{self.binding.base_url}/api/contents/{quote(api_path, safe="/")}'
|
||||||
|
response = self._session.get(
|
||||||
|
url,
|
||||||
|
params={'content': 0},
|
||||||
|
headers={'X-XSRFToken': self._xsrf_token},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise JupyterRuntimeError(
|
||||||
|
f'Unable to inspect remote file: HTTP {response.status_code} {response.text[:500]}'
|
||||||
|
)
|
||||||
|
payload = response.json()
|
||||||
|
if payload.get('type') != 'file':
|
||||||
|
raise JupyterRuntimeError('Remote path is not a file.')
|
||||||
|
name = str(payload.get('name') or PurePosixPath(target).name or 'remote-file')
|
||||||
|
size = payload.get('size')
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'path': target,
|
||||||
|
'size': int(size) if isinstance(size, int) and size >= 0 else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def open_file_stream(self, path: str) -> tuple[Any, str]:
|
||||||
|
"""打开远端文件流,由调用方负责关闭 response。"""
|
||||||
|
|
||||||
|
target = self.resolve_workspace_path(path)
|
||||||
|
api_path = api_path_for_absolute_path(target)
|
||||||
|
url = f'{self.binding.base_url}/files/{quote(api_path, safe="/")}'
|
||||||
|
response = self._session.get(
|
||||||
|
url,
|
||||||
|
headers={'X-XSRFToken': self._xsrf_token},
|
||||||
|
stream=True,
|
||||||
|
timeout=(10, 300),
|
||||||
|
)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
response.close()
|
||||||
|
raise JupyterRuntimeError(
|
||||||
|
f'Unable to stream remote file: HTTP {response.status_code} {response.text[:500]}'
|
||||||
|
)
|
||||||
|
return response, str(PurePosixPath(target).name or 'remote-file')
|
||||||
|
|
||||||
def read_text(
|
def read_text(
|
||||||
self,
|
self,
|
||||||
path: str,
|
path: str,
|
||||||
|
|||||||
Reference in New Issue
Block a user