mulmoclaude 0.1.2 → 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.
Files changed (251) hide show
  1. package/bin/mulmoclaude.js +7 -24
  2. package/client/assets/html2canvas-Cx501zZr-Cv5snK9D.js +5 -0
  3. package/client/assets/index-CubzmCVK.css +2 -0
  4. package/client/assets/{index-D8rhwXLq.js → index-DtcyExH9.js} +80 -61
  5. package/client/assets/{index.es-D4YyL_Dg-BfRHLTZV.js → index.es-D4YyL_Dg-DnizuhIY.js} +5 -5
  6. package/client/index.html +2 -4
  7. package/package.json +13 -13
  8. package/server/agent/attachmentConverter.ts +2 -2
  9. package/server/agent/config.ts +12 -12
  10. package/server/agent/index.ts +9 -3
  11. package/server/agent/mcp-server.ts +19 -19
  12. package/server/agent/mcp-tools/index.ts +6 -6
  13. package/server/agent/mcp-tools/x.ts +7 -6
  14. package/server/agent/prompt.ts +195 -29
  15. package/server/agent/resumeFailover.ts +5 -5
  16. package/server/agent/sandboxMounts.ts +10 -10
  17. package/server/agent/stream.ts +4 -4
  18. package/server/api/auth/bearerAuth.ts +3 -3
  19. package/server/api/auth/token.ts +2 -2
  20. package/server/api/routes/agent.ts +21 -3
  21. package/server/api/routes/config.ts +1 -1
  22. package/server/api/routes/files.ts +22 -21
  23. package/server/api/routes/html.ts +2 -2
  24. package/server/api/routes/image.ts +7 -7
  25. package/server/api/routes/mulmo-script.ts +33 -31
  26. package/server/api/routes/pdf.ts +2 -2
  27. package/server/api/routes/plugins.ts +16 -6
  28. package/server/api/routes/roles.ts +2 -2
  29. package/server/api/routes/scheduler.ts +14 -12
  30. package/server/api/routes/schedulerHandlers.ts +12 -12
  31. package/server/api/routes/schedulerTasks.ts +19 -17
  32. package/server/api/routes/sessions.ts +26 -26
  33. package/server/api/routes/sessionsCursor.ts +4 -4
  34. package/server/api/routes/skills.ts +5 -5
  35. package/server/api/routes/sources.ts +3 -3
  36. package/server/api/routes/todosColumnsHandlers.ts +30 -30
  37. package/server/api/routes/todosHandlers.ts +1 -1
  38. package/server/api/routes/todosItemsHandlers.ts +14 -14
  39. package/server/api/routes/wiki.ts +36 -22
  40. package/server/api/sandboxStatus.ts +1 -1
  41. package/server/events/notifications.ts +6 -6
  42. package/server/events/pub-sub/index.ts +3 -3
  43. package/server/events/relay-client.ts +17 -16
  44. package/server/events/scheduler-adapter.ts +20 -20
  45. package/server/events/session-store/index.ts +10 -10
  46. package/server/events/task-manager/index.ts +7 -7
  47. package/server/index.ts +59 -65
  48. package/server/system/config.ts +5 -5
  49. package/server/system/credentials.ts +7 -5
  50. package/server/system/env.ts +5 -5
  51. package/server/utils/date.ts +18 -18
  52. package/server/utils/files/atomic.ts +16 -16
  53. package/server/utils/files/html-io.ts +5 -5
  54. package/server/utils/files/image-store.ts +19 -8
  55. package/server/utils/files/journal-io.ts +4 -4
  56. package/server/utils/files/json.ts +5 -5
  57. package/server/utils/files/markdown-store.ts +4 -4
  58. package/server/utils/files/naming.ts +2 -2
  59. package/server/utils/files/reference-dirs-io.ts +3 -3
  60. package/server/utils/files/roles-io.ts +12 -12
  61. package/server/utils/files/safe.ts +14 -14
  62. package/server/utils/files/scheduler-io.ts +5 -5
  63. package/server/utils/files/scheduler-overrides-io.ts +2 -2
  64. package/server/utils/files/session-io.ts +35 -35
  65. package/server/utils/files/spreadsheet-store.ts +7 -7
  66. package/server/utils/files/todos-io.ts +9 -9
  67. package/server/utils/files/user-tasks-io.ts +5 -5
  68. package/server/utils/files/workspace-io.ts +12 -12
  69. package/server/utils/gemini.ts +2 -2
  70. package/server/utils/gitignore.ts +9 -9
  71. package/server/utils/json.ts +5 -5
  72. package/server/utils/logBackgroundError.ts +12 -3
  73. package/server/utils/markdown.ts +5 -5
  74. package/server/utils/port.d.mts +6 -0
  75. package/server/utils/port.mjs +48 -0
  76. package/server/utils/request.ts +12 -6
  77. package/server/utils/spawn.ts +1 -1
  78. package/server/utils/types.ts +2 -2
  79. package/server/workspace/chat-index/indexer.ts +15 -15
  80. package/server/workspace/chat-index/summarizer.ts +4 -4
  81. package/server/workspace/custom-dirs.ts +16 -16
  82. package/server/workspace/journal/archivist.ts +35 -35
  83. package/server/workspace/journal/dailyPass.ts +31 -28
  84. package/server/workspace/journal/diff.ts +2 -2
  85. package/server/workspace/journal/index.ts +4 -4
  86. package/server/workspace/journal/indexFile.ts +29 -25
  87. package/server/workspace/journal/optimizationPass.ts +2 -2
  88. package/server/workspace/journal/state.ts +6 -6
  89. package/server/workspace/paths.ts +3 -3
  90. package/server/workspace/reference-dirs.ts +20 -20
  91. package/server/workspace/roles.ts +6 -6
  92. package/server/workspace/skills/discovery.ts +4 -4
  93. package/server/workspace/skills/parser.ts +6 -6
  94. package/server/workspace/skills/scheduler.ts +3 -3
  95. package/server/workspace/skills/user-tasks.ts +34 -34
  96. package/server/workspace/skills/writer.ts +3 -3
  97. package/server/workspace/sources/arxivDiscovery.ts +10 -10
  98. package/server/workspace/sources/classifier.ts +7 -7
  99. package/server/workspace/sources/fetchers/arxiv.ts +7 -7
  100. package/server/workspace/sources/fetchers/githubIssues.ts +7 -7
  101. package/server/workspace/sources/fetchers/githubReleases.ts +7 -7
  102. package/server/workspace/sources/fetchers/rss.ts +5 -5
  103. package/server/workspace/sources/fetchers/rssParser.ts +4 -4
  104. package/server/workspace/sources/interests.ts +12 -12
  105. package/server/workspace/sources/paths.ts +6 -6
  106. package/server/workspace/sources/pipeline/fetch.ts +36 -13
  107. package/server/workspace/sources/pipeline/index.ts +8 -13
  108. package/server/workspace/sources/pipeline/notify.ts +3 -3
  109. package/server/workspace/sources/pipeline/plan.ts +15 -13
  110. package/server/workspace/sources/pipeline/write.ts +5 -5
  111. package/server/workspace/sources/rateLimiter.ts +1 -1
  112. package/server/workspace/sources/registry.ts +16 -16
  113. package/server/workspace/sources/robots.ts +14 -14
  114. package/server/workspace/sources/sourceState.ts +17 -10
  115. package/server/workspace/sources/types.ts +9 -0
  116. package/server/workspace/sources/urls.ts +1 -1
  117. package/server/workspace/tool-trace/classify.ts +4 -4
  118. package/server/workspace/tool-trace/index.ts +1 -1
  119. package/server/workspace/tool-trace/writeSearch.ts +26 -16
  120. package/server/workspace/wiki-backlinks/index.ts +8 -8
  121. package/server/workspace/wiki-backlinks/sessionBacklinks.ts +15 -15
  122. package/server/workspace/workspace.ts +7 -7
  123. package/src/App.vue +315 -141
  124. package/src/components/CanvasViewToggle.vue +10 -7
  125. package/src/components/ChatInput.vue +67 -33
  126. package/src/components/FileContentHeader.vue +7 -4
  127. package/src/components/FileContentRenderer.vue +20 -6
  128. package/src/components/FileTree.vue +6 -3
  129. package/src/components/FileTreePane.vue +11 -8
  130. package/src/components/FilesView.vue +5 -3
  131. package/src/components/LockStatusPopup.vue +17 -14
  132. package/src/components/NotificationBell.vue +14 -5
  133. package/src/components/NotificationToast.vue +6 -3
  134. package/src/components/PluginLauncher.vue +19 -56
  135. package/src/components/RightSidebar.vue +13 -10
  136. package/src/components/RoleSelector.vue +2 -2
  137. package/src/components/SessionHistoryPanel.vue +38 -34
  138. package/src/components/SessionTabBar.vue +8 -10
  139. package/src/components/SettingsMcpTab.vue +49 -36
  140. package/src/components/SettingsModal.vue +24 -22
  141. package/src/components/SettingsReferenceDirsTab.vue +39 -34
  142. package/src/components/SettingsWorkspaceDirsTab.vue +37 -27
  143. package/src/components/SidebarHeader.vue +25 -4
  144. package/src/components/StackView.vue +4 -1
  145. package/src/components/SuggestionsPanel.vue +7 -4
  146. package/src/components/TodoExplorer.vue +26 -15
  147. package/src/components/ToolResultsPanel.vue +27 -13
  148. package/src/components/todo/TodoAddDialog.vue +19 -14
  149. package/src/components/todo/TodoEditDialog.vue +7 -2
  150. package/src/components/todo/TodoEditPanel.vue +17 -12
  151. package/src/components/todo/TodoKanbanView.vue +10 -5
  152. package/src/components/todo/TodoListView.vue +10 -7
  153. package/src/components/todo/TodoTableView.vue +5 -2
  154. package/src/composables/useAppApi.ts +9 -0
  155. package/src/composables/useClickOutside.ts +2 -2
  156. package/src/composables/useDynamicFavicon.ts +172 -37
  157. package/src/composables/useEventListeners.ts +7 -8
  158. package/src/composables/useFaviconState.ts +13 -2
  159. package/src/composables/useFileSelection.ts +24 -6
  160. package/src/composables/useFreshPluginData.ts +3 -3
  161. package/src/composables/useKeyNavigation.ts +11 -11
  162. package/src/composables/useLayoutMode.ts +32 -0
  163. package/src/composables/useMcpTools.ts +2 -2
  164. package/src/composables/useNotifications.ts +3 -3
  165. package/src/composables/usePdfDownload.ts +4 -4
  166. package/src/composables/usePendingCalls.ts +1 -1
  167. package/src/composables/usePubSub.ts +10 -10
  168. package/src/composables/useRoles.ts +1 -1
  169. package/src/composables/useSandboxStatus.ts +1 -1
  170. package/src/composables/useSessionDerived.ts +3 -3
  171. package/src/composables/useSessionHistory.ts +7 -17
  172. package/src/composables/useSessionSync.ts +8 -8
  173. package/src/composables/useViewLayout.ts +20 -34
  174. package/src/config/roles.ts +2 -2
  175. package/src/lang/de.ts +536 -0
  176. package/src/lang/en.ts +558 -0
  177. package/src/lang/es.ts +543 -0
  178. package/src/lang/fr.ts +536 -0
  179. package/src/lang/ja.ts +536 -0
  180. package/src/lang/ko.ts +540 -0
  181. package/src/lang/pt-BR.ts +534 -0
  182. package/src/lang/zh.ts +537 -0
  183. package/src/lib/vue-i18n.ts +97 -0
  184. package/src/main.ts +2 -0
  185. package/src/plugins/canvas/View.vue +102 -186
  186. package/src/plugins/canvas/definition.ts +0 -8
  187. package/src/plugins/chart/Preview.vue +5 -5
  188. package/src/plugins/chart/View.vue +9 -4
  189. package/src/plugins/manageRoles/Preview.vue +4 -1
  190. package/src/plugins/manageRoles/View.vue +59 -43
  191. package/src/plugins/manageSkills/Preview.vue +8 -3
  192. package/src/plugins/manageSkills/View.vue +29 -25
  193. package/src/plugins/manageSource/Preview.vue +2 -2
  194. package/src/plugins/manageSource/View.vue +73 -52
  195. package/src/plugins/markdown/Preview.vue +1 -1
  196. package/src/plugins/markdown/View.vue +26 -36
  197. package/src/plugins/presentHtml/Preview.vue +1 -1
  198. package/src/plugins/presentHtml/View.vue +7 -4
  199. package/src/plugins/presentHtml/helpers.ts +8 -8
  200. package/src/plugins/presentMulmoScript/Preview.vue +1 -1
  201. package/src/plugins/presentMulmoScript/View.vue +40 -30
  202. package/src/plugins/presentMulmoScript/helpers.ts +1 -1
  203. package/src/plugins/scheduler/Preview.vue +13 -10
  204. package/src/plugins/scheduler/TasksTab.vue +57 -28
  205. package/src/plugins/scheduler/View.vue +28 -19
  206. package/src/plugins/scheduler/formatSchedule.ts +93 -0
  207. package/src/plugins/spreadsheet/Preview.vue +8 -3
  208. package/src/plugins/spreadsheet/View.vue +21 -12
  209. package/src/plugins/textResponse/Preview.vue +15 -58
  210. package/src/plugins/textResponse/View.vue +29 -9
  211. package/src/plugins/todo/Preview.vue +13 -8
  212. package/src/plugins/todo/View.vue +38 -24
  213. package/src/plugins/todo/composables/useTodos.ts +5 -5
  214. package/src/plugins/ui-image/ImagePreview.vue +6 -3
  215. package/src/plugins/ui-image/ImageView.vue +7 -4
  216. package/src/plugins/wiki/Preview.vue +10 -7
  217. package/src/plugins/wiki/View.vue +202 -81
  218. package/src/plugins/wiki/helpers.ts +4 -4
  219. package/src/plugins/wiki/route.ts +112 -0
  220. package/src/router/guards.ts +46 -28
  221. package/src/router/index.ts +41 -26
  222. package/src/types/session.ts +4 -3
  223. package/src/types/vue-i18n.d.ts +20 -0
  224. package/src/utils/agent/request.ts +22 -3
  225. package/src/utils/canvas/layoutMode.ts +26 -0
  226. package/src/utils/dom/scrollable.ts +2 -2
  227. package/src/utils/files/expandedDirs.ts +1 -1
  228. package/src/utils/files/sortChildren.ts +6 -6
  229. package/src/utils/format/frontmatter.ts +6 -6
  230. package/src/utils/image/cacheBust.ts +16 -0
  231. package/src/utils/image/resolve.ts +16 -0
  232. package/src/utils/image/rewriteMarkdownImageRefs.ts +5 -5
  233. package/src/utils/markdown/extractFirstH1.ts +2 -2
  234. package/src/utils/path/relativeLink.ts +15 -15
  235. package/src/utils/path/workspaceLinkRouter.ts +81 -0
  236. package/src/utils/role/icon.ts +2 -2
  237. package/src/utils/role/merge.ts +2 -2
  238. package/src/utils/role/plugins.ts +1 -1
  239. package/src/utils/session/sessionFactory.ts +2 -2
  240. package/src/utils/session/sessionHelpers.ts +2 -2
  241. package/src/utils/tools/dedup.ts +4 -4
  242. package/src/utils/tools/result.ts +3 -3
  243. package/src/utils/types.ts +2 -2
  244. package/src/vite-env.d.ts +9 -0
  245. package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
  246. package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
  247. package/client/assets/index-KNLBjwuh.css +0 -1
  248. package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
  249. package/src/composables/useCanvasViewMode.ts +0 -121
  250. package/src/utils/canvas/viewMode.ts +0 -46
  251. /package/client/assets/{purify.es-Fx1Nqyry-PeS5RUhs.js → purify.es-Fx1Nqyry-BwJECkqS.js} +0 -0
