跳至主要内容

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)。

File Explorer 載入中…
這個示範用的是假檔案樹

面板把檔案存取當成一組非同步 callback 收下,所以上面的示範把 listDir / readFile / saveFile / watchFile 換成瀏覽器記憶體裡的假樹——延遲展開、編輯、存檔、右鍵操作全都是真的 在跑,只是背後沒有 sandbox。正式環境改接 createSandboxFsProviders 即可。

內建 vs 自行擺放

// 預設:header 出現資料夾圖示,點了開右側側欄,一切自動接好
<Chatbot fileExplorer="builtin" {...rest} />

// 關掉:兩者都不出現,改由你把 <FileExplorerPanel> 擺在任何位置
<Chatbot fileExplorer="off" {...rest} />

相關的 Chatbot props:

Prop用途
fileExplorer'builtin'(預設)或 'off'
autoRevealOnOpenFileCardopen-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 會直接丟錯,而不是安靜地吃預設值。

可用的零件:ProviderRootHeaderHeaderRowSourceSelectCloseButtonCwdToolbarBodyTreeViewContextMenuEmptyStateWorkspace

來源不一定是 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/file500fs/watch 會斷線,所以「先當檔案試」不可行。 open-fileopen-folder 是兩個獨立的 uri action,原因就在這裡。

型別破壞性變更(0.3.84)

kind 從選填改為必填。實務上唯一的產生者是 controller 自己、消費端只讀不寫,所以呼叫 requestFile() / requestFolder() 的程式碼不受影響;只有自行組裝 RequestedFile 物件的 地方需要補上這個欄位。

瀏覽狀態存在 controller 上,而且每個來源一份

「使用者在這個來源看到哪裡」——哪些資料夾展開著、選了什麼、檢視器裡開著哪個檔——記在 controller 上:

interface SourceViewState {
expanded: Set<string>; // 展開中的資料夾(絕對路徑)
selectedPath: string | null;
selectedEntry: FsEntry | null;
openFile: FsEntry | null; // 檢視器裡開著的檔案
}

放在 controller 而不是 <FileExplorer.Provider> 裡面,有兩個理由:

  1. 每個來源各一份,離開再回來才會還原而不是重置。以前 provider 只存一份、每次切換就清空, 於是 A → B → A 會落在一棵空樹上。
  2. 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 是必要的。其餘全部選用,沒給的話對應的操作就不會出現在選單裡: readFilesaveFilewatchFilemkdirremovecopymoveuploaddownload ——這也是「唯讀來源」的表達方式。

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,避免使用者按了沒反應。

也看看