// DSH 动态 Cordis 插件 —— 文件拖拽上传(Client 半) // 本文件是 cordis_define 的 code.client 函数体:直接整文件复制使用。 // 职责:监听拖拽事件、显示遮罩提示、读取文件并经包私有 RPC 上传(Host 半保存到系统临时目录), // 成功后把绝对路径追加到输入框草稿(作为任务输入)。 return { apply(ctx) { const slots = ctx.get('slots') if (slots === undefined) return const timer = ctx.get('timer') styles.insert([ '.dsh-fdrop-overlay { position: fixed; inset: 0; z-index: 99990; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; pointer-events: auto; }', '.dsh-fdrop-card { background: var(--dsw-alias-bg-overlay); color: var(--dsw-alias-label-primary); border: 2px dashed var(--dsw-alias-brand-primary); border-radius: 12px; padding: 32px 48px; text-align: center; box-shadow: 0 12px 40px rgba(0,0,0,0.25); max-width: 420px; }', '.dsh-fdrop-title { font-size: 16px; font-weight: 600; margin-bottom: 6px; }', '.dsh-fdrop-sub { font-size: 12px; color: var(--dsw-alias-label-secondary); margin-top: 4px; }', '.dsh-fdrop-toast { position: fixed; right: 24px; bottom: 140px; z-index: 99991; background: var(--dsw-alias-bg-overlay); color: var(--dsw-alias-label-primary); border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 10px 14px; font-size: 13px; box-shadow: 0 6px 24px rgba(0,0,0,0.2); pointer-events: none; }', '.dsh-fdrop-toast-ok { border-color: var(--dsw-alias-state-success-primary); }', '.dsh-fdrop-toast-error { border-color: var(--dsw-alias-state-error-primary); }', ].join('\n')) const MAX_FILES = 6 const MAX_FILE_BYTES = 2 * 1024 * 1024 const MAX_TOTAL_BYTES = 8 * 1024 * 1024 let dragDepth = 0 const live = { sessionId: undefined, actions: undefined, draft: '' } function hasFiles(e) { const dt = e.dataTransfer if (dt === undefined || dt === null) return false if (dt.files !== undefined && dt.files !== null && dt.files.length > 0) return true const types = dt.types if (types !== undefined && types !== null) { for (let i = 0; i < types.length; i += 1) { if (types[i] === 'Files') return true } } return false } function bytesToBase64(bytes) { const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' const chunks = [] const CHUNK = 0x8000 for (let start = 0; start < bytes.length; start += CHUNK) { const end = Math.min(start + CHUNK, bytes.length) let out = '' let i = start for (; i + 2 < end; i += 3) { const n = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2] out += alphabet[(n >> 18) & 63] + alphabet[(n >> 12) & 63] + alphabet[(n >> 6) & 63] + alphabet[n & 63] } if (i + 1 === end) { const n = bytes[i] << 16 out += alphabet[(n >> 18) & 63] + alphabet[(n >> 12) & 63] + '==' } else if (i + 2 === end) { const n = (bytes[i] << 16) | (bytes[i + 1] << 8) out += alphabet[(n >> 18) & 63] + alphabet[(n >> 12) & 63] + alphabet[(n >> 6) & 63] + '=' } chunks.push(out) } return chunks.join('') } function messageOf(err) { if (err !== null && typeof err === 'object' && typeof err.message === 'string') return err.message return String(err) } function flash(setNotice, level, text) { const n = { level, text } setNotice(n) if (timer !== undefined) { timer.timeout(() => setNotice((cur) => (cur === n ? null : cur)), 5000) } } async function handleDrop(files, setBusy, setNotice) { if (files.length > MAX_FILES) { flash(setNotice, 'error', '一次最多拖入 ' + MAX_FILES + ' 个文件') return } let total = 0 for (const f of files) { total += f.size if (f.size > MAX_FILE_BYTES) { flash(setNotice, 'error', '「' + f.name + '」超过单文件 2MB 限制') return } } if (total > MAX_TOTAL_BYTES) { flash(setNotice, 'error', '文件总大小超过 8MB 限制') return } const sessionId = live.sessionId if (typeof sessionId !== 'string' || sessionId === '') { flash(setNotice, 'error', '请先打开一个会话') return } const actions = live.actions if (actions === undefined || actions === null || typeof actions.setDraft !== 'function') { flash(setNotice, 'error', '输入框不可用') return } setBusy(true) try { const payload = [] for (const f of files) { const buffer = await f.arrayBuffer() const bytes = new Uint8Array(buffer) let binary = false try { new TextDecoder('utf-8', { fatal: true }).decode(bytes) } catch (err) { binary = true } payload.push({ name: f.name, size: f.size, binary, contentBase64: bytesToBase64(bytes) }) } const result = await host.call('drop-upload', { sessionId, files: payload }) if (result === null || typeof result !== 'object' || !Array.isArray(result.files)) { flash(setNotice, 'error', '保存失败:没有返回结果') return } const lines = [] let failed = 0 for (const item of result.files) { if (item !== null && typeof item === 'object' && item.ok === true && typeof item.path === 'string') { lines.push(item.path) } else { failed += 1 } } if (lines.length > 0) { const current = live.draft const next = current.length > 0 ? current + '\n' + lines.join('\n') : lines.join('\n') actions.setDraft(next) } if (lines.length === 0) { flash(setNotice, 'error', '没有文件保存成功(仅支持 UTF-8 文本文件)') } else if (failed > 0) { flash(setNotice, 'error', failed + ' 个文件未保存(仅支持 UTF-8 文本文件)') } else { flash(setNotice, 'ok', '已添加 ' + lines.length + ' 个文件到输入框') } } catch (err) { flash(setNotice, 'error', '保存失败:' + messageOf(err)) } finally { setBusy(false) } } function DropUpload(props) { const draft = typeof props.useInput === 'function' ? props.useInput((s) => s.draft) : '' live.sessionId = props.sessionId live.actions = props.inputActions live.draft = typeof draft === 'string' ? draft : '' const [active, setActive] = React.useState(false) const [busy, setBusy] = React.useState(false) const [notice, setNotice] = React.useState(null) React.useEffect(() => { const onDragEnter = (e) => { if (!hasFiles(e)) return e.preventDefault() dragDepth += 1 setActive(true) } const onDragOver = (e) => { if (!hasFiles(e)) return e.preventDefault() } const onDragLeave = (e) => { if (!hasFiles(e)) return e.preventDefault() dragDepth = Math.max(0, dragDepth - 1) if (dragDepth === 0) setActive(false) } const onDrop = (e) => { if (!hasFiles(e)) return e.preventDefault() dragDepth = 0 setActive(false) const dt = e.dataTransfer const files = dt !== null && dt.files !== undefined && dt.files !== null && dt.files.length > 0 ? Array.from(dt.files) : [] if (files.length === 0) return void handleDrop(files, setBusy, setNotice) } window.addEventListener('dragenter', onDragEnter) window.addEventListener('dragover', onDragOver) window.addEventListener('dragleave', onDragLeave) window.addEventListener('drop', onDrop) return () => { window.removeEventListener('dragenter', onDragEnter) window.removeEventListener('dragover', onDragOver) window.removeEventListener('dragleave', onDragLeave) window.removeEventListener('drop', onDrop) dragDepth = 0 } }, []) const showToast = busy || notice !== null return React.createElement('div', null, active ? React.createElement('div', { className: 'dsh-fdrop-overlay' }, React.createElement('div', { className: 'dsh-fdrop-card' }, React.createElement('div', { className: 'dsh-fdrop-title' }, '松开鼠标,添加文件到任务输入'), React.createElement('div', { className: 'dsh-fdrop-sub' }, '最多 6 个文件 · 单个不超过 2MB · 仅支持 UTF-8 文本文件'), React.createElement('div', { className: 'dsh-fdrop-sub' }, '保存到系统临时目录 dsh-file-drop/ 并写入输入框'))) : null, showToast ? React.createElement('div', { className: 'dsh-fdrop-toast' + (notice !== null ? ' dsh-fdrop-toast-' + notice.level : ''), }, notice !== null ? notice.text : '正在保存文件…') : null) } slots.inject('conversation.input.overlay', () => slots.register( { name: 'conversation.input.overlay', id: 'fdrop-layer', order: 100, label: 'File drop' }, (props) => React.createElement(DropUpload, props), )) }, }