mulmoclaude 0.9.2 → 0.9.4
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-B3NxFcEH.js} +46 -46
- package/client/assets/{index-Dxo1Zdd-.css → index-BXb--as6.css} +1 -1
- package/client/assets/{marp-CSq0PPfK.js → marp-C9QDHFAJ.js} +1 -1
- package/client/index.html +2 -2
- package/package.json +11 -10
- package/server/api/routes/agent.ts +5 -1
- package/server/api/routes/chat-index.ts +5 -1
- package/server/api/routes/collections.ts +152 -1
- package/server/api/routes/config.ts +12 -0
- package/server/index.ts +14 -2
- package/server/prompts/system/system.md +7 -1
- package/server/remoteHost/firebase.ts +6 -0
- 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 +10 -0
- package/server/remoteHost/handlers/ingestAttachments.ts +88 -0
- package/server/remoteHost/handlers/listAccountingBooks.ts +31 -0
- package/server/remoteHost/handlers/listCollections.ts +6 -1
- package/server/remoteHost/handlers/listSkills.ts +39 -0
- package/server/remoteHost/handlers/mutateRemoteView.ts +40 -0
- package/server/remoteHost/handlers/startChat.ts +103 -39
- package/server/system/config.ts +101 -4
- package/server/system/logs/aaa +737 -0
- package/server/system/logs/bb +446 -0
- package/server/utils/files/thumbnail-store.ts +97 -0
- package/server/workspace/chat-index/index.ts +29 -3
- package/server/workspace/chat-index/indexer.ts +187 -29
- package/server/workspace/chat-index/summarizer.ts +18 -10
- package/server/workspace/collections/index.ts +2 -1
- package/server/workspace/collections/remoteView.ts +290 -0
- package/server/workspace/journal/archivist-cli.ts +17 -3
- package/server/workspace/journal/dailyPass.ts +52 -2
- package/server/workspace/journal/index.ts +20 -4
- package/src/App.vue +62 -3
- package/src/components/FileTree.vue +21 -1
- package/src/components/FileTreePane.vue +41 -25
- package/src/components/FilesView.vue +4 -0
- package/src/components/RemoteHostControl.vue +18 -0
- package/src/components/SessionHistoryPanel.vue +24 -11
- package/src/components/SettingsChatIndexTab.vue +119 -0
- package/src/components/SettingsJournalTab.vue +117 -0
- package/src/components/SettingsModal.vue +12 -2
- package/src/composables/collections/uiHost.ts +15 -0
- package/src/composables/collections/useDynamicShortcutIcons.ts +93 -0
- package/src/composables/usePubSub.ts +40 -2
- package/src/composables/useSessionSync.ts +9 -0
- package/src/composables/useShowHiddenSystemFiles.ts +23 -0
- package/src/config/apiRoutes.ts +20 -0
- package/src/config/roles.ts +2 -2
- package/src/config/visibleWorkspaceDirs.ts +21 -0
- package/src/lang/de.ts +47 -0
- package/src/lang/en.ts +45 -0
- package/src/lang/es.ts +46 -0
- package/src/lang/fr.ts +47 -0
- package/src/lang/ja.ts +46 -0
- package/src/lang/ko.ts +45 -0
- package/src/lang/pt-BR.ts +46 -0
- package/src/lang/zh.ts +44 -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
- package/src/utils/session/sessionPreview.ts +29 -0
|
@@ -1,30 +1,42 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="w-72 flex-shrink-0 border-r border-gray-200 overflow-y-auto p-2 bg-gray-50">
|
|
3
|
-
<div class="flex justify-end items-center gap-
|
|
4
|
-
<
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
:
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
3
|
+
<div class="flex flex-wrap justify-end items-center gap-x-3 gap-y-1 pb-1 text-xs">
|
|
4
|
+
<label class="flex items-center gap-1 text-gray-500 cursor-pointer select-none" :title="t('fileTreePane.showSystemFilesTitle')">
|
|
5
|
+
<input
|
|
6
|
+
type="checkbox"
|
|
7
|
+
class="h-3.5 w-3.5"
|
|
8
|
+
data-testid="file-tree-show-system-toggle"
|
|
9
|
+
:checked="showHiddenSystem"
|
|
10
|
+
@change="(e) => emit('update:showHiddenSystem', (e.target as HTMLInputElement).checked)"
|
|
11
|
+
/>
|
|
12
|
+
{{ t("fileTreePane.showSystemFiles") }}
|
|
13
|
+
</label>
|
|
14
|
+
<div class="flex items-center gap-2">
|
|
15
|
+
<span class="text-gray-400">{{ t("fileTreePane.sort") }}</span>
|
|
16
|
+
<div class="flex border border-gray-300 rounded overflow-hidden">
|
|
17
|
+
<button
|
|
18
|
+
type="button"
|
|
19
|
+
class="h-8 px-2.5 flex items-center gap-1 transition-colors"
|
|
20
|
+
:class="sortMode === 'name' ? 'bg-blue-50 text-blue-700 font-medium' : 'bg-white text-gray-500 hover:bg-gray-50'"
|
|
21
|
+
:aria-pressed="sortMode === 'name'"
|
|
22
|
+
:title="t('fileTreePane.sortByName')"
|
|
23
|
+
data-testid="file-sort-name"
|
|
24
|
+
@click="emit('update:sortMode', 'name')"
|
|
25
|
+
>
|
|
26
|
+
{{ t("fileTreePane.name") }}
|
|
27
|
+
</button>
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
class="h-8 px-2.5 flex items-center gap-1 transition-colors"
|
|
31
|
+
:class="sortMode === 'recent' ? 'bg-blue-50 text-blue-700 font-medium' : 'bg-white text-gray-500 hover:bg-gray-50'"
|
|
32
|
+
:aria-pressed="sortMode === 'recent'"
|
|
33
|
+
:title="t('fileTreePane.sortByRecent')"
|
|
34
|
+
data-testid="file-sort-recent"
|
|
35
|
+
@click="emit('update:sortMode', 'recent')"
|
|
36
|
+
>
|
|
37
|
+
{{ t("fileTreePane.recent") }}
|
|
38
|
+
</button>
|
|
39
|
+
</div>
|
|
28
40
|
</div>
|
|
29
41
|
</div>
|
|
30
42
|
<div v-if="treeError" class="p-2 text-xs text-red-600">
|
|
@@ -38,6 +50,7 @@
|
|
|
38
50
|
:recent-paths="recentPaths"
|
|
39
51
|
:children-by-path="childrenByPath"
|
|
40
52
|
:sort-mode="sortMode"
|
|
53
|
+
:show-hidden-system="showHiddenSystem"
|
|
41
54
|
@select="emit('select', $event)"
|
|
42
55
|
@load-children="emit('loadChildren', $event)"
|
|
43
56
|
@create-file="emit('createFile', $event)"
|
|
@@ -55,6 +68,7 @@
|
|
|
55
68
|
:recent-paths="emptySet"
|
|
56
69
|
:children-by-path="childrenByPath"
|
|
57
70
|
:sort-mode="sortMode"
|
|
71
|
+
show-hidden-system
|
|
58
72
|
@select="emit('select', $event)"
|
|
59
73
|
@load-children="emit('loadChildren', $event)"
|
|
60
74
|
/>
|
|
@@ -78,12 +92,14 @@ defineProps<{
|
|
|
78
92
|
selectedPath: string | null;
|
|
79
93
|
recentPaths: Set<string>;
|
|
80
94
|
sortMode: FileSortMode;
|
|
95
|
+
showHiddenSystem: boolean;
|
|
81
96
|
}>();
|
|
82
97
|
|
|
83
98
|
const emit = defineEmits<{
|
|
84
99
|
select: [path: string];
|
|
85
100
|
loadChildren: [path: string];
|
|
86
101
|
"update:sortMode": [mode: FileSortMode];
|
|
102
|
+
"update:showHiddenSystem": [next: boolean];
|
|
87
103
|
createFile: [args: { folder: string; filename: string; resolve: (ok: boolean, error?: string) => void }];
|
|
88
104
|
}>();
|
|
89
105
|
|
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
:selected-path="selectedPath"
|
|
10
10
|
:recent-paths="recentPaths"
|
|
11
11
|
:sort-mode="sortMode"
|
|
12
|
+
:show-hidden-system="showHiddenSystem"
|
|
12
13
|
@select="selectFile"
|
|
13
14
|
@load-children="loadDirChildren"
|
|
14
15
|
@update:sort-mode="setSortMode"
|
|
16
|
+
@update:show-hidden-system="setShowHiddenSystem"
|
|
15
17
|
@create-file="handleCreateFile"
|
|
16
18
|
/>
|
|
17
19
|
<!-- Content pane -->
|
|
@@ -72,6 +74,7 @@ import { useFileTree } from "../composables/useFileTree";
|
|
|
72
74
|
import { useFileSelection, isValidFilePath, readPathMatch } from "../composables/useFileSelection";
|
|
73
75
|
import { useMarkdownMode } from "../composables/useMarkdownMode";
|
|
74
76
|
import { useFileSortMode } from "../composables/useFileSortMode";
|
|
77
|
+
import { useShowHiddenSystemFiles } from "../composables/useShowHiddenSystemFiles";
|
|
75
78
|
import { useContentDisplay } from "../composables/useContentDisplay";
|
|
76
79
|
import { useMarkdownLinkHandler } from "../composables/useMarkdownLinkHandler";
|
|
77
80
|
import { apiPost, apiPut } from "../utils/api";
|
|
@@ -100,6 +103,7 @@ const { selectedPath, content, contentLoading, contentError, loadContent, select
|
|
|
100
103
|
const { mdRawMode, toggleMdRaw } = useMarkdownMode();
|
|
101
104
|
|
|
102
105
|
const { sortMode, setSortMode } = useFileSortMode();
|
|
106
|
+
const { showHiddenSystem, setShowHiddenSystem } = useShowHiddenSystemFiles();
|
|
103
107
|
|
|
104
108
|
const { isMarkdown, isHtml, isJson, isJsonl, sandboxedHtml, htmlPreviewUrl, jsonTokens, jsonlLines, mdFrontmatter } = useContentDisplay(selectedPath, content);
|
|
105
109
|
|
|
@@ -52,6 +52,20 @@
|
|
|
52
52
|
</div>
|
|
53
53
|
|
|
54
54
|
<p v-if="error" class="mt-2 text-red-600 break-words" data-testid="remote-host-error">{{ error }}</p>
|
|
55
|
+
|
|
56
|
+
<div class="mt-3 pt-2 border-t border-gray-100 text-[11px] leading-snug text-gray-600 space-y-2" data-testid="remote-host-help">
|
|
57
|
+
<p>{{ t("remoteHost.description") }}</p>
|
|
58
|
+
<i18n-t keypath="remoteHost.howTo" tag="p" scope="global">
|
|
59
|
+
<template #url>
|
|
60
|
+
<a :href="MOBILE_URL" target="_blank" rel="noopener noreferrer" class="font-mono text-blue-600 hover:underline break-all">{{ MOBILE_URL }}</a>
|
|
61
|
+
</template>
|
|
62
|
+
</i18n-t>
|
|
63
|
+
<i18n-t keypath="remoteHost.customViewHint" tag="p" scope="global">
|
|
64
|
+
<template #keyword>
|
|
65
|
+
<code class="px-1 py-0.5 rounded bg-gray-100 text-gray-800">custom remote view</code>
|
|
66
|
+
</template>
|
|
67
|
+
</i18n-t>
|
|
68
|
+
</div>
|
|
55
69
|
</div>
|
|
56
70
|
</div>
|
|
57
71
|
</template>
|
|
@@ -82,6 +96,10 @@ interface StatusResponse {
|
|
|
82
96
|
|
|
83
97
|
const { t } = useI18n();
|
|
84
98
|
|
|
99
|
+
// Mobile companion PWA. Shown in the popover as help text; not fetched from
|
|
100
|
+
// this desktop app, so no runtime env override is needed.
|
|
101
|
+
const MOBILE_URL = "https://mulmoserver.web.app";
|
|
102
|
+
|
|
85
103
|
const open = ref(false);
|
|
86
104
|
const busy = ref(false);
|
|
87
105
|
const error = ref<string | null>(null);
|
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
:key="session.id"
|
|
36
36
|
tabindex="0"
|
|
37
37
|
role="button"
|
|
38
|
-
:aria-label="t('sessionHistoryPanel.openRowAria', { preview: session
|
|
38
|
+
:aria-label="t('sessionHistoryPanel.openRowAria', { preview: primaryText(session) })"
|
|
39
|
+
:title="primaryText(session)"
|
|
39
40
|
class="relative cursor-pointer rounded p-2 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
|
|
40
41
|
:class="rowClasses(session)"
|
|
41
42
|
:data-testid="`session-item-${session.id}`"
|
|
@@ -91,20 +92,22 @@
|
|
|
91
92
|
{{ t("sessionHistoryPanel.delete") }}
|
|
92
93
|
</button>
|
|
93
94
|
</div>
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
<!-- Primary line prefers the AI-generated summary (chat indexer,
|
|
96
|
+
#123) when present — it's typically more informative than
|
|
97
|
+
the first user message. Fall back to the first user message,
|
|
98
|
+
then to a "no messages" placeholder. `line-clamp-2` lets a
|
|
99
|
+
summary wrap so more of it is readable at a glance; the raw
|
|
100
|
+
first-message fallback stays on a single line via `truncate`
|
|
101
|
+
so short prompts don't disturb row heights. -->
|
|
102
|
+
<div class="flex items-start gap-1.5">
|
|
103
|
+
<SessionRoleIcon :session="session" :roles="roles" size="sm" class="flex-shrink-0 mt-0.5" />
|
|
104
|
+
<p class="flex-1 min-w-0" :class="[previewClasses(session), sessionHasVisibleSummary(session) ? 'line-clamp-2' : 'truncate']">
|
|
105
|
+
{{ primaryText(session) }}
|
|
98
106
|
</p>
|
|
99
|
-
<span v-if="isSessionRunning(session)" class="flex-shrink-0 flex items-center" :aria-label="t('sessionHistoryPanel.running')">
|
|
107
|
+
<span v-if="isSessionRunning(session)" class="flex-shrink-0 flex items-center mt-0.5" :aria-label="t('sessionHistoryPanel.running')">
|
|
100
108
|
<span class="w-1.5 h-1.5 rounded-full bg-yellow-400 animate-pulse" />
|
|
101
109
|
</span>
|
|
102
110
|
</div>
|
|
103
|
-
<!-- Optional second line: AI-generated summary of the
|
|
104
|
-
session, populated by the chat indexer (#123). -->
|
|
105
|
-
<p v-if="session.summary" class="text-xs text-gray-500 truncate mt-0.5">
|
|
106
|
-
{{ session.summary }}
|
|
107
|
-
</p>
|
|
108
111
|
</div>
|
|
109
112
|
</div>
|
|
110
113
|
</div>
|
|
@@ -118,6 +121,7 @@ import type { SessionSummary, SessionOrigin } from "../types/session";
|
|
|
118
121
|
import { SESSION_ORIGINS } from "../types/session";
|
|
119
122
|
import { HISTORY_FILTERS, HISTORY_FILTER_ORDER, type HistoryFilter } from "../config/historyFilters";
|
|
120
123
|
import { isLongRunningConversation } from "../utils/session/longRunning";
|
|
124
|
+
import { resolveSessionPrimaryText, sessionHasVisibleSummary } from "../utils/session/sessionPreview";
|
|
121
125
|
import { formatDate } from "../utils/format/date";
|
|
122
126
|
import SessionRoleIcon from "./SessionRoleIcon.vue";
|
|
123
127
|
import FilterChip from "./FilterChip.vue";
|
|
@@ -199,6 +203,15 @@ function previewClasses(session: SessionSummary): string {
|
|
|
199
203
|
return "text-gray-700";
|
|
200
204
|
}
|
|
201
205
|
|
|
206
|
+
// Primary line resolution: prefer the AI summary (trim + whitespace
|
|
207
|
+
// guarded so a summarizer returning " " doesn't blank the row) →
|
|
208
|
+
// first user message → localised placeholder. Same call is used for
|
|
209
|
+
// visible text, aria-label, and the hover title so all three stay in
|
|
210
|
+
// lockstep.
|
|
211
|
+
function primaryText(session: SessionSummary): string {
|
|
212
|
+
return resolveSessionPrimaryText(session) ?? t("sessionHistoryPanel.noMessages");
|
|
213
|
+
}
|
|
214
|
+
|
|
202
215
|
// ── Row action menu ─────────────────────────────────────────
|
|
203
216
|
//
|
|
204
217
|
// Only one popover is open at a time, tracked by session id. A
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="space-y-3" data-testid="settings-chat-index-tab">
|
|
3
|
+
<p class="text-sm text-gray-700">{{ t("settingsModal.chatIndexTab.description") }}</p>
|
|
4
|
+
|
|
5
|
+
<div class="space-y-2">
|
|
6
|
+
<label class="block text-sm font-medium text-gray-800" for="settings-chat-index-mode">{{ t("settingsModal.chatIndexTab.modeLabel") }}</label>
|
|
7
|
+
<select
|
|
8
|
+
id="settings-chat-index-mode"
|
|
9
|
+
v-model="modeDraft"
|
|
10
|
+
class="w-full px-3 py-2 text-sm rounded border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
11
|
+
data-testid="settings-chat-index-mode-select"
|
|
12
|
+
@change="save"
|
|
13
|
+
>
|
|
14
|
+
<option v-for="mode in CHAT_INDEX_MODES" :key="mode" :value="mode">{{ t(`settingsModal.chatIndexTab.mode.${mode}`) }}</option>
|
|
15
|
+
</select>
|
|
16
|
+
<p class="text-xs text-gray-500">{{ t("settingsModal.chatIndexTab.helperText") }}</p>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div v-if="loaded && !errorMessage" class="flex items-center gap-3 text-xs">
|
|
20
|
+
<span :class="statusColour" data-testid="settings-chat-index-status">
|
|
21
|
+
{{ statusText }}
|
|
22
|
+
</span>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<p v-if="errorMessage" class="text-sm text-red-700" role="alert" data-testid="settings-chat-index-error">{{ errorMessage }}</p>
|
|
26
|
+
</div>
|
|
27
|
+
</template>
|
|
28
|
+
|
|
29
|
+
<script setup lang="ts">
|
|
30
|
+
import { computed, ref, watch } from "vue";
|
|
31
|
+
import { useI18n } from "vue-i18n";
|
|
32
|
+
import { apiGet, apiPut, type ApiResult } from "../utils/api";
|
|
33
|
+
import { API_ROUTES } from "../config/apiRoutes";
|
|
34
|
+
|
|
35
|
+
const CHAT_INDEX_MODES = ["off", "haiku", "sonnet"] as const;
|
|
36
|
+
type ChatIndexMode = (typeof CHAT_INDEX_MODES)[number];
|
|
37
|
+
|
|
38
|
+
const { t } = useI18n();
|
|
39
|
+
|
|
40
|
+
const props = defineProps<{
|
|
41
|
+
reloadToken: number;
|
|
42
|
+
}>();
|
|
43
|
+
|
|
44
|
+
const emit = defineEmits<{
|
|
45
|
+
saved: [];
|
|
46
|
+
}>();
|
|
47
|
+
|
|
48
|
+
interface SettingsResponse {
|
|
49
|
+
settings: { chatIndex?: ChatIndexMode };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const modeDraft = ref<ChatIndexMode>("off");
|
|
53
|
+
const storedMode = ref<ChatIndexMode>("off");
|
|
54
|
+
const loaded = ref(false);
|
|
55
|
+
const saving = ref(false);
|
|
56
|
+
const errorMessage = ref("");
|
|
57
|
+
|
|
58
|
+
const statusText = computed(() => {
|
|
59
|
+
if (saving.value) return t("common.saving");
|
|
60
|
+
return t(`settingsModal.chatIndexTab.status.${storedMode.value}`);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const statusColour = computed(() => {
|
|
64
|
+
if (saving.value) return "text-gray-500";
|
|
65
|
+
return storedMode.value === "off" ? "text-gray-500" : "text-green-600";
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
async function load(): Promise<void> {
|
|
69
|
+
errorMessage.value = "";
|
|
70
|
+
const response = await apiGet<SettingsResponse>(API_ROUTES.config.base);
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
errorMessage.value = response.error || t("settingsModal.chatIndexTab.loadError");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
storedMode.value = response.data.settings.chatIndex ?? "off";
|
|
76
|
+
modeDraft.value = storedMode.value;
|
|
77
|
+
loaded.value = true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function save(): Promise<void> {
|
|
81
|
+
if (saving.value) return;
|
|
82
|
+
if (modeDraft.value === storedMode.value) return;
|
|
83
|
+
const requested = modeDraft.value;
|
|
84
|
+
saving.value = true;
|
|
85
|
+
errorMessage.value = "";
|
|
86
|
+
// The Settings tab uses the PATCH endpoint (`config.settings`) — the
|
|
87
|
+
// full-shape `config.base` PUT expects `{ settings, mcp }` and would
|
|
88
|
+
// reject a bare `{ chatIndex }` payload. Send null on "off" so the
|
|
89
|
+
// server drops the field and settings.json stays default-clean.
|
|
90
|
+
const payload = { chatIndex: requested === "off" ? null : requested };
|
|
91
|
+
const response = await apiPut<unknown>(API_ROUTES.config.settings, payload);
|
|
92
|
+
saving.value = false;
|
|
93
|
+
applySaveOutcome(response, requested);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// On failure with drift, respect the user's newer selection and retry.
|
|
97
|
+
// On failure without drift, revert modeDraft so re-selecting the same
|
|
98
|
+
// option fires @change again — otherwise the select value hasn't
|
|
99
|
+
// changed and the user is stuck with no way to retry.
|
|
100
|
+
function applySaveOutcome(response: ApiResult<unknown>, requested: ChatIndexMode): void {
|
|
101
|
+
if (!response.ok) {
|
|
102
|
+
errorMessage.value = response.error || t("settingsModal.chatIndexTab.saveError");
|
|
103
|
+
if (modeDraft.value !== requested) void save();
|
|
104
|
+
else modeDraft.value = storedMode.value;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
storedMode.value = requested;
|
|
108
|
+
emit("saved");
|
|
109
|
+
if (modeDraft.value !== requested) void save();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
watch(
|
|
113
|
+
() => props.reloadToken,
|
|
114
|
+
() => {
|
|
115
|
+
void load();
|
|
116
|
+
},
|
|
117
|
+
{ immediate: true },
|
|
118
|
+
);
|
|
119
|
+
</script>
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="space-y-3" data-testid="settings-journal-tab">
|
|
3
|
+
<p class="text-sm text-gray-700">{{ t("settingsModal.journalTab.description") }}</p>
|
|
4
|
+
|
|
5
|
+
<div class="space-y-2">
|
|
6
|
+
<label class="block text-sm font-medium text-gray-800" for="settings-journal-mode">{{ t("settingsModal.journalTab.modeLabel") }}</label>
|
|
7
|
+
<select
|
|
8
|
+
id="settings-journal-mode"
|
|
9
|
+
v-model="modeDraft"
|
|
10
|
+
class="w-full px-3 py-2 text-sm rounded border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
11
|
+
data-testid="settings-journal-mode-select"
|
|
12
|
+
@change="save"
|
|
13
|
+
>
|
|
14
|
+
<option v-for="mode in JOURNAL_MODES" :key="mode" :value="mode">{{ t(`settingsModal.journalTab.mode.${mode}`) }}</option>
|
|
15
|
+
</select>
|
|
16
|
+
<p class="text-xs text-gray-500">{{ t("settingsModal.journalTab.helperText") }}</p>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div v-if="loaded && !errorMessage" class="flex items-center gap-3 text-xs">
|
|
20
|
+
<span :class="statusColour" data-testid="settings-journal-status">
|
|
21
|
+
{{ statusText }}
|
|
22
|
+
</span>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<p v-if="errorMessage" class="text-sm text-red-700" role="alert" data-testid="settings-journal-error">{{ errorMessage }}</p>
|
|
26
|
+
</div>
|
|
27
|
+
</template>
|
|
28
|
+
|
|
29
|
+
<script setup lang="ts">
|
|
30
|
+
import { computed, ref, watch } from "vue";
|
|
31
|
+
import { useI18n } from "vue-i18n";
|
|
32
|
+
import { apiGet, apiPut, type ApiResult } from "../utils/api";
|
|
33
|
+
import { API_ROUTES } from "../config/apiRoutes";
|
|
34
|
+
|
|
35
|
+
const JOURNAL_MODES = ["off", "haiku", "sonnet"] as const;
|
|
36
|
+
type JournalMode = (typeof JOURNAL_MODES)[number];
|
|
37
|
+
|
|
38
|
+
const { t } = useI18n();
|
|
39
|
+
|
|
40
|
+
const props = defineProps<{
|
|
41
|
+
reloadToken: number;
|
|
42
|
+
}>();
|
|
43
|
+
|
|
44
|
+
const emit = defineEmits<{
|
|
45
|
+
saved: [];
|
|
46
|
+
}>();
|
|
47
|
+
|
|
48
|
+
interface SettingsResponse {
|
|
49
|
+
settings: { journal?: JournalMode };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const modeDraft = ref<JournalMode>("off");
|
|
53
|
+
const storedMode = ref<JournalMode>("off");
|
|
54
|
+
const loaded = ref(false);
|
|
55
|
+
const saving = ref(false);
|
|
56
|
+
const errorMessage = ref("");
|
|
57
|
+
|
|
58
|
+
const statusText = computed(() => {
|
|
59
|
+
if (saving.value) return t("common.saving");
|
|
60
|
+
return t(`settingsModal.journalTab.status.${storedMode.value}`);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const statusColour = computed(() => {
|
|
64
|
+
if (saving.value) return "text-gray-500";
|
|
65
|
+
return storedMode.value === "off" ? "text-gray-500" : "text-green-600";
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
async function load(): Promise<void> {
|
|
69
|
+
errorMessage.value = "";
|
|
70
|
+
const response = await apiGet<SettingsResponse>(API_ROUTES.config.base);
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
errorMessage.value = response.error || t("settingsModal.journalTab.loadError");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
storedMode.value = response.data.settings.journal ?? "off";
|
|
76
|
+
modeDraft.value = storedMode.value;
|
|
77
|
+
loaded.value = true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function save(): Promise<void> {
|
|
81
|
+
if (saving.value) return;
|
|
82
|
+
if (modeDraft.value === storedMode.value) return;
|
|
83
|
+
const requested = modeDraft.value;
|
|
84
|
+
saving.value = true;
|
|
85
|
+
errorMessage.value = "";
|
|
86
|
+
// PATCH endpoint (not the atomic base) so a bare `{ journal }` body
|
|
87
|
+
// is accepted. Send null on "off" so settings.json stays default-clean.
|
|
88
|
+
const payload = { journal: requested === "off" ? null : requested };
|
|
89
|
+
const response = await apiPut<unknown>(API_ROUTES.config.settings, payload);
|
|
90
|
+
saving.value = false;
|
|
91
|
+
applySaveOutcome(response, requested);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// On failure with drift, respect the user's newer selection and retry.
|
|
95
|
+
// On failure without drift, revert modeDraft so re-selecting the same
|
|
96
|
+
// option fires @change again — otherwise the select value hasn't
|
|
97
|
+
// changed and the user is stuck with no way to retry.
|
|
98
|
+
function applySaveOutcome(response: ApiResult<unknown>, requested: JournalMode): void {
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
errorMessage.value = response.error || t("settingsModal.journalTab.saveError");
|
|
101
|
+
if (modeDraft.value !== requested) void save();
|
|
102
|
+
else modeDraft.value = storedMode.value;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
storedMode.value = requested;
|
|
106
|
+
emit("saved");
|
|
107
|
+
if (modeDraft.value !== requested) void save();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
watch(
|
|
111
|
+
() => props.reloadToken,
|
|
112
|
+
() => {
|
|
113
|
+
void load();
|
|
114
|
+
},
|
|
115
|
+
{ immediate: true },
|
|
116
|
+
);
|
|
117
|
+
</script>
|
|
@@ -167,6 +167,10 @@
|
|
|
167
167
|
<SettingsModelTab v-else-if="activeTab === 'model'" :reload-token="modelReloadToken" @saved="emit('saved')" />
|
|
168
168
|
|
|
169
169
|
<SettingsVoiceTab v-else-if="activeTab === 'voice'" :reload-token="voiceReloadToken" />
|
|
170
|
+
|
|
171
|
+
<SettingsChatIndexTab v-else-if="activeTab === 'chatIndex'" :reload-token="chatIndexReloadToken" @saved="emit('saved')" />
|
|
172
|
+
|
|
173
|
+
<SettingsJournalTab v-else-if="activeTab === 'journal'" :reload-token="journalReloadToken" @saved="emit('saved')" />
|
|
170
174
|
</template>
|
|
171
175
|
</div>
|
|
172
176
|
</div>
|
|
@@ -197,6 +201,8 @@ import SettingsMapTab from "./SettingsMapTab.vue";
|
|
|
197
201
|
import SettingsPhotosTab from "./SettingsPhotosTab.vue";
|
|
198
202
|
import SettingsModelTab from "./SettingsModelTab.vue";
|
|
199
203
|
import SettingsVoiceTab from "./SettingsVoiceTab.vue";
|
|
204
|
+
import SettingsChatIndexTab from "./SettingsChatIndexTab.vue";
|
|
205
|
+
import SettingsJournalTab from "./SettingsJournalTab.vue";
|
|
200
206
|
import SkillsView from "../plugins/manageSkills/View.vue";
|
|
201
207
|
import RolesView from "./RolesView.vue";
|
|
202
208
|
import PluginScopedRoot from "./PluginScopedRoot.vue";
|
|
@@ -245,7 +251,7 @@ const emit = defineEmits<{
|
|
|
245
251
|
// update / remove persist immediately).
|
|
246
252
|
const mcpTabRef = ref<{ flushDraft: () => boolean; hasPendingDraft: () => boolean } | null>(null);
|
|
247
253
|
|
|
248
|
-
type TabId = "gemini" | "tools" | "mcp" | "dirs" | "refs" | "map" | "photos" | "model" | "voice" | "skills" | "roles";
|
|
254
|
+
type TabId = "gemini" | "tools" | "mcp" | "dirs" | "refs" | "map" | "photos" | "model" | "voice" | "chatIndex" | "journal" | "skills" | "roles";
|
|
249
255
|
|
|
250
256
|
const activeTab = ref<TabId>("tools");
|
|
251
257
|
|
|
@@ -261,7 +267,7 @@ const isFullTab = computed(() => FULL_TABS.includes(activeTab.value));
|
|
|
261
267
|
// item is filtered out by `visibleGroups` when geminiAvailable === true
|
|
262
268
|
// (env var present → user has nothing to configure).
|
|
263
269
|
const GROUPS: readonly { key: string; items: readonly TabId[] }[] = [
|
|
264
|
-
{ key: "llm", items: ["model", "voice", "tools", "gemini"] },
|
|
270
|
+
{ key: "llm", items: ["model", "voice", "chatIndex", "journal", "tools", "gemini"] },
|
|
265
271
|
{ key: "servers", items: ["mcp"] },
|
|
266
272
|
{ key: "workspace", items: ["dirs", "refs"] },
|
|
267
273
|
{ key: "plugins", items: ["map", "photos"] },
|
|
@@ -294,6 +300,8 @@ function onMapSaved(): void {
|
|
|
294
300
|
const photosReloadToken = ref(0);
|
|
295
301
|
const modelReloadToken = ref(0);
|
|
296
302
|
const voiceReloadToken = ref(0);
|
|
303
|
+
const chatIndexReloadToken = ref(0);
|
|
304
|
+
const journalReloadToken = ref(0);
|
|
297
305
|
const toolsText = ref("");
|
|
298
306
|
// Server truth for tools — updated on load and on a successful Save
|
|
299
307
|
// from the Tools tab. `toolsDirty` compares this against `toolsText`
|
|
@@ -501,6 +509,8 @@ watch(
|
|
|
501
509
|
photosReloadToken.value += 1;
|
|
502
510
|
modelReloadToken.value += 1;
|
|
503
511
|
voiceReloadToken.value += 1;
|
|
512
|
+
chatIndexReloadToken.value += 1;
|
|
513
|
+
journalReloadToken.value += 1;
|
|
504
514
|
statusMessage.value = "";
|
|
505
515
|
statusError.value = false;
|
|
506
516
|
}
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
// `notifiedSeverities` returns an empty map.
|
|
13
13
|
import {
|
|
14
14
|
configureCollectionUi,
|
|
15
|
+
type CollectionRemoteViewResult,
|
|
16
|
+
type CollectionRemoteViewMutateResult,
|
|
17
|
+
type CollectionRemoteViewItemsResult,
|
|
15
18
|
type CollectionViewI18nResult,
|
|
16
19
|
type CollectionViewToken,
|
|
17
20
|
type RegistryListResponse,
|
|
@@ -57,6 +60,10 @@ const collectionActionUrl = (slug: string, actionId: string): string =>
|
|
|
57
60
|
withSlug(API_ROUTES.collections.collectionAction, slug).replace(":actionId", encodeURIComponent(actionId));
|
|
58
61
|
const viewDeleteUrl = (slug: string, viewId: string): string =>
|
|
59
62
|
withSlug(API_ROUTES.collections.viewDelete, slug).replace(":viewId", encodeURIComponent(viewId));
|
|
63
|
+
const remoteViewMutateUrl = (slug: string, viewId: string): string =>
|
|
64
|
+
withSlug(API_ROUTES.collections.remoteViewMutate, slug).replace(":viewId", encodeURIComponent(viewId));
|
|
65
|
+
const remoteViewItemsUrl = (slug: string, viewId: string): string =>
|
|
66
|
+
withSlug(API_ROUTES.collections.remoteViewItems, slug).replace(":viewId", encodeURIComponent(viewId));
|
|
60
67
|
|
|
61
68
|
// ── Deferred app bindings (need a component context; set by App.vue setup) ──
|
|
62
69
|
type StartChat = (prompt: string, role: string) => void;
|
|
@@ -98,6 +105,14 @@ configureCollectionUi({
|
|
|
98
105
|
}
|
|
99
106
|
},
|
|
100
107
|
fetchViewI18n: (slug, viewId, locale) => apiGet<CollectionViewI18nResult>(withSlug(API_ROUTES.collections.viewI18n, slug), { id: viewId, locale }),
|
|
108
|
+
fetchRemoteView: (slug, viewId, locale) => apiGet<CollectionRemoteViewResult>(withSlug(API_ROUTES.collections.remoteView, slug), { id: viewId, locale }),
|
|
109
|
+
mutateRemoteView: (slug, viewId, request) => apiPost<CollectionRemoteViewMutateResult>(remoteViewMutateUrl(slug, viewId), request),
|
|
110
|
+
fetchRemoteViewItems: (slug, viewId, request) =>
|
|
111
|
+
apiGet<CollectionRemoteViewItemsResult>(remoteViewItemsUrl(slug, viewId), {
|
|
112
|
+
offset: request.offset,
|
|
113
|
+
limit: request.limit,
|
|
114
|
+
fields: request.fields?.join(",") || undefined,
|
|
115
|
+
}),
|
|
101
116
|
buildViewSrcdoc: (html, boot) => buildCustomViewSrcdoc(html, boot),
|
|
102
117
|
|
|
103
118
|
// record CRUD + actions
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Keeps pinned launcher shortcuts for collections that declare a
|
|
2
|
+
// `dynamicIcon` (see `CollectionSchema.dynamicIcon`) live: subscribes to
|
|
3
|
+
// each dynamic collection's source collection(s) and re-reconciles the
|
|
4
|
+
// shortcut cache whenever their records change, so the launcher icon
|
|
5
|
+
// tracks the data state live instead of waiting for the next
|
|
6
|
+
// Collections-index visit — `CollectionsIndexView`'s own reconcile-on-mount
|
|
7
|
+
// remains the fallback for a cold boot, before this composable's first
|
|
8
|
+
// fetch resolves.
|
|
9
|
+
//
|
|
10
|
+
// Mounted once at app level (src/App.vue) so it runs regardless of
|
|
11
|
+
// whether the Collections index is open. Module-singleton state, like
|
|
12
|
+
// `useShortcuts` — there's only ever one launcher to keep live.
|
|
13
|
+
|
|
14
|
+
import { onMounted, onUnmounted, ref } from "vue";
|
|
15
|
+
import { collectionUi } from "@mulmoclaude/collection-plugin/vue";
|
|
16
|
+
import { useShortcuts } from "../useShortcuts";
|
|
17
|
+
import type { CollectionSummary } from "@mulmoclaude/core/collection";
|
|
18
|
+
|
|
19
|
+
/** Wait for a burst of source-collection changes to settle (e.g. a feed
|
|
20
|
+
* refresh writing several records) before re-fetching, rather than
|
|
21
|
+
* re-fetching per record. */
|
|
22
|
+
const SOURCE_CHANGE_DEBOUNCE_MS = 300;
|
|
23
|
+
|
|
24
|
+
type Unsubscribe = () => void;
|
|
25
|
+
|
|
26
|
+
const summaries = ref<CollectionSummary[]>([]);
|
|
27
|
+
let subscriptions: Unsubscribe[] = [];
|
|
28
|
+
let debounceHandle: ReturnType<typeof setTimeout> | null = null;
|
|
29
|
+
let refreshSeq = 0;
|
|
30
|
+
|
|
31
|
+
/** Slugs of every source collection a dynamic-icon shortcut watches,
|
|
32
|
+
* deduped (two dynamic collections can share a source). */
|
|
33
|
+
function dynamicSourceSlugs(): string[] {
|
|
34
|
+
const slugs = new Set<string>();
|
|
35
|
+
for (const summary of summaries.value) {
|
|
36
|
+
for (const source of summary.iconSources ?? []) slugs.add(source);
|
|
37
|
+
}
|
|
38
|
+
return [...slugs];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function clearSubscriptions(): void {
|
|
42
|
+
subscriptions.forEach((unsubscribe) => unsubscribe());
|
|
43
|
+
subscriptions = [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Re-derive the watched source-collection set from the current
|
|
47
|
+
* `summaries` and (re)subscribe — so a newly-added dynamic collection
|
|
48
|
+
* gets watched on the next refresh. A host without `subscribeChanges`
|
|
49
|
+
* (no pub/sub transport) leaves this a no-op; v1's reconcile-on-visit
|
|
50
|
+
* still applies. */
|
|
51
|
+
function resubscribe(): void {
|
|
52
|
+
clearSubscriptions();
|
|
53
|
+
const subscribe = collectionUi().subscribeChanges;
|
|
54
|
+
if (!subscribe) return;
|
|
55
|
+
subscriptions = dynamicSourceSlugs().map((slug) => subscribe(slug, scheduleRefresh));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function scheduleRefresh(): void {
|
|
59
|
+
if (debounceHandle) clearTimeout(debounceHandle);
|
|
60
|
+
debounceHandle = setTimeout(() => {
|
|
61
|
+
debounceHandle = null;
|
|
62
|
+
void refresh();
|
|
63
|
+
}, SOURCE_CHANGE_DEBOUNCE_MS);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Fetch the authoritative collections list, reconcile pinned
|
|
67
|
+
* `"collection"` shortcuts against it, and re-derive which source
|
|
68
|
+
* collections to watch. Feed-source entries are excluded — they're
|
|
69
|
+
* reconciled separately (kind `"feed"`) by `FeedsView`. A failed fetch
|
|
70
|
+
* leaves the prior summaries (and subscriptions) untouched. A monotonic
|
|
71
|
+
* `refreshSeq` drops a superseded fetch so a slow older `listCollections`
|
|
72
|
+
* can't resolve last and overwrite fresher summaries / resubscribe stale. */
|
|
73
|
+
async function refresh(): Promise<void> {
|
|
74
|
+
const seq = ++refreshSeq;
|
|
75
|
+
const result = await collectionUi().listCollections();
|
|
76
|
+
if (seq !== refreshSeq) return;
|
|
77
|
+
if (!result.ok) return;
|
|
78
|
+
summaries.value = result.data.collections.filter((summary) => summary.source !== "feed");
|
|
79
|
+
resubscribe();
|
|
80
|
+
await useShortcuts().reconcile(
|
|
81
|
+
"collection",
|
|
82
|
+
summaries.value.map((summary) => ({ slug: summary.slug, title: summary.title, icon: summary.icon })),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Mount once at app level. No template — side effects only. */
|
|
87
|
+
export function useDynamicShortcutIcons(): void {
|
|
88
|
+
onMounted(() => void refresh());
|
|
89
|
+
onUnmounted(() => {
|
|
90
|
+
if (debounceHandle) clearTimeout(debounceHandle);
|
|
91
|
+
clearSubscriptions();
|
|
92
|
+
});
|
|
93
|
+
}
|