mulmoclaude 0.9.2 → 0.9.3

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.
Files changed (40) hide show
  1. package/bin/mulmoclaude.js +2 -1
  2. package/client/assets/{index-Dc0R-HW5.js → index-40ErrJ4a.js} +23 -23
  3. package/client/assets/{index-Dxo1Zdd-.css → index-BXb--as6.css} +1 -1
  4. package/client/assets/{marp-CSq0PPfK.js → marp-Dh7C24F1.js} +1 -1
  5. package/client/index.html +2 -2
  6. package/package.json +8 -7
  7. package/server/api/routes/chat-index.ts +5 -1
  8. package/server/api/routes/collections.ts +136 -0
  9. package/server/index.ts +14 -2
  10. package/server/prompts/system/system.md +7 -1
  11. package/server/remoteHost/handlers/collectionPage.ts +15 -17
  12. package/server/remoteHost/handlers/getCollection.ts +2 -2
  13. package/server/remoteHost/handlers/getFeed.ts +2 -2
  14. package/server/remoteHost/handlers/getRemoteView.ts +37 -0
  15. package/server/remoteHost/handlers/getRemoteViewItems.ts +38 -0
  16. package/server/remoteHost/handlers/index.ts +6 -0
  17. package/server/remoteHost/handlers/mutateRemoteView.ts +40 -0
  18. package/server/utils/files/thumbnail-store.ts +97 -0
  19. package/server/workspace/chat-index/index.ts +8 -1
  20. package/server/workspace/chat-index/indexer.ts +142 -27
  21. package/server/workspace/collections/index.ts +1 -0
  22. package/server/workspace/collections/remoteView.ts +290 -0
  23. package/src/App.vue +57 -3
  24. package/src/composables/collections/uiHost.ts +15 -0
  25. package/src/composables/usePubSub.ts +40 -2
  26. package/src/composables/useSessionSync.ts +9 -0
  27. package/src/config/apiRoutes.ts +20 -0
  28. package/src/config/roles.ts +2 -2
  29. package/src/lang/de.ts +2 -0
  30. package/src/lang/en.ts +2 -0
  31. package/src/lang/es.ts +2 -0
  32. package/src/lang/fr.ts +2 -0
  33. package/src/lang/ja.ts +2 -0
  34. package/src/lang/ko.ts +2 -0
  35. package/src/lang/pt-BR.ts +2 -0
  36. package/src/lang/zh.ts +1 -0
  37. package/src/plugins/textResponse/View.vue +49 -14
  38. package/src/plugins/textResponse/utils.ts +37 -0
  39. package/src/utils/html/previewCsp.ts +7 -16
  40. package/src/utils/role/icon.ts +9 -2
@@ -18,6 +18,18 @@
18
18
  <span class="material-icons text-gray-400 text-base shrink-0 group-open:rotate-180 transition-transform">expand_more</span>
19
19
  </summary>
20
20
  <div class="border-t border-purple-200 p-4 bg-white rounded-b-lg">
21
+ <div
22
+ v-if="truncationInfo.wasTruncated"
23
+ class="mb-3 p-3 rounded border border-amber-300 bg-amber-50 text-amber-900 text-sm"
24
+ data-testid="text-response-seeded-truncation-banner"
25
+ >
26
+ {{
27
+ t("pluginTextResponse.truncatedForRender", {
28
+ omitted: truncationInfo.omittedChars.toLocaleString(locale),
29
+ total: truncationInfo.originalChars.toLocaleString(locale),
30
+ })
31
+ }}
32
+ </div>
21
33
  <!-- eslint-disable vue/no-v-html -- marked.parse output of the plugin-seeded prompt; trusted in-process render matching the standard textResponse path. Multi-line element so disable/enable pair (CLAUDE.md UI rule). -->
22
34
  <div ref="markdownContainerRef" class="markdown-content prose prose-slate max-w-none" @click="openLinksInNewTab" v-html="renderedHtml"></div>
23
35
  <!-- eslint-enable vue/no-v-html -->
@@ -62,6 +74,18 @@
62
74
  <span class="font-medium text-gray-700">{{ speakerLabel }}</span>
63
75
  <span v-if="transportKind" class="italic">{{ transportKind }}</span>
64
76
  </div>
