Skip to content
Open
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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 未发布,源码宿主实机验证(粘贴入框/悬停预览/查看器正常),无需代码改动
Expand Down
96 changes: 94 additions & 2 deletions lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)}
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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',
Expand All @@ -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.',
};
}

Expand All @@ -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) {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 => {
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down