2026-03-31 18:47:00 +03:00
|
|
|
/**
|
|
|
|
|
* Input Store — pending input text, synthetic parts, and attached files.
|
|
|
|
|
* Extracted from session-ui-store for subscription isolation.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { create } from "zustand"
|
|
|
|
|
import type { AttachedFile } from "@/stores/types/sessionTypes"
|
|
|
|
|
|
2026-05-05 00:24:03 +02:00
|
|
|
const FILE_URI_PREFIX = "file://"
|
|
|
|
|
const pendingVSCodeSelectionKeys = new Set<string>()
|
2026-05-08 08:47:46 -04:00
|
|
|
let attachmentReadGeneration = 0
|
2026-05-05 00:24:03 +02:00
|
|
|
|
2026-07-22 11:33:22 +03:00
|
|
|
const ACCEPTED_ATTACHMENT_TYPES = [
|
|
|
|
|
"image/png",
|
|
|
|
|
"image/jpeg",
|
|
|
|
|
"image/gif",
|
|
|
|
|
"image/webp",
|
|
|
|
|
"application/pdf",
|
|
|
|
|
"text/*",
|
|
|
|
|
"application/json",
|
|
|
|
|
"application/ld+json",
|
|
|
|
|
"application/toml",
|
|
|
|
|
"application/x-toml",
|
|
|
|
|
"application/x-yaml",
|
|
|
|
|
"application/xml",
|
|
|
|
|
"application/yaml",
|
|
|
|
|
".c",
|
|
|
|
|
".cc",
|
|
|
|
|
".cjs",
|
|
|
|
|
".conf",
|
|
|
|
|
".cpp",
|
|
|
|
|
".css",
|
|
|
|
|
".csv",
|
|
|
|
|
".cts",
|
|
|
|
|
".env",
|
|
|
|
|
".go",
|
|
|
|
|
".gql",
|
|
|
|
|
".graphql",
|
|
|
|
|
".h",
|
|
|
|
|
".hh",
|
|
|
|
|
".hpp",
|
|
|
|
|
".htm",
|
|
|
|
|
".html",
|
|
|
|
|
".ini",
|
|
|
|
|
".java",
|
|
|
|
|
".js",
|
|
|
|
|
".json",
|
|
|
|
|
".jsx",
|
|
|
|
|
".log",
|
|
|
|
|
".md",
|
|
|
|
|
".mdx",
|
|
|
|
|
".mjs",
|
|
|
|
|
".mts",
|
|
|
|
|
".py",
|
|
|
|
|
".rb",
|
|
|
|
|
".rs",
|
|
|
|
|
".sass",
|
|
|
|
|
".scss",
|
|
|
|
|
".sh",
|
|
|
|
|
".sql",
|
|
|
|
|
".toml",
|
|
|
|
|
".ts",
|
|
|
|
|
".tsx",
|
|
|
|
|
".txt",
|
|
|
|
|
".xml",
|
|
|
|
|
".yaml",
|
|
|
|
|
".yml",
|
|
|
|
|
".zsh",
|
|
|
|
|
] as const
|
|
|
|
|
|
|
|
|
|
export const ATTACHMENT_ACCEPT = ACCEPTED_ATTACHMENT_TYPES.join(",")
|
|
|
|
|
|
|
|
|
|
const ATTACHMENT_MIME_EXTENSIONS = new Map<string, string>([
|
|
|
|
|
["image/png", "png"],
|
|
|
|
|
["image/jpeg", "jpg"],
|
|
|
|
|
["image/gif", "gif"],
|
|
|
|
|
["image/webp", "webp"],
|
|
|
|
|
["application/pdf", "pdf"],
|
|
|
|
|
["application/json", "json"],
|
|
|
|
|
["application/ld+json", "jsonld"],
|
|
|
|
|
["application/toml", "toml"],
|
|
|
|
|
["application/x-toml", "toml"],
|
|
|
|
|
["application/x-yaml", "yaml"],
|
|
|
|
|
["application/xml", "xml"],
|
|
|
|
|
["application/yaml", "yaml"],
|
|
|
|
|
])
|
|
|
|
|
const TEXT_ATTACHMENT_EXTENSIONS = ["txt", "text", "md", "markdown", "log", "csv"]
|
|
|
|
|
|
|
|
|
|
export const ACCEPTED_ATTACHMENT_EXTENSIONS = Array.from(new Set(
|
|
|
|
|
ACCEPTED_ATTACHMENT_TYPES.flatMap((type) => {
|
|
|
|
|
if (type.startsWith(".")) return [type.slice(1)]
|
|
|
|
|
if (type === "text/*") return TEXT_ATTACHMENT_EXTENSIONS
|
|
|
|
|
const extension = ATTACHMENT_MIME_EXTENSIONS.get(type)
|
|
|
|
|
return extension ? [extension] : []
|
|
|
|
|
})
|
|
|
|
|
)).sort()
|
|
|
|
|
|
|
|
|
|
const IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
|
|
|
|
|
const IMAGE_EXTENSIONS = new Map([
|
|
|
|
|
["gif", "image/gif"],
|
|
|
|
|
["jpeg", "image/jpeg"],
|
|
|
|
|
["jpg", "image/jpeg"],
|
|
|
|
|
["png", "image/png"],
|
|
|
|
|
["webp", "image/webp"],
|
|
|
|
|
])
|
|
|
|
|
const TEXT_MIMES = new Set([
|
|
|
|
|
"application/json",
|
|
|
|
|
"application/ld+json",
|
|
|
|
|
"application/toml",
|
|
|
|
|
"application/x-toml",
|
|
|
|
|
"application/x-yaml",
|
|
|
|
|
"application/xml",
|
|
|
|
|
"application/yaml",
|
|
|
|
|
])
|
|
|
|
|
const ATTACHMENT_SAMPLE_BYTES = 4096
|
|
|
|
|
|
2026-05-05 00:24:03 +02:00
|
|
|
const encodeFilePath = (filepath: string): string => {
|
|
|
|
|
let normalized = filepath.replace(/\\/g, "/")
|
|
|
|
|
if (/^[A-Za-z]:/.test(normalized)) {
|
|
|
|
|
normalized = `/${normalized}`
|
|
|
|
|
}
|
|
|
|
|
return normalized
|
|
|
|
|
.split("/")
|
|
|
|
|
.map((segment, index) => {
|
|
|
|
|
if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment
|
|
|
|
|
return encodeURIComponent(segment)
|
|
|
|
|
})
|
|
|
|
|
.join("/")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const toFileUrl = (filepath: string): string => {
|
|
|
|
|
const normalized = filepath.replace(/\\/g, "/").trim()
|
|
|
|
|
if (normalized.toLowerCase().startsWith(FILE_URI_PREFIX)) {
|
|
|
|
|
return normalized
|
|
|
|
|
}
|
|
|
|
|
return `${FILE_URI_PREFIX}${encodeFilePath(normalized)}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const getVSCodeSelectionKey = (path: string, filename: string): string => `${path}\u0000${filename}`
|
|
|
|
|
|
2026-07-22 11:33:22 +03:00
|
|
|
const readFileAsDataUrl = (file: File, mime: string): Promise<string> => new Promise((resolve, reject) => {
|
2026-05-12 04:09:31 -04:00
|
|
|
const reader = new FileReader()
|
2026-07-22 11:33:22 +03:00
|
|
|
reader.onload = () => {
|
|
|
|
|
const value = typeof reader.result === "string" ? reader.result : ""
|
|
|
|
|
const commaIndex = value.indexOf(",")
|
|
|
|
|
resolve(commaIndex === -1 ? value : `data:${mime};base64,${value.slice(commaIndex + 1)}`)
|
|
|
|
|
}
|
2026-05-12 04:09:31 -04:00
|
|
|
reader.onerror = () => reject(reader.error ?? new Error("Failed to read file"))
|
|
|
|
|
reader.onabort = () => reject(new Error("File read aborted"))
|
|
|
|
|
reader.readAsDataURL(file)
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-22 11:33:22 +03:00
|
|
|
const inspectTextContent = async (file: File): Promise<"text/plain" | undefined> => {
|
|
|
|
|
const bytes = new Uint8Array(await file.slice(0, ATTACHMENT_SAMPLE_BYTES).arrayBuffer())
|
|
|
|
|
if (bytes.some((byte) => byte === 0)) return
|
|
|
|
|
const controlBytes = bytes.filter((byte) => byte < 9 || (byte > 13 && byte < 32)).length
|
|
|
|
|
if (bytes.length > 0 && controlBytes / bytes.length > 0.3) return
|
|
|
|
|
return "text/plain"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const getAttachmentMime = (file: File): string | Promise<"text/plain" | undefined> | undefined => {
|
|
|
|
|
const type = file.type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
|
|
|
|
|
if (IMAGE_MIMES.has(type) || type === "application/pdf") return type
|
|
|
|
|
|
|
|
|
|
const extensionIndex = file.name.lastIndexOf(".")
|
|
|
|
|
const extension = extensionIndex === -1 ? "" : file.name.slice(extensionIndex + 1).toLowerCase()
|
|
|
|
|
const fallback = IMAGE_EXTENSIONS.get(extension) ?? (extension === "pdf" ? "application/pdf" : undefined)
|
|
|
|
|
if ((!type || type === "application/octet-stream") && fallback) return fallback
|
|
|
|
|
|
|
|
|
|
if (type.startsWith("text/") || TEXT_MIMES.has(type) || type.endsWith("+json") || type.endsWith("+xml")) {
|
|
|
|
|
return "text/plain"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return inspectTextContent(file)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 15:25:59 +03:00
|
|
|
const getDataUrlByteSize = (url: string): number => {
|
|
|
|
|
if (!url.startsWith("data:")) return 0
|
|
|
|
|
const commaIndex = url.indexOf(",")
|
|
|
|
|
if (commaIndex < 0) return 0
|
|
|
|
|
const metadata = url.slice(0, commaIndex).toLowerCase()
|
|
|
|
|
const payload = url.slice(commaIndex + 1)
|
|
|
|
|
if (!metadata.endsWith(";base64")) return 0
|
|
|
|
|
let padding = 0
|
|
|
|
|
if (payload.endsWith("==")) {
|
|
|
|
|
padding = 2
|
|
|
|
|
} else if (payload.endsWith("=")) {
|
|
|
|
|
padding = 1
|
|
|
|
|
}
|
|
|
|
|
return Math.max(0, Math.floor((payload.length * 3) / 4) - padding)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 00:24:03 +02:00
|
|
|
const isSameVSCodeActiveEditorFile = (a: VSCodeActiveEditorFile | null, b: VSCodeActiveEditorFile | null): boolean => {
|
|
|
|
|
if (a === b) return true
|
|
|
|
|
if (!a || !b) return false
|
|
|
|
|
return a.filePath === b.filePath
|
|
|
|
|
&& a.fileName === b.fileName
|
|
|
|
|
&& a.relativePath === b.relativePath
|
|
|
|
|
&& a.fileSize === b.fileSize
|
|
|
|
|
&& a.selection?.startLine === b.selection?.startLine
|
|
|
|
|
&& a.selection?.endLine === b.selection?.endLine
|
|
|
|
|
&& a.selection?.text === b.selection?.text
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
export type SyntheticContextPart = {
|
|
|
|
|
text: string
|
|
|
|
|
attachments?: AttachedFile[]
|
|
|
|
|
synthetic?: boolean
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 00:24:03 +02:00
|
|
|
export type VSCodeActiveEditorFile = {
|
|
|
|
|
filePath: string
|
|
|
|
|
fileName: string
|
|
|
|
|
relativePath: string
|
|
|
|
|
fileSize: number | null
|
|
|
|
|
selection: { startLine: number; endLine: number; text: string } | null
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
export type InputState = {
|
|
|
|
|
pendingInputText: string | null
|
|
|
|
|
pendingInputMode: "replace" | "append" | "append-inline"
|
|
|
|
|
pendingSyntheticParts: SyntheticContextPart[] | null
|
2026-05-29 13:31:07 +03:00
|
|
|
/**
|
|
|
|
|
* Text a draft preset chip asked to submit immediately. Set by surfaces that
|
|
|
|
|
* render the chips outside ChatInput (e.g. under the welcome message on
|
|
|
|
|
* narrow layouts); consumed by ChatInput, which owns the command-aware submit.
|
|
|
|
|
*/
|
|
|
|
|
pendingPresetSubmit: string | null
|
2026-03-31 18:47:00 +03:00
|
|
|
attachedFiles: AttachedFile[]
|
2026-05-05 00:24:03 +02:00
|
|
|
activeEditorFile: VSCodeActiveEditorFile | null
|
2026-03-31 18:47:00 +03:00
|
|
|
|
|
|
|
|
setPendingInputText: (text: string | null, mode?: "replace" | "append" | "append-inline") => void
|
|
|
|
|
consumePendingInputText: () => { text: string; mode: "replace" | "append" | "append-inline" } | null
|
2026-05-29 13:31:07 +03:00
|
|
|
requestPresetSubmit: (text: string) => void
|
|
|
|
|
consumePendingPresetSubmit: () => string | null
|
2026-03-31 18:47:00 +03:00
|
|
|
setPendingSyntheticParts: (parts: SyntheticContextPart[] | null) => void
|
|
|
|
|
consumePendingSyntheticParts: () => SyntheticContextPart[] | null
|
2026-07-22 11:33:22 +03:00
|
|
|
addAttachedFile: (file: File) => Promise<boolean>
|
2026-03-31 18:47:00 +03:00
|
|
|
removeAttachedFile: (id: string) => void
|
2026-05-08 08:47:46 -04:00
|
|
|
setAttachedFiles: (files: AttachedFile[]) => void
|
2026-03-31 18:47:00 +03:00
|
|
|
clearAttachedFiles: () => void
|
2026-05-05 00:24:03 +02:00
|
|
|
addVSCodeFileAttachment: (path: string, name: string, fileSize: number | null) => void
|
|
|
|
|
addVSCodeSelectionAttachment: (path: string, file: File) => Promise<void>
|
|
|
|
|
setActiveEditorFile: (file: VSCodeActiveEditorFile | null) => void
|
2026-05-16 21:44:37 +08:00
|
|
|
/** Add attachments restored from a reverted message (file already on server) */
|
|
|
|
|
addRestoredAttachment: (file: { url: string; mimeType: string; filename: string }) => void
|
2026-03-31 18:47:00 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const useInputStore = create<InputState>()((set, get) => ({
|
|
|
|
|
pendingInputText: null,
|
|
|
|
|
pendingInputMode: "replace",
|
|
|
|
|
pendingSyntheticParts: null,
|
2026-05-29 13:31:07 +03:00
|
|
|
pendingPresetSubmit: null,
|
2026-03-31 18:47:00 +03:00
|
|
|
attachedFiles: [],
|
2026-05-05 00:24:03 +02:00
|
|
|
activeEditorFile: null,
|
2026-03-31 18:47:00 +03:00
|
|
|
|
|
|
|
|
setPendingInputText: (text, mode = "replace") =>
|
|
|
|
|
set({ pendingInputText: text, pendingInputMode: mode }),
|
|
|
|
|
|
|
|
|
|
consumePendingInputText: () => {
|
|
|
|
|
const { pendingInputText, pendingInputMode } = get()
|
|
|
|
|
if (pendingInputText === null) return null
|
|
|
|
|
set({ pendingInputText: null, pendingInputMode: "replace" })
|
|
|
|
|
return { text: pendingInputText, mode: pendingInputMode }
|
|
|
|
|
},
|
|
|
|
|
|
2026-05-29 13:31:07 +03:00
|
|
|
requestPresetSubmit: (text) => set({ pendingPresetSubmit: text }),
|
|
|
|
|
|
|
|
|
|
consumePendingPresetSubmit: () => {
|
|
|
|
|
const { pendingPresetSubmit } = get()
|
|
|
|
|
if (pendingPresetSubmit === null) return null
|
|
|
|
|
set({ pendingPresetSubmit: null })
|
|
|
|
|
return pendingPresetSubmit
|
|
|
|
|
},
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
setPendingSyntheticParts: (parts) => set({ pendingSyntheticParts: parts }),
|
|
|
|
|
|
|
|
|
|
consumePendingSyntheticParts: () => {
|
|
|
|
|
const { pendingSyntheticParts } = get()
|
|
|
|
|
if (pendingSyntheticParts !== null) {
|
|
|
|
|
set({ pendingSyntheticParts: null })
|
|
|
|
|
}
|
|
|
|
|
return pendingSyntheticParts
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
addAttachedFile: async (file: File) => {
|
|
|
|
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
2026-05-08 08:47:46 -04:00
|
|
|
const generation = attachmentReadGeneration
|
2026-07-22 11:33:22 +03:00
|
|
|
const resolvedMime = getAttachmentMime(file)
|
|
|
|
|
const mimeType = typeof resolvedMime === "string" ? resolvedMime : await resolvedMime
|
|
|
|
|
if (!mimeType) return false
|
2026-05-12 04:09:31 -04:00
|
|
|
let dataUrl: string
|
|
|
|
|
try {
|
2026-07-22 11:33:22 +03:00
|
|
|
dataUrl = await readFileAsDataUrl(file, mimeType)
|
2026-05-12 04:09:31 -04:00
|
|
|
} catch {
|
2026-07-22 11:33:22 +03:00
|
|
|
return false
|
2026-05-12 04:09:31 -04:00
|
|
|
}
|
2026-07-22 11:33:22 +03:00
|
|
|
if (!dataUrl || generation !== attachmentReadGeneration) return false
|
2026-03-31 18:47:00 +03:00
|
|
|
const attached: AttachedFile = {
|
|
|
|
|
id,
|
|
|
|
|
file,
|
|
|
|
|
dataUrl,
|
2026-07-22 11:33:22 +03:00
|
|
|
mimeType,
|
2026-03-31 18:47:00 +03:00
|
|
|
filename: file.name,
|
|
|
|
|
size: file.size,
|
|
|
|
|
source: "local",
|
|
|
|
|
}
|
|
|
|
|
set((s) => ({ attachedFiles: [...s.attachedFiles, attached] }))
|
2026-07-22 11:33:22 +03:00
|
|
|
return true
|
2026-03-31 18:47:00 +03:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
removeAttachedFile: (id) =>
|
|
|
|
|
set((s) => ({ attachedFiles: s.attachedFiles.filter((f) => f.id !== id) })),
|
|
|
|
|
|
2026-05-08 08:47:46 -04:00
|
|
|
setAttachedFiles: (files) => {
|
|
|
|
|
attachmentReadGeneration += 1
|
|
|
|
|
set({ attachedFiles: files })
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
clearAttachedFiles: () => {
|
|
|
|
|
attachmentReadGeneration += 1
|
|
|
|
|
set({ attachedFiles: [] })
|
|
|
|
|
},
|
2026-05-05 00:24:03 +02:00
|
|
|
|
|
|
|
|
addVSCodeFileAttachment: (path: string, name: string, fileSize: number | null) => {
|
|
|
|
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
|
|
|
const isDuplicate = get().attachedFiles.some(
|
|
|
|
|
(f) => f.source === 'vscode' && f.vscodeSource === 'file' && (f.vscodePath || '') === path
|
|
|
|
|
)
|
|
|
|
|
if (isDuplicate) return
|
|
|
|
|
const dataUrl = toFileUrl(path)
|
|
|
|
|
// `file://` URLs are the same contract used by server-source attachments.
|
|
|
|
|
// The submission path passes `dataUrl` as `url` directly to the OpenCode
|
|
|
|
|
// server, which resolves `file://` paths natively. No base64 encoding needed.
|
|
|
|
|
const attached: AttachedFile = {
|
|
|
|
|
id,
|
|
|
|
|
file: new File([], name, { type: 'text/plain' }),
|
|
|
|
|
dataUrl,
|
|
|
|
|
mimeType: 'text/plain',
|
|
|
|
|
filename: name,
|
|
|
|
|
size: fileSize || 0,
|
|
|
|
|
source: 'vscode',
|
|
|
|
|
vscodePath: path,
|
|
|
|
|
vscodeSource: 'file',
|
|
|
|
|
}
|
|
|
|
|
set((s) => ({ attachedFiles: [...s.attachedFiles, attached] }))
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
addVSCodeSelectionAttachment: async (path: string, file: File) => {
|
|
|
|
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
2026-05-08 08:47:46 -04:00
|
|
|
const generation = attachmentReadGeneration
|
2026-05-05 00:24:03 +02:00
|
|
|
const selectionKey = getVSCodeSelectionKey(path, file.name)
|
|
|
|
|
const isDuplicate = get().attachedFiles.some(
|
|
|
|
|
(f) => f.source === 'vscode' && f.vscodeSource === 'selection' && f.filename === file.name && f.vscodePath === path
|
|
|
|
|
)
|
|
|
|
|
if (isDuplicate || pendingVSCodeSelectionKeys.has(selectionKey)) return
|
|
|
|
|
pendingVSCodeSelectionKeys.add(selectionKey)
|
|
|
|
|
let dataUrl: string
|
|
|
|
|
try {
|
2026-07-22 11:33:22 +03:00
|
|
|
dataUrl = await readFileAsDataUrl(file, file.type)
|
2026-05-12 04:09:31 -04:00
|
|
|
} catch {
|
|
|
|
|
return
|
2026-05-05 00:24:03 +02:00
|
|
|
} finally {
|
|
|
|
|
pendingVSCodeSelectionKeys.delete(selectionKey)
|
|
|
|
|
}
|
2026-05-08 08:47:46 -04:00
|
|
|
if (generation !== attachmentReadGeneration) return
|
2026-05-05 00:24:03 +02:00
|
|
|
const attached: AttachedFile = {
|
|
|
|
|
id,
|
|
|
|
|
file,
|
|
|
|
|
dataUrl,
|
|
|
|
|
mimeType: file.type,
|
|
|
|
|
filename: file.name,
|
|
|
|
|
size: file.size,
|
|
|
|
|
source: 'vscode',
|
|
|
|
|
vscodePath: path,
|
|
|
|
|
vscodeSource: 'selection',
|
|
|
|
|
}
|
|
|
|
|
set((s) => ({ attachedFiles: [...s.attachedFiles, attached] }))
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
setActiveEditorFile: (file) => {
|
|
|
|
|
if (isSameVSCodeActiveEditorFile(get().activeEditorFile, file)) return
|
|
|
|
|
set({ activeEditorFile: file })
|
|
|
|
|
},
|
2026-05-16 21:44:37 +08:00
|
|
|
|
|
|
|
|
addRestoredAttachment: ({ url, mimeType, filename }) => {
|
|
|
|
|
const id = `restored-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
|
|
|
// Use "local" source so the file renders in AttachedFilesList.
|
|
|
|
|
// Set serverPath to the URL so ImagePreview can use it as the img src
|
|
|
|
|
// when dataUrl is not a data: URL. sanitizeAttachmentsForSend leaves
|
|
|
|
|
// dataUrl alone for non-server sources, so the URL stays intact on send.
|
|
|
|
|
const attached: AttachedFile = {
|
|
|
|
|
id,
|
|
|
|
|
file: new File([], filename, { type: mimeType }),
|
|
|
|
|
dataUrl: url,
|
|
|
|
|
mimeType,
|
|
|
|
|
filename,
|
2026-05-17 15:25:59 +03:00
|
|
|
size: getDataUrlByteSize(url),
|
2026-05-16 21:44:37 +08:00
|
|
|
source: "local",
|
|
|
|
|
serverPath: url,
|
|
|
|
|
}
|
|
|
|
|
set((s) => ({ attachedFiles: [...s.attachedFiles, attached] }))
|
|
|
|
|
},
|
2026-03-31 18:47:00 +03:00
|
|
|
}))
|