2025-12-07 19:32:53 +02:00
|
|
|
import { create } from "zustand";
|
|
|
|
|
import { devtools, persist, createJSONStorage } from "zustand/middleware";
|
|
|
|
|
import type { AttachedFile } from "./types/sessionTypes";
|
|
|
|
|
import { getSafeStorage } from "./utils/safeStorage";
|
|
|
|
|
|
|
|
|
|
interface FileState {
|
|
|
|
|
attachedFiles: AttachedFile[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface FileActions {
|
|
|
|
|
addAttachedFile: (file: File) => Promise<void>;
|
|
|
|
|
addServerFile: (path: string, name: string, content?: string) => Promise<void>;
|
|
|
|
|
removeAttachedFile: (id: string) => void;
|
|
|
|
|
clearAttachedFiles: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type FileStore = FileState & FileActions;
|
|
|
|
|
|
2026-01-14 13:23:17 +02:00
|
|
|
const MAX_ATTACHMENT_SIZE = 50 * 1024 * 1024;
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2025-12-15 13:15:45 +02:00
|
|
|
const guessMimeTypeFromName = (filename: string): string => {
|
|
|
|
|
const name = (filename || "").toLowerCase();
|
|
|
|
|
const ext = name.includes(".") ? name.split(".").pop() || "" : "";
|
|
|
|
|
switch (ext) {
|
|
|
|
|
case "png":
|
|
|
|
|
return "image/png";
|
|
|
|
|
case "jpg":
|
|
|
|
|
case "jpeg":
|
|
|
|
|
return "image/jpeg";
|
|
|
|
|
case "gif":
|
|
|
|
|
return "image/gif";
|
|
|
|
|
case "webp":
|
|
|
|
|
return "image/webp";
|
|
|
|
|
case "svg":
|
|
|
|
|
return "image/svg+xml";
|
|
|
|
|
case "bmp":
|
|
|
|
|
return "image/bmp";
|
|
|
|
|
case "ico":
|
|
|
|
|
return "image/x-icon";
|
|
|
|
|
case "pdf":
|
|
|
|
|
return "application/pdf";
|
|
|
|
|
default:
|
2026-02-11 19:28:22 +02:00
|
|
|
return "application/octet-stream";
|
2025-12-15 13:15:45 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
const guessMimeType = (file: File): string => {
|
|
|
|
|
if (file.type && file.type.trim().length > 0) {
|
|
|
|
|
return file.type;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const name = (file.name || "").toLowerCase();
|
|
|
|
|
const ext = name.includes(".") ? name.split(".").pop() || "" : "";
|
|
|
|
|
const noExtNames = new Set([
|
|
|
|
|
"license",
|
|
|
|
|
"readme",
|
|
|
|
|
"changelog",
|
|
|
|
|
"notice",
|
|
|
|
|
"authors",
|
|
|
|
|
"copying",
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
if (noExtNames.has(name)) return "text/plain";
|
|
|
|
|
|
|
|
|
|
switch (ext) {
|
|
|
|
|
case "md":
|
|
|
|
|
case "markdown":
|
|
|
|
|
return "text/markdown";
|
|
|
|
|
case "txt":
|
|
|
|
|
return "text/plain";
|
|
|
|
|
case "json":
|
|
|
|
|
return "application/json";
|
|
|
|
|
case "yaml":
|
|
|
|
|
case "yml":
|
|
|
|
|
return "application/x-yaml";
|
|
|
|
|
case "ts":
|
|
|
|
|
case "tsx":
|
|
|
|
|
case "js":
|
|
|
|
|
case "jsx":
|
|
|
|
|
case "mjs":
|
|
|
|
|
case "cjs":
|
|
|
|
|
case "py":
|
|
|
|
|
case "rb":
|
|
|
|
|
case "sh":
|
|
|
|
|
case "bash":
|
|
|
|
|
case "zsh":
|
|
|
|
|
return "text/plain";
|
|
|
|
|
default:
|
|
|
|
|
return "application/octet-stream";
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-11 19:28:22 +02:00
|
|
|
const normalizeServerPath = (inputPath: string): string => inputPath.replace(/\\/g, "/").trim();
|
|
|
|
|
|
|
|
|
|
const toFileUrl = (inputPath: string): string => {
|
|
|
|
|
const normalized = normalizeServerPath(inputPath);
|
|
|
|
|
if (normalized.startsWith("file://")) {
|
|
|
|
|
return normalized;
|
2025-12-15 13:15:45 +02:00
|
|
|
}
|
2026-02-11 19:28:22 +02:00
|
|
|
|
|
|
|
|
const withLeadingSlash = normalized.startsWith("/") ? normalized : `/${normalized}`;
|
|
|
|
|
return `file://${encodeURI(withLeadingSlash)}`;
|
2025-12-15 13:15:45 +02:00
|
|
|
};
|
|
|
|
|
|
2026-02-11 19:28:22 +02:00
|
|
|
const readRawFileAsDataUrl = async (absolutePath: string): Promise<string> => {
|
|
|
|
|
const response = await fetch(`/api/fs/raw?path=${encodeURIComponent(absolutePath)}`);
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(`Failed to read raw file: ${response.status}`);
|
2025-12-15 13:15:45 +02:00
|
|
|
}
|
2026-02-11 19:28:22 +02:00
|
|
|
const blob = await response.blob();
|
|
|
|
|
return await new Promise<string>((resolve, reject) => {
|
|
|
|
|
const reader = new FileReader();
|
|
|
|
|
reader.onload = () => resolve(reader.result as string);
|
|
|
|
|
reader.onerror = reject;
|
|
|
|
|
reader.readAsDataURL(blob);
|
|
|
|
|
});
|
2025-12-15 13:15:45 +02:00
|
|
|
};
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
export const useFileStore = create<FileStore>()(
|
|
|
|
|
|
|
|
|
|
devtools(
|
|
|
|
|
persist(
|
|
|
|
|
(set, get) => ({
|
|
|
|
|
|
|
|
|
|
attachedFiles: [],
|
|
|
|
|
|
|
|
|
|
addAttachedFile: async (file: File) => {
|
|
|
|
|
|
|
|
|
|
const { attachedFiles } = get();
|
|
|
|
|
const isDuplicate = attachedFiles.some((f) => f.filename === file.name && f.size === file.size);
|
|
|
|
|
if (isDuplicate) {
|
|
|
|
|
console.log(`File "${file.name}" is already attached`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const maxSize = MAX_ATTACHMENT_SIZE;
|
|
|
|
|
if (file.size > maxSize) {
|
2026-01-14 13:23:17 +02:00
|
|
|
throw new Error(`File "${file.name}" is too large. Maximum size is 50MB.`);
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const allowedTypes = [
|
|
|
|
|
"text/",
|
|
|
|
|
"application/json",
|
|
|
|
|
"application/xml",
|
|
|
|
|
"application/pdf",
|
|
|
|
|
"image/",
|
|
|
|
|
"video/",
|
|
|
|
|
"audio/",
|
|
|
|
|
"application/javascript",
|
|
|
|
|
"application/typescript",
|
|
|
|
|
"application/x-python",
|
|
|
|
|
"application/x-ruby",
|
|
|
|
|
"application/x-sh",
|
|
|
|
|
"application/yaml",
|
|
|
|
|
"application/octet-stream",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const mimeType = guessMimeType(file);
|
|
|
|
|
const isAllowed = allowedTypes.some((type) => mimeType.startsWith(type) || mimeType === type || mimeType === "");
|
|
|
|
|
|
|
|
|
|
if (!isAllowed && mimeType !== "") {
|
|
|
|
|
console.warn(`File type "${mimeType}" might not be supported`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const reader = new FileReader();
|
|
|
|
|
const rawDataUrl = await new Promise<string>((resolve, reject) => {
|
|
|
|
|
reader.onload = () => resolve(reader.result as string);
|
|
|
|
|
reader.onerror = reject;
|
|
|
|
|
reader.readAsDataURL(file);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const dataUrl = rawDataUrl.startsWith("data:")
|
|
|
|
|
? rawDataUrl.replace(/^data:[^;]*/, `data:${mimeType}`)
|
|
|
|
|
: rawDataUrl;
|
|
|
|
|
|
|
|
|
|
const extractFilename = (fullPath: string) => {
|
|
|
|
|
|
|
|
|
|
const parts = fullPath.replace(/\\/g, "/").split("/");
|
|
|
|
|
return parts[parts.length - 1] || fullPath;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const attachedFile: AttachedFile = {
|
|
|
|
|
id: `file-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
|
|
|
|
file,
|
|
|
|
|
dataUrl,
|
|
|
|
|
mimeType,
|
|
|
|
|
filename: extractFilename(file.name),
|
|
|
|
|
size: file.size,
|
|
|
|
|
source: "local",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
set((state) => ({
|
|
|
|
|
attachedFiles: [...state.attachedFiles, attachedFile],
|
|
|
|
|
}));
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
addServerFile: async (path: string, name: string, content?: string) => {
|
|
|
|
|
|
2026-02-11 19:28:22 +02:00
|
|
|
const normalizedPath = normalizeServerPath(path);
|
2025-12-07 19:32:53 +02:00
|
|
|
const { attachedFiles } = get();
|
2026-02-11 19:28:22 +02:00
|
|
|
const isDuplicate = attachedFiles.some((f) => normalizeServerPath(f.serverPath || "") === normalizedPath && f.source === "server");
|
2025-12-07 19:32:53 +02:00
|
|
|
if (isDuplicate) {
|
|
|
|
|
console.log(`Server file "${name}" is already attached`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 19:28:22 +02:00
|
|
|
const inferredMime = guessMimeTypeFromName(name);
|
|
|
|
|
const safeMimeType = inferredMime && inferredMime.trim().length > 0 ? inferredMime : "application/octet-stream";
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-02-11 19:28:22 +02:00
|
|
|
const shouldInlineBinary = safeMimeType !== "text/plain" && safeMimeType !== "application/x-directory";
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-02-11 19:28:22 +02:00
|
|
|
let dataUrl = toFileUrl(normalizedPath);
|
|
|
|
|
if (shouldInlineBinary) {
|
|
|
|
|
try {
|
|
|
|
|
dataUrl = await readRawFileAsDataUrl(normalizedPath);
|
2025-12-07 19:32:53 +02:00
|
|
|
} catch (error) {
|
2026-02-11 19:28:22 +02:00
|
|
|
console.warn("Failed to inline binary server file, falling back to file://", error);
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 19:28:22 +02:00
|
|
|
const sizeBytes = typeof content === "string"
|
|
|
|
|
? new TextEncoder().encode(content).length
|
|
|
|
|
: 0;
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2025-12-15 13:15:45 +02:00
|
|
|
const file = new File([], name, { type: safeMimeType });
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
const attachedFile: AttachedFile = {
|
|
|
|
|
id: `server-file-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
|
|
|
|
file,
|
|
|
|
|
dataUrl,
|
2025-12-15 13:15:45 +02:00
|
|
|
mimeType: safeMimeType,
|
2025-12-07 19:32:53 +02:00
|
|
|
filename: name,
|
2025-12-15 13:15:45 +02:00
|
|
|
size: sizeBytes,
|
2025-12-07 19:32:53 +02:00
|
|
|
source: "server",
|
2026-02-11 19:28:22 +02:00
|
|
|
serverPath: normalizedPath,
|
2025-12-07 19:32:53 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
set((state) => ({
|
|
|
|
|
attachedFiles: [...state.attachedFiles, attachedFile],
|
|
|
|
|
}));
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
removeAttachedFile: (id: string) => {
|
|
|
|
|
set((state) => ({
|
|
|
|
|
attachedFiles: state.attachedFiles.filter((f) => f.id !== id),
|
|
|
|
|
}));
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
clearAttachedFiles: () => {
|
|
|
|
|
set({ attachedFiles: [] });
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
{
|
|
|
|
|
name: "file-store",
|
|
|
|
|
storage: createJSONStorage(() => getSafeStorage()),
|
2026-02-11 19:28:22 +02:00
|
|
|
version: 3,
|
|
|
|
|
migrate: (persistedState) => {
|
|
|
|
|
const state = persistedState as { attachedFiles?: AttachedFile[] } | undefined;
|
|
|
|
|
return { attachedFiles: Array.isArray(state?.attachedFiles) ? state.attachedFiles : [] };
|
|
|
|
|
},
|
|
|
|
|
// Keep unsent draft attachments across restarts.
|
2025-12-07 19:32:53 +02:00
|
|
|
partialize: (state) => ({
|
|
|
|
|
attachedFiles: state.attachedFiles,
|
|
|
|
|
}),
|
|
|
|
|
}
|
|
|
|
|
),
|
|
|
|
|
{
|
|
|
|
|
name: "file-store",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
);
|