Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions store/console/src/tabs/KnowledgeTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ interface DriveFile {
webViewLink?: string;
}

interface WorkDriveFile {
id: string;
name: string;
type: string;
isFolder?: boolean;
mimeType?: string;
extension?: string;
permalink?: string;
}

interface Props {
instanceId: string;
isApply: boolean;
Expand Down Expand Up @@ -50,6 +60,14 @@ export default function KnowledgeTab({ instanceId, isApply }: Props) {
const [driveLoading, setDriveLoading] = useState(false);
const [driveMsg, setDriveMsg] = useState("");
const [importingDriveId, setImportingDriveId] = useState<string | null>(null);
const [showWorkdrive, setShowWorkdrive] = useState(false);
const [workdriveStatus, setWorkdriveStatus] = useState<{ connected: boolean; configured: boolean; account?: string | null } | null>(null);
const [workdriveRef, setWorkdriveRef] = useState("");
const [workdriveFiles, setWorkdriveFiles] = useState<WorkDriveFile[]>([]);
const [workdriveNextOffset, setWorkdriveNextOffset] = useState<number | null>(null);
const [workdriveLoading, setWorkdriveLoading] = useState(false);
const [workdriveMsg, setWorkdriveMsg] = useState("");
const [importingWorkdriveId, setImportingWorkdriveId] = useState<string | null>(null);

const loadDocs = useCallback(async () => {
try {
Expand Down Expand Up @@ -87,6 +105,10 @@ export default function KnowledgeTab({ instanceId, isApply }: Props) {
const s = await api<{ connected: boolean; configured: boolean; email?: string | null }>("/v1/drive/status");
setDriveStatus(s);
} catch {}
try {
const s = await api<{ connected: boolean; configured: boolean; account?: string | null }>("/v1/workdrive/status");
setWorkdriveStatus(s);
} catch {}
})();
}, [subTab]);

Expand Down Expand Up @@ -171,6 +193,52 @@ export default function KnowledgeTab({ instanceId, isApply }: Props) {
setImportingDriveId(null);
};

const listWorkdriveFolder = async (ref = workdriveRef, offset = 0, append = false) => {
const folderRef = ref.trim();
if (!folderRef) return;
setWorkdriveLoading(true);
setWorkdriveMsg("");
try {
const params = new URLSearchParams({ folder: folderRef, offset: String(offset), limit: "50" });
const d = await api<{ files?: WorkDriveFile[]; nextOffset?: number | null }>(`/v1/workdrive/folder?${params}`);
const files = d.files || [];
setWorkdriveRef(folderRef);
setWorkdriveFiles((prev) => (append ? [...prev, ...files] : files));
setWorkdriveNextOffset(typeof d.nextOffset === "number" ? d.nextOffset : null);
if (!append && !files.length) setWorkdriveMsg("No files or folders found.");
} catch (e) {
setWorkdriveMsg(e instanceof Error ? e.message : "WorkDrive folder lookup failed");
}
setWorkdriveLoading(false);
};

const openWorkdriveFolder = (file: WorkDriveFile) => {
setWorkdriveFiles([]);
setWorkdriveNextOffset(null);
listWorkdriveFolder(file.id);
};

const importWorkdriveFile = async (file?: WorkDriveFile) => {
const ref = file?.id || workdriveRef.trim();
if (!ref) return;
setImportingWorkdriveId(file?.id || "__direct__");
setWorkdriveMsg("");
try {
await api(`/v1/workdrive/instances/${instanceId}/import`, {
method: "POST",
body: JSON.stringify(file ? { resourceId: file.id } : { url: ref }),
});
setWorkdriveMsg(`Imported ${file?.name || "WorkDrive file"}.`);
setShowWorkdrive(false);
setWorkdriveFiles([]);
setWorkdriveNextOffset(null);
loadDocs();
} catch (e) {
setWorkdriveMsg(e instanceof Error ? e.message : "WorkDrive import failed");
}
setImportingWorkdriveId(null);
};

const deleteDoc = async (docId: string) => {
if (!confirm("Delete this document?")) return;
try {
Expand Down Expand Up @@ -237,6 +305,17 @@ export default function KnowledgeTab({ instanceId, isApply }: Props) {
file.mimeType === "application/vnd.google-apps.presentation"
);

const supportedWorkdriveFile = (file: WorkDriveFile) => {
const type = (file.mimeType || "").toLowerCase();
const ext = (file.extension || file.name.split(".").pop() || "").toLowerCase();
return type.startsWith("text/") || type.includes("json") || type.includes("xml") || ["txt", "md", "csv", "json", "xml", "html", "htm", "yaml", "yml", "tsv"].includes(ext);
};

const isWorkdriveFolder = (file: WorkDriveFile) => {
const type = file.type.toLowerCase();
return file.isFolder === true || type.includes("folder");
};

const subTabs: { id: KbSubTab; label: string }[] = [
{ id: "docs", label: "Documents" },
{ id: "memory", label: "Memory" },
Expand Down Expand Up @@ -333,6 +412,7 @@ export default function KnowledgeTab({ instanceId, isApply }: Props) {
<button type="button" onClick={openNew} className="text-xs px-2.5 py-1.5 rounded-lg bg-accent text-white font-bold">+ New</button>
<button type="button" onClick={() => setShowUrl((s) => !s)} className="text-xs px-2.5 py-1.5 rounded-lg border border-line text-muted hover:border-accent hover:text-accent font-semibold">+ URL</button>
<button type="button" onClick={() => setShowDrive((s) => !s)} className="text-xs px-2.5 py-1.5 rounded-lg border border-line text-muted hover:border-accent hover:text-accent font-semibold">+ Drive</button>
<button type="button" onClick={() => setShowWorkdrive((s) => !s)} className="text-xs px-2.5 py-1.5 rounded-lg border border-line text-muted hover:border-accent hover:text-accent font-semibold">+ WorkDrive</button>
<label className="text-xs px-2.5 py-1.5 rounded-lg border border-line text-muted hover:border-accent hover:text-accent font-semibold cursor-pointer">
+ File
<input type="file" accept=".txt,.md,.csv,.json,.html,.htm,.pdf,.xml" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadKbFile(f); e.target.value = ""; }} />
Expand Down Expand Up @@ -399,6 +479,68 @@ export default function KnowledgeTab({ instanceId, isApply }: Props) {
</div>
)}

{showWorkdrive && (
<div className="bg-panel border border-line rounded-xl p-4 mb-3">
{workdriveStatus?.connected ? (
<>
<div className="flex gap-2 mb-2 flex-wrap">
<input
value={workdriveRef}
onChange={(e) => setWorkdriveRef(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") listWorkdriveFolder(); }}
placeholder="Zoho WorkDrive file or folder URL / ID"
className="flex-1 min-w-[12rem] bg-paper border border-line rounded-lg px-3 py-2 text-sm"
/>
<button type="button" onClick={() => importWorkdriveFile()} disabled={!workdriveRef.trim() || importingWorkdriveId === "__direct__"} className="text-xs px-3 py-1.5 rounded-lg bg-accent text-white font-bold disabled:opacity-50">
{importingWorkdriveId === "__direct__" ? "Importing..." : "Import file"}
</button>
<button type="button" onClick={() => listWorkdriveFolder()} disabled={workdriveLoading || !workdriveRef.trim()} className="text-xs px-3 py-1.5 rounded-lg border border-line text-muted hover:border-accent hover:text-accent font-semibold disabled:opacity-50">
{workdriveLoading ? "Listing..." : "List folder"}
</button>
<button type="button" onClick={() => setShowWorkdrive(false)} className="text-xs px-3 py-1.5 rounded-lg border border-line text-muted font-semibold">Cancel</button>
</div>
{workdriveFiles.length > 0 && (
<div className="flex flex-col gap-2 mt-3">
{workdriveFiles.map((f) => {
const folder = isWorkdriveFolder(f);
const supported = supportedWorkdriveFile(f);
return (
<div key={f.id} className="bg-paper border border-line rounded-lg p-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{f.name}</div>
<div className="text-xs text-muted truncate">{folder ? "Folder" : f.mimeType || f.extension || f.type}</div>
</div>
<button
type="button"
disabled={(!folder && !supported) || importingWorkdriveId === f.id || workdriveLoading}
onClick={() => folder ? openWorkdriveFolder(f) : importWorkdriveFile(f)}
className="text-xs px-2.5 py-1.5 rounded-lg border border-line text-muted hover:border-accent hover:text-accent font-semibold disabled:opacity-40 disabled:hover:border-line disabled:hover:text-muted"
>
{importingWorkdriveId === f.id ? "Importing..." : folder ? "Open" : supported ? "Import" : "Unsupported"}
</button>
</div>
);
})}
{workdriveNextOffset !== null && (
<button
type="button"
onClick={() => listWorkdriveFolder(workdriveRef, workdriveNextOffset, true)}
disabled={workdriveLoading}
className="self-start text-xs px-3 py-1.5 rounded-lg border border-line text-muted hover:border-accent hover:text-accent font-semibold disabled:opacity-50"
>
{workdriveLoading ? "Loading..." : "Load more"}
</button>
)}
</div>
)}
</>
) : (
<p className="text-sm text-muted">Connect Zoho WorkDrive in Settings before importing WorkDrive files.</p>
)}
{workdriveMsg && <div className="text-xs text-muted mt-2">{workdriveMsg}</div>}
</div>
)}

{docs.length === 0 ? (
<p className="text-center py-6 text-muted-soft text-sm">No documents yet. Click <b>+ New</b> to write one in Markdown.</p>
) : (
Expand Down
66 changes: 53 additions & 13 deletions store/console/src/tabs/SettingsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export default function SettingsTab({ instanceId, isApply, settingsSchema, onUns
const [emailMsg, setEmailMsg] = useState("");
const [driveStatus, setDriveStatus] = useState<{ connected: boolean; configured: boolean; email?: string | null } | null>(null);
const [driveMsg, setDriveMsg] = useState("");
const [workdriveStatus, setWorkdriveStatus] = useState<{ connected: boolean; configured: boolean; account?: string | null } | null>(null);
const [workdriveMsg, setWorkdriveMsg] = useState("");

useEffect(() => {
(async () => {
Expand Down Expand Up @@ -102,30 +104,23 @@ export default function SettingsTab({ instanceId, isApply, settingsSchema, onUns
const s = await api<{ connected: boolean; configured: boolean; email?: string | null }>("/v1/drive/status");
setDriveStatus(s);
} catch {}
try {
const s = await api<{ connected: boolean; configured: boolean; account?: string | null }>("/v1/workdrive/status");
setWorkdriveStatus(s);
} catch {}
try {
const st = await api<{ permissions?: { email?: boolean } }>(`/v1/instances/${instanceId}/state`);
setEmailPermission(st.permissions?.email === true);
} catch {}
})();
}, [instanceId]);

// Gmail is connected in a popup; re-check when the user returns to this tab.
const refreshEmail = async () => {
try {
const s = await api<{ connected: boolean; configured: boolean; email?: string | null }>("/v1/email/status");
setEmailStatus(s);
} catch {}
};
const refreshDrive = async () => {
try {
const s = await api<{ connected: boolean; configured: boolean; email?: string | null }>("/v1/drive/status");
setDriveStatus(s);
} catch {}
};
// Cloud connectors open OAuth in a popup; re-check when the user returns to this tab.
useEffect(() => {
const onFocus = () => {
api<{ connected: boolean; configured: boolean; email?: string | null }>("/v1/email/status").then(setEmailStatus).catch(() => {});
api<{ connected: boolean; configured: boolean; email?: string | null }>("/v1/drive/status").then(setDriveStatus).catch(() => {});
api<{ connected: boolean; configured: boolean; account?: string | null }>("/v1/workdrive/status").then(setWorkdriveStatus).catch(() => {});
};
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
Expand Down Expand Up @@ -210,6 +205,27 @@ export default function SettingsTab({ instanceId, isApply, settingsSchema, onUns
}
};

const connectWorkdrive = async () => {
try {
const { url } = await api<{ url: string }>("/v1/workdrive/zoho/start");
window.open(url, "_blank", "noopener");
setWorkdriveMsg("Complete the Zoho sign-in in the new tab, then come back here.");
} catch (e) {
setWorkdriveMsg(e instanceof Error ? e.message : "Failed to start Zoho WorkDrive connection");
}
};

const disconnectWorkdrive = async () => {
if (!confirm("Disconnect Zoho WorkDrive? Existing imported documents stay, but new WorkDrive imports will stop working.")) return;
try {
await api("/v1/workdrive/zoho", { method: "DELETE" });
setWorkdriveStatus((s) => (s ? { ...s, connected: false } : s));
setWorkdriveMsg("Zoho WorkDrive disconnected.");
} catch (e) {
setWorkdriveMsg(e instanceof Error ? e.message : "Failed");
}
};

const toggleEmailPermission = async (on: boolean) => {
setEmailPermission(on);
try {
Expand Down Expand Up @@ -406,6 +422,9 @@ export default function SettingsTab({ instanceId, isApply, settingsSchema, onUns
{driveStatus && !driveStatus.configured && (
<p className="text-xs text-red mb-2">Google Drive connection isn’t configured on this deployment yet.</p>
)}
{workdriveStatus && !workdriveStatus.configured && (
<p className="text-xs text-red mb-2">Zoho WorkDrive connection isn’t configured on this deployment yet.</p>
)}

<div className="flex items-center justify-between gap-3 mb-3">
<div className="text-sm">
Expand Down Expand Up @@ -447,6 +466,26 @@ export default function SettingsTab({ instanceId, isApply, settingsSchema, onUns
)}
</div>

<div className="flex items-center justify-between gap-3 mb-3">
<div className="text-sm">
<span className="font-semibold">Zoho WorkDrive</span>{" "}
{workdriveStatus?.connected
? <span className="text-green">· connected{workdriveStatus.account ? ` (${workdriveStatus.account})` : ""}</span>
: <span className="text-muted">· not connected</span>}
</div>
{workdriveStatus?.configured && (
workdriveStatus.connected ? (
<button type="button" onClick={disconnectWorkdrive} className="text-xs px-3 py-1.5 rounded-lg border border-line text-muted hover:border-red hover:text-red font-bold">
Disconnect
</button>
) : (
<button type="button" onClick={connectWorkdrive} className="text-xs px-3 py-1.5 rounded-lg border border-line text-muted hover:border-accent hover:text-accent font-bold">
Connect WorkDrive
</button>
)
)}
</div>

<label className={`flex items-center gap-2 text-sm ${emailStatus?.connected ? "" : "opacity-50"}`}>
<input
type="checkbox"
Expand All @@ -461,6 +500,7 @@ export default function SettingsTab({ instanceId, isApply, settingsSchema, onUns
)}
{emailMsg && <div className="text-xs text-muted mt-2">{emailMsg}</div>}
{driveMsg && <div className="text-xs text-muted mt-2">{driveMsg}</div>}
{workdriveMsg && <div className="text-xs text-muted mt-2">{workdriveMsg}</div>}
</div>

{/* Voice */}
Expand Down
2 changes: 2 additions & 0 deletions workers/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { profileRoutes } from "./routes/profile.js";
import { keysRoutes } from "./routes/keys.js";
import { emailRoutes } from "./routes/email.js";
import { driveRoutes } from "./routes/drive.js";
import { workdriveRoutes } from "./routes/workdrive.js";
import { errorRoutes } from "./routes/errors.js";
import { dashboardRoutes } from "./routes/dashboard.js";
import { notificationRoutes } from "./routes/notifications.js";
Expand Down Expand Up @@ -101,6 +102,7 @@ app.route("/v1/batch", batchRoutes); // /v1/batch/bulk-visibility, /bulk-d
app.route("/v1/keys", keysRoutes); // /v1/keys/providers, /status, /:provider, /proxy/:host/*
app.route("/v1/email", emailRoutes); // /v1/email/google/start, /callback, /status, DELETE /google
app.route("/v1/drive", driveRoutes); // /v1/drive/google/start, /callback, /status, /files, /instances/:id/import
app.route("/v1/workdrive", workdriveRoutes); // /v1/workdrive/zoho/start, /callback, /status, /folder, /instances/:id/import
app.route("/v1/errors", errorRoutes); // GET /v1/errors — durable error log read-back
app.route("/v1/public", publicRoutes); // /v1/public/agents/:id, /agents/:id/try, /webhook/:id/ingest
app.route("/v1/billing", billingRoutes);
Expand Down
Loading
Loading