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.
- package/bin/mulmoclaude.js +2 -1
- package/client/assets/{index-Dc0R-HW5.js → index-40ErrJ4a.js} +23 -23
- package/client/assets/{index-Dxo1Zdd-.css → index-BXb--as6.css} +1 -1
- package/client/assets/{marp-CSq0PPfK.js → marp-Dh7C24F1.js} +1 -1
- package/client/index.html +2 -2
- package/package.json +8 -7
- package/server/api/routes/chat-index.ts +5 -1
- package/server/api/routes/collections.ts +136 -0
- package/server/index.ts +14 -2
- package/server/prompts/system/system.md +7 -1
- package/server/remoteHost/handlers/collectionPage.ts +15 -17
- package/server/remoteHost/handlers/getCollection.ts +2 -2
- package/server/remoteHost/handlers/getFeed.ts +2 -2
- package/server/remoteHost/handlers/getRemoteView.ts +37 -0
- package/server/remoteHost/handlers/getRemoteViewItems.ts +38 -0
- package/server/remoteHost/handlers/index.ts +6 -0
- package/server/remoteHost/handlers/mutateRemoteView.ts +40 -0
- package/server/utils/files/thumbnail-store.ts +97 -0
- package/server/workspace/chat-index/index.ts +8 -1
- package/server/workspace/chat-index/indexer.ts +142 -27
- package/server/workspace/collections/index.ts +1 -0
- package/server/workspace/collections/remoteView.ts +290 -0
- package/src/App.vue +57 -3
- package/src/composables/collections/uiHost.ts +15 -0
- package/src/composables/usePubSub.ts +40 -2
- package/src/composables/useSessionSync.ts +9 -0
- package/src/config/apiRoutes.ts +20 -0
- package/src/config/roles.ts +2 -2
- package/src/lang/de.ts +2 -0
- package/src/lang/en.ts +2 -0
- package/src/lang/es.ts +2 -0
- package/src/lang/fr.ts +2 -0
- package/src/lang/ja.ts +2 -0
- package/src/lang/ko.ts +2 -0
- package/src/lang/pt-BR.ts +2 -0
- package/src/lang/zh.ts +1 -0
- package/src/plugins/textResponse/View.vue +49 -14
- package/src/plugins/textResponse/utils.ts +37 -0
- package/src/utils/html/previewCsp.ts +7 -16
- 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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
|
|
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
|
-
//
|
|
9
|
-
//
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
package/src/utils/role/icon.ts
CHANGED
|
@@ -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 ??
|
|
15
|
-
return MATERIAL_ICON_RE.test(icon) ? icon :
|
|
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 {
|