ai_co_discuss 0.4.7 → 0.4.8

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.
@@ -1,6 +1,7 @@
1
1
  /** Read-only directory and text previews, confined to a room's saved project directories. */
2
- import { closeSync, fstatSync, openSync, readdirSync, readSync, realpathSync, statSync } from 'node:fs';
3
- import { isAbsolute, relative, resolve } from 'node:path';
2
+ import { createHash } from 'node:crypto';
3
+ import { closeSync, fstatSync, openSync, readdirSync, readFileSync, readSync, realpathSync, statSync } from 'node:fs';
4
+ import { isAbsolute, posix, relative, resolve } from 'node:path';
4
5
  import { adapter_profile } from './adapters/profiles.mjs';
5
6
  import { PINYIN_TABLE } from './pinyin_table.mjs';
6
7
 
@@ -223,6 +224,105 @@ export function read_project_files(cwd, path = '', offset = 0, query = '') {
223
224
  next_offset: offset + entries.length < items.length ? offset + entries.length : null };
224
225
  }
225
226
 
227
+ // An HTML page with the files it refers to inlined stays within this size; references past it are left as they are.
228
+ export const PAGE_MAX_BYTES = 8 * 1024 * 1024;
229
+ // Media types of the files a page may refer to; others are left as they are.
230
+ const PAGE_TYPES = {
231
+ css: 'text/css', js: 'text/javascript', mjs: 'text/javascript', json: 'application/json', txt: 'text/plain', csv: 'text/csv',
232
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif', svg: 'image/svg+xml',
233
+ ico: 'image/x-icon', bmp: 'image/bmp', woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf',
234
+ mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', m4a: 'audio/mp4', mp4: 'video/mp4', webm: 'video/webm',
235
+ };
236
+ // Script and style bodies are kept apart so only real tags are rewritten; comments are left alone.
237
+ const PAGE_PARTS = /(<script\b[^>]*>)([\s\S]*?)(<\/script\s*>)|(<style\b[^>]*>)([\s\S]*?)(<\/style\s*>)|(<!--[\s\S]*?-->)|(<[a-zA-Z][^>]*>)/gi;
238
+ const TAG_ATTRIBUTE = /(\s)(src|poster|href|style)(\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi;
239
+ const CSS_URL = /url\(\s*(['"]?)([^'")]+)\1\s*\)/gi;
240
+ const CSS_IMPORT = /@import\s+(['"])([^'"]+)\1/gi;
241
+
242
+ /** The project path a reference in the file at `base` (a folder) names, or null for other URLs (schemes, fragments). */
243
+ function page_reference(base, reference) {
244
+ const value = reference.trim().replace(/&amp;/g, '&');
245
+ if (!value || /^([a-z][a-z0-9+.-]*:|\/\/|#)/i.test(value)) return null;
246
+ let path = value.replace(/[?#].*$/, '');
247
+ try { path = decodeURIComponent(path); } catch { /* Kept as written. */ }
248
+ return posix.normalize(path.startsWith('/') ? path.slice(1) : posix.join(base, path));
249
+ }
250
+
251
+ /** A referenced project file as a data URL (a style sheet with its own references inlined), or null when it is left as written. */
252
+ function page_resource(path, state, depth) {
253
+ if (path === null) return null;
254
+ if (state.inlined.has(path)) return state.inlined.get(path);
255
+ const type = PAGE_TYPES[posix.extname(path).slice(1).toLowerCase()];
256
+ if (!type) return null;
257
+ let bytes;
258
+ try {
259
+ if (path.startsWith('..')) throw new Error('outside');
260
+ const target = project_path(state.root, path);
261
+ if (!statSync(target).isFile()) throw new Error('not a file');
262
+ bytes = readFileSync(target);
263
+ } catch { state.missing.add(path); return null; }
264
+ if (type === 'text/css' && depth < 4) bytes = Buffer.from(page_css(bytes.toString('utf8'), posix.dirname(path), state, depth + 1));
265
+ const url = `data:${type};base64,${bytes.toString('base64')}`;
266
+ if (url.length > state.budget) { state.skipped.add(path); return null; }
267
+ state.budget -= url.length;
268
+ state.inlined.set(path, url);
269
+ return url;
270
+ }
271
+
272
+ /** Inline the files a style sheet refers to; `base` is the folder its relative URLs start from. */
273
+ function page_css(css, base, state, depth) {
274
+ return css.replace(CSS_IMPORT, (whole, quote, reference) => {
275
+ const url = page_resource(page_reference(base, reference), state, depth);
276
+ return url ? `@import url(${url})` : whole;
277
+ }).replace(CSS_URL, (whole, quote, reference) => {
278
+ const url = page_resource(page_reference(base, reference), state, depth);
279
+ return url ? `url(${url})` : whole;
280
+ });
281
+ }
282
+
283
+ /** Inline the files one tag refers to: `src`/`poster` of any tag, `href` of style sheets and icons, URLs in `style`. */
284
+ function page_tag(tag, base, state) {
285
+ const name = /^<([a-zA-Z][\w-]*)/.exec(tag)[1].toLowerCase();
286
+ const linked = name === 'link' && /\brel\s*=\s*["']?[^"'>]*\b(stylesheet|icon)\b/i.test(tag);
287
+ return tag.replace(TAG_ATTRIBUTE, (whole, space, attribute, equals, double, single, bare) => {
288
+ const value = double ?? single ?? bare;
289
+ const key = attribute.toLowerCase();
290
+ if (key === 'style') {
291
+ const css = page_css(value, base, state, 0);
292
+ return css === value ? whole : `${space}${attribute}${equals}"${css.replace(/"/g, '&quot;')}"`;
293
+ }
294
+ if (key === 'href' && !linked) return whole;
295
+ const url = page_resource(page_reference(base, value), state, 0);
296
+ return url ? `${space}${attribute}${equals}"${url}"` : whole;
297
+ });
298
+ }
299
+
300
+ /**
301
+ * One HTML file of a project with the style sheets, scripts, images, fonts and media it refers to by relative path
302
+ * inlined as data URLs, so it shows in full where only its text can be sent (another device, through the relay).
303
+ * Only files inside the project directory are read; references that are missing, outside it or past the size limit
304
+ * are left as written and listed. Resources a script asks for while running are not included.
305
+ */
306
+ export function bundle_project_page(cwd, path) {
307
+ const root = project_root(cwd);
308
+ const target = project_path(root, path);
309
+ if (!/\.html?$/i.test(target)) throw new Error('只能打开 HTML 文件');
310
+ const info = statSync(target);
311
+ if (!info.isFile()) throw new Error('只能预览普通文件');
312
+ if (info.size > PAGE_MAX_BYTES) throw new Error('HTML 文件超过 8 MiB,无法完整显示');
313
+ const normalized = path.replaceAll('\\', '/').split('/').filter(part => part && part !== '.').join('/');
314
+ const base = posix.dirname(normalized);
315
+ const state = { root, budget: PAGE_MAX_BYTES - info.size, inlined: new Map(), missing: new Set(), skipped: new Set() };
316
+ const html = readFileSync(target, 'utf8').replace(PAGE_PARTS, (whole, script_open, script_body, script_close, style_open, style_body, style_close, comment, tag) => {
317
+ if (script_open) return page_tag(script_open, base, state) + script_body + script_close;
318
+ if (style_open) return style_open + page_css(style_body, base, state, 0) + style_close;
319
+ return comment ?? page_tag(tag, base, state);
320
+ });
321
+ const bytes = Buffer.from(html, 'utf8');
322
+ return { path: normalized, bytes, version: createHash('sha256').update(bytes).digest('hex').slice(0, 16), modified_at: info.mtime.toISOString(),
323
+ missing: [...state.missing], skipped: [...state.skipped] };
324
+ }
325
+
226
326
  /** Search names under every directory of a room at once; each entry's `root` is the index of its directory. */
227
327
  export function search_project_roots(directories, query) {
228
328
  if (!directories.length) project_root('');
@@ -10,7 +10,7 @@ import { setTimeout as sleep } from 'node:timers/promises';
10
10
  import { gzipSync } from 'node:zlib';
11
11
 
12
12
  import { atomic_json } from './files.mjs';
13
- import { remote_room_files } from './discussion_launch.mjs';
13
+ import { remote_room_files, remote_room_page } from './discussion_launch.mjs';
14
14
  import { acquire_lock } from './discussion_runner.mjs';
15
15
  import { local_settings } from './discussion_setup.mjs';
16
16
  import { machine_links, relay_cipher, save_machine_links, relayed_command, ROOM_ACTIONS, run_launch_command, sync_remote_launches, unseal } from './remote_launch.mjs';
@@ -162,10 +162,15 @@ export function computer_command(home, site, action, payload) {
162
162
  return relayed_command(room?.site ?? local_origin(home), action, input);
163
163
  }
164
164
 
165
- /** Run one local operation; browsing project files is answered in this process, since it is asked for on every folder opened. */
165
+ /**
166
+ * Run one local operation; browsing project files and reading an HTML page are answered in this process, since they
167
+ * are asked for on every folder opened or page part.
168
+ */
166
169
  async function run_command(home, command, run) {
167
- if (command.command !== 'room-files' || run !== run_launch_command) return run(command);
168
- return { status: 200, body: await remote_room_files(home, command.input) };
170
+ if (run !== run_launch_command) return run(command);
171
+ if (command.command === 'room-files') return { status: 200, body: await remote_room_files(home, command.input) };
172
+ if (command.command === 'room-page') return { status: 200, body: await remote_room_page(home, command.input) };
173
+ return run(command);
169
174
  }
170
175
 
171
176
  /** Answer one relayed request: room routes and uploads go to the local website, everything else to the local entrypoint as before. */
@@ -29,9 +29,9 @@ const RELAY_CIPHER_INFO = 'codiscuss-relay-v1';
29
29
  const LAUNCH_SCRIPT = fileURLToPath(new URL('./discussion_launch.mjs', import.meta.url));
30
30
  // What a connected browser may ask the local bridge for, minus the folder dialog nobody would see and pairing itself.
31
31
  const RELAYED_ACTIONS = new Set(['settings', 'launch', 'room-models', 'room-member', 'room-member-access', 'room-member-allow', 'room-member-adapter',
32
- 'room-member-model', 'room-profiles', 'room-files', 'execution-details', 'room-restore', 'room-fork', 'models', 'api-key', 'api-check', 'scheduler-check', 'tts-settings', 'tts', 'tts-release', 'stt', 'projects', 'presets', 'workflows', 'room-workflow', 'room-settings', 'browse-directory']);
32
+ 'room-member-model', 'room-profiles', 'room-files', 'room-page', 'execution-details', 'room-restore', 'room-fork', 'models', 'api-key', 'api-check', 'scheduler-check', 'tts-settings', 'tts', 'tts-release', 'stt', 'projects', 'presets', 'workflows', 'room-workflow', 'room-settings', 'browse-directory']);
33
33
  export const ROOM_ACTIONS = new Set(['launch', 'room-models', 'room-member', 'room-member-access', 'room-member-allow', 'room-member-adapter',
34
- 'room-member-model', 'room-profiles', 'room-files', 'execution-details', 'room-restore', 'room-fork', 'room-workflow', 'room-settings', 'workflows']);
34
+ 'room-member-model', 'room-profiles', 'room-files', 'room-page', 'execution-details', 'room-restore', 'room-fork', 'room-workflow', 'room-settings', 'workflows']);
35
35
 
36
36
  // On Windows the file holding this computer's private keys and tokens is sealed with DPAPI for the signed-in user, so a
37
37
  // copy of the file alone is useless elsewhere; other systems rely on the file's 0600 mode.
package/web/app.js CHANGED
@@ -1736,6 +1736,14 @@ function attachment_size(size) {
1736
1736
  return size < 1024 ? `${size} B` : size < 1024 * 1024 ? `${(size / 1024).toFixed(1)} KiB` : `${(size / 1024 / 1024).toFixed(1)} MiB`;
1737
1737
  }
1738
1738
 
1739
+ // Attachments read as text in the file preview window (Markdown and HTML rendered, the rest as source).
1740
+ const TEXT_ATTACHMENT = /\.(md|markdown|txt|log|csv|tsv|json|jsonc|ya?ml|toml|ini|xml|html?|css|vue|[cm]?js|jsx|tsx?|py|sql|sh|ps1|bat)$/i;
1741
+
1742
+ /** Whether an attachment opens in the file preview window as text. */
1743
+ function is_text_attachment(file) {
1744
+ return TEXT_ATTACHMENT.test(file.file_name);
1745
+ }
1746
+
1739
1747
  /** Render a file-shaped type badge; the extension is a label, not a preview capability. */
1740
1748
  function attachment_file_icon(file_name) {
1741
1749
  const extension = String(file_name).match(/\.([a-z0-9]{1,5})$/i)?.[1].toUpperCase() || "FILE";
@@ -1834,7 +1842,7 @@ function attachment_markup(files = []) {
1834
1842
  if (file.content_type.startsWith("image/")) return `<button type="button" class="attachment-image" ${preview_attributes}>
1835
1843
  <img ${source} alt="${name}" loading="lazy"></button>`;
1836
1844
  const content = `${attachment_file_icon(file.file_name)}<span class="attachment-name"><span title="${name}">${name}</span><small>${attachment_size(file.size)}</small></span>`;
1837
- return `<div class="attachment-file">${file.content_type === "application/pdf"
1845
+ return `<div class="attachment-file">${file.content_type === "application/pdf" || is_text_attachment(file)
1838
1846
  ? `<button type="button" class="attachment-file-main" ${preview_attributes}>${content}</button>`
1839
1847
  : `<a class="attachment-file-main" ${download} download aria-label="下载 ${name}">${content}</a>`}
1840
1848
  <a class="attachment-download" ${download} download aria-label="下载 ${name}" title="下载 ${name}">
@@ -1863,6 +1871,13 @@ async function preview_attachment({ room_id, id }) {
1863
1871
  file.url = file.download_url = await relay_file_url(new URL(file.url, location.origin).pathname);
1864
1872
  if (state.attachment_preview !== preview) return;
1865
1873
  }
1874
+ if (is_text_attachment(file)) {
1875
+ state.attachment_preview = null;
1876
+ // The file preview window belongs to the Vue workspace; without it the file is downloaded.
1877
+ if (state.text_preview) state.text_preview(file);
1878
+ else location.assign(file.download_url);
1879
+ return;
1880
+ }
1866
1881
  preview.file = file;
1867
1882
  const is_pdf = file.content_type === "application/pdf";
1868
1883
  element("attachment-title").textContent = file.file_name;
@@ -3056,6 +3071,28 @@ function read_room_files(room_id, path = "", offset = 0, query = "", root = 0) {
3056
3071
  return local_launch_request("room-files", { room_id, path, offset, ...(query ? { query } : { root }), token: localStorage.getItem(`discuss-access:${room_id}`) });
3057
3072
  }
3058
3073
 
3074
+ /**
3075
+ * Read one HTML file of the room's project with the files it refers to inlined by the computer, in parts when it comes
3076
+ * through the relay; `{ text, missing, skipped, modified_at }`.
3077
+ */
3078
+ async function read_room_page(room_id, path, root = 0) {
3079
+ const parts = [];
3080
+ let offset = 0, answer, version = "";
3081
+ do {
3082
+ answer = await local_launch_request("room-page", { room_id, path, root, offset, token: localStorage.getItem(`discuss-access:${room_id}`) });
3083
+ if (version && answer.version !== version) throw new Error("页面在读取时发生了变化,请重试");
3084
+ version = answer.version;
3085
+ const bytes = base64_to_bytes(answer.data || "");
3086
+ if (!bytes.length && offset < answer.total) throw new Error("页面读取失败");
3087
+ parts.push(bytes);
3088
+ offset += bytes.length;
3089
+ } while (offset < answer.total);
3090
+ const bytes = new Uint8Array(offset);
3091
+ let at = 0;
3092
+ for (const part of parts) { bytes.set(part, at); at += part.length; }
3093
+ return { text: new TextDecoder().decode(bytes), missing: answer.missing || [], skipped: answer.skipped || [], modified_at: answer.modified_at };
3094
+ }
3095
+
3059
3096
  /**
3060
3097
  * Read what a member did in one execution, or in one private chat turn (`{chat_id}`), from the computer's own log; the
3061
3098
  * details never pass through the website.
@@ -5014,7 +5051,7 @@ window.discuss_view = { snapshot: view_snapshot, write_field: write_view_field,
5014
5051
  write_launch: write_launch_field, default_launch_entry, description_template, save_description_template, description_draft, write_description_draft, describe_member, set_chain_rounds, continue_chain, expert_presets, apply_expert_preset, unique_instance, member_label, default_name, label_draft, write_label_draft, preset_identity, save_expert_preset, delete_expert_preset, write_room_connection, write_room_adapter, launch_clients: LAUNCH_CLIENTS,
5015
5052
  launch_model_state, load_launch_models, write_launch_model, save_launch_api_key, write_scheduler, save_scheduler_api_key, check_scheduler, write_named_followup, write_create_discussion, write_create_goal, write_create_chain_rounds, check_launch_api, launch_summary, launch_can_set_access, pick_launch_directory, browse_local_directory, list_remote_directories, save_launch_projects, restore_launch_profile, reset_create_form, empty_discussion_message,
5016
5053
  start_drag: start_directory_drag, reconnect: reconnect_live_updates, stop_label, local_request: local_launch_request,
5017
- attachment_markup, attachment_size, attachment_file_icon, drop_attachments, paste_attachments,
5054
+ attachment_markup, attachment_size, attachment_file_icon, drop_attachments, paste_attachments, set_text_preview: (open) => { state.text_preview = open; },
5018
5055
  write_expert: write_view_expert, add_instance: add_view_instance, add_preset: add_preset_instance, platform_of, execute: execute_view_action, theme: change_view_theme,
5019
5056
  reload_workflows, workflow_preset, write_create_host_workflows, write_create_api_max_requests, set_room_api_max_requests, set_room_extra_directories, set_room_project_directory,
5020
5057
  admin: { register: register_admin, login: login_admin, logout: logout_admin,
@@ -5023,7 +5060,7 @@ window.discuss_view = { snapshot: view_snapshot, write_field: write_view_field,
5023
5060
  device: { request: device_request, pair: pair_this_device, take_scanned: take_scanned_pairing, invite: invite_device, renew: renew_this_device, forget: forget_pairing,
5024
5061
  label: device_label, prompt_confirm: () => state.device.prompt?.run(), prompt_cancel: () => state.device.prompt?.cancel() },
5025
5062
  request: request_json, rewind_plan, identities: IDENTITIES, display_name, mention_handles, mentioned_members, render_room_markdown, sources: SOURCE_LABELS,
5026
- set_member_access, set_member_model, allow_denials, set_member_adapter, reload_room_models, read_room_files, read_execution_details, interject_execution, read_member_chat, send_member_chat, stop_member_chat, pause_execution, limits_label, stop_reason_label, participant_model, participant_files, participant_session_hint, participant_access,
5063
+ set_member_access, set_member_model, allow_denials, set_member_adapter, reload_room_models, read_room_files, read_room_page, read_execution_details, interject_execution, read_member_chat, send_member_chat, stop_member_chat, pause_execution, limits_label, stop_reason_label, participant_model, participant_files, participant_session_hint, participant_access,
5027
5064
  turn_label, turn_guidance, round_groups, limit_rounds, progress_speaker, brief_meta, max_message_length: MAX_MESSAGE_LENGTH, provenance: message_provenance, format_time };
5028
5065
 
5029
5066
  restore_theme();