mulmoclaude 0.3.0 → 0.4.0
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 +7 -24
- package/client/assets/html2canvas-Cx501zZr-Cv5snK9D.js +5 -0
- package/client/assets/index-CubzmCVK.css +2 -0
- package/client/assets/{index-eHWB79u5.js → index-DtcyExH9.js} +80 -61
- package/client/assets/{index.es-D4YyL_Dg-BfRHLTZV.js → index.es-D4YyL_Dg-DnizuhIY.js} +5 -5
- package/client/index.html +2 -4
- package/package.json +13 -13
- package/server/agent/attachmentConverter.ts +2 -2
- package/server/agent/index.ts +9 -3
- package/server/agent/mcp-tools/index.ts +6 -6
- package/server/agent/mcp-tools/x.ts +2 -1
- package/server/agent/prompt.ts +187 -26
- package/server/agent/resumeFailover.ts +5 -5
- package/server/agent/sandboxMounts.ts +3 -3
- package/server/api/auth/bearerAuth.ts +3 -3
- package/server/api/auth/token.ts +2 -2
- package/server/api/routes/agent.ts +21 -3
- package/server/api/routes/config.ts +1 -1
- package/server/api/routes/files.ts +13 -12
- package/server/api/routes/html.ts +2 -2
- package/server/api/routes/image.ts +7 -7
- package/server/api/routes/mulmo-script.ts +33 -31
- package/server/api/routes/pdf.ts +2 -2
- package/server/api/routes/plugins.ts +16 -6
- package/server/api/routes/roles.ts +2 -2
- package/server/api/routes/scheduler.ts +8 -6
- package/server/api/routes/schedulerTasks.ts +5 -3
- package/server/api/routes/sessions.ts +2 -2
- package/server/api/routes/sessionsCursor.ts +4 -4
- package/server/api/routes/skills.ts +5 -5
- package/server/api/routes/sources.ts +3 -3
- package/server/api/routes/todosHandlers.ts +1 -1
- package/server/api/routes/todosItemsHandlers.ts +14 -14
- package/server/api/routes/wiki.ts +22 -8
- package/server/api/sandboxStatus.ts +1 -1
- package/server/events/notifications.ts +6 -6
- package/server/events/pub-sub/index.ts +3 -3
- package/server/events/relay-client.ts +17 -16
- package/server/index.ts +40 -46
- package/server/system/config.ts +5 -5
- package/server/system/credentials.ts +7 -5
- package/server/system/env.ts +5 -5
- package/server/utils/files/atomic.ts +11 -11
- package/server/utils/files/image-store.ts +17 -6
- package/server/utils/files/journal-io.ts +2 -2
- package/server/utils/files/json.ts +5 -5
- package/server/utils/files/markdown-store.ts +4 -4
- package/server/utils/files/reference-dirs-io.ts +3 -3
- package/server/utils/files/roles-io.ts +4 -4
- package/server/utils/files/safe.ts +14 -14
- package/server/utils/files/scheduler-overrides-io.ts +2 -2
- package/server/utils/files/spreadsheet-store.ts +5 -5
- package/server/utils/files/workspace-io.ts +12 -12
- package/server/utils/gemini.ts +2 -2
- package/server/utils/gitignore.ts +9 -9
- package/server/utils/json.ts +5 -5
- package/server/utils/logBackgroundError.ts +12 -3
- package/server/utils/markdown.ts +5 -5
- package/server/utils/port.d.mts +6 -0
- package/server/utils/port.mjs +48 -0
- package/server/utils/request.ts +12 -6
- package/server/utils/spawn.ts +1 -1
- package/server/utils/types.ts +2 -2
- package/server/workspace/chat-index/summarizer.ts +4 -4
- package/server/workspace/custom-dirs.ts +5 -5
- package/server/workspace/journal/diff.ts +2 -2
- package/server/workspace/journal/index.ts +4 -4
- package/server/workspace/journal/optimizationPass.ts +2 -2
- package/server/workspace/journal/state.ts +6 -6
- package/server/workspace/paths.ts +3 -3
- package/server/workspace/reference-dirs.ts +3 -3
- package/server/workspace/skills/parser.ts +6 -6
- package/server/workspace/skills/scheduler.ts +3 -3
- package/server/workspace/skills/writer.ts +3 -3
- package/server/workspace/sources/arxivDiscovery.ts +2 -2
- package/server/workspace/sources/fetchers/rss.ts +5 -5
- package/server/workspace/sources/fetchers/rssParser.ts +4 -4
- package/server/workspace/sources/interests.ts +3 -3
- package/server/workspace/sources/paths.ts +6 -6
- package/server/workspace/sources/pipeline/fetch.ts +36 -13
- package/server/workspace/sources/pipeline/index.ts +2 -7
- package/server/workspace/sources/pipeline/notify.ts +3 -3
- package/server/workspace/sources/pipeline/plan.ts +11 -9
- package/server/workspace/sources/pipeline/write.ts +5 -5
- package/server/workspace/sources/rateLimiter.ts +1 -1
- package/server/workspace/sources/sourceState.ts +9 -4
- package/server/workspace/sources/types.ts +9 -0
- package/server/workspace/sources/urls.ts +1 -1
- package/server/workspace/tool-trace/classify.ts +4 -4
- package/server/workspace/workspace.ts +7 -7
- package/src/App.vue +286 -112
- package/src/components/CanvasViewToggle.vue +10 -7
- package/src/components/ChatInput.vue +60 -26
- package/src/components/FileContentHeader.vue +7 -4
- package/src/components/FileContentRenderer.vue +20 -6
- package/src/components/FileTree.vue +6 -3
- package/src/components/FileTreePane.vue +11 -8
- package/src/components/FilesView.vue +5 -3
- package/src/components/LockStatusPopup.vue +15 -12
- package/src/components/NotificationBell.vue +14 -5
- package/src/components/NotificationToast.vue +4 -1
- package/src/components/PluginLauncher.vue +19 -56
- package/src/components/RightSidebar.vue +13 -10
- package/src/components/SessionHistoryPanel.vue +33 -29
- package/src/components/SessionTabBar.vue +8 -10
- package/src/components/SettingsMcpTab.vue +43 -30
- package/src/components/SettingsModal.vue +21 -19
- package/src/components/SettingsReferenceDirsTab.vue +29 -24
- package/src/components/SettingsWorkspaceDirsTab.vue +32 -22
- package/src/components/SidebarHeader.vue +25 -4
- package/src/components/StackView.vue +4 -1
- package/src/components/SuggestionsPanel.vue +5 -2
- package/src/components/TodoExplorer.vue +26 -15
- package/src/components/ToolResultsPanel.vue +27 -13
- package/src/components/todo/TodoAddDialog.vue +17 -12
- package/src/components/todo/TodoEditDialog.vue +7 -2
- package/src/components/todo/TodoEditPanel.vue +15 -10
- package/src/components/todo/TodoKanbanView.vue +10 -5
- package/src/components/todo/TodoListView.vue +5 -2
- package/src/components/todo/TodoTableView.vue +5 -2
- package/src/composables/useAppApi.ts +9 -0
- package/src/composables/useDynamicFavicon.ts +172 -37
- package/src/composables/useEventListeners.ts +7 -8
- package/src/composables/useFaviconState.ts +13 -2
- package/src/composables/useFileSelection.ts +24 -6
- package/src/composables/useLayoutMode.ts +32 -0
- package/src/composables/useSessionHistory.ts +7 -17
- package/src/composables/useViewLayout.ts +20 -34
- package/src/lang/de.ts +536 -0
- package/src/lang/en.ts +558 -0
- package/src/lang/es.ts +543 -0
- package/src/lang/fr.ts +536 -0
- package/src/lang/ja.ts +536 -0
- package/src/lang/ko.ts +540 -0
- package/src/lang/pt-BR.ts +534 -0
- package/src/lang/zh.ts +537 -0
- package/src/lib/vue-i18n.ts +97 -0
- package/src/main.ts +2 -0
- package/src/plugins/canvas/View.vue +102 -186
- package/src/plugins/canvas/definition.ts +0 -8
- package/src/plugins/chart/Preview.vue +1 -1
- package/src/plugins/chart/View.vue +9 -4
- package/src/plugins/manageRoles/Preview.vue +4 -1
- package/src/plugins/manageRoles/View.vue +59 -43
- package/src/plugins/manageSkills/Preview.vue +8 -3
- package/src/plugins/manageSkills/View.vue +26 -22
- package/src/plugins/manageSource/Preview.vue +1 -1
- package/src/plugins/manageSource/View.vue +73 -52
- package/src/plugins/markdown/Preview.vue +1 -1
- package/src/plugins/markdown/View.vue +24 -34
- package/src/plugins/presentHtml/Preview.vue +1 -1
- package/src/plugins/presentHtml/View.vue +7 -4
- package/src/plugins/presentMulmoScript/Preview.vue +1 -1
- package/src/plugins/presentMulmoScript/View.vue +36 -26
- package/src/plugins/scheduler/Preview.vue +7 -4
- package/src/plugins/scheduler/TasksTab.vue +53 -24
- package/src/plugins/scheduler/View.vue +28 -19
- package/src/plugins/scheduler/formatSchedule.ts +93 -0
- package/src/plugins/spreadsheet/Preview.vue +8 -3
- package/src/plugins/spreadsheet/View.vue +21 -12
- package/src/plugins/textResponse/Preview.vue +15 -58
- package/src/plugins/textResponse/View.vue +27 -7
- package/src/plugins/todo/Preview.vue +11 -6
- package/src/plugins/todo/View.vue +27 -13
- package/src/plugins/ui-image/ImagePreview.vue +6 -3
- package/src/plugins/ui-image/ImageView.vue +7 -4
- package/src/plugins/wiki/Preview.vue +5 -2
- package/src/plugins/wiki/View.vue +202 -81
- package/src/plugins/wiki/route.ts +112 -0
- package/src/router/guards.ts +42 -24
- package/src/router/index.ts +41 -26
- package/src/types/vue-i18n.d.ts +20 -0
- package/src/utils/agent/request.ts +19 -0
- package/src/utils/canvas/layoutMode.ts +26 -0
- package/src/utils/image/cacheBust.ts +16 -0
- package/src/utils/image/resolve.ts +16 -0
- package/src/utils/path/workspaceLinkRouter.ts +81 -0
- package/src/vite-env.d.ts +9 -0
- package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
- package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
- package/client/assets/index-Bm70FDU2.css +0 -1
- package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
- package/src/composables/useCanvasViewMode.ts +0 -121
- package/src/utils/canvas/viewMode.ts +0 -46
- /package/client/assets/{purify.es-Fx1Nqyry-PeS5RUhs.js → purify.es-Fx1Nqyry-BwJECkqS.js} +0 -0
|
@@ -3,48 +3,53 @@
|
|
|
3
3
|
<!-- Header -->
|
|
4
4
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 shrink-0">
|
|
5
5
|
<div class="flex items-center gap-3">
|
|
6
|
-
<button v-if="action !== 'index'" class="text-gray-400 hover:text-gray-700" title="
|
|
6
|
+
<button v-if="action !== 'index'" class="text-gray-400 hover:text-gray-700" :title="t('pluginWiki.backToIndex')" @click="router.back()">
|
|
7
7
|
<span class="material-icons text-base">arrow_back</span>
|
|
8
8
|
</button>
|
|
9
9
|
<h2 class="text-lg font-semibold text-gray-800">{{ title }}</h2>
|
|
10
10
|
</div>
|
|
11
11
|
<div class="flex gap-1 items-center">
|
|
12
12
|
<template v-if="action === 'page' && content">
|
|
13
|
+
<div class="button-group">
|
|
14
|
+
<button class="download-btn download-btn-green" :disabled="pdfDownloading" @click="downloadPdf">
|
|
15
|
+
<span class="material-icons">{{ pdfDownloading ? "hourglass_empty" : "download" }}</span>
|
|
16
|
+
{{ t("pluginWiki.pdf") }}
|
|
17
|
+
</button>
|
|
18
|
+
</div>
|
|
19
|
+
<span v-if="pdfError" class="text-xs text-red-500 self-center ml-2" :title="pdfError">{{ t("pluginWiki.pdfFailed") }}</span>
|
|
20
|
+
</template>
|
|
21
|
+
<div class="flex border border-gray-300 rounded overflow-hidden text-xs">
|
|
13
22
|
<button
|
|
14
|
-
class="
|
|
15
|
-
|
|
16
|
-
|
|
23
|
+
:class="[
|
|
24
|
+
'px-2.5 py-1 flex items-center gap-1 border-r border-gray-200 last:border-r-0 transition-colors',
|
|
25
|
+
action === 'index' ? 'bg-blue-50 text-blue-600 font-medium' : 'bg-white text-gray-600 hover:bg-gray-50',
|
|
26
|
+
]"
|
|
27
|
+
@click="navigate('index')"
|
|
17
28
|
>
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
|
21
|
-
</svg>
|
|
22
|
-
<span v-else>↓ PDF</span>
|
|
23
|
-
<span v-if="pdfDownloading">PDF</span>
|
|
29
|
+
<span class="material-icons text-sm">list</span>
|
|
30
|
+
<span>{{ t("pluginWiki.tabIndex") }}</span>
|
|
24
31
|
</button>
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
>
|
|
46
|
-
Lint
|
|
47
|
-
</button>
|
|
32
|
+
<button
|
|
33
|
+
:class="[
|
|
34
|
+
'px-2.5 py-1 flex items-center gap-1 border-r border-gray-200 last:border-r-0 transition-colors',
|
|
35
|
+
action === 'log' ? 'bg-blue-50 text-blue-600 font-medium' : 'bg-white text-gray-600 hover:bg-gray-50',
|
|
36
|
+
]"
|
|
37
|
+
@click="navigate('log')"
|
|
38
|
+
>
|
|
39
|
+
<span class="material-icons text-sm">history</span>
|
|
40
|
+
<span>{{ t("pluginWiki.tabLog") }}</span>
|
|
41
|
+
</button>
|
|
42
|
+
<button
|
|
43
|
+
:class="[
|
|
44
|
+
'px-2.5 py-1 flex items-center gap-1 border-r border-gray-200 last:border-r-0 transition-colors',
|
|
45
|
+
action === 'lint_report' ? 'bg-blue-50 text-blue-600 font-medium' : 'bg-white text-gray-600 hover:bg-gray-50',
|
|
46
|
+
]"
|
|
47
|
+
@click="navigate('lint_report')"
|
|
48
|
+
>
|
|
49
|
+
<span class="material-icons text-sm">rule</span>
|
|
50
|
+
<span>{{ t("pluginWiki.tabLint") }}</span>
|
|
51
|
+
</button>
|
|
52
|
+
</div>
|
|
48
53
|
</div>
|
|
49
54
|
</div>
|
|
50
55
|
|
|
@@ -57,42 +62,86 @@
|
|
|
57
62
|
<div v-if="!content && !navError" class="flex-1 flex items-center justify-center text-gray-400 text-sm">
|
|
58
63
|
<div class="text-center space-y-2">
|
|
59
64
|
<span class="material-icons text-4xl text-gray-300">menu_book</span>
|
|
60
|
-
<p>
|
|
65
|
+
<p>{{ t("pluginWiki.empty") }}</p>
|
|
61
66
|
</div>
|
|
62
67
|
</div>
|
|
63
68
|
|
|
64
69
|
<!-- Index: page card list -->
|
|
65
|
-
<div v-else-if="action === 'index' && pageEntries && pageEntries.length > 0" class="flex-1 overflow-y-auto
|
|
70
|
+
<div v-else-if="action === 'index' && pageEntries && pageEntries.length > 0" class="flex-1 overflow-y-auto">
|
|
66
71
|
<div
|
|
67
72
|
v-for="entry in pageEntries"
|
|
68
73
|
:key="entry.slug"
|
|
69
|
-
class="
|
|
74
|
+
class="flex items-baseline gap-2 px-4 py-1 cursor-pointer hover:bg-blue-50 transition-colors"
|
|
75
|
+
:data-testid="`wiki-page-entry-${entry.slug || entry.title}`"
|
|
70
76
|
@click="navigatePage(entry.slug || entry.title)"
|
|
71
77
|
>
|
|
72
|
-
<
|
|
73
|
-
<
|
|
78
|
+
<span class="font-medium text-sm text-gray-800 shrink-0">{{ entry.title }}</span>
|
|
79
|
+
<span v-if="entry.description" class="text-xs text-gray-500 truncate">
|
|
74
80
|
{{ entry.description }}
|
|
75
|
-
</
|
|
81
|
+
</span>
|
|
76
82
|
</div>
|
|
77
83
|
</div>
|
|
78
84
|
|
|
79
85
|
<!-- Markdown content -->
|
|
80
86
|
<div v-else class="flex-1 overflow-y-auto px-6 py-4 prose prose-sm max-w-none wiki-content" @click="handleContentClick" v-html="renderedContent" />
|
|
87
|
+
|
|
88
|
+
<!-- Per-page chat composer (standalone /wiki route only). Sending
|
|
89
|
+
spawns a fresh chat session with a prepended "read this page
|
|
90
|
+
first" instruction — see AppApi.startNewChat. Hidden when
|
|
91
|
+
WikiView is mounted as a manageWiki tool result inside /chat:
|
|
92
|
+
the enclosing chat already has its own composer, and spawning
|
|
93
|
+
a nested new session from there is confusing. -->
|
|
94
|
+
<div v-if="action === 'page' && content && isStandaloneWikiRoute" class="border-t border-gray-200 px-4 py-3 shrink-0 bg-gray-50">
|
|
95
|
+
<div class="flex gap-2">
|
|
96
|
+
<textarea
|
|
97
|
+
v-model="chatDraft"
|
|
98
|
+
data-testid="wiki-page-chat-input"
|
|
99
|
+
:placeholder="t('pluginWiki.chatPlaceholder')"
|
|
100
|
+
rows="2"
|
|
101
|
+
class="flex-1 bg-white border border-gray-300 rounded px-3 py-2 text-sm text-gray-900 placeholder-gray-400 resize-none"
|
|
102
|
+
@compositionstart="imeEnter.onCompositionStart"
|
|
103
|
+
@compositionend="imeEnter.onCompositionEnd"
|
|
104
|
+
@keydown="imeEnter.onKeydown"
|
|
105
|
+
@blur="imeEnter.onBlur"
|
|
106
|
+
/>
|
|
107
|
+
<button
|
|
108
|
+
data-testid="wiki-page-chat-send"
|
|
109
|
+
class="bg-blue-600 hover:bg-blue-700 text-white rounded w-8 h-8 flex items-center justify-center shrink-0 disabled:opacity-50 disabled:cursor-not-allowed self-start"
|
|
110
|
+
:title="t('pluginWiki.chatSend')"
|
|
111
|
+
:disabled="!canSendChat"
|
|
112
|
+
@click="submitChat"
|
|
113
|
+
>
|
|
114
|
+
<span class="material-icons text-base leading-none">send</span>
|
|
115
|
+
</button>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
81
118
|
</div>
|
|
82
119
|
</template>
|
|
83
120
|
|
|
84
121
|
<script setup lang="ts">
|
|
85
|
-
import { computed, ref, watch } from "vue";
|
|
122
|
+
import { computed, onMounted, ref, watch } from "vue";
|
|
123
|
+
import { useRoute, useRouter, isNavigationFailure } from "vue-router";
|
|
124
|
+
import { useI18n } from "vue-i18n";
|
|
86
125
|
import { marked } from "marked";
|
|
87
126
|
import type { ToolResultComplete } from "gui-chat-protocol/vue";
|
|
88
127
|
import type { WikiData, WikiPageEntry } from "./index";
|
|
89
128
|
import { handleExternalLinkClick } from "../../utils/dom/externalLink";
|
|
90
129
|
import { useFreshPluginData } from "../../composables/useFreshPluginData";
|
|
130
|
+
import { useImeAwareEnter } from "../../composables/useImeAwareEnter";
|
|
131
|
+
import { usePdfDownload } from "../../composables/usePdfDownload";
|
|
132
|
+
import { useAppApi } from "../../composables/useAppApi";
|
|
91
133
|
import { renderWikiLinks } from "./helpers";
|
|
92
134
|
import { rewriteMarkdownImageRefs } from "../../utils/image/rewriteMarkdownImageRefs";
|
|
93
|
-
import { apiPost
|
|
135
|
+
import { apiPost } from "../../utils/api";
|
|
94
136
|
import { API_ROUTES } from "../../config/apiRoutes";
|
|
95
|
-
import {
|
|
137
|
+
import { PAGE_ROUTES } from "../../router";
|
|
138
|
+
import { WIKI_ACTION, WIKI_ROUTE_SECTION, buildWikiRouteParams, isSafeWikiSlug, readWikiRouteTarget, wikiActionFor, type WikiTarget } from "./route";
|
|
139
|
+
|
|
140
|
+
type WikiTabView = typeof WIKI_ACTION.log | typeof WIKI_ACTION.lintReport;
|
|
141
|
+
|
|
142
|
+
const route = useRoute();
|
|
143
|
+
const router = useRouter();
|
|
144
|
+
const { t } = useI18n();
|
|
96
145
|
|
|
97
146
|
const props = defineProps<{
|
|
98
147
|
selectedResult?: ToolResultComplete<WikiData>;
|
|
@@ -104,8 +153,14 @@ const action = ref(props.selectedResult?.data?.action ?? "index");
|
|
|
104
153
|
const title = ref(props.selectedResult?.data?.title ?? "Wiki");
|
|
105
154
|
const content = ref(props.selectedResult?.data?.content ?? "");
|
|
106
155
|
const pageEntries = ref<WikiPageEntry[]>(props.selectedResult?.data?.pageEntries ?? []);
|
|
156
|
+
// Declared up here — not next to callApi — because the URL watcher
|
|
157
|
+
// below fires with `immediate: true`, which invokes callApi
|
|
158
|
+
// synchronously during setup. If this ref were declared after the
|
|
159
|
+
// watcher, callApi's `navError.value = null` would hit the TDZ on
|
|
160
|
+
// direct loads of /wiki and the fetch would never run.
|
|
161
|
+
const navError = ref<string | null>(null);
|
|
107
162
|
|
|
108
|
-
const { refresh } = useFreshPluginData<WikiData>({
|
|
163
|
+
const { refresh, abort: abortFreshFetch } = useFreshPluginData<WikiData>({
|
|
109
164
|
// Slug-aware: when the view is currently showing a specific page,
|
|
110
165
|
// fetch that page by slug; otherwise fetch the index.
|
|
111
166
|
endpoint: () => {
|
|
@@ -121,6 +176,15 @@ const { refresh } = useFreshPluginData<WikiData>({
|
|
|
121
176
|
},
|
|
122
177
|
});
|
|
123
178
|
|
|
179
|
+
onMounted(() => {
|
|
180
|
+
// On /wiki, the route watcher below fires with `immediate: true` and
|
|
181
|
+
// is the source of truth for the initial fetch (via POST callApi).
|
|
182
|
+
// useFreshPluginData's mount fetch is GET-only and always returns
|
|
183
|
+
// the index payload — if it resolves last, it clobbers log / lint /
|
|
184
|
+
// page state. Cancel it here so the two can't race.
|
|
185
|
+
if (route.name === PAGE_ROUTES.wiki) abortFreshFetch();
|
|
186
|
+
});
|
|
187
|
+
|
|
124
188
|
watch(
|
|
125
189
|
() => props.selectedResult?.uuid,
|
|
126
190
|
() => {
|
|
@@ -135,6 +199,30 @@ watch(
|
|
|
135
199
|
},
|
|
136
200
|
);
|
|
137
201
|
|
|
202
|
+
// URL is the single source of truth for wiki navigation. Button
|
|
203
|
+
// handlers push to the router; this watcher drives callApi(). Only
|
|
204
|
+
// runs when WikiView is mounted as the /wiki page — when mounted as
|
|
205
|
+
// a manageWiki tool-result inside /chat, the tool-result watcher
|
|
206
|
+
// above seeds state and this watcher does nothing. Unsafe params
|
|
207
|
+
// (e.g. `/wiki/pages/..%2Fsecrets` decoded to `slug === "../secrets"`)
|
|
208
|
+
// are already intercepted by the router guard in `router/guards.ts`
|
|
209
|
+
// and redirected to `/wiki`; by the time the watcher fires, the
|
|
210
|
+
// params are known-safe. `readWikiRouteTarget` returning `null` here
|
|
211
|
+
// therefore means an unexpected shape — fall back to the index view.
|
|
212
|
+
watch(
|
|
213
|
+
() => (route.name === PAGE_ROUTES.wiki ? [route.params.section, route.params.slug] : null),
|
|
214
|
+
(params) => {
|
|
215
|
+
if (!params) return;
|
|
216
|
+
const target = readWikiRouteTarget({ section: params[0], slug: params[1] }) ?? { kind: "index" };
|
|
217
|
+
if (target.kind === "page") {
|
|
218
|
+
callApi({ action: WIKI_ACTION.page, pageName: target.slug });
|
|
219
|
+
} else {
|
|
220
|
+
callApi({ action: wikiActionFor(target) });
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
{ immediate: true },
|
|
224
|
+
);
|
|
225
|
+
|
|
138
226
|
const renderedContent = computed(() => {
|
|
139
227
|
if (!content.value) return "";
|
|
140
228
|
// Rewrite workspace-relative image refs (``)
|
|
@@ -147,9 +235,11 @@ const renderedContent = computed(() => {
|
|
|
147
235
|
return marked.parse(renderWikiLinks(withImages)) as string;
|
|
148
236
|
});
|
|
149
237
|
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
238
|
+
const { pdfDownloading, pdfError, downloadPdf: rawDownloadPdf } = usePdfDownload();
|
|
239
|
+
|
|
240
|
+
async function downloadPdf() {
|
|
241
|
+
await rawDownloadPdf(content.value, `${title.value}.pdf`);
|
|
242
|
+
}
|
|
153
243
|
|
|
154
244
|
async function callApi(body: Record<string, unknown>) {
|
|
155
245
|
navError.value = null;
|
|
@@ -180,48 +270,54 @@ async function callApi(body: Record<string, unknown>) {
|
|
|
180
270
|
}
|
|
181
271
|
}
|
|
182
272
|
|
|
183
|
-
function
|
|
184
|
-
|
|
273
|
+
function pushWiki(target: WikiTarget) {
|
|
274
|
+
router.push({ name: PAGE_ROUTES.wiki, params: buildWikiRouteParams(target) }).catch((err: unknown) => {
|
|
275
|
+
if (!isNavigationFailure(err)) {
|
|
276
|
+
console.error("[wiki] navigation failed:", err);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function navigate(newAction: typeof WIKI_ACTION.index | WikiTabView) {
|
|
282
|
+
pushWiki(newAction === WIKI_ACTION.index ? { kind: "index" } : { kind: newAction });
|
|
185
283
|
}
|
|
186
284
|
|
|
187
285
|
function navigatePage(pageName: string) {
|
|
188
|
-
|
|
286
|
+
pushWiki({ kind: "page", slug: pageName });
|
|
189
287
|
}
|
|
190
288
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
anchor.download = `${title.value}.pdf`;
|
|
220
|
-
anchor.click();
|
|
221
|
-
URL.revokeObjectURL(url);
|
|
222
|
-
pdfDownloading.value = false;
|
|
289
|
+
// --- Per-page chat composer ---
|
|
290
|
+
const appApi = useAppApi();
|
|
291
|
+
const chatDraft = ref("");
|
|
292
|
+
|
|
293
|
+
const isStandaloneWikiRoute = computed(() => route.name === PAGE_ROUTES.wiki);
|
|
294
|
+
const canSendChat = computed(() => chatDraft.value.trim().length > 0 && currentSlug() !== null);
|
|
295
|
+
|
|
296
|
+
function currentSlug(): string | null {
|
|
297
|
+
// Prefer the URL on /wiki (source of truth for that route); fall
|
|
298
|
+
// back to the tool-result payload when WikiView is mounted as a
|
|
299
|
+
// manageWiki result inside /chat. `isSafeWikiSlug` guards against
|
|
300
|
+
// traversal tokens — the router guard already strips these from
|
|
301
|
+
// standalone /wiki URLs, but the tool-result payload arrives from
|
|
302
|
+
// the server/agent and can't assume that upstream filter.
|
|
303
|
+
const raw =
|
|
304
|
+
route.name === PAGE_ROUTES.wiki && route.params.section === WIKI_ROUTE_SECTION.pages && typeof route.params.slug === "string"
|
|
305
|
+
? route.params.slug
|
|
306
|
+
: (props.selectedResult?.data?.pageName ?? null);
|
|
307
|
+
return isSafeWikiSlug(raw) ? raw : null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function submitChat() {
|
|
311
|
+
const text = chatDraft.value.trim();
|
|
312
|
+
const slug = currentSlug();
|
|
313
|
+
if (!text || !slug) return;
|
|
314
|
+
const prompt = `Before answering, read the wiki page at data/wiki/pages/${slug}.md.\n\n${text}`;
|
|
315
|
+
chatDraft.value = "";
|
|
316
|
+
appApi.startNewChat(prompt);
|
|
223
317
|
}
|
|
224
318
|
|
|
319
|
+
const imeEnter = useImeAwareEnter(submitChat);
|
|
320
|
+
|
|
225
321
|
function handleContentClick(event: MouseEvent) {
|
|
226
322
|
// 1. Internal wiki links: `[[Page Name]]` was rewritten to a
|
|
227
323
|
// `<span class="wiki-link">` during markdown pre-processing,
|
|
@@ -241,6 +337,31 @@ function handleContentClick(event: MouseEvent) {
|
|
|
241
337
|
</script>
|
|
242
338
|
|
|
243
339
|
<style scoped>
|
|
340
|
+
.button-group {
|
|
341
|
+
display: flex;
|
|
342
|
+
gap: 0.5em;
|
|
343
|
+
}
|
|
344
|
+
.download-btn {
|
|
345
|
+
padding: 0.5em 1em;
|
|
346
|
+
color: white;
|
|
347
|
+
border: none;
|
|
348
|
+
border-radius: 4px;
|
|
349
|
+
cursor: pointer;
|
|
350
|
+
font-size: 0.9em;
|
|
351
|
+
display: flex;
|
|
352
|
+
align-items: center;
|
|
353
|
+
gap: 0.5em;
|
|
354
|
+
}
|
|
355
|
+
.download-btn-green {
|
|
356
|
+
background-color: #4caf50;
|
|
357
|
+
}
|
|
358
|
+
.download-btn .material-icons {
|
|
359
|
+
font-size: 1.2em;
|
|
360
|
+
}
|
|
361
|
+
.download-btn:disabled {
|
|
362
|
+
opacity: 0.6;
|
|
363
|
+
cursor: not-allowed;
|
|
364
|
+
}
|
|
244
365
|
.wiki-content :deep(.wiki-link) {
|
|
245
366
|
color: #2563eb;
|
|
246
367
|
cursor: pointer;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Pure helpers for reading, building, and validating wiki route
|
|
2
|
+
// params. Kept free of Vue / vue-router imports so it can be used
|
|
3
|
+
// from:
|
|
4
|
+
//
|
|
5
|
+
// - `src/router/guards.ts` (synchronous validation at navigation time)
|
|
6
|
+
// - `src/plugins/wiki/View.vue` (watcher + push helpers)
|
|
7
|
+
// - `src/App.vue` (workspace-link click handler)
|
|
8
|
+
// - unit tests
|
|
9
|
+
//
|
|
10
|
+
// Mirrors the pattern established by `src/composables/useFileSelection.ts`
|
|
11
|
+
// (#633): one file owns the URL ↔ domain mapping, so the literals don't
|
|
12
|
+
// drift across the router definition, guards, views, and tests.
|
|
13
|
+
|
|
14
|
+
// URL segment used in `/wiki/:section/...`. Closed enum — the router
|
|
15
|
+
// regex `(pages|log|lint-report)` rejects anything else.
|
|
16
|
+
export const WIKI_ROUTE_SECTION = {
|
|
17
|
+
pages: "pages",
|
|
18
|
+
log: "log",
|
|
19
|
+
lintReport: "lint-report",
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
export type WikiRouteSection = (typeof WIKI_ROUTE_SECTION)[keyof typeof WIKI_ROUTE_SECTION];
|
|
23
|
+
|
|
24
|
+
// Internal action name sent to the server / shown in the View. Diverges
|
|
25
|
+
// from the URL segment in one place only: `lint-report` (URL) vs
|
|
26
|
+
// `lint_report` (action), because the server API still speaks the
|
|
27
|
+
// underscore form.
|
|
28
|
+
export const WIKI_ACTION = {
|
|
29
|
+
index: "index",
|
|
30
|
+
page: "page",
|
|
31
|
+
log: "log",
|
|
32
|
+
lintReport: "lint_report",
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
export type WikiAction = (typeof WIKI_ACTION)[keyof typeof WIKI_ACTION];
|
|
36
|
+
|
|
37
|
+
// Route-level representation. `pushWiki(target)` and
|
|
38
|
+
// `readWikiRouteTarget(params)` both speak this so the watcher, the
|
|
39
|
+
// button handlers, and the router guard agree on the same shape.
|
|
40
|
+
export type WikiTarget = { kind: "index" } | { kind: "page"; slug: string } | { kind: "log" } | { kind: "lint_report" };
|
|
41
|
+
|
|
42
|
+
// Reject anything that could escape `data/wiki/pages/` or collide
|
|
43
|
+
// with a different page. Vue Router decodes `%2F` back to `/` in
|
|
44
|
+
// `route.params.slug`, so `/wiki/pages/..%2Fsecrets` lands here as
|
|
45
|
+
// `slug === "../secrets"` — this check is the last line of defence
|
|
46
|
+
// before the slug is passed to the server's page resolver
|
|
47
|
+
// (`wikiSlugify` strips `..` but would still match a page literally
|
|
48
|
+
// named `secrets` via its fuzzy fallback). Non-ASCII characters
|
|
49
|
+
// (e.g. Japanese page titles) are allowed; only separators and `..`
|
|
50
|
+
// are blocked.
|
|
51
|
+
export function isSafeWikiSlug(value: unknown): value is string {
|
|
52
|
+
if (typeof value !== "string") return false;
|
|
53
|
+
if (value.length === 0) return false;
|
|
54
|
+
if (value.includes("/")) return false;
|
|
55
|
+
if (value.includes("\\")) return false;
|
|
56
|
+
if (value.includes("..")) return false;
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Read `route.params` from the wiki route and normalise to a
|
|
61
|
+
// `WikiTarget`. Returns `null` when the params describe an invalid
|
|
62
|
+
// state (unknown section, missing slug for a page view, unsafe slug)
|
|
63
|
+
// so the caller can decide what to do — the router guard redirects to
|
|
64
|
+
// `/wiki`, the view watcher treats it as "render the index".
|
|
65
|
+
export function readWikiRouteTarget(params: unknown): WikiTarget | null {
|
|
66
|
+
if (!params || typeof params !== "object") return null;
|
|
67
|
+
const { section, slug } = params as { section?: unknown; slug?: unknown };
|
|
68
|
+
|
|
69
|
+
if (section === undefined || section === "") return { kind: "index" };
|
|
70
|
+
|
|
71
|
+
if (section === WIKI_ROUTE_SECTION.pages) {
|
|
72
|
+
if (!isSafeWikiSlug(slug)) return null;
|
|
73
|
+
return { kind: "page", slug };
|
|
74
|
+
}
|
|
75
|
+
if (section === WIKI_ROUTE_SECTION.log) return { kind: "log" };
|
|
76
|
+
if (section === WIKI_ROUTE_SECTION.lintReport) return { kind: "lint_report" };
|
|
77
|
+
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Inverse of `readWikiRouteTarget`: given a target, produce the
|
|
82
|
+
// `{ section, slug }` params object that `router.push({ name: "wiki",
|
|
83
|
+
// params })` needs. Index returns `{}` so the router strips the
|
|
84
|
+
// optional segments and lands on `/wiki`.
|
|
85
|
+
export function buildWikiRouteParams(target: WikiTarget): Record<string, string> {
|
|
86
|
+
switch (target.kind) {
|
|
87
|
+
case "index":
|
|
88
|
+
return {};
|
|
89
|
+
case "page":
|
|
90
|
+
return { section: WIKI_ROUTE_SECTION.pages, slug: target.slug };
|
|
91
|
+
case "log":
|
|
92
|
+
return { section: WIKI_ROUTE_SECTION.log };
|
|
93
|
+
case "lint_report":
|
|
94
|
+
return { section: WIKI_ROUTE_SECTION.lintReport };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Resolve a target to the action name the server expects. Centralises
|
|
99
|
+
// the one place URL-shape and action-shape diverge (`lint-report` ↔
|
|
100
|
+
// `lint_report`).
|
|
101
|
+
export function wikiActionFor(target: WikiTarget): WikiAction {
|
|
102
|
+
switch (target.kind) {
|
|
103
|
+
case "index":
|
|
104
|
+
return WIKI_ACTION.index;
|
|
105
|
+
case "page":
|
|
106
|
+
return WIKI_ACTION.page;
|
|
107
|
+
case "log":
|
|
108
|
+
return WIKI_ACTION.log;
|
|
109
|
+
case "lint_report":
|
|
110
|
+
return WIKI_ACTION.lintReport;
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/router/guards.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
// doesn't push a history entry).
|
|
8
8
|
|
|
9
9
|
import type { Router } from "vue-router";
|
|
10
|
-
import {
|
|
10
|
+
import { readPathMatch } from "../composables/useFileSelection";
|
|
11
|
+
import { readWikiRouteTarget } from "../plugins/wiki/route";
|
|
11
12
|
|
|
12
13
|
// Basic sanity check for a session ID. Real existence verification
|
|
13
14
|
// happens in App.vue's onMounted / loadSession — we can't do async
|
|
@@ -24,37 +25,54 @@ function isValidSessionId(value: unknown): boolean {
|
|
|
24
25
|
|
|
25
26
|
export function installGuards(router: Router): void {
|
|
26
27
|
router.beforeEach((dest) => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const sessionId = dest.params.sessionId;
|
|
33
|
-
if (typeof sessionId === "string" && sessionId.length > 0 && !isValidSessionId(sessionId)) {
|
|
34
|
-
// Garbage sessionId → strip it and go to /chat (new session).
|
|
35
|
-
return { name: "chat", params: {}, query: {}, replace: true };
|
|
28
|
+
if (dest.name === "chat") {
|
|
29
|
+
const sessionId = dest.params.sessionId;
|
|
30
|
+
if (typeof sessionId === "string" && sessionId.length > 0 && !isValidSessionId(sessionId)) {
|
|
31
|
+
return { name: "chat", params: {}, query: {}, replace: true };
|
|
32
|
+
}
|
|
36
33
|
}
|
|
37
34
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
35
|
+
if (dest.name === "wiki") {
|
|
36
|
+
// Vue Router decodes `%2F` back to `/` in `route.params.slug`,
|
|
37
|
+
// so `/wiki/pages/..%2Fsecrets` arrives here as
|
|
38
|
+
// `{ section: "pages", slug: "../secrets" }`. `readWikiRouteTarget`
|
|
39
|
+
// returns `null` for unsafe slugs, a missing slug on the `pages`
|
|
40
|
+
// section, or an unknown section. In any of those cases, bounce
|
|
41
|
+
// to `/wiki` with `replace: true` so no broken URL lands in
|
|
42
|
+
// history. Legal targets fall through to the view, where the
|
|
43
|
+
// route watcher drives the fetch.
|
|
44
|
+
if (readWikiRouteTarget(dest.params) === null) {
|
|
45
|
+
return { name: "wiki", params: {}, query: dest.query, replace: true };
|
|
46
|
+
}
|
|
44
47
|
}
|
|
45
48
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
if (dest.name === "files") {
|
|
50
|
+
// Back-compat: old query-string form `/files?path=foo.md` →
|
|
51
|
+
// rewrite to the new path form `/files/foo.md`. Silent
|
|
52
|
+
// replace so bookmarks / log links keep working. Do this
|
|
53
|
+
// before the traversal check so `?path=../bad` also lands in
|
|
54
|
+
// the `..` rejection below.
|
|
55
|
+
const legacyPath = dest.query.path;
|
|
56
|
+
if (typeof legacyPath === "string" && legacyPath.length > 0) {
|
|
50
57
|
const cleaned = { ...dest.query };
|
|
51
58
|
delete cleaned.path;
|
|
52
|
-
return {
|
|
59
|
+
return {
|
|
60
|
+
name: "files",
|
|
61
|
+
params: { pathMatch: legacyPath.split("/") },
|
|
62
|
+
query: cleaned,
|
|
63
|
+
replace: true,
|
|
64
|
+
};
|
|
53
65
|
}
|
|
54
66
|
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
67
|
+
// Traversal / absolute-path rejection against the new param.
|
|
68
|
+
const filePath = readPathMatch(dest.params.pathMatch);
|
|
69
|
+
if (typeof filePath === "string" && (filePath.includes("..") || filePath.startsWith("/"))) {
|
|
70
|
+
return {
|
|
71
|
+
name: "files",
|
|
72
|
+
params: { pathMatch: [] },
|
|
73
|
+
query: dest.query,
|
|
74
|
+
replace: true,
|
|
75
|
+
};
|
|
58
76
|
}
|
|
59
77
|
}
|
|
60
78
|
});
|