@@ -1,15 +1,18 @@
1
1
  <template>
2
2
  <div class="min-h-24 flex items-center justify-center">
3
3
  <img v-if="resolvedSrc" :src="resolvedSrc" class="max-w-full h-auto rounded" :alt="alt" />
4
- <div v-else class="text-gray-400 text-sm">No image yet</div>
4
+ <div v-else class="text-gray-400 text-sm">{{ t("common.noImageYet") }}</div>
5
5
  </div>
6
6
  </template>
7
7
 
8
8
  <script setup lang="ts">
9
9
  import { computed } from "vue";
10
+ import { useI18n } from "vue-i18n";
10
11
  import type { ToolResult } from "gui-chat-protocol/vue";
11
12
  import type { ImageToolData } from "./types";
12
- import { resolveImageSrc } from "../../utils/image/resolve";
13
+ import { resolveImageSrcFresh } from "../../utils/image/resolve";
14
+
15
+ const { t } = useI18n();
13
16
 
14
17
  const props = withDefaults(
15
18
  defineProps<{
@@ -19,5 +22,5 @@ const props = withDefaults(
19
22
  { alt: "Image" },
20
23
  );
21
24
 
22
- const resolvedSrc = computed(() => (props.result.data?.imageData ? resolveImageSrc(props.result.data.imageData) : ""));
25
+ const resolvedSrc = computed(() => (props.result.data?.imageData ? resolveImageSrcFresh(props.result.data.imageData) : ""));
23
26
  </script>
@@ -4,10 +4,10 @@
4
4
  <div v-if="resolvedSrc" class="flex-1 flex items-center justify-center min-h-0">
5
5
  <img :src="resolvedSrc" class="max-w-full max-h-full object-contain rounded" :alt="alt" />
6
6
  </div>
7
- <div v-else class="flex-1 flex items-center justify-center text-gray-400 text-sm">No image yet</div>
7
+ <div v-else class="flex-1 flex items-center justify-center text-gray-400 text-sm">{{ t("common.noImageYet") }}</div>
8
8
  <div v-if="selectedResult.data?.prompt" class="mt-4 p-3 bg-gray-100 rounded-lg max-w-full flex-shrink-0">
9
9
  <p class="text-sm text-gray-700">
10
- <span class="font-medium">{{ promptLabel }}:</span>
10
+ <span class="font-medium">{{ t("pluginUiImage.promptLabel", { label: promptLabel }) }}</span>
11
11
  {{ selectedResult.data.prompt }}
12
12
  </p>
13
13
  </div>
@@ -17,9 +17,12 @@
17
17
 
18
18
  <script setup lang="ts">
19
19
  import { computed } from "vue";
20
+ import { useI18n } from "vue-i18n";
20
21
  import type { ToolResult } from "gui-chat-protocol/vue";
21
22
  import type { ImageToolData } from "./types";
22
- import { resolveImageSrc } from "../../utils/image/resolve";
23
+ import { resolveImageSrcFresh } from "../../utils/image/resolve";
24
+
25
+ const { t } = useI18n();
23
26
 
24
27
  const props = withDefaults(
25
28
  defineProps<{
@@ -30,5 +33,5 @@ const props = withDefaults(
30
33
  { alt: "Image", promptLabel: "Prompt" },
31
34
  );
32
35
 
33
- const resolvedSrc = computed(() => (props.selectedResult.data?.imageData ? resolveImageSrc(props.selectedResult.data.imageData) : ""));
36
+ const resolvedSrc = computed(() => (props.selectedResult.data?.imageData ? resolveImageSrcFresh(props.selectedResult.data.imageData) : ""));
34
37
  </script>
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div class="text-sm">
2
+ <div class="p-2 text-sm">
3
3
  <div class="flex items-center gap-1 font-medium text-gray-700 mb-1">
4
4
  <span class="material-icons" style="font-size: 14px">menu_book</span>
5
5
  <span>{{ label }}</span>
@@ -7,13 +7,16 @@
7
7
  <div v-for="entry in previewEntries" :key="entry.slug" class="text-xs text-gray-500 truncate">
8
8
  {{ entry.title }}
9
9
  </div>
10
- <div v-if="more > 0" class="text-xs text-gray-400">+ {{ more }} more…</div>
10
+ <div v-if="more > 0" class="text-xs text-gray-400">{{ t("pluginWiki.previewMore", { count: more }) }}</div>
11
11
  </div>
12
12
  </template>
13
13
 
14
14
  <script setup lang="ts">
15
15
  import { computed, ref, watch } from "vue";
16
+ import { useI18n } from "vue-i18n";
16
17
  import type { ToolResultComplete } from "gui-chat-protocol/vue";
18
+
19
+ const { t } = useI18n();
17
20
  import type { WikiData, WikiPageEntry } from "./index";
18
21
  import { useFreshPluginData } from "../../composables/useFreshPluginData";
19
22
  import { API_ROUTES } from "../../config/apiRoutes";
@@ -43,11 +46,11 @@ const { refresh } = useFreshPluginData<WikiData>({
43
46
  watch(
44
47
  () => props.result.uuid,
45
48
  () => {
46
- const d = props.result.data;
47
- if (d) {
48
- action.value = d.action ?? "index";
49
- title.value = d.title ?? "Wiki";
50
- pageEntries.value = d.pageEntries ?? [];
49
+ const wikiData = props.result.data;
50
+ if (wikiData) {
51
+ action.value = wikiData.action ?? "index";
52
+ title.value = wikiData.title ?? "Wiki";
53
+ pageEntries.value = wikiData.pageEntries ?? [];
51
54
  }
52
55
  void refresh();
53
56
  },
@@ -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="Back to index" @click="navigate('index')">
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="px-3 py-1 text-xs rounded-full border transition-colors border-gray-200 text-gray-500 hover:bg-gray-50 disabled:opacity-40 w-16 flex items-center justify-center gap-1"
15
- :disabled="pdfDownloading"
16
- @click="downloadPdf"
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
- <svg v-if="pdfDownloading" class="animate-spin w-3 h-3 shrink-0" viewBox="0 0 24 24" fill="none">
19
- <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
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
- <span v-if="pdfError" class="text-xs text-red-500" :title="pdfError">⚠ PDF failed</span>
26
- </template>
27
- <button
28
- class="px-3 py-1 text-xs rounded-full border transition-colors"
29
- :class="action === 'index' ? 'border-blue-400 bg-blue-50 text-blue-700' : 'border-gray-200 text-gray-500 hover:bg-gray-50'"
30
- @click="navigate('index')"
31
- >
32
- Index
33
- </button>
34
- <button
35
- class="px-3 py-1 text-xs rounded-full border transition-colors"
36
- :class="action === 'log' ? 'border-blue-400 bg-blue-50 text-blue-700' : 'border-gray-200 text-gray-500 hover:bg-gray-50'"
37
- @click="navigate('log')"
38
- >
39
- Log
40
- </button>
41
- <button
42
- class="px-3 py-1 text-xs rounded-full border transition-colors"
43
- :class="action === 'lint_report' ? 'border-blue-400 bg-blue-50 text-blue-700' : 'border-gray-200 text-gray-500 hover:bg-gray-50'"
44
- @click="navigate('lint_report')"
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>Wiki is empty. Ask the Wiki Manager to ingest a source.</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 p-4 space-y-2">
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="rounded-lg border border-gray-200 p-3 cursor-pointer hover:border-blue-300 hover:bg-blue-50 transition-colors"
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
- <div class="font-medium text-sm text-gray-800">{{ entry.title }}</div>
73
- <div v-if="entry.description" class="text-xs text-gray-500 mt-0.5">
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
- </div>
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, apiFetchRaw } from "../../utils/api";
135
+ import { apiPost } from "../../utils/api";
94
136
  import { API_ROUTES } from "../../config/apiRoutes";
95
- import { errorMessage } from "../../utils/errors";
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 (`![alt](images/foo.png)`)
@@ -147,9 +235,11 @@ const renderedContent = computed(() => {
147
235
  return marked.parse(renderWikiLinks(withImages)) as string;
148
236
  });
149
237
 
150
- const navError = ref<string | null>(null);
151
- const pdfDownloading = ref(false);
152
- const pdfError = ref<string | null>(null);
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 navigate(newAction: string) {
184
- callApi({ action: newAction });
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
- callApi({ action: "page", pageName });
286
+ pushWiki({ kind: "page", slug: pageName });
189
287
  }
190
288
 
191
- async function downloadPdf() {
192
- pdfError.value = null;
193
- pdfDownloading.value = true;
194
- let response: Response;
195
- try {
196
- response = await apiFetchRaw(API_ROUTES.pdf.markdown, {
197
- method: "POST",
198
- body: JSON.stringify({
199
- markdown: content.value,
200
- filename: `${title.value}.pdf`,
201
- }),
202
- headers: { "Content-Type": "application/json" },
203
- });
204
- } catch (err) {
205
- pdfError.value = errorMessage(err);
206
- pdfDownloading.value = false;
207
- return;
208
- }
209
- if (!response.ok) {
210
- const text = await response.text().catch(() => "");
211
- pdfError.value = `PDF error ${response.status}: ${text}`;
212
- pdfDownloading.value = false;
213
- return;
214
- }
215
- const blob = await response.blob();
216
- const url = URL.createObjectURL(blob);
217
- const anchor = document.createElement("a");
218
- anchor.href = url;
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;
@@ -43,11 +43,11 @@ export function renderWikiLinks(content: string): string {
43
43
  * immediately after `from` (zero-length page name, which the old
44
44
  * regex rejected via the `+` quantifier).
45
45
  */
46
- function findNextCloseBrackets(s: string, from: number): number {
46
+ function findNextCloseBrackets(str: string, from: number): number {
47
47
  let j = from;
48
- while (j < s.length) {
49
- if (s[j] === "]") {
50
- if (s[j + 1] === "]" && j > from) return j;
48
+ while (j < str.length) {
49
+ if (str[j] === "]") {
50
+ if (str[j + 1] === "]" && j > from) return j;
51
51
  // Bare `]` inside the page-name span — old regex would not
52
52
  // match here, so we bail and let the caller emit the `[[`
53
53
  // as literal text.
@@ -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
+ }