Skip to content
Toggle navigation
P
Projects
G
Groups
S
Snippets
Help
赵月辉
/
fastgpt-migrated
This project
Loading...
Sign in
Toggle navigation
Go to a project
Project
Repository
Issues
0
Merge Requests
0
Pipelines
Wiki
Snippets
Members
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Unverified
Commit
d1ab0854
authored
Jun 29, 2026
by
DigHuang
Committed by
GitHub
Jun 29, 2026
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(sandbox): support ReadableStream upload and auto refresh workspace (#7209)
parent
8271ef53
Hide whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
112 additions
and
9 deletions
+112
-9
projects/app/src/pageComponents/chat/SandboxEditor/hook.tsx
+26
-2
sdk/sandbox-adapter/src/adapters/SealosDevboxAdapter/api.ts
+16
-2
sdk/sandbox-adapter/src/adapters/SealosDevboxAdapter/index.ts
+17
-5
sdk/sandbox-adapter/tests/unit/adapters/SealosDevboxAdapter.test.ts
+53
-0
No files found.
projects/app/src/pageComponents/chat/SandboxEditor/hook.tsx
View file @
d1ab0854
...
@@ -605,7 +605,7 @@ export const useSandboxFileStore = ({
...
@@ -605,7 +605,7 @@ export const useSandboxFileStore = ({
if
(
!
hasPendingWorkspaceRefreshRef
.
current
)
{
if
(
!
hasPendingWorkspaceRefreshRef
.
current
)
{
const
filesToReload
=
const
filesToReload
=
openedFilesRef
.
current
openedFilesRef
.
current
?.
filter
((
f
)
=>
!
f
.
isDirty
&&
!
f
.
isLoading
&&
!
f
.
is
Binary
&&
!
f
.
is
Unknown
)
?.
filter
((
f
)
=>
!
f
.
isDirty
&&
!
f
.
isLoading
&&
!
f
.
isUnknown
)
.
map
((
f
)
=>
({
.
map
((
f
)
=>
({
path
:
f
.
path
,
path
:
f
.
path
,
language
:
f
.
language
,
language
:
f
.
language
,
...
@@ -654,6 +654,14 @@ export const useSandboxFileStore = ({
...
@@ -654,6 +654,14 @@ export const useSandboxFileStore = ({
item
.
content
===
snapshot
.
content
&&
item
.
content
===
snapshot
.
content
&&
item
.
etag
===
snapshot
.
etag
item
.
etag
===
snapshot
.
etag
)
{
)
{
if
(
item
.
isBinary
&&
item
.
content
.
startsWith
(
'blob:'
)
&&
item
.
content
!==
update
.
content
)
{
URL
.
revokeObjectURL
(
item
.
content
);
}
return
{
return
{
...
item
,
...
item
,
content
:
update
.
content
,
content
:
update
.
content
,
...
@@ -1579,6 +1587,7 @@ export const useSandboxFileStore = ({
...
@@ -1579,6 +1587,7 @@ export const useSandboxFileStore = ({
uploadTasks
.
push
({
path
,
file
});
uploadTasks
.
push
({
path
,
file
});
}
}
let
hasUploaded
=
false
;
try
{
try
{
for
(
const
task
of
uploadTasks
)
{
for
(
const
task
of
uploadTasks
)
{
await
uploadSandboxFile
({
await
uploadSandboxFile
({
...
@@ -1588,6 +1597,7 @@ export const useSandboxFileStore = ({
...
@@ -1588,6 +1597,7 @@ export const useSandboxFileStore = ({
path
:
task
.
path
,
path
:
task
.
path
,
file
:
task
.
file
file
:
task
.
file
});
});
hasUploaded
=
true
;
}
}
}
catch
(
error
)
{
}
catch
(
error
)
{
console
.
error
(
'Failed to upload files:'
,
error
);
console
.
error
(
'Failed to upload files:'
,
error
);
...
@@ -1596,9 +1606,23 @@ export const useSandboxFileStore = ({
...
@@ -1596,9 +1606,23 @@ export const useSandboxFileStore = ({
description
:
getErrText
(
error
),
description
:
getErrText
(
error
),
status
:
'error'
status
:
'error'
});
});
}
finally
{
if
(
hasUploaded
)
{
await
refreshWorkspace
({
preserveExpandedDirs
:
true
});
}
}
}
}
,
}
,
[chatId, fileTree, maxFileBytes, maxFileSizeMB, outLinkAuthData, sandboxTarget, toast, t]
[
chatId,
fileTree,
maxFileBytes,
maxFileSizeMB,
outLinkAuthData,
refreshWorkspace,
sandboxTarget,
toast,
t
]
);
);
// 展开折叠目录
// 展开折叠目录
...
...
sdk/sandbox-adapter/src/adapters/SealosDevboxAdapter/api.ts
View file @
d1ab0854
...
@@ -10,6 +10,15 @@ import {
...
@@ -10,6 +10,15 @@ import {
type
UploadFileParams
,
type
UploadFileParams
,
type
UploadResponseData
type
UploadResponseData
}
from
'./type'
;
}
from
'./type'
;
import
{
isReadableStreamData
}
from
'@/utils/files'
;
type
StreamingRequestInit
=
RequestInit
&
{
/**
* Node fetch requires this extension when the request body is a stream.
* Browser typings do not expose it yet, but undici accepts the field.
*/
duplex
?:
'half'
;
};
export
class
DevboxApiError
extends
Error
{
export
class
DevboxApiError
extends
Error
{
constructor
(
constructor
(
...
@@ -138,11 +147,16 @@ export class DevboxApi {
...
@@ -138,11 +147,16 @@ export class DevboxApi {
if
(
params
.
timeoutSeconds
!=
null
)
queryParams
.
timeoutSeconds
=
String
(
params
.
timeoutSeconds
);
if
(
params
.
timeoutSeconds
!=
null
)
queryParams
.
timeoutSeconds
=
String
(
params
.
timeoutSeconds
);
if
(
params
.
container
)
queryParams
.
container
=
params
.
container
;
if
(
params
.
container
)
queryParams
.
container
=
params
.
container
;
return
this
.
request
(
this
.
url
(
`/api/v1/devbox/
${
name
}
/files/upload`
,
queryParams
),
{
const
requestInit
:
StreamingRequestInit
=
{
method
:
'POST'
,
method
:
'POST'
,
headers
:
{
'Content-Type'
:
'application/octet-stream'
},
headers
:
{
'Content-Type'
:
'application/octet-stream'
},
body
:
content
body
:
content
});
};
if
(
isReadableStreamData
(
content
))
{
requestInit
.
duplex
=
'half'
;
}
return
this
.
request
(
this
.
url
(
`/api/v1/devbox/
${
name
}
/files/upload`
,
queryParams
),
requestInit
);
}
}
private
buildDownloadFileUrl
(
name
:
string
,
params
:
DownloadFileParams
):
string
{
private
buildDownloadFileUrl
(
name
:
string
,
params
:
DownloadFileParams
):
string
{
...
...
sdk/sandbox-adapter/src/adapters/SealosDevboxAdapter/index.ts
View file @
d1ab0854
...
@@ -19,7 +19,7 @@ import { DevboxApi, DevboxApiError } from './api';
...
@@ -19,7 +19,7 @@ import { DevboxApi, DevboxApiError } from './api';
import
{
DevboxPhaseEnum
,
type
DevboxCreateRequest
,
type
DevboxInfoData
}
from
'./type'
;
import
{
DevboxPhaseEnum
,
type
DevboxCreateRequest
,
type
DevboxInfoData
}
from
'./type'
;
import
{
formatImageSpec
,
parseImageSpec
}
from
'@/utils/image'
;
import
{
formatImageSpec
,
parseImageSpec
}
from
'@/utils/image'
;
import
{
joinUrlPath
,
normalizePathPrefix
}
from
'@/utils/url'
;
import
{
joinUrlPath
,
normalizePathPrefix
}
from
'@/utils/url'
;
import
{
fileDataToUint8Array
}
from
'@/utils/files'
;
import
{
fileDataToUint8Array
,
isReadableStreamData
}
from
'@/utils/files'
;
const
GET_INFO_RETRY_TIMEOUT_MS
=
30
_000
;
const
GET_INFO_RETRY_TIMEOUT_MS
=
30
_000
;
const
GET_INFO_RETRY_INTERVAL_MS
=
1
_000
;
const
GET_INFO_RETRY_INTERVAL_MS
=
1
_000
;
...
@@ -319,8 +319,13 @@ export class SealosDevboxAdapter extends BaseSandboxAdapter {
...
@@ -319,8 +319,13 @@ export class SealosDevboxAdapter extends BaseSandboxAdapter {
for (const entry of entries) {
for (const entry of entries) {
const normalizedPath = this.normalizePath(entry.path);
const normalizedPath = this.normalizePath(entry.path);
try {
try {
const content = await fileDataToUint8Array(entry.data);
const uploadBody = await (async () => {
const bytesWritten = content.byteLength;
if (isReadableStreamData(entry.data)) {
return entry.data;
}
return fileDataToUint8Array(entry.data);
})();
let modeStr: string | undefined;
let modeStr: string | undefined;
if (entry.mode !== undefined) {
if (entry.mode !== undefined) {
...
@@ -333,14 +338,21 @@ export class SealosDevboxAdapter extends BaseSandboxAdapter {
...
@@ -333,14 +338,21 @@ export class SealosDevboxAdapter extends BaseSandboxAdapter {
path: normalizedPath,
path: normalizedPath,
mode: modeStr
mode: modeStr
},
},
content
uploadBody
);
);
if (res.code !== 200) {
if (res.code !== 200) {
throw new Error(res.message || `
Upload
failed
with
code
$
{
res
.
code
}
`);
throw new Error(res.message || `
Upload
failed
with
code
$
{
res
.
code
}
`);
}
}
results.push({ path: normalizedPath, bytesWritten, error: null });
const bytesWritten =
res.data?.sizeBytes ?? (uploadBody instanceof Uint8Array ? uploadBody.byteLength : 0);
results.push({
path: normalizedPath,
bytesWritten,
error: null
});
} catch (error) {
} catch (error) {
results.push({
results.push({
path: normalizedPath,
path: normalizedPath,
...
...
sdk/sandbox-adapter/tests/unit/adapters/SealosDevboxAdapter.test.ts
View file @
d1ab0854
...
@@ -549,6 +549,59 @@ describe('SealosDevboxAdapter', () => {
...
@@ -549,6 +549,59 @@ describe('SealosDevboxAdapter', () => {
body
:
new
TextEncoder
().
encode
(
'hello world'
)
body
:
new
TextEncoder
().
encode
(
'hello world'
)
})
})
);
);
expect
(
fetchMock
.
mock
.
calls
[
0
]?.[
1
]).
not
.
toHaveProperty
(
'duplex'
);
});
it
(
'should upload ReadableStream through SealosDevbox without buffering'
,
async
()
=>
{
const
uploadStream
=
new
ReadableStream
<
Uint8Array
>
({
start
(
controller
)
{
controller
.
enqueue
(
new
TextEncoder
().
encode
(
'streamed '
));
controller
.
enqueue
(
new
TextEncoder
().
encode
(
'content'
));
controller
.
close
();
}
});
const
fetchMock
=
vi
.
fn
(
async
()
=>
({
json
:
async
()
=>
({
code
:
200
,
message
:
'ok'
,
data
:
{
name
:
'devbox-1'
,
podName
:
'pod-1'
,
container
:
'c1'
,
path
:
'/home/devbox/workspace/stream.txt'
,
sizeBytes
:
16
,
mode
:
'0644'
,
uploadedAt
:
'2026-06-02T10:00:00Z'
,
timeoutSecond
:
300
}
})
}));
vi
.
stubGlobal
(
'fetch'
,
fetchMock
);
const
adapter
=
new
SealosDevboxAdapter
(
CONFIG
,
{
workingDir
:
'/home/devbox/workspace'
});
const
results
=
await
adapter
.
writeFiles
([
{
path
:
'stream.txt'
,
data
:
uploadStream
}
]);
expect
(
results
).
toHaveLength
(
1
);
expect
(
results
[
0
].
error
).
toBeNull
();
expect
(
results
[
0
].
bytesWritten
).
toBe
(
16
);
expect
(
results
[
0
].
path
).
toBe
(
'/home/devbox/workspace/stream.txt'
);
expect
(
fetchMock
).
toHaveBeenCalledWith
(
'https://devbox-server.example.com/api/v1/devbox/devbox-1/files/upload?path=%2Fhome%2Fdevbox%2Fworkspace%2Fstream.txt'
,
expect
.
objectContaining
({
method
:
'POST'
,
headers
:
expect
.
any
(
Headers
),
body
:
uploadStream
,
duplex
:
'half'
})
);
});
});
it
(
'should download file through SealosDevbox api.downloadFile'
,
async
()
=>
{
it
(
'should download file through SealosDevbox api.downloadFile'
,
async
()
=>
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment