File Explorer
當 agent 在 sandbox 裡動檔案時,使用者需要看得到那棵樹。SDK 內建一個 File Explorer 側欄:
來源下拉選單、以 workingDirectory 為根的延遲展開檔案樹、以 CodeMirror 6 呈現的單檔
檢視(含監看自動重載),以及右鍵選單與剪貼簿式的搬移/複製。
它不綁 <Chatbot>,也不綁 sandbox:沒有對話、沒有 channel、只有一個固定檔案來源的畫面同樣能用
(見自行組裝零件與來源不一定是-sandbox)。
Sandbox File Explorer
當 agent 在 sandbox 裡動檔案時,使用者需要看得到那棵樹。SDK 內建一個 File Explorer 側欄:sandbox 下拉選單、以 workingDirectory 為根的延遲展開檔案樹、以 CodeMirror 6 呈現的單檔檢視(含監看自動重載),以及右鍵選單與剪貼簿式的搬移/複製。
展開 src/ 或 docs/ → 雙擊檔案開啟 → 直接編輯後存檔 → 右鍵可新增資料夾、刪除、複製貼上。
內建 vs 自行擺放
預設 fileExplorer="builtin":header 上出現資料夾圖示,點了開右側側欄,一切自動接好。設成 "off" 則兩者都不出現,改由你把外露的 <FileExplorerPanel> 擺在任何位置——就像這個示範。
import {
FileExplorerPanel, useFileExplorerController, AsgardThemeScope,
} from '@asgard-js/react';
import { createSandboxFsProviders } from '@asgard-js/react';
const controller = useFileExplorerController({ open: true });
const fs = createSandboxFsProviders(client);
<Chatbot fileExplorer="off" {...rest} />
<AsgardThemeScope theme={theme}>
<FileExplorerPanel
sandboxes={launchedSandboxes} // 來自 channel.launchedSandboxes$
controller={controller}
{...fs} // listDir / readFile / saveFile / watchFile…
onNudge={() => channel.nudge()} // 喚醒閒置的 sandbox
nudgeDisabled={isConnecting}
/>
</AsgardThemeScope>這個示範把 listDir / readFile / saveFile / watchFile 換成瀏覽器記憶體裡的假檔案樹——面板本身不在意資料從哪來,正式環境只要接上 AsgardServiceClient 的 sandbox fs 方法即可(見下方 createSandboxFsProviders)。
面板把檔案存取當成一組非同步 callback 收下,所以上面的示範把 listDir / readFile /
saveFile / watchFile 換成瀏覽器記憶體裡的假樹——延遲展開、編輯、存檔、右鍵操作全都是真的
在跑,只是背後沒有 sandbox。正式環境改接 createSandboxFsProviders 即可。
內建 vs 自行擺放
// 預設:header 出現資料夾圖示,點了開右側側欄,一切自動接好
<Chatbot fileExplorer="builtin" {...rest} />
// 關掉:兩者都不出現,改由你把 <FileExplorerPanel> 擺在任何位置
<Chatbot fileExplorer="off" {...rest} />
相關的 Chatbot props:
| Prop | 用途 |
|---|---|
fileExplorer | 'builtin'(預設)或 'off' |
autoRevealOnOpenFileCard | open-file 卡片抵達時是否自動展開側欄(預設 true);檔案正在編輯且未存檔時會抑制這個動作 |
fileExplorerBasePath | 覆寫樹根的絕對路徑,取代 sandbox 的 workingDirectory |
自行擺放
import {
FileExplorerPanel,
useFileExplorerController,
createSandboxFsProviders,
AsgardThemeScope,
} from "@asgard-js/react";
const controller = useFileExplorerController({ open: true });
const fs = createSandboxFsProviders(client);
<Chatbot fileExplorer="off" {...rest} />
<AsgardThemeScope theme={theme}>
<FileExplorerPanel
sandboxes={launchedSandboxes} // 來自 channel.launchedSandboxes$
controller={controller}
{...fs} // listDir / readFile / saveFile / watchFile / mkdir…
onNudge={() => channel.nudge()} // 喚醒閒置的 sandbox
nudgeDisabled={isConnecting}
chrome="card" // 'card' 獨立卡片 / 'flush' 內嵌側欄
/>
</AsgardThemeScope>
AsgardThemeScope面板擺在 <Chatbot> 之外就拿不到聊天外殼掛上的設計 token,會退回淺色預設值。
詳見在聊天外殼之外重建主題。
自行組裝零件
<FileExplorerPanel> 是現成的組合,適合絕大多數情況。當你需要的不是它的標頭時——例如瀏覽的
根本不是 sandbox,而是一個固定的檔案來源,沒有台可以選——可以改用同一組零件自己組:
import { FileExplorer } from "@asgard-js/react";
<FileExplorer.Provider sources={[mySource]} controller={controller} providers={fs}>
<FileExplorer.Root chrome="card">
<FileExplorer.Header>
<FileExplorer.HeaderRow>
<FileExplorer.SourceSelect /> {/* 只有一個來源時就別組這顆 */}
<FileExplorer.CloseButton />
</FileExplorer.HeaderRow>
<FileExplorer.Cwd />
</FileExplorer.Header>
<FileExplorer.Workspace /> {/* 工具列 + 樹/單檔檢視 + 右鍵選單 */}
</FileExplorer.Root>
</FileExplorer.Provider>
Workspace,不要組它的零件FileExplorer.Workspace 把標頭以下的所有行為包成一顆:工具列、延遲展開的樹、單檔檢視、
右鍵選單、剪貼簿、重新整理。兩種組裝共用它,行為才會持續一致;各自從
Toolbar / Tree / View / ContextMenu 拼起來雖然也能跑,但兩邊會隨時間漂移。
真正該分岔的只有標頭。
Root 一定要組——面板外框、右鍵選單的定位基準、以及永遠掛載的確認/輸入對話框與隱藏的上傳
input 都在它身上。零件在 Provider 外面 render 會直接丟錯,而不是安靜地吃預設值。
可用的零件:Provider、Root、Header、HeaderRow、SourceSelect、CloseButton、Cwd、
Toolbar、Body、Tree、View、ContextMenu、EmptyState、Workspace。
來源不一定是 sandbox
面板認的是來源(source),sandbox 只是其中一種:
interface FsSource {
id: string; // 身分鍵,每次 provider 呼叫都以它定位
label: string; // 下拉選單顯示的名稱
rootPath: string; // 樹根;sandbox 就是它的 workingDirectory
}
sandboxesAsSources(launchedSandboxes) 把 LaunchedSandbox[] 轉成 FsSource[](多台時會把
sandboxName 併進 label 以資區別)。
面板內部流通的路徑一律是以 rootPath 為根的絕對路徑;後端只收相對路徑的來源,在自己的
provider 裡轉換即可。
共用的 controller
header 的切換鈕、open-file / open-folder 交接卡、以及面板本身綁的是同一個 controller:
interface FileExplorerController {
open: boolean;
activeSourceId: string | null; // null = 未指定,面板退回第一個來源
requestedFile: RequestedFile | null; // kind 決定 reveal 的終點,見下方
isEditingDirty: boolean; // 檔案有未存檔的修改
sourceViews: Record<string, SourceViewState>; // 每個來源各自的瀏覽狀態
openExplorer(): void;
closeExplorer(): void;
toggle(): void;
selectSource(sourceId: string): void;
requestFile(
sourceId: string,
absolutePath: string,
options?: { reveal?: boolean }, // reveal: false = 只送出 intent,不強拉面板
): void;
requestFolder( // 同一個請求,終點停在樹上
sourceId: string,
absolutePath: string,
options?: { reveal?: boolean },
): void;
setEditingDirty(dirty: boolean): void;
sourceView(sourceId: string | null): SourceViewState;
updateSourceView(sourceId: string, update: (prev: SourceViewState) => SourceViewState): void;
}
activeSandboxName / selectSandbox 以及 RequestedFile.sandboxName 都保留為別名,指向同一份
狀態,既有程式碼一行都不用改。
requestFile 就是 sandbox://…/open-file 卡片背後呼叫的東西,
requestFolder 則對應 open-folder 卡。
兩者送出的是同一種請求,差別只在終點:
interface RequestedFile {
kind: "file" | "folder"; // 'file' 開檢視器(讀取+追蹤變更);'folder' 只展開並選取該目錄
sourceId: string;
absolutePath: string;
nonce: number; // 同一個路徑再請求一次也能重新觸發 reveal
}
kind 是必填,而且一定來自卡片自己,不是猜的——reveal 抵達時面板還沒列過那一層,分不出路徑
是檔案還是目錄,而後端對目錄的 fs/file 回 500、fs/watch 會斷線,所以「先當檔案試」不可行。
open-file 和 open-folder 是兩個獨立的 uri action,原因就在這裡。
kind 從選填改為必填。實務上唯一的產生者是 controller 自己、消費端只讀不寫,所以呼叫
requestFile() / requestFolder() 的程式碼不受影響;只有自行組裝 RequestedFile 物件的
地方需要補上這個欄位。
瀏覽狀態存在 controller 上,而且每個來源一份
「使用者在這個來源看到哪裡」——哪些資料夾展開著、選了什麼、檢視器裡開著哪個檔——記在 controller 上:
interface SourceViewState {
expanded: Set<string>; // 展開中的資料夾(絕對路徑)
selectedPath: string | null;
selectedEntry: FsEntry | null;
openFile: FsEntry | null; // 檢視器裡開著的檔案
}
放在 controller 而不是 <FileExplorer.Provider> 裡面,有兩個理由:
- 每個來源各一份,離開再回來才會還原而不是重置。以前 provider 只存一份、每次切換就清空, 於是 A → B → A 會落在一棵空樹上。
- controller 由使用端建立(
useFileExplorerController()),所以會重新掛載面板的宿主——例如 切換對話時整棵子樹重建的情況——可以把 controller 提到那個邊界之上,狀態就跨得過重新掛載。 存在 provider 裡的狀態在結構上不可能做到這件事。
沒訪問過的來源不會有紀錄;一律用 sourceView(id) 讀,它會退回空狀態。寫入用
updateSourceView(id, prev => next) 的 updater 形式——同一個 handler 裡的兩次寫入才不會互相
覆蓋。
下拉選單裡選中的來源不見了(sandbox 收掉了之類),面板會退回第一個可用來源,而不是停在空狀態。
取消選取
選取是可以取消的:點樹狀區的空白處(最後一列底下那片空白),或按 Esc。
這件事有實際後果,不只是 highlight 消失——所有以選取為目標的動作都會落回樹根:上傳、新增檔案、 新增資料夾、貼上。少了這條路,點過一次子資料夾之後就再也傳不到根目錄,只能重新整理頁面。同時, 需要選取才能用的工具列動作(下載/複製/剪下/重新命名/刪除)也會跟著回到 disabled。
Esc 的優先權是讓開:對話框或右鍵選單開著時,那顆 Esc 歸它們,選取不受影響;要再按一次才
輪到取消選取。檢視器裡開著檔案時 Esc 也不作用——那時樹和工具列都不在畫面上,取消一個看不見的
選取只會在返回時變成「我沒動過它,它怎麼不見了」。
面板本身不攔截 Esc:它沒處理的那些會照常往外傳,所以宿主自己掛在外層的 Esc 不會被吃掉。
貼上時的名稱去重
剪貼簿式的搬移/複製貼到已經有同名檔案的位置時,貼上的那份會自動取一個沒被用掉的名字, 而不是覆蓋既有檔案或直接失敗。
檔案存取 provider
面板不在意資料從哪來,只要給它這些函式:
type FsListDir = (sourceId: string, path: string) => Promise<FsListResult>;
type FsReadFile = (sourceId: string, path: string) => Promise<string>;
type FsSaveFile = (sourceId: string, path: string, content: string) => Promise<void> | void;
// 訂閱單一路徑的變動,回傳退訂函式。事件內容刻意不外露——反正 view 都要重讀。
type FsWatchFile = (sourceId: string, path: string, onChange: () => void) => () => void;
只有 listDir 是必要的。其餘全部選用,沒給的話對應的操作就不會出現在選單裡:
readFile、saveFile、watchFile、mkdir、remove、copy、move、upload、download
——這也是「唯讀來源」的表達方式。
watchFile 選用是刻意的:sandbox 的 edge server 有 SSE 監看端點,但不是每種來源都有;缺它時
單檔檢視退化成開檔時讀一次。
createSandboxFsProviders(client, options) 會把 core 的 sandbox fs 方法接成上面這整組——
圖片檔解析成 data URL 供 <img src> 用,文字檔解成字串。它還會追蹤失敗次數:某個 sandbox
連續失敗到門檻時呼叫 onSandboxUnreachable,讓你把它從下拉選單移除。
Nudge:喚醒閒置的 sandbox
sandbox 閒置關掉之後,面板的空狀態會出現一顆 Nudge 鈕。它送出的是一個隱形的
NUDGE 回合——不會在對話串留下訊息:
await channel.nudge();
nudge 也是一個回合,所以 run 進行中會被直接拒絕。把宿主的「目前有 run 佔住 channel」狀態
傳給 nudgeDisabled,避免使用者按了沒反應。
也看看
- sandbox:// 交接卡 —
open-file從哪裡來 - 冷啟動 HUD —
launchedSandboxes從哪裡來 - 在聊天外殼之外重建主題 — 自行擺放時的必要步驟