diff --git a/README.md b/README.md index c5e8ffd..217296c 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,13 @@ hip 全链路可用;0811 与 0812 最终快照实机 boot 验证通过(见 ## 更新记录 / Changelog +### 2026-09-08 · v0.1.23 — 大段文本粘贴自动转附件(基于上游 v0.1.22,PR #1) + +- **新功能**:在输入框粘贴**纯文本**时,超过阈值(默认 **1000 字符**,设置面板可调 关闭/1000/2000/5000)或超过 **20 行**的内容不再插入输入框,而是自动转为 `paste_text.txt` 附件芯片——复用粘贴文件全管道(`paste_image`/`paste_file` 同族命名,重名自动加 `(2)`、`(3)…` 序号,发送时复制进 `.dsh/tmp/attachments/`,气泡自动折叠) +- **拦截位置**:document capture 阶段,先于 composer 自身的 paste 监听(兼容 Lexical contenteditable 与 textarea 两代 composer),只对落在 composer 上的粘贴生效,不影响队列编辑器/设置搜索框等其他输入 +- **设置面板**:附件设置页新增「大段文本粘贴自动转附件」阈值下拉框(localStorage 持久化) +- 已提 PR:[lhh010/dsh-paste-input#1](https://github.com/lhh010/dsh-paste-input/pull/1) + ### 2026-09-04 · v0.1.19 — 声明支持 dsh-v0.1.3-alpha.1 - **验证**:0.1.3 破坏性变更集中在 host/session 侧(SessionHandle / session format v2),composer/输入面实测无影响;npm 未发布,源码宿主实机验证(粘贴入框/悬停预览/查看器正常),无需代码改动 diff --git a/lib/client.js b/lib/client.js index 7355f00..32ef2da 100644 --- a/lib/client.js +++ b/lib/client.js @@ -19,6 +19,29 @@ window.__ModuleLoader__.load({ if (!next) { try { window.sessionStorage.removeItem(PERSIST_KEY); } catch (e2) { /* best effort */ } } else persistSnapshot(); } + // --- 大段文本粘贴阈值:超过阈值的纯文本粘贴不插入输入框,自动转为 + // paste_text.txt 附件芯片(复用粘贴文件管道;重名由 add() 的 + // uniqueLabel 自动加 (2)、(3)…)。localStorage 持久化,设置面板可调。 + // 行数规则独立于阈值:超过 TEXT_LINE_LIMIT 行一律视为大段文本(阈值开启时)。 + const TEXT_THRESHOLD_KEY = 'dsh-paste-input.textThreshold'; + const TEXT_LINE_LIMIT = 20; + const TEXT_THRESHOLD_DEFAULT = '1000'; + const textThresholdOptions = [ + { value: '0', label: '关闭' }, + { value: '1000', label: '1000 字符(默认)' }, + { value: '2000', label: '2000 字符' }, + { value: '5000', label: '5000 字符' }, + ]; + function textThresholdRaw() { + try { + const raw = window.localStorage.getItem(TEXT_THRESHOLD_KEY); + return raw === null ? TEXT_THRESHOLD_DEFAULT : raw; + } catch { return TEXT_THRESHOLD_DEFAULT; } + } + function textThreshold() { + const n = Number(textThresholdRaw()); + return Number.isFinite(n) && n > 0 ? n : 0; + } function persistSnapshot() { if (!persistRecords) return; try { @@ -107,6 +130,10 @@ window.__ModuleLoader__.load({ .dshca-settings-action[data-danger=true]{color:var(--dsw-alias-state-error-primary)} .dshca-settings-action:disabled{opacity:.45;cursor:default} .dshca-settings-status{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px} + .dshca-settings-textpaste{display:flex;flex-direction:column;gap:8px;padding:16px;border:1px solid var(--dsw-alias-border-l1);border-radius:14px;background:var(--dsw-alias-bg-layer-1)} + .dshca-settings-textpaste-row{display:flex;align-items:center;justify-content:space-between;gap:12px} + .dshca-settings-textpaste-label{font-size:13px} + .dshca-settings-select{height:32px;padding:0 8px;border:1px solid var(--dsw-alias-border-l1);border-radius:9px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;cursor:pointer} @media(max-width:720px){.dshca-settings-card{grid-template-columns:1fr}.dshca-dock{width:calc(100% - 16px)}} .dshca-notice-overlay{position:fixed;inset:0;z-index:100;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.45)} .dshca-notice{width:min(420px,calc(100vw - 48px));box-sizing:border-box;padding:18px;border:1px solid var(--dsw-alias-border-l1);border-radius:14px;background:var(--dsw-alias-bg-base);box-shadow:var(--dsw-shadow-lv3);display:flex;flex-direction:column;gap:10px;color:var(--dsw-alias-label-primary)} @@ -587,7 +614,7 @@ window.__ModuleLoader__.load({ title.textContent = '粘贴文件提示'; const copy = document.createElement('div'); copy.className = 'dshca-notice-copy'; - copy.textContent = '你粘贴了图片或文件。DSH Paste Input 会把它们复制到当前会话工作区的临时附件目录(.dsh/tmp/attachments/),并在发送时随消息一起交给模型。'; + copy.textContent = '你粘贴了图片或文件。DSH Paste Input 会把它们复制到当前会话工作区的临时附件目录(.dsh/tmp/attachments/),并在发送时随消息一起交给模型。在输入框粘贴的大段纯文本也会自动转为 paste_text.txt 附件(阈值可在设置面板调整)。'; const label = document.createElement('label'); label.className = 'dshca-notice-check'; const checkbox = document.createElement('input'); @@ -754,6 +781,8 @@ window.__ModuleLoader__.load({ loading: '正在读取当前会话附件…', cleanedCurrent: result => `已清理当前会话 ${result.deletedFiles} 个文件(${humanBytes(result.deletedBytes)})。`, cleanedWorkspace: result => `已清理当前工作区 ${result.deletedSessionDirectories} 个会话目录、${result.deletedFiles} 个文件(${humanBytes(result.deletedBytes)})。`, + textPasteLabel: '大段文本粘贴自动转附件', + textPasteCopy: '在输入框粘贴纯文本时,超过所选字符数(或超过 20 行)的内容不再插入输入框,而是自动转为 paste_text.txt 附件芯片——与粘贴图片同一管道,同名自动加 (2)、(3)… 序号。选择「关闭」则所有文本粘贴保持原样。', } : { nav: 'Multimedia input', title: 'Multimedia input & file management', @@ -775,6 +804,8 @@ window.__ModuleLoader__.load({ loading: 'Reading attachments for the active session…', cleanedCurrent: result => `Removed ${result.deletedFiles} files from the active session (${humanBytes(result.deletedBytes)}).`, cleanedWorkspace: result => `Removed ${result.deletedFiles} files from ${result.deletedSessionDirectories} session directories in this workspace (${humanBytes(result.deletedBytes)}).`, + textPasteLabel: 'Paste large text as attachment', + textPasteCopy: 'When you paste plain text into the composer, content beyond the chosen character count (or 20 lines) becomes a paste_text.txt attachment chip instead of being inserted — same pipeline as pasted images, with (2), (3)… suffixes on name collisions. Choose Off to keep all text pastes as-is.', }; } @@ -800,6 +831,11 @@ window.__ModuleLoader__.load({ const [status, setStatus] = React.useState(sessionId === undefined ? copy.noSession : copy.loading); const [busy, setBusy] = React.useState(false); const [confirming, setConfirming] = React.useState(null); + const [textThresholdState, setTextThresholdState] = React.useState(textThresholdRaw()); + const onTextThresholdChange = next => { + setTextThresholdState(next); + try { window.localStorage.setItem(TEXT_THRESHOLD_KEY, next); } catch { /* best effort */ } + }; const load = React.useCallback(async (signal) => { if (sessionId === undefined) { @@ -878,6 +914,15 @@ window.__ModuleLoader__.load({ h('div', { className: 'dshca-stat' }, h('strong', null, String(usage.sends)), h('span', null, copy.sends)), h('div', { className: 'dshca-stat' }, h('strong', null, String(usage.files)), h('span', null, copy.files)), h('div', { className: 'dshca-stat' }, h('strong', null, humanBytes(usage.bytes)), h('span', null, copy.size))), + h('div', { className: 'dshca-settings-textpaste' }, + h('div', { className: 'dshca-settings-textpaste-row' }, + h('label', { className: 'dshca-settings-textpaste-label' }, copy.textPasteLabel), + h('select', { + className: 'dshca-settings-select', + value: textThresholdState, + onChange: e => onTextThresholdChange(e.target.value), + }, textThresholdOptions.map(opt => h('option', { key: opt.value, value: opt.value }, opt.label)))), + h('div', { className: 'dshca-settings-copy' }, copy.textPasteCopy)), h('div', { className: 'dshca-settings-actions' }, h('button', { type: 'button', @@ -1175,7 +1220,7 @@ window.__ModuleLoader__.load({ const UPDATE_ID = 'dsh-paste-input'; const PKG = '@dsh-community/dsh-paste-input'; const MIRROR = 'lhh010/dsh-paste-input'; - const PLUGIN_VERSION = '0.1.22'; + const PLUGIN_VERSION = '0.1.23'; function semverCmp(a, b) { const p = (v) => { const x = v.replace(/^v/, '').split('.').map(n => Number(n) || 0); while (x.length < 3) x.push(0); return x }; const pa = p(a), pb = p(b); @@ -1412,6 +1457,53 @@ window.__ModuleLoader__.load({ }; ctx.effect(() => inputTriggers.registerSource(source), 'dsh-paste-input: reference codec'); + // Big-text paste interception — CAPTURE phase, so it runs before the + // composer's own paste listener (Lexical PASTE_COMMAND on the editor + // root; the textarea-era composer inserts via the same event). A pure + // text/plain paste landing on the composer that exceeds the threshold + // (or TEXT_LINE_LIMIT lines) becomes a paste_text.txt attachment chip + // through the same add() pipeline as pasted files: unified naming + // family, uniqueLabel (2)/(3) de-dup, upload on send, DSH_PASTE_INPUT_V1 + // model block, bubble folding. stopPropagation in capture keeps the + // event from ever reaching the composer, so the text is not inserted. + const onBigTextPaste = event => { + if (event.defaultPrevented) return; + const clipboardData = event.clipboardData; + if (clipboardData === null) return; + if (clipboardData.files.length > 0) return; // file paste: existing onPaste owns it + const threshold = textThreshold(); + if (threshold <= 0) return; // feature switched off + const text = clipboardData.getData('text/plain'); + if (text === '') return; + if (!(text.length > threshold || text.split('\n').length > TEXT_LINE_LIMIT)) return; + // Only intercept pastes landing on the composer itself, never on + // unrelated inputs (queue editors, settings search, …). + const target = event.target; + if (!(target instanceof HTMLElement)) return; + if (target instanceof HTMLTextAreaElement) { + // Textarea-era composer: its value mirrors the draft (the same + // guard onBeforeInput uses to identify the composer). + const activeSession = sessions.list?.getSnapshot()?.current; + if (activeSession === undefined) return; + let mirrorsDraft = false; + try { mirrorsDraft = target.value === inputFor(activeSession).state.getSnapshot().draft; } catch { mirrorsDraft = false; } + if (!mirrorsDraft) return; + } else if (!target.isContentEditable) { + return; // Lexical-era composer root is the only contenteditable + } + event.preventDefault(); + event.stopPropagation(); + const sessionId = sessions.list?.getSnapshot()?.current; + if (sessionId === undefined) { showToast('请先打开一个会话。'); return; } + const file = new File([text], 'paste_text.txt', { type: 'text/plain' }); + add(sessionId, [{ file, path: 'paste_text.txt' }]).then( + () => showToast(`文本较长(${text.length} 字符),已转为 paste_text.txt 附件`), + cause => showToast(cause instanceof Error ? cause.message : String(cause)), + ); + }; + document.addEventListener('paste', onBigTextPaste, true); + ctx.effect(() => () => document.removeEventListener('paste', onBigTextPaste, true), 'dsh-paste-input: big text paste listener'); + // Global paste interception: clipboard files become attachments; plain // text keeps the browser default. First-time paste shows a notice modal. const onPaste = event => { diff --git a/package.json b/package.json index ab56d94..70fcce5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@dsh-community/dsh-paste-input", - "version": "0.1.22", - "description": "DSH WebUI 文件输入增强:Ctrl+V 粘贴(带首次告知弹窗)与拖拽/选择文件,发送时复制进会话工作区临时目录", + "version": "0.1.23", + "description": "DSH WebUI 文件输入增强:Ctrl+V 粘贴(带首次告知弹窗)、大段纯文本自动转 paste_text.txt 附件、拖拽/选择文件,发送时复制进会话工作区临时目录", "type": "module", "main": "lib/index.js", "exports": {