77
+ <div
78
+ v-if="truncationInfo.wasTruncated"
79
+ class="mb-3 p-3 rounded border border-amber-300 bg-amber-50 text-amber-900 text-sm"
80
+ data-testid="text-response-truncation-banner"
81
+ >
82
+ {{
83
+ t("pluginTextResponse.truncatedForRender", {
84
+ omitted: truncationInfo.omittedChars.toLocaleString(locale),
85
+ total: truncationInfo.originalChars.toLocaleString(locale),
86
+ })
87
+ }}
88
+ </div>
65
89
  <!-- eslint-disable vue/no-v-html -- marked.parse output of app-owned assistant response text; trusted in-process render. Multi-line element so disable/enable pair (CLAUDE.md UI rule) instead of -next-line. -->
66
90
  <div
67
91
  ref="markdownContainerRef"
@@ -110,9 +134,9 @@ import { usePdfDownload } from "../../composables/usePdfDownload";
110
134
  import { useMarkdownZip } from "../../composables/useMarkdownZip";
111
135
  import { useClipboardCopy } from "../../composables/useClipboardCopy";
112
136
  import { buildPdfFilename } from "../../utils/files/filename";
113
- import { extractTextResponseTitle } from "./utils";
137
+ import { extractTextResponseTitle, truncateForRender } from "./utils";
114
138
 
115
- const { t } = useI18n();
139
+ const { t, locale } = useI18n();
116
140
  const appApi = useAppApi();
117
141
 
