openclacky 1.5.13 → 1.5.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -0
- data/lib/clacky/agent/chunk_index.rb +83 -0
- data/lib/clacky/agent/history_navigation.rb +239 -0
- data/lib/clacky/agent/session_serializer.rb +38 -97
- data/lib/clacky/agent.rb +281 -276
- data/lib/clacky/agent_config.rb +1 -1
- data/lib/clacky/billing/billing_store.rb +2 -2
- data/lib/clacky/billing/platform_billing.rb +6 -0
- data/lib/clacky/brand_config.rb +13 -2
- data/lib/clacky/cli.rb +27 -0
- data/lib/clacky/client.rb +2 -2
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +91 -5
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
- data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
- data/lib/clacky/default_extensions/preview/ext.yml +20 -0
- data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
- data/lib/clacky/extension/api_extension.rb +1 -1
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/media/openai_compat.rb +70 -30
- data/lib/clacky/message_format/bedrock.rb +6 -1
- data/lib/clacky/prompts/base.md +1 -1
- data/lib/clacky/providers.rb +38 -3
- data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
- data/lib/clacky/search_config.rb +3 -3
- data/lib/clacky/server/channel/channel_manager.rb +8 -5
- data/lib/clacky/server/git_panel.rb +10 -2
- data/lib/clacky/server/http_server.rb +161 -14
- data/lib/clacky/server/preview.rb +351 -0
- data/lib/clacky/server/web_ui_controller.rb +1 -1
- data/lib/clacky/skill.rb +48 -0
- data/lib/clacky/tools/browser.rb +176 -19
- data/lib/clacky/tools/web_search.rb +79 -8
- data/lib/clacky/ui2/components/command_suggestions.rb +2 -1
- data/lib/clacky/ui2/components/input_area.rb +22 -2
- data/lib/clacky/ui2/components/modal_component.rb +35 -6
- data/lib/clacky/ui2/ui_controller.rb +44 -7
- data/lib/clacky/utils/file_processor.rb +19 -7
- data/lib/clacky/utils/mac_app_detector.rb +186 -0
- data/lib/clacky/utils/model_pricing.rb +114 -66
- data/lib/clacky/utils/windows_app_detector.rb +334 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +596 -130
- data/lib/clacky/web/components/chat-navigator.js +489 -202
- data/lib/clacky/web/components/code-editor.js +191 -5
- data/lib/clacky/web/components/model-picker.js +2 -1
- data/lib/clacky/web/components/quote-select.js +235 -0
- data/lib/clacky/web/core/aside.js +157 -8
- data/lib/clacky/web/core/ext.js +16 -0
- data/lib/clacky/web/features/billing/view.js +75 -21
- data/lib/clacky/web/features/skills/store.js +18 -1
- data/lib/clacky/web/features/skills/view.js +128 -7
- data/lib/clacky/web/features/workspace/store.js +80 -7
- data/lib/clacky/web/features/workspace/view.js +392 -40
- data/lib/clacky/web/i18n.js +90 -5
- data/lib/clacky/web/index.html +15 -6
- data/lib/clacky/web/sessions.js +193 -38
- data/lib/clacky/web/utils.js +34 -0
- data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
- data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
- data/lib/clacky/web/vendor/codemirror/package.json +29 -0
- data/lib/clacky/web/ws-dispatcher.js +20 -0
- data/lib/clacky.rb +2 -0
- metadata +11 -1
|
@@ -17,24 +17,208 @@
|
|
|
17
17
|
const LANG_MAP = {
|
|
18
18
|
markdown: () => CM.markdown({ base: CM.markdownLanguage }),
|
|
19
19
|
md: () => CM.markdown({ base: CM.markdownLanguage }),
|
|
20
|
+
python: () => CM.python(),
|
|
21
|
+
javascript: () => CM.javascript(),
|
|
22
|
+
jsx: () => CM.jsx(),
|
|
23
|
+
typescript: () => CM.typescript(),
|
|
24
|
+
tsx: () => CM.tsx(),
|
|
25
|
+
html: () => CM.html(),
|
|
26
|
+
css: () => CM.css(),
|
|
27
|
+
json: () => CM.json(),
|
|
28
|
+
sql: () => CM.sql(),
|
|
29
|
+
xml: () => CM.xml(),
|
|
30
|
+
rust: () => CM.rust(),
|
|
31
|
+
java: () => CM.java(),
|
|
32
|
+
cpp: () => CM.cpp(),
|
|
33
|
+
php: () => CM.php(),
|
|
34
|
+
ruby: () => CM.ruby(),
|
|
35
|
+
go: () => CM.go(),
|
|
36
|
+
shell: () => CM.shell(),
|
|
37
|
+
yaml: () => CM.yaml(),
|
|
38
|
+
toml: () => CM.toml(),
|
|
39
|
+
dockerfile: () => CM.dockerfile(),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Extension → language id. Keyed by lowercase extension (no leading dot).
|
|
43
|
+
const EXT_LANG = {
|
|
44
|
+
md: "markdown", markdown: "markdown", mdx: "markdown",
|
|
45
|
+
py: "python", pyw: "python",
|
|
46
|
+
js: "javascript", mjs: "javascript", cjs: "javascript",
|
|
47
|
+
jsx: "jsx",
|
|
48
|
+
ts: "typescript", mts: "typescript", cts: "typescript",
|
|
49
|
+
tsx: "tsx",
|
|
50
|
+
html: "html", htm: "html",
|
|
51
|
+
css: "css",
|
|
52
|
+
json: "json", jsonc: "json",
|
|
53
|
+
sql: "sql",
|
|
54
|
+
xml: "xml", svg: "xml", xsd: "xml", xsl: "xml", plist: "xml",
|
|
55
|
+
rs: "rust",
|
|
56
|
+
java: "java",
|
|
57
|
+
c: "cpp", h: "cpp", cpp: "cpp", cc: "cpp", cxx: "cpp", hpp: "cpp", hxx: "cpp", hh: "cpp",
|
|
58
|
+
php: "php", php3: "php", php4: "php", php5: "php", phtml: "php",
|
|
59
|
+
rb: "ruby", rbw: "ruby", gemspec: "ruby", rake: "ruby", ru: "ruby",
|
|
60
|
+
go: "go",
|
|
61
|
+
sh: "shell", bash: "shell", zsh: "shell", ksh: "shell",
|
|
62
|
+
yaml: "yaml", yml: "yaml",
|
|
63
|
+
toml: "toml",
|
|
64
|
+
vue: "html", svelte: "html",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Whole filename (no extension) → language id.
|
|
68
|
+
const FILENAME_LANG = {
|
|
69
|
+
dockerfile: "dockerfile",
|
|
70
|
+
gemfile: "ruby", rakefile: "ruby", vagrantfile: "ruby", guardfile: "ruby",
|
|
71
|
+
podfile: "ruby", berksfile: "ruby", brewfile: "ruby",
|
|
20
72
|
};
|
|
21
73
|
|
|
22
74
|
const IMAGE_EXTS = new Set(["png","jpg","jpeg","gif","bmp","webp","svg","ico","tiff","tif","avif"]);
|
|
23
|
-
const
|
|
75
|
+
const PDF_EXTS = new Set(["pdf"]);
|
|
76
|
+
const BINARY_EXTS = new Set([
|
|
77
|
+
// archives
|
|
78
|
+
"zip","gz","7z","tar","dmg","rar","tgz","iso","jar","war",
|
|
79
|
+
// office docs (previewed via the "open with app" fallback instead)
|
|
80
|
+
"xls","xlsx","doc","docx","ppt","pptx","pot","pps","ppsx","dot",
|
|
81
|
+
"odt","ods","odp","wps","et","dps","key","numbers","pages",
|
|
82
|
+
// media
|
|
83
|
+
"mov","mp4","mp3","avi","mkv","wmv","weba","flac","wav","m4a","aac","aiff",
|
|
84
|
+
// fonts
|
|
85
|
+
"ttf","otf","woff","woff2","eot",
|
|
86
|
+
// system / misc
|
|
87
|
+
"exe","msi","db","db3","sqlite","sqlite3","dat","wasm","bin","so","dylib","dll",
|
|
88
|
+
"class","pyc","psd","ai","eps","indd","sketch","fig",
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
// Sample size matches the server-side 512-byte sniff; the ratio tolerates a
|
|
92
|
+
// few stray replacement chars in otherwise valid text.
|
|
93
|
+
const SNIFF_SAMPLE_CHARS = 512;
|
|
94
|
+
const SNIFF_REPLACEMENT_RATIO = 0.1;
|
|
95
|
+
|
|
96
|
+
const TABLE_EXTS = new Set(["csv","tsv"]);
|
|
97
|
+
|
|
98
|
+
// Rendering a full <table> forces the browser to lay out every cell to resolve
|
|
99
|
+
// column widths, so cost grows with cells rather than lines. Measured in Chrome:
|
|
100
|
+
// 1000x20 (20k cells) ~73ms, 3000x20 (60k cells) ~231ms.
|
|
101
|
+
const TABLE_MAX_ROWS = 1000;
|
|
102
|
+
const TABLE_MAX_CELLS = 50000;
|
|
24
103
|
|
|
25
104
|
function _fileKind(filename) {
|
|
26
105
|
if (!filename) return "text";
|
|
27
106
|
const ext = filename.split(".").pop().toLowerCase();
|
|
28
107
|
if (IMAGE_EXTS.has(ext)) return "image";
|
|
108
|
+
if (PDF_EXTS.has(ext)) return "pdf";
|
|
29
109
|
if (BINARY_EXTS.has(ext)) return "binary";
|
|
110
|
+
if (TABLE_EXTS.has(ext)) return "table";
|
|
30
111
|
return "text";
|
|
31
112
|
}
|
|
32
113
|
|
|
114
|
+
// BINARY_EXTS can only list what we thought of, so unlisted binaries still
|
|
115
|
+
// reach the text branch and render as noise. Mirrors the server-side sniff in
|
|
116
|
+
// Utils::FileProcessor.binary_file_path?: a NUL byte means "not text". Decoding
|
|
117
|
+
// already turned invalid sequences into U+FFFD, so a high density of those is
|
|
118
|
+
// the same signal for encodings that carry no NUL.
|
|
119
|
+
function _looksBinary(text) {
|
|
120
|
+
if (!text) return false;
|
|
121
|
+
const head = text.slice(0, SNIFF_SAMPLE_CHARS);
|
|
122
|
+
if (head.indexOf("\u0000") >= 0) return true;
|
|
123
|
+
let replacements = 0;
|
|
124
|
+
for (let i = 0; i < head.length; i++) {
|
|
125
|
+
if (head.charCodeAt(i) === 0xFFFD) replacements++;
|
|
126
|
+
}
|
|
127
|
+
return replacements / head.length > SNIFF_REPLACEMENT_RATIO;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function _tableDelimiter(filename) {
|
|
131
|
+
const ext = (filename || "").split(".").pop().toLowerCase();
|
|
132
|
+
return ext === "tsv" ? "\t" : ",";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// RFC 4180: quoted fields may contain the delimiter, newlines and "" escapes.
|
|
136
|
+
function _parseDelimited(text, delimiter, maxRows) {
|
|
137
|
+
const rows = [];
|
|
138
|
+
let row = [];
|
|
139
|
+
let field = "";
|
|
140
|
+
let quoted = false;
|
|
141
|
+
let i = 0;
|
|
142
|
+
const limit = typeof maxRows === "number" ? maxRows : Infinity;
|
|
143
|
+
|
|
144
|
+
const endRow = () => {
|
|
145
|
+
row.push(field);
|
|
146
|
+
field = "";
|
|
147
|
+
rows.push(row);
|
|
148
|
+
row = [];
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
while (i < text.length) {
|
|
152
|
+
const ch = text[i];
|
|
153
|
+
if (quoted) {
|
|
154
|
+
if (ch === '"') {
|
|
155
|
+
if (text[i + 1] === '"') { field += '"'; i += 2; continue; }
|
|
156
|
+
quoted = false; i++; continue;
|
|
157
|
+
}
|
|
158
|
+
field += ch; i++; continue;
|
|
159
|
+
}
|
|
160
|
+
if (ch === '"' && field === "") { quoted = true; i++; continue; }
|
|
161
|
+
if (ch === delimiter) { row.push(field); field = ""; i++; continue; }
|
|
162
|
+
if (ch === "\r") { i += text[i + 1] === "\n" ? 2 : 1; endRow(); if (rows.length >= limit) return rows; continue; }
|
|
163
|
+
if (ch === "\n") { i++; endRow(); if (rows.length >= limit) return rows; continue; }
|
|
164
|
+
field += ch; i++;
|
|
165
|
+
}
|
|
166
|
+
if (field !== "" || row.length) endRow();
|
|
167
|
+
return rows;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Returns { table, rows, cols, truncated }. Cells are set via textContent so
|
|
171
|
+
// untrusted file data can never be interpreted as markup.
|
|
172
|
+
function _renderTable(text, filename) {
|
|
173
|
+
const delimiter = _tableDelimiter(filename);
|
|
174
|
+
const rows = _parseDelimited(text, delimiter, TABLE_MAX_ROWS + 1);
|
|
175
|
+
|
|
176
|
+
let truncated = rows.length > TABLE_MAX_ROWS;
|
|
177
|
+
if (truncated) rows.length = TABLE_MAX_ROWS;
|
|
178
|
+
|
|
179
|
+
const cols = rows.reduce((max, r) => (r.length > max ? r.length : max), 0);
|
|
180
|
+
if (cols > 0) {
|
|
181
|
+
const maxRowsByCells = Math.max(1, Math.floor(TABLE_MAX_CELLS / cols));
|
|
182
|
+
if (rows.length > maxRowsByCells) {
|
|
183
|
+
rows.length = maxRowsByCells;
|
|
184
|
+
truncated = true;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const table = document.createElement("table");
|
|
189
|
+
if (rows.length) {
|
|
190
|
+
const thead = document.createElement("thead");
|
|
191
|
+
const headTr = document.createElement("tr");
|
|
192
|
+
rows[0].forEach((cell) => {
|
|
193
|
+
const th = document.createElement("th");
|
|
194
|
+
th.textContent = cell;
|
|
195
|
+
headTr.appendChild(th);
|
|
196
|
+
});
|
|
197
|
+
thead.appendChild(headTr);
|
|
198
|
+
table.appendChild(thead);
|
|
199
|
+
|
|
200
|
+
const tbody = document.createElement("tbody");
|
|
201
|
+
for (let r = 1; r < rows.length; r++) {
|
|
202
|
+
const tr = document.createElement("tr");
|
|
203
|
+
for (let c = 0; c < cols; c++) {
|
|
204
|
+
const td = document.createElement("td");
|
|
205
|
+
td.textContent = rows[r][c] == null ? "" : rows[r][c];
|
|
206
|
+
tr.appendChild(td);
|
|
207
|
+
}
|
|
208
|
+
tbody.appendChild(tr);
|
|
209
|
+
}
|
|
210
|
+
table.appendChild(tbody);
|
|
211
|
+
}
|
|
212
|
+
return { table: table, rows: rows.length, cols: cols, truncated: truncated };
|
|
213
|
+
}
|
|
214
|
+
|
|
33
215
|
function _detectLanguage(filename) {
|
|
34
216
|
if (!filename) return null;
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
217
|
+
const base = filename.split(/[\\/]/).pop().toLowerCase();
|
|
218
|
+
if (FILENAME_LANG[base]) return FILENAME_LANG[base];
|
|
219
|
+
const dot = base.lastIndexOf(".");
|
|
220
|
+
if (dot < 0) return null;
|
|
221
|
+
return EXT_LANG[base.slice(dot + 1)] || null;
|
|
38
222
|
}
|
|
39
223
|
|
|
40
224
|
function _isDark() {
|
|
@@ -76,7 +260,7 @@
|
|
|
76
260
|
extensions.push(CM.oneDark);
|
|
77
261
|
}
|
|
78
262
|
|
|
79
|
-
const langFn = LANG_MAP[opts.language
|
|
263
|
+
const langFn = opts.language ? LANG_MAP[opts.language] : null;
|
|
80
264
|
if (langFn) extensions.push(langFn());
|
|
81
265
|
|
|
82
266
|
if (opts.readOnly) {
|
|
@@ -261,7 +445,9 @@
|
|
|
261
445
|
open,
|
|
262
446
|
createInline,
|
|
263
447
|
fileKind: _fileKind,
|
|
448
|
+
looksBinary: _looksBinary,
|
|
264
449
|
detectLanguage: _detectLanguage,
|
|
265
450
|
renderMarkdown: _renderMarkdownHtml,
|
|
451
|
+
renderTable: _renderTable,
|
|
266
452
|
};
|
|
267
453
|
})(window);
|
|
@@ -36,7 +36,8 @@ const ModelPicker = (() => {
|
|
|
36
36
|
svg: '<path d="M16.5376 0.0307374L14.3326 3.13223C14.1617 3.37577 13.9331 3.57415 13.6667 3.71001C13.4004 3.84586 13.1045 3.91504 12.8048 3.9115H0.787598V0.015152H16.5376V0.0307374Z"/><path d="M31.5 0.0321655L12.6 26.5273H0L18.9 0.0321655H31.5Z"/><path d="M14.9624 26.5272L17.1832 23.4101C17.3564 23.1689 17.5856 22.9723 17.8513 22.8368C18.1171 22.7012 18.4119 22.6306 18.7109 22.6308H30.7124V26.5272H14.9624Z"/>' },
|
|
37
37
|
{ re: /gemini/i, label: "G", color: "#3B78E7",
|
|
38
38
|
svg: '<path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81"/>' },
|
|
39
|
-
{ re: /gpt|openai/i, label: "
|
|
39
|
+
{ re: /gpt|openai/i, label: "G", color: "#000",
|
|
40
|
+
svg: '<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"/>' },
|
|
40
41
|
{ re: /minimax/i, label: "M", color: "#F2402A",
|
|
41
42
|
svg: '<path style="fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round" d="M4 9.5V14.5M8 6V18M12 3V21M16 6V18M20 9.5V14.5"/>' },
|
|
42
43
|
{ re: /qwen|tongyi/i, label: "Q", color: "#615CED" },
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// ── quote-select.js — Quote-on-selection ────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Drag-select text inside any user or assistant message bubble; a floating
|
|
4
|
+
// "Quote" pill appears above the selection. Clicking it appends the selected
|
|
5
|
+
// text to the composer as a markdown blockquote with a provenance line:
|
|
6
|
+
//
|
|
7
|
+
// > [Assistant · #3]
|
|
8
|
+
// > selected text…
|
|
9
|
+
//
|
|
10
|
+
// Pure frontend: the quote is plain text in the composer and rides the
|
|
11
|
+
// existing WS message pipeline unchanged (no backend/protocol changes).
|
|
12
|
+
//
|
|
13
|
+
// Event bindings are delegation-style on document, so they survive history
|
|
14
|
+
// lazy-loading and streaming DOM rebuilds. The button itself is a single
|
|
15
|
+
// recycled element, created lazily and hidden when any selection dies.
|
|
16
|
+
|
|
17
|
+
const QuoteSelect = (() => {
|
|
18
|
+
let _btn = null; // floating quote button (lazily created, recycled)
|
|
19
|
+
let _snap = null; // selection snapshot captured when the button appears
|
|
20
|
+
let _pending = null; // mouseup validation timer
|
|
21
|
+
|
|
22
|
+
// ── DOM helpers ──────────────────────────────────────────────────────────
|
|
23
|
+
// Nearest message root: .msg-user-wrap (user) or .msg-assistant. Returns
|
|
24
|
+
// null when the node is outside any quotable message (e.g. composer, modal).
|
|
25
|
+
function _msgRoot(node) {
|
|
26
|
+
if (!node || node.nodeType === Node.DOCUMENT_NODE) return null;
|
|
27
|
+
const el = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
|
28
|
+
if (!el) return null;
|
|
29
|
+
return el.closest(".msg-user-wrap, .msg-assistant");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Message role + 1-based chat index, counted among quoted messages only
|
|
33
|
+
// (user / assistant), so index stays consistent with rendered history.
|
|
34
|
+
function _msgMeta(root) {
|
|
35
|
+
const items = root.parentElement.querySelectorAll(
|
|
36
|
+
":scope > .msg-user-wrap, :scope > .msg-assistant"
|
|
37
|
+
);
|
|
38
|
+
return {
|
|
39
|
+
role: root.classList.contains("msg-user-wrap") ? "user" : "assistant",
|
|
40
|
+
index: Array.prototype.indexOf.call(items, root) + 1,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Text cleaning ────────────────────────────────────────────────────────
|
|
45
|
+
// Normalize raw selection text into quote lines: strip leading/trailing
|
|
46
|
+
// blank lines, collapse consecutive blanks inside, keep each line's
|
|
47
|
+
// trailing whitespace trimmed.
|
|
48
|
+
function _cleanLines(raw) {
|
|
49
|
+
const lines = String(raw || "").replace(/\r\n/g, "\n").split("\n");
|
|
50
|
+
while (lines.length && !lines[0].trim()) lines.shift();
|
|
51
|
+
while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
if (!line.trim()) {
|
|
55
|
+
if (!out.length || out[out.length - 1] !== "") out.push("");
|
|
56
|
+
} else {
|
|
57
|
+
out.push(line.replace(/\s+$/, ""));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Build the markdown blockquote inserted into the composer (方案 B).
|
|
64
|
+
function _buildBlock(snap) {
|
|
65
|
+
const roleWord = snap.role === "user"
|
|
66
|
+
? I18n.t("chat.quote.roleUser")
|
|
67
|
+
: I18n.t("chat.quote.roleAssistant");
|
|
68
|
+
const sig = I18n.t("chat.quote.sig", { role: roleWord, index: snap.index });
|
|
69
|
+
const body = _cleanLines(snap.text).map(line => {
|
|
70
|
+
if (!line) return ">"; // keep paragraph gaps inside the quote
|
|
71
|
+
return line.startsWith(">") ? line : "> " + line;
|
|
72
|
+
});
|
|
73
|
+
if (!body.length) return "";
|
|
74
|
+
return "> [" + sig + "]\n" + body.join("\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Button lifecycle ─────────────────────────────────────────────────────
|
|
78
|
+
function _createBtn() {
|
|
79
|
+
const btn = document.createElement("button");
|
|
80
|
+
btn.type = "button";
|
|
81
|
+
btn.className = "quote-select-btn";
|
|
82
|
+
btn.disabled = false;
|
|
83
|
+
btn.innerHTML =
|
|
84
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 6H3"/><path d="M21 12H8"/><path d="M21 18H8"/><path d="M3 12v6"/></svg>` +
|
|
85
|
+
`<span>${escapeHtml(I18n.t("chat.quote.btn"))}</span>`;
|
|
86
|
+
// Keep the textarea focus / selection alive: a bare mousedown on a button
|
|
87
|
+
// collapses the selection before click fires.
|
|
88
|
+
btn.addEventListener("mousedown", e => e.preventDefault());
|
|
89
|
+
btn.addEventListener("click", _onQuoteClick);
|
|
90
|
+
document.body.appendChild(btn);
|
|
91
|
+
return btn;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function _show(rect) {
|
|
95
|
+
if (!_btn) _btn = _createBtn();
|
|
96
|
+
// The button is a single recycled element — when it was first created it
|
|
97
|
+
// baked in the language of that moment. Sync the label with the current
|
|
98
|
+
// language on every show so switching en/zh mid-session stays consistent.
|
|
99
|
+
// (textContent assignment, no escaping needed.)
|
|
100
|
+
const labelQ = _btn.querySelector("span");
|
|
101
|
+
if (labelQ) labelQ.textContent = I18n.t("chat.quote.btn");
|
|
102
|
+
// The button is display:none until visible — measuring its rect while
|
|
103
|
+
// hidden returns 0×0, which would misplace it by its own height. Make it
|
|
104
|
+
// visible first, then measure and position (all synchronous, no flicker).
|
|
105
|
+
_btn.classList.add("visible");
|
|
106
|
+
const btnRect = _btn.getBoundingClientRect();
|
|
107
|
+
let top = rect.top - btnRect.height - 10; // 10px above the selection
|
|
108
|
+
if (top < 8) top = rect.bottom + 10; // flip below when clipped at top
|
|
109
|
+
_btn.style.top = top + "px";
|
|
110
|
+
// Horizontal center of the selection; CSS translateX(-50%) finishes it.
|
|
111
|
+
_btn.style.left = (rect.left + rect.width / 2) + "px";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function _hide() {
|
|
115
|
+
if (_pending) { clearTimeout(_pending); _pending = null; }
|
|
116
|
+
if (_btn) _btn.classList.remove("visible");
|
|
117
|
+
_snap = null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Quote action ─────────────────────────────────────────────────────────
|
|
121
|
+
function _onQuoteClick(e) {
|
|
122
|
+
e.preventDefault();
|
|
123
|
+
e.stopPropagation();
|
|
124
|
+
if (!_snap) return _hide();
|
|
125
|
+
const block = _buildBlock(_snap);
|
|
126
|
+
_hide();
|
|
127
|
+
if (block) _insert(block);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Append the blockquote to the composer: keep any existing draft and
|
|
131
|
+
// mention chips (we append — never overwrite), ensure a blank line separates
|
|
132
|
+
// it, leave the caret on a fresh line after the quote.
|
|
133
|
+
// Composer is a contenteditable div since v1.5.13 (Clacky.Composer); the
|
|
134
|
+
// textarea branch is a fallback for older builds / custom templates.
|
|
135
|
+
// Append plain text to the composer's end, after any existing content and
|
|
136
|
+
// mention chips (chips are contenteditable=false nodes — appending at the
|
|
137
|
+
// very end naturally preserves them). Newlines become <br>. Works without a
|
|
138
|
+
// focused caret, so it is deterministic in headless environments where
|
|
139
|
+
// execCommand / focus-based insertion silently no-ops.
|
|
140
|
+
function _appendToComposer(el, str) {
|
|
141
|
+
const range = document.createRange();
|
|
142
|
+
range.selectNodeContents(el);
|
|
143
|
+
range.collapse(false);
|
|
144
|
+
const frag = document.createDocumentFragment();
|
|
145
|
+
String(str).split("\n").forEach((line, i) => {
|
|
146
|
+
if (i > 0) frag.appendChild(document.createElement("br"));
|
|
147
|
+
if (line !== "") frag.appendChild(document.createTextNode(line));
|
|
148
|
+
});
|
|
149
|
+
range.insertNode(frag);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function _insert(block) {
|
|
153
|
+
const el = document.getElementById("user-input");
|
|
154
|
+
if (!el) return;
|
|
155
|
+
const api = (typeof Clacky !== "undefined" && Clacky.Composer) || null;
|
|
156
|
+
|
|
157
|
+
if (api) {
|
|
158
|
+
const cur = api.text(el);
|
|
159
|
+
let prefix = "";
|
|
160
|
+
if (cur.length > 0 && !cur.endsWith("\n\n")) {
|
|
161
|
+
prefix = cur.endsWith("\n") ? "\n" : "\n\n";
|
|
162
|
+
}
|
|
163
|
+
_appendToComposer(el, prefix + block + "\n");
|
|
164
|
+
api.focusEnd(el);
|
|
165
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (el.tagName === "TEXTAREA") {
|
|
170
|
+
let v = el.value || "";
|
|
171
|
+
if (v.length > 0 && !v.endsWith("\n\n")) {
|
|
172
|
+
v += v.endsWith("\n") ? "\n" : "\n\n";
|
|
173
|
+
}
|
|
174
|
+
v += block + "\n";
|
|
175
|
+
el.value = v;
|
|
176
|
+
el.focus();
|
|
177
|
+
el.setSelectionRange(v.length, v.length);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Selection validation ─────────────────────────────────────────────────
|
|
182
|
+
// Runs 60ms after mouseup/touchend so the browser has finished updating the
|
|
183
|
+
// selection (dbl-click word selection, drag end, etc.).
|
|
184
|
+
function _onMouseUp() {
|
|
185
|
+
if (_pending) clearTimeout(_pending);
|
|
186
|
+
_pending = setTimeout(_validate, 60);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function _validate() {
|
|
190
|
+
_pending = null;
|
|
191
|
+
const sel = window.getSelection();
|
|
192
|
+
if (!sel || sel.isCollapsed || sel.rangeCount < 1) return _hide();
|
|
193
|
+
const range = sel.getRangeAt(0);
|
|
194
|
+
const r1 = _msgRoot(range.startContainer);
|
|
195
|
+
const r2 = _msgRoot(range.endContainer);
|
|
196
|
+
if (!r1 || r1 !== r2) return _hide(); // cross-message selection: no unique provenance
|
|
197
|
+
const messages = document.getElementById("messages");
|
|
198
|
+
if (!messages || !messages.contains(r1)) return _hide();
|
|
199
|
+
|
|
200
|
+
const text = sel.toString().trim();
|
|
201
|
+
if (!text) return _hide();
|
|
202
|
+
|
|
203
|
+
_snap = Object.assign({ text }, _msgMeta(r1), { rect: range.getBoundingClientRect() });
|
|
204
|
+
_show(_snap.rect);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function _onDocMouseDown(e) {
|
|
208
|
+
if (_btn && e.target !== _btn && !_btn.contains(e.target)) _hide();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Init ─────────────────────────────────────────────────────────────────
|
|
212
|
+
let _inited = false; // guard against double init (listener stacking)
|
|
213
|
+
function init() {
|
|
214
|
+
if (_inited) return;
|
|
215
|
+
_inited = true;
|
|
216
|
+
document.addEventListener("mouseup", _onMouseUp);
|
|
217
|
+
document.addEventListener("touchend", _onMouseUp);
|
|
218
|
+
document.addEventListener("mousedown", _onDocMouseDown);
|
|
219
|
+
document.addEventListener("scroll", _hide, true); // any scroll collides with the anchor
|
|
220
|
+
document.addEventListener("keydown", e => { if (e.key === "Escape") _hide(); });
|
|
221
|
+
window.addEventListener("resize", _hide);
|
|
222
|
+
|
|
223
|
+
// Streaming rewrites message DOM wholesale; a detached selection cannot
|
|
224
|
+
// anchor the button anymore, so hide whenever the message list mutates.
|
|
225
|
+
const messages = document.getElementById("messages");
|
|
226
|
+
if (messages && typeof MutationObserver !== "undefined") {
|
|
227
|
+
new MutationObserver(() => { if (_snap) _hide(); }).observe(messages, {
|
|
228
|
+
childList: true,
|
|
229
|
+
subtree: true,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { init };
|
|
235
|
+
})();
|