118
142
  const props = withDefaults(
@@ -162,19 +186,30 @@ const seededByPlugin = computed<string>(() => props.selectedResult.data?.seededB
162
186
  // inherently scoped to the opening message.
163
187
  const isSeededUserTurn = computed(() => Boolean(seededByPlugin.value) && messageRole.value === "user");
164
188
 
189
+ // Truncation summary surfaced to the template — the banner renders when
190
+ // `wasTruncated` is true, telling the user the visible content is a preview
191
+ // and the raw text is available via Copy. See #1863 for the pathological
192
+ // input this defends against (Opus 4.8 degenerate repetition freezing Safari).
193
+ const truncationInfo = computed(() => truncateForRender(messageText.value ?? ""));
194
+
165
195
  const renderedHtml = computed(() => {
166
- if (!messageText.value) return "";
167
-
168
- let processedText = messageText.value;
169
-
170
- // Detect and wrap JSON content in code fences
171
- const trimmedText = processedText.trim();
172
- if ((trimmedText.startsWith("{") && trimmedText.endsWith("}")) || (trimmedText.startsWith("[") && trimmedText.endsWith("]"))) {
173
- try {
174
- JSON.parse(trimmedText);
175
- processedText = `\`\`\`json\n${trimmedText}\n\`\`\``;
176
- } catch {
177
- // Not valid JSON, continue with original text
196
+ const { displayText } = truncationInfo.value;
197
+ if (!displayText) return "";
198
+
199
+ let processedText = displayText;
200
+
201
+ // Detect and wrap JSON content in code fences (skip for truncated views —
202
+ // JSON.parse of a truncated tail would throw anyway, and a partial JSON
203
+ // dump is more informative rendered as markdown).
204
+ if (!truncationInfo.value.wasTruncated) {
205
+ const trimmedText = processedText.trim();
206
+ if ((trimmedText.startsWith("{") && trimmedText.endsWith("}")) || (trimmedText.startsWith("[") && trimmedText.endsWith("]"))) {
207
+ try {
208
+ JSON.parse(trimmedText);
209
+ processedText = `\`\`\`json\n${trimmedText}\n\`\`\``;
210
+ } catch {
211
+ // Not valid JSON, continue with original text
212
+ }
178
213
  }
179
214
  }
180
215
 
@@ -4,6 +4,43 @@
4
4
 
5
5
  const MAX_TITLE_CHARS = 50;
6
6
 
7
+ // Cap on how much of an assistant message we feed into marked() at once.
8
+ // The Opus 4.8 "degenerate repetition" bug can generate hundreds of
9
+ // thousands of chars of blank-line-separated single words; marked itself
10
+ // parses that fine (~120ms), but Safari's layout/paint on ~30k <p>
11
+ // elements freezes the tab for minutes (#1863). 100_000 is comfortably
12
+ // larger than any real assistant reply (Claude's ~200k token ceiling is
13
+ // well below that in bytes) and small enough that pathological input
14
+ // bounces off it well before the render blows up.
15
+ export const RENDER_TRUNCATE_CHARS = 100_000;
16
+ // The preview slice we DO render when a message trips the cap. Small
17
+ // enough that even a fully-blank-line-separated payload stays under a
18
+ // few thousand block elements. Users get "Copy" for the full raw text.
19
+ export const RENDER_TRUNCATE_PREVIEW_CHARS = 20_000;
20
+
21
+ export interface TruncationResult {
22
+ displayText: string;
23
+ wasTruncated: boolean;
24
+ originalChars: number;
25
+ omittedChars: number;
26
+ }
27
+
28
+ // Truncate an assistant message so pathological model output can't
29
+ // freeze the render path. Pure so it can be unit-tested from
30
+ // node:test without a Vue runtime.
31
+ export function truncateForRender(text: string): TruncationResult {
32
+ const originalChars = text.length;
33
+ if (originalChars <= RENDER_TRUNCATE_CHARS) {
34
+ return { displayText: text, wasTruncated: false, originalChars, omittedChars: 0 };
35
+ }
36
+ return {
37
+ displayText: text.slice(0, RENDER_TRUNCATE_PREVIEW_CHARS),
38
+ wasTruncated: true,
39
+ originalChars,
40
+ omittedChars: originalChars - RENDER_TRUNCATE_PREVIEW_CHARS,
41
+ };
42
+ }
43
+
7
44
  // Pull a short, human-meaningful title out of a chat reply for use as
8
45
  // a download filename. Priority:
9
46
  // 1. First markdown H1 ("# ...") — the model often opens a long
@@ -5,23 +5,14 @@
5
5
  // random `https://` origins, phone-home `fetch()` calls, etc. —
6
6
  // is rejected.
7
7
  //
8
- // Widen by editing `HTML_PREVIEW_CSP_ALLOWED_CDNS` below. Keep the
9
- // list audited every entry is a potential supply-chain surface.
8
+ // The list itself lives in `@mulmoclaude/core/remote-view`
9
+ // (SANDBOXED_VIEW_CDN_ALLOWLIST) so the remote-view CSP and these
10
+ // desktop policies can't drift — widen it THERE, and keep it
11
+ // audited: every entry is a potential supply-chain surface.
10
12
 
11
- export const HTML_PREVIEW_CSP_ALLOWED_CDNS: readonly string[] = [
12
- "https://cdn.jsdelivr.net",
13
- "https://unpkg.com",
14
- "https://cdnjs.cloudflare.com",
15
- "https://fonts.googleapis.com",
16
- "https://fonts.gstatic.com",
17
- // Plotly's official CDN. The LLM defaults to this URL when it
18
- // includes a Sankey or other Plotly chart in presentHtml output —
19
- // Plotly's docs recommend it, so unconditioned LLM output ends up
20
- // pointing here. Also reachable through jsdelivr, but adding the
21
- // first-party CDN keeps historical artifacts (where the URL is
22
- // already baked into the file on disk) rendering correctly.
23
- "https://cdn.plot.ly",
24
- ];
13
+ import { SANDBOXED_VIEW_CDN_ALLOWLIST } from "@mulmoclaude/core/remote-view";
14
+
15
+ export const HTML_PREVIEW_CSP_ALLOWED_CDNS: readonly string[] = SANDBOXED_VIEW_CDN_ALLOWLIST;
25
16
 
26
17
  /**
27
18
  * Build the CSP string. Split from the wrapper so tests can exercise
@@ -10,9 +10,16 @@ import type { Role } from "../../config/roles";
10
10
  // don't render the literal text inside a Material Icons span.
11
11
  const MATERIAL_ICON_RE = /^[a-z_]+$/;
12
12
 
13
+ // `smart_toy` (robot glyph) is used for both fallback cases —
14
+ // "role not found" and "role icon isn't a valid Material Icon name".
15
+ // Reserved specifically to avoid collision with `star`, which is the
16
+ // PinToggle glyph for collection shortcuts; using `star` here would
17
+ // make an unknown role look identical to a pinned collection (#1684).
18
+ const FALLBACK_ICON = "smart_toy";
19
+
13
20
  export function roleIcon(roles: Role[], roleId: string): string {
14
- const icon = roles.find((role) => role.id === roleId)?.icon ?? "star";
15
- return MATERIAL_ICON_RE.test(icon) ? icon : "smart_toy";
21
+ const icon = roles.find((role) => role.id === roleId)?.icon ?? FALLBACK_ICON;
22
+ return MATERIAL_ICON_RE.test(icon) ? icon : FALLBACK_ICON;
16
23
  }
17
24
 
18
25
  export function roleName(roles: Role[], roleId: string): string {