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.
- 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-D8rhwXLq.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/config.ts +12 -12
- package/server/agent/index.ts +9 -3
- package/server/agent/mcp-server.ts +19 -19
- package/server/agent/mcp-tools/index.ts +6 -6
- package/server/agent/mcp-tools/x.ts +7 -6
- package/server/agent/prompt.ts +195 -29
- package/server/agent/resumeFailover.ts +5 -5
- package/server/agent/sandboxMounts.ts +10 -10
- package/server/agent/stream.ts +4 -4
- 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 +22 -21
- 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 +14 -12
- package/server/api/routes/schedulerHandlers.ts +12 -12
- package/server/api/routes/schedulerTasks.ts +19 -17
- package/server/api/routes/sessions.ts +26 -26
- 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/todosColumnsHandlers.ts +30 -30
- package/server/api/routes/todosHandlers.ts +1 -1
- package/server/api/routes/todosItemsHandlers.ts +14 -14
- package/server/api/routes/wiki.ts +36 -22
- 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/events/scheduler-adapter.ts +20 -20
- package/server/events/session-store/index.ts +10 -10
- package/server/events/task-manager/index.ts +7 -7
- package/server/index.ts +59 -65
- package/server/system/config.ts +5 -5
- package/server/system/credentials.ts +7 -5
- package/server/system/env.ts +5 -5
- package/server/utils/date.ts +18 -18
- package/server/utils/files/atomic.ts +16 -16
- package/server/utils/files/html-io.ts +5 -5
- package/server/utils/files/image-store.ts +19 -8
- package/server/utils/files/journal-io.ts +4 -4
- package/server/utils/files/json.ts +5 -5
- package/server/utils/files/markdown-store.ts +4 -4
- package/server/utils/files/naming.ts +2 -2
- package/server/utils/files/reference-dirs-io.ts +3 -3
- package/server/utils/files/roles-io.ts +12 -12
- package/server/utils/files/safe.ts +14 -14
- package/server/utils/files/scheduler-io.ts +5 -5
- package/server/utils/files/scheduler-overrides-io.ts +2 -2
- package/server/utils/files/session-io.ts +35 -35
- package/server/utils/files/spreadsheet-store.ts +7 -7
- package/server/utils/files/todos-io.ts +9 -9
- package/server/utils/files/user-tasks-io.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/indexer.ts +15 -15
- package/server/workspace/chat-index/summarizer.ts +4 -4
- package/server/workspace/custom-dirs.ts +16 -16
- package/server/workspace/journal/archivist.ts +35 -35
- package/server/workspace/journal/dailyPass.ts +31 -28
- package/server/workspace/journal/diff.ts +2 -2
- package/server/workspace/journal/index.ts +4 -4
- package/server/workspace/journal/indexFile.ts +29 -25
- 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 +20 -20
- package/server/workspace/roles.ts +6 -6
- package/server/workspace/skills/discovery.ts +4 -4
- package/server/workspace/skills/parser.ts +6 -6
- package/server/workspace/skills/scheduler.ts +3 -3
- package/server/workspace/skills/user-tasks.ts +34 -34
- package/server/workspace/skills/writer.ts +3 -3
- package/server/workspace/sources/arxivDiscovery.ts +10 -10
- package/server/workspace/sources/classifier.ts +7 -7
- package/server/workspace/sources/fetchers/arxiv.ts +7 -7
- package/server/workspace/sources/fetchers/githubIssues.ts +7 -7
- package/server/workspace/sources/fetchers/githubReleases.ts +7 -7
- package/server/workspace/sources/fetchers/rss.ts +5 -5
- package/server/workspace/sources/fetchers/rssParser.ts +4 -4
- package/server/workspace/sources/interests.ts +12 -12
- package/server/workspace/sources/paths.ts +6 -6
- package/server/workspace/sources/pipeline/fetch.ts +36 -13
- package/server/workspace/sources/pipeline/index.ts +8 -13
- package/server/workspace/sources/pipeline/notify.ts +3 -3
- package/server/workspace/sources/pipeline/plan.ts +15 -13
- package/server/workspace/sources/pipeline/write.ts +5 -5
- package/server/workspace/sources/rateLimiter.ts +1 -1
- package/server/workspace/sources/registry.ts +16 -16
- package/server/workspace/sources/robots.ts +14 -14
- package/server/workspace/sources/sourceState.ts +17 -10
- 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/tool-trace/index.ts +1 -1
- package/server/workspace/tool-trace/writeSearch.ts +26 -16
- package/server/workspace/wiki-backlinks/index.ts +8 -8
- package/server/workspace/wiki-backlinks/sessionBacklinks.ts +15 -15
- package/server/workspace/workspace.ts +7 -7
- package/src/App.vue +315 -141
- package/src/components/CanvasViewToggle.vue +10 -7
- package/src/components/ChatInput.vue +67 -33
- 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 +17 -14
- package/src/components/NotificationBell.vue +14 -5
- package/src/components/NotificationToast.vue +6 -3
- package/src/components/PluginLauncher.vue +19 -56
- package/src/components/RightSidebar.vue +13 -10
- package/src/components/RoleSelector.vue +2 -2
- package/src/components/SessionHistoryPanel.vue +38 -34
- package/src/components/SessionTabBar.vue +8 -10
- package/src/components/SettingsMcpTab.vue +49 -36
- package/src/components/SettingsModal.vue +24 -22
- package/src/components/SettingsReferenceDirsTab.vue +39 -34
- package/src/components/SettingsWorkspaceDirsTab.vue +37 -27
- package/src/components/SidebarHeader.vue +25 -4
- package/src/components/StackView.vue +4 -1
- package/src/components/SuggestionsPanel.vue +7 -4
- package/src/components/TodoExplorer.vue +26 -15
- package/src/components/ToolResultsPanel.vue +27 -13
- package/src/components/todo/TodoAddDialog.vue +19 -14
- package/src/components/todo/TodoEditDialog.vue +7 -2
- package/src/components/todo/TodoEditPanel.vue +17 -12
- package/src/components/todo/TodoKanbanView.vue +10 -5
- package/src/components/todo/TodoListView.vue +10 -7
- package/src/components/todo/TodoTableView.vue +5 -2
- package/src/composables/useAppApi.ts +9 -0
- package/src/composables/useClickOutside.ts +2 -2
- 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/useFreshPluginData.ts +3 -3
- package/src/composables/useKeyNavigation.ts +11 -11
- package/src/composables/useLayoutMode.ts +32 -0
- package/src/composables/useMcpTools.ts +2 -2
- package/src/composables/useNotifications.ts +3 -3
- package/src/composables/usePdfDownload.ts +4 -4
- package/src/composables/usePendingCalls.ts +1 -1
- package/src/composables/usePubSub.ts +10 -10
- package/src/composables/useRoles.ts +1 -1
- package/src/composables/useSandboxStatus.ts +1 -1
- package/src/composables/useSessionDerived.ts +3 -3
- package/src/composables/useSessionHistory.ts +7 -17
- package/src/composables/useSessionSync.ts +8 -8
- package/src/composables/useViewLayout.ts +20 -34
- package/src/config/roles.ts +2 -2
- 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 +5 -5
- 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 +29 -25
- package/src/plugins/manageSource/Preview.vue +2 -2
- package/src/plugins/manageSource/View.vue +73 -52
- package/src/plugins/markdown/Preview.vue +1 -1
- package/src/plugins/markdown/View.vue +26 -36
- package/src/plugins/presentHtml/Preview.vue +1 -1
- package/src/plugins/presentHtml/View.vue +7 -4
- package/src/plugins/presentHtml/helpers.ts +8 -8
- package/src/plugins/presentMulmoScript/Preview.vue +1 -1
- package/src/plugins/presentMulmoScript/View.vue +40 -30
- package/src/plugins/presentMulmoScript/helpers.ts +1 -1
- package/src/plugins/scheduler/Preview.vue +13 -10
- package/src/plugins/scheduler/TasksTab.vue +57 -28
- 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 +29 -9
- package/src/plugins/todo/Preview.vue +13 -8
- package/src/plugins/todo/View.vue +38 -24
- package/src/plugins/todo/composables/useTodos.ts +5 -5
- package/src/plugins/ui-image/ImagePreview.vue +6 -3
- package/src/plugins/ui-image/ImageView.vue +7 -4
- package/src/plugins/wiki/Preview.vue +10 -7
- package/src/plugins/wiki/View.vue +202 -81
- package/src/plugins/wiki/helpers.ts +4 -4
- package/src/plugins/wiki/route.ts +112 -0
- package/src/router/guards.ts +46 -28
- package/src/router/index.ts +41 -26
- package/src/types/session.ts +4 -3
- package/src/types/vue-i18n.d.ts +20 -0
- package/src/utils/agent/request.ts +22 -3
- package/src/utils/canvas/layoutMode.ts +26 -0
- package/src/utils/dom/scrollable.ts +2 -2
- package/src/utils/files/expandedDirs.ts +1 -1
- package/src/utils/files/sortChildren.ts +6 -6
- package/src/utils/format/frontmatter.ts +6 -6
- package/src/utils/image/cacheBust.ts +16 -0
- package/src/utils/image/resolve.ts +16 -0
- package/src/utils/image/rewriteMarkdownImageRefs.ts +5 -5
- package/src/utils/markdown/extractFirstH1.ts +2 -2
- package/src/utils/path/relativeLink.ts +15 -15
- package/src/utils/path/workspaceLinkRouter.ts +81 -0
- package/src/utils/role/icon.ts +2 -2
- package/src/utils/role/merge.ts +2 -2
- package/src/utils/role/plugins.ts +1 -1
- package/src/utils/session/sessionFactory.ts +2 -2
- package/src/utils/session/sessionHelpers.ts +2 -2
- package/src/utils/tools/dedup.ts +4 -4
- package/src/utils/tools/result.ts +3 -3
- package/src/utils/types.ts +2 -2
- 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-KNLBjwuh.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
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="h-full bg-white flex flex-col">
|
|
3
3
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
|
4
|
-
<h2 class="text-lg font-semibold text-gray-800">
|
|
4
|
+
<h2 class="text-lg font-semibold text-gray-800">{{ t("pluginManageRoles.heading") }}</h2>
|
|
5
5
|
<div class="flex items-center gap-3">
|
|
6
|
-
<span class="text-sm text-gray-500">
|
|
7
|
-
{{ customRoles.length }}
|
|
8
|
-
role{{ customRoles.length !== 1 ? "s" : "" }}
|
|
9
|
-
</span>
|
|
6
|
+
<span class="text-sm text-gray-500">{{ t("pluginManageRoles.roleCount", customRoles.length, { named: { count: customRoles.length } }) }}</span>
|
|
10
7
|
<button v-if="!creating" data-testid="role-add-btn" class="px-2 py-1 text-xs rounded bg-blue-500 text-white hover:bg-blue-600" @click="startCreate">
|
|
11
|
-
|
|
8
|
+
{{ t("pluginManageRoles.addButton") }}
|
|
12
9
|
</button>
|
|
13
10
|
</div>
|
|
14
11
|
</div>
|
|
@@ -16,12 +13,12 @@
|
|
|
16
13
|
<div class="flex-1 overflow-y-auto">
|
|
17
14
|
<!-- New role creation panel -->
|
|
18
15
|
<div v-if="creating" class="m-4 border border-blue-300 bg-blue-50 rounded-lg p-4 space-y-3">
|
|
19
|
-
<div class="text-sm font-semibold text-gray-700">
|
|
16
|
+
<div class="text-sm font-semibold text-gray-700">{{ t("pluginManageRoles.createPanel") }}</div>
|
|
20
17
|
|
|
21
18
|
<!-- ID + Name + Icon row -->
|
|
22
19
|
<div class="flex gap-3">
|
|
23
20
|
<div class="w-40">
|
|
24
|
-
<label class="block text-xs font-medium text-gray-600 mb-1">
|
|
21
|
+
<label class="block text-xs font-medium text-gray-600 mb-1">{{ t("pluginManageRoles.fieldId") }}</label>
|
|
25
22
|
<input
|
|
26
23
|
v-model="newForm.id"
|
|
27
24
|
type="text"
|
|
@@ -30,7 +27,7 @@
|
|
|
30
27
|
/>
|
|
31
28
|
</div>
|
|
32
29
|
<div class="flex-1">
|
|
33
|
-
<label class="block text-xs font-medium text-gray-600 mb-1">
|
|
30
|
+
<label class="block text-xs font-medium text-gray-600 mb-1">{{ t("pluginManageRoles.fieldName") }}</label>
|
|
34
31
|
<input
|
|
35
32
|
v-model="newForm.name"
|
|
36
33
|
type="text"
|
|
@@ -39,8 +36,10 @@
|
|
|
39
36
|
</div>
|
|
40
37
|
<div class="w-32">
|
|
41
38
|
<label class="block text-xs font-medium text-gray-600 mb-1">
|
|
42
|
-
|
|
43
|
-
<a class="text-blue-400 font-normal ml-1" href="https://fonts.google.com/icons" target="_blank" rel="noopener"
|
|
39
|
+
{{ t("pluginManageRoles.fieldIcon") }}
|
|
40
|
+
<a class="text-blue-400 font-normal ml-1" href="https://fonts.google.com/icons" target="_blank" rel="noopener">{{
|
|
41
|
+
t("pluginManageRoles.helpLink")
|
|
42
|
+
}}</a>
|
|
44
43
|
</label>
|
|
45
44
|
<input
|
|
46
45
|
v-model="newForm.icon"
|
|
@@ -52,7 +51,7 @@
|
|
|
52
51
|
|
|
53
52
|
<!-- Prompt -->
|
|
54
53
|
<div>
|
|
55
|
-
<label class="block text-xs font-medium text-gray-600 mb-1">
|
|
54
|
+
<label class="block text-xs font-medium text-gray-600 mb-1">{{ t("pluginManageRoles.fieldPrompt") }}</label>
|
|
56
55
|
<textarea
|
|
57
56
|
v-model="newForm.prompt"
|
|
58
57
|
rows="6"
|
|
@@ -63,14 +62,14 @@
|
|
|
63
62
|
|
|
64
63
|
<!-- Plugins -->
|
|
65
64
|
<div>
|
|
66
|
-
<label class="block text-xs font-medium text-gray-600 mb-2">
|
|
65
|
+
<label class="block text-xs font-medium text-gray-600 mb-2">{{ t("pluginManageRoles.fieldPlugins") }}</label>
|
|
67
66
|
<div class="grid gap-x-4 gap-y-1 [grid-template-columns:repeat(auto-fit,minmax(180px,1fr))]">
|
|
68
67
|
<label
|
|
69
68
|
v-for="plugin in availablePlugins"
|
|
70
69
|
:key="plugin.name"
|
|
71
70
|
class="flex items-center gap-2 text-sm cursor-pointer"
|
|
72
71
|
:class="plugin.enabled ? 'text-gray-700' : 'text-gray-400 cursor-not-allowed'"
|
|
73
|
-
:title="plugin.enabled ? '' :
|
|
72
|
+
:title="plugin.enabled ? '' : t('pluginManageRoles.requiresEnv', { env: plugin.requiredEnv.join(', ') })"
|
|
74
73
|
>
|
|
75
74
|
<input
|
|
76
75
|
v-model="newForm.selectedPlugins"
|
|
@@ -80,7 +79,7 @@
|
|
|
80
79
|
class="cursor-pointer disabled:cursor-not-allowed"
|
|
81
80
|
/>
|
|
82
81
|
{{ plugin.name }}
|
|
83
|
-
<span v-if="!plugin.enabled" class="text-xs text-gray-400">(
|
|
82
|
+
<span v-if="!plugin.enabled" class="text-xs text-gray-400">{{ t("pluginManageRoles.missingEnv", { env: plugin.requiredEnv.join(", ") }) }}</span>
|
|
84
83
|
</label>
|
|
85
84
|
</div>
|
|
86
85
|
</div>
|
|
@@ -88,8 +87,8 @@
|
|
|
88
87
|
<!-- Starter queries -->
|
|
89
88
|
<div>
|
|
90
89
|
<label class="block text-xs font-medium text-gray-600 mb-1">
|
|
91
|
-
|
|
92
|
-
<span class="text-gray-400 font-normal">(
|
|
90
|
+
{{ t("pluginManageRoles.fieldStarterQueries") }}
|
|
91
|
+
<span class="text-gray-400 font-normal">{{ t("pluginManageRoles.onePerLine") }}</span>
|
|
93
92
|
</label>
|
|
94
93
|
<textarea
|
|
95
94
|
v-model="newForm.queriesText"
|
|
@@ -106,9 +105,11 @@
|
|
|
106
105
|
:title="newFormError ?? ''"
|
|
107
106
|
@click="saveNew"
|
|
108
107
|
>
|
|
109
|
-
{{ saving ? "
|
|
108
|
+
{{ saving ? t("pluginManageRoles.creating") : t("pluginManageRoles.create") }}
|
|
109
|
+
</button>
|
|
110
|
+
<button class="px-3 py-1.5 text-sm rounded border border-gray-300 text-gray-600 hover:bg-gray-50" @click="cancelCreate">
|
|
111
|
+
{{ t("common.cancel") }}
|
|
110
112
|
</button>
|
|
111
|
-
<button class="px-3 py-1.5 text-sm rounded border border-gray-300 text-gray-600 hover:bg-gray-50" @click="cancelCreate">Cancel</button>
|
|
112
113
|
</div>
|
|
113
114
|
<div v-if="newFormError" class="text-xs text-gray-500" data-testid="role-form-hint">
|
|
114
115
|
{{ newFormError }}
|
|
@@ -119,7 +120,7 @@
|
|
|
119
120
|
</div>
|
|
120
121
|
|
|
121
122
|
<div v-if="!creating && customRoles.length === 0" class="h-full flex items-center justify-center text-gray-400 text-sm">
|
|
122
|
-
|
|
123
|
+
{{ t("pluginManageRoles.emptyHint") }}
|
|
123
124
|
</div>
|
|
124
125
|
|
|
125
126
|
<ul v-if="customRoles.length > 0" class="p-4 space-y-2">
|
|
@@ -134,13 +135,16 @@
|
|
|
134
135
|
<div class="flex-1 min-w-0">
|
|
135
136
|
<div class="font-medium text-sm text-gray-800">
|
|
136
137
|
{{ role.name }}
|
|
137
|
-
<span class="ml-1 text-xs font-mono text-gray-400">
|
|
138
|
+
<span class="ml-1 text-xs font-mono text-gray-400">{{ t("pluginManageRoles.idFormatted", { id: role.id }) }}</span>
|
|
138
139
|
</div>
|
|
139
140
|
<div class="text-xs text-gray-400 truncate">
|
|
140
141
|
{{ role.availablePlugins.join(", ") }}
|
|
141
142
|
</div>
|
|
142
143
|
</div>
|
|
143
|
-
<span
|
|
144
|
+
<span
|
|
145
|
+
class="material-icons text-gray-400 text-sm"
|
|
146
|
+
:title="selectedId === role.id ? t('pluginManageRoles.collapse') : t('pluginManageRoles.expand')"
|
|
147
|
+
>
|
|
144
148
|
{{ selectedId === role.id ? "expand_less" : "expand_more" }}
|
|
145
149
|
</span>
|
|
146
150
|
</div>
|
|
@@ -150,7 +154,7 @@
|
|
|
150
154
|
<!-- ID + Name + Icon row -->
|
|
151
155
|
<div class="flex gap-3">
|
|
152
156
|
<div class="w-40">
|
|
153
|
-
<label class="block text-xs font-medium text-gray-600 mb-1">
|
|
157
|
+
<label class="block text-xs font-medium text-gray-600 mb-1">{{ t("pluginManageRoles.fieldId") }}</label>
|
|
154
158
|
<input
|
|
155
159
|
v-model="editForm.id"
|
|
156
160
|
type="text"
|
|
@@ -158,7 +162,7 @@
|
|
|
158
162
|
/>
|
|
159
163
|
</div>
|
|
160
164
|
<div class="flex-1">
|
|
161
|
-
<label class="block text-xs font-medium text-gray-600 mb-1">
|
|
165
|
+
<label class="block text-xs font-medium text-gray-600 mb-1">{{ t("pluginManageRoles.fieldName") }}</label>
|
|
162
166
|
<input
|
|
163
167
|
v-model="editForm.name"
|
|
164
168
|
type="text"
|
|
@@ -168,8 +172,10 @@
|
|
|
168
172
|
</div>
|
|
169
173
|
<div class="w-32">
|
|
170
174
|
<label class="block text-xs font-medium text-gray-600 mb-1">
|
|
171
|
-
|
|
172
|
-
<a class="text-blue-400 font-normal ml-1" href="https://fonts.google.com/icons" target="_blank" rel="noopener"
|
|
175
|
+
{{ t("pluginManageRoles.fieldIcon") }}
|
|
176
|
+
<a class="text-blue-400 font-normal ml-1" href="https://fonts.google.com/icons" target="_blank" rel="noopener">{{
|
|
177
|
+
t("pluginManageRoles.helpLink")
|
|
178
|
+
}}</a>
|
|
173
179
|
</label>
|
|
174
180
|
<input
|
|
175
181
|
v-model="editForm.icon"
|
|
@@ -181,7 +187,7 @@
|
|
|
181
187
|
|
|
182
188
|
<!-- Prompt -->
|
|
183
189
|
<div>
|
|
184
|
-
<label class="block text-xs font-medium text-gray-600 mb-1">
|
|
190
|
+
<label class="block text-xs font-medium text-gray-600 mb-1">{{ t("pluginManageRoles.fieldPrompt") }}</label>
|
|
185
191
|
<textarea
|
|
186
192
|
v-model="editForm.prompt"
|
|
187
193
|
rows="6"
|
|
@@ -192,14 +198,14 @@
|
|
|
192
198
|
|
|
193
199
|
<!-- Plugins -->
|
|
194
200
|
<div>
|
|
195
|
-
<label class="block text-xs font-medium text-gray-600 mb-2">
|
|
201
|
+
<label class="block text-xs font-medium text-gray-600 mb-2">{{ t("pluginManageRoles.fieldPlugins") }}</label>
|
|
196
202
|
<div class="grid gap-x-4 gap-y-1 [grid-template-columns:repeat(auto-fit,minmax(180px,1fr))]">
|
|
197
203
|
<label
|
|
198
204
|
v-for="plugin in availablePlugins"
|
|
199
205
|
:key="plugin.name"
|
|
200
206
|
class="flex items-center gap-2 text-sm cursor-pointer"
|
|
201
207
|
:class="plugin.enabled ? 'text-gray-700' : 'text-gray-400 cursor-not-allowed'"
|
|
202
|
-
:title="plugin.enabled ? '' :
|
|
208
|
+
:title="plugin.enabled ? '' : t('pluginManageRoles.requiresEnv', { env: plugin.requiredEnv.join(', ') })"
|
|
203
209
|
>
|
|
204
210
|
<input
|
|
205
211
|
v-model="editForm.selectedPlugins"
|
|
@@ -209,7 +215,9 @@
|
|
|
209
215
|
class="cursor-pointer disabled:cursor-not-allowed"
|
|
210
216
|
/>
|
|
211
217
|
{{ plugin.name }}
|
|
212
|
-
<span v-if="!plugin.enabled" class="text-xs text-gray-400">
|
|
218
|
+
<span v-if="!plugin.enabled" class="text-xs text-gray-400">{{
|
|
219
|
+
t("pluginManageRoles.missingEnv", { env: plugin.requiredEnv.join(", ") })
|
|
220
|
+
}}</span>
|
|
213
221
|
</label>
|
|
214
222
|
</div>
|
|
215
223
|
</div>
|
|
@@ -217,8 +225,8 @@
|
|
|
217
225
|
<!-- Starter queries -->
|
|
218
226
|
<div>
|
|
219
227
|
<label class="block text-xs font-medium text-gray-600 mb-1">
|
|
220
|
-
|
|
221
|
-
<span class="text-gray-400 font-normal">(
|
|
228
|
+
{{ t("pluginManageRoles.fieldStarterQueries") }}
|
|
229
|
+
<span class="text-gray-400 font-normal">{{ t("pluginManageRoles.onePerLine") }}</span>
|
|
222
230
|
</label>
|
|
223
231
|
<textarea
|
|
224
232
|
v-model="editForm.queriesText"
|
|
@@ -236,16 +244,18 @@
|
|
|
236
244
|
:title="editFormError ?? ''"
|
|
237
245
|
@click="saveEdit(role.id)"
|
|
238
246
|
>
|
|
239
|
-
{{ saving ? "
|
|
247
|
+
{{ saving ? t("pluginManageRoles.updating") : t("pluginManageRoles.update") }}
|
|
248
|
+
</button>
|
|
249
|
+
<button class="px-3 py-1.5 text-sm rounded border border-gray-300 text-gray-600 hover:bg-gray-50" @click="selectedId = null">
|
|
250
|
+
{{ t("common.cancel") }}
|
|
240
251
|
</button>
|
|
241
|
-
<button class="px-3 py-1.5 text-sm rounded border border-gray-300 text-gray-600 hover:bg-gray-50" @click="selectedId = null">Cancel</button>
|
|
242
252
|
</div>
|
|
243
253
|
<button
|
|
244
254
|
class="px-3 py-1.5 text-sm rounded border border-red-200 text-red-500 hover:bg-red-50 disabled:opacity-50"
|
|
245
255
|
:disabled="saving"
|
|
246
256
|
@click="deleteRole(role.id)"
|
|
247
257
|
>
|
|
248
|
-
|
|
258
|
+
{{ t("pluginManageRoles.delete") }}
|
|
249
259
|
</button>
|
|
250
260
|
</div>
|
|
251
261
|
<div v-if="editFormError" class="text-xs text-gray-500">
|
|
@@ -263,6 +273,7 @@
|
|
|
263
273
|
|
|
264
274
|
<script setup lang="ts">
|
|
265
275
|
import { ref, computed, watch, onMounted } from "vue";
|
|
276
|
+
import { useI18n } from "vue-i18n";
|
|
266
277
|
import { useFreshPluginData } from "../../composables/useFreshPluginData";
|
|
267
278
|
import { useAppApi } from "../../composables/useAppApi";
|
|
268
279
|
import type { ToolResultComplete } from "gui-chat-protocol/vue";
|
|
@@ -271,6 +282,8 @@ import { getAllPluginNames } from "../../tools/index";
|
|
|
271
282
|
import { apiGet, apiPost } from "../../utils/api";
|
|
272
283
|
import { API_ROUTES } from "../../config/apiRoutes";
|
|
273
284
|
|
|
285
|
+
const { t } = useI18n();
|
|
286
|
+
|
|
274
287
|
interface PluginEntry {
|
|
275
288
|
name: string;
|
|
276
289
|
enabled: boolean;
|
|
@@ -406,7 +419,10 @@ async function callManage(body: Record<string, unknown>): Promise<ManageResult>
|
|
|
406
419
|
// give us anything useful.
|
|
407
420
|
return {
|
|
408
421
|
success: false,
|
|
409
|
-
error:
|
|
422
|
+
error:
|
|
423
|
+
result.status === 0
|
|
424
|
+
? result.error || t("pluginManageRoles.errNetworkError")
|
|
425
|
+
: result.error || t("pluginManageRoles.errServerError", { status: result.status }),
|
|
410
426
|
};
|
|
411
427
|
}
|
|
412
428
|
return result.data;
|
|
@@ -432,13 +448,13 @@ async function refreshList() {
|
|
|
432
448
|
function validateRoleForm(form: EditForm, excludeId: string | null): string | null {
|
|
433
449
|
const trimmedId = form.id.trim();
|
|
434
450
|
const trimmedName = form.name.trim();
|
|
435
|
-
if (!trimmedId) return "
|
|
451
|
+
if (!trimmedId) return t("pluginManageRoles.errIdRequired");
|
|
436
452
|
if (!/^[a-zA-Z0-9_-]+$/.test(trimmedId)) {
|
|
437
|
-
return "
|
|
453
|
+
return t("pluginManageRoles.errIdInvalid");
|
|
438
454
|
}
|
|
439
|
-
if (!trimmedName) return "
|
|
455
|
+
if (!trimmedName) return t("pluginManageRoles.errNameRequired");
|
|
440
456
|
if (customRoles.value.some((existing) => existing.id === trimmedId && existing.id !== excludeId)) {
|
|
441
|
-
return
|
|
457
|
+
return t("pluginManageRoles.errIdDuplicate", { id: trimmedId });
|
|
442
458
|
}
|
|
443
459
|
return null;
|
|
444
460
|
}
|
|
@@ -473,7 +489,7 @@ async function saveNew() {
|
|
|
473
489
|
creating.value = false;
|
|
474
490
|
await refreshList();
|
|
475
491
|
} else {
|
|
476
|
-
createError.value = result.error ?? "
|
|
492
|
+
createError.value = result.error ?? t("pluginManageRoles.errCreateFailed");
|
|
477
493
|
}
|
|
478
494
|
saving.value = false;
|
|
479
495
|
}
|
|
@@ -505,7 +521,7 @@ async function saveEdit(originalId: string) {
|
|
|
505
521
|
selectedId.value = null;
|
|
506
522
|
await refreshList();
|
|
507
523
|
} else {
|
|
508
|
-
saveError.value = result.error ?? "
|
|
524
|
+
saveError.value = result.error ?? t("pluginManageRoles.errSaveFailed");
|
|
509
525
|
}
|
|
510
526
|
saving.value = false;
|
|
511
527
|
}
|
|
@@ -518,7 +534,7 @@ async function deleteRole(roleId: string) {
|
|
|
518
534
|
selectedId.value = null;
|
|
519
535
|
await refreshList();
|
|
520
536
|
} else {
|
|
521
|
-
saveError.value = result.error ?? "
|
|
537
|
+
saveError.value = result.error ?? t("pluginManageRoles.errDeleteFailed");
|
|
522
538
|
}
|
|
523
539
|
saving.value = false;
|
|
524
540
|
}
|
|
@@ -1,21 +1,26 @@
|
|
|
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">auto_awesome</span>
|
|
5
|
-
<span>{{ skills.length
|
|
5
|
+
<span>{{ t("pluginManageSkills.previewCount", skills.length, { named: { count: skills.length } }) }}</span>
|
|
6
6
|
</div>
|
|
7
7
|
<div v-for="skill in skills.slice(0, 6)" :key="skill.name" class="text-xs text-gray-600 truncate">
|
|
8
8
|
{{ skill.name }}
|
|
9
9
|
</div>
|
|
10
|
-
<div v-if="skills.length > 6" class="text-xs text-gray-400 italic"
|
|
10
|
+
<div v-if="skills.length > 6" class="text-xs text-gray-400 italic">
|
|
11
|
+
{{ t("pluginManageSkills.previewMore", { count: skills.length - 6 }) }}
|
|
12
|
+
</div>
|
|
11
13
|
</div>
|
|
12
14
|
</template>
|
|
13
15
|
|
|
14
16
|
<script setup lang="ts">
|
|
15
17
|
import { computed } from "vue";
|
|
18
|
+
import { useI18n } from "vue-i18n";
|
|
16
19
|
import type { ToolResultComplete } from "gui-chat-protocol/vue";
|
|
17
20
|
import type { ManageSkillsData } from "./index";
|
|
18
21
|
|
|
22
|
+
const { t } = useI18n();
|
|
23
|
+
|
|
19
24
|
const props = defineProps<{ result: ToolResultComplete<ManageSkillsData> }>();
|
|
20
25
|
const skills = computed(() => props.result.data?.skills ?? []);
|
|
21
26
|
</script>
|
|
@@ -3,8 +3,8 @@
|
|
|
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>
|
|
6
|
-
<h2 class="text-lg font-semibold text-gray-800">
|
|
7
|
-
<p class="text-xs text-gray-400 mt-0.5">{{ skills.length }}
|
|
6
|
+
<h2 class="text-lg font-semibold text-gray-800">{{ t("pluginManageSkills.heading") }}</h2>
|
|
7
|
+
<p class="text-xs text-gray-400 mt-0.5">{{ t("pluginManageSkills.subheading", { count: skills.length }) }}</p>
|
|
8
8
|
</div>
|
|
9
9
|
</div>
|
|
10
10
|
|
|
@@ -32,15 +32,16 @@
|
|
|
32
32
|
{{ skill.source }}
|
|
33
33
|
</div>
|
|
34
34
|
</div>
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
<i18n-t v-if="skills.length === 0" keypath="pluginManageSkills.emptyWithPath" tag="p" class="p-4 text-sm text-gray-400 italic">
|
|
36
|
+
<template #path>
|
|
37
|
+
<code class="text-[11px]">{{ t("pluginManageSkills.emptySkillPath") }}</code>
|
|
38
|
+
</template>
|
|
39
|
+
</i18n-t>
|
|
39
40
|
</div>
|
|
40
41
|
|
|
41
42
|
<!-- Right: detail pane -->
|
|
42
43
|
<div class="flex-1 min-w-0 overflow-y-auto">
|
|
43
|
-
<div v-if="!selected" class="p-6 text-sm text-gray-400 italic">
|
|
44
|
+
<div v-if="!selected" class="p-6 text-sm text-gray-400 italic">{{ t("pluginManageSkills.selectHint") }}</div>
|
|
44
45
|
<div v-else class="p-6">
|
|
45
46
|
<div class="flex items-start justify-between gap-4 mb-4">
|
|
46
47
|
<div class="min-w-0">
|
|
@@ -58,7 +59,7 @@
|
|
|
58
59
|
data-testid="skill-cancel-btn"
|
|
59
60
|
@click="cancelEdit"
|
|
60
61
|
>
|
|
61
|
-
|
|
62
|
+
{{ t("common.cancel") }}
|
|
62
63
|
</button>
|
|
63
64
|
<button
|
|
64
65
|
class="px-3 py-1.5 text-sm rounded bg-green-600 hover:bg-green-700 text-white disabled:opacity-40 flex items-center gap-1"
|
|
@@ -67,7 +68,7 @@
|
|
|
67
68
|
@click="saveEdit"
|
|
68
69
|
>
|
|
69
70
|
<span class="material-icons text-base">save</span>
|
|
70
|
-
|
|
71
|
+
{{ t("common.save") }}
|
|
71
72
|
</button>
|
|
72
73
|
</template>
|
|
73
74
|
<template v-else>
|
|
@@ -79,18 +80,18 @@
|
|
|
79
80
|
@click="startEdit"
|
|
80
81
|
>
|
|
81
82
|
<span class="material-icons text-base">edit</span>
|
|
82
|
-
|
|
83
|
+
{{ t("pluginManageSkills.btnEdit") }}
|
|
83
84
|
</button>
|
|
84
85
|
<button
|
|
85
86
|
v-if="detail && detail.source === 'project'"
|
|
86
87
|
class="px-3 py-1.5 text-sm rounded border border-red-300 text-red-600 hover:bg-red-50 disabled:opacity-40 flex items-center gap-1"
|
|
87
88
|
:disabled="detailLoading || deleting"
|
|
88
89
|
data-testid="skill-delete-btn"
|
|
89
|
-
title="
|
|
90
|
+
:title="t('pluginManageSkills.deleteProjectSkill')"
|
|
90
91
|
@click="deleteSkill"
|
|
91
92
|
>
|
|
92
93
|
<span class="material-icons text-base">delete</span>
|
|
93
|
-
|
|
94
|
+
{{ t("pluginManageSkills.btnDelete") }}
|
|
94
95
|
</button>
|
|
95
96
|
<button
|
|
96
97
|
class="px-3 py-1.5 text-sm rounded bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-40 flex items-center gap-1"
|
|
@@ -99,19 +100,19 @@
|
|
|
99
100
|
@click="runSkill"
|
|
100
101
|
>
|
|
101
102
|
<span class="material-icons text-base">play_arrow</span>
|
|
102
|
-
|
|
103
|
+
{{ t("pluginManageSkills.btnRun") }}
|
|
103
104
|
</button>
|
|
104
105
|
</template>
|
|
105
106
|
</div>
|
|
106
107
|
</div>
|
|
107
|
-
<div v-if="detailLoading" class="text-sm text-gray-400 italic">
|
|
108
|
+
<div v-if="detailLoading" class="text-sm text-gray-400 italic">{{ t("pluginManageSkills.loading") }}</div>
|
|
108
109
|
<div v-else-if="detailError" class="text-sm text-red-600">
|
|
109
110
|
{{ detailError }}
|
|
110
111
|
</div>
|
|
111
112
|
<!-- Edit mode -->
|
|
112
113
|
<div v-else-if="editing && detail" class="space-y-4">
|
|
113
114
|
<div>
|
|
114
|
-
<label class="block text-xs font-medium text-gray-500 mb-1">
|
|
115
|
+
<label class="block text-xs font-medium text-gray-500 mb-1"> {{ t("pluginManageSkills.fieldDescription") }} </label>
|
|
115
116
|
<input
|
|
116
117
|
v-model="editDescription"
|
|
117
118
|
data-testid="skill-edit-description"
|
|
@@ -119,7 +120,7 @@
|
|
|
119
120
|
/>
|
|
120
121
|
</div>
|
|
121
122
|
<div class="flex-1">
|
|
122
|
-
<label class="block text-xs font-medium text-gray-500 mb-1">
|
|
123
|
+
<label class="block text-xs font-medium text-gray-500 mb-1"> {{ t("pluginManageSkills.fieldBody") }} </label>
|
|
123
124
|
<textarea
|
|
124
125
|
v-model="editBody"
|
|
125
126
|
data-testid="skill-edit-body"
|
|
@@ -130,7 +131,7 @@
|
|
|
130
131
|
<!-- View mode -->
|
|
131
132
|
<!-- eslint-disable-next-line vue/no-v-html -- sanitized via DOMPurify -->
|
|
132
133
|
<div v-else-if="detail && renderedBody" class="markdown-content text-gray-700" data-testid="skill-body-rendered" v-html="renderedBody"></div>
|
|
133
|
-
<p v-else-if="detail" class="text-sm text-gray-400 italic">(
|
|
134
|
+
<p v-else-if="detail" class="text-sm text-gray-400 italic">{{ t("pluginManageSkills.emptyBody") }}</p>
|
|
134
135
|
</div>
|
|
135
136
|
</div>
|
|
136
137
|
</div>
|
|
@@ -139,6 +140,7 @@
|
|
|
139
140
|
|
|
140
141
|
<script setup lang="ts">
|
|
141
142
|
import { computed, onMounted, ref, watch } from "vue";
|
|
143
|
+
import { useI18n } from "vue-i18n";
|
|
142
144
|
import { marked } from "marked";
|
|
143
145
|
import DOMPurify from "dompurify";
|
|
144
146
|
import type { ToolResultComplete } from "gui-chat-protocol/vue";
|
|
@@ -147,6 +149,8 @@ import { useAppApi } from "../../composables/useAppApi";
|
|
|
147
149
|
import { apiGet, apiPut, apiDelete } from "../../utils/api";
|
|
148
150
|
import { API_ROUTES } from "../../config/apiRoutes";
|
|
149
151
|
|
|
152
|
+
const { t } = useI18n();
|
|
153
|
+
|
|
150
154
|
interface SkillDetail {
|
|
151
155
|
name: string;
|
|
152
156
|
description: string;
|
|
@@ -173,7 +177,7 @@ const saving = ref(false);
|
|
|
173
177
|
const editDescription = ref("");
|
|
174
178
|
const editBody = ref("");
|
|
175
179
|
|
|
176
|
-
const selected = computed(() => skills.value.find((
|
|
180
|
+
const selected = computed(() => skills.value.find((skill) => skill.name === selectedName.value) ?? null);
|
|
177
181
|
|
|
178
182
|
const renderedBody = computed(() => {
|
|
179
183
|
const body = detail.value?.body;
|
|
@@ -199,7 +203,7 @@ onMounted(async () => {
|
|
|
199
203
|
if (props.selectedResult || skills.value.length > 0) return;
|
|
200
204
|
const response = await apiGet<{ skills: SkillSummary[] }>(API_ROUTES.skills.list);
|
|
201
205
|
if (!response.ok) {
|
|
202
|
-
listError.value =
|
|
206
|
+
listError.value = t("pluginManageSkills.errListFailed", { error: response.error });
|
|
203
207
|
return;
|
|
204
208
|
}
|
|
205
209
|
if (Array.isArray(response.data.skills)) {
|
|
@@ -231,7 +235,7 @@ watch(
|
|
|
231
235
|
return;
|
|
232
236
|
}
|
|
233
237
|
if (!response.ok) {
|
|
234
|
-
detailError.value =
|
|
238
|
+
detailError.value = t("pluginManageSkills.errDetailFailed", { error: response.error });
|
|
235
239
|
detail.value = null;
|
|
236
240
|
} else {
|
|
237
241
|
detail.value = response.data.skill;
|
|
@@ -263,7 +267,7 @@ async function saveEdit(): Promise<void> {
|
|
|
263
267
|
});
|
|
264
268
|
saving.value = false;
|
|
265
269
|
if (!result.ok) {
|
|
266
|
-
detailError.value =
|
|
270
|
+
detailError.value = t("pluginManageSkills.errSaveFailed", { error: result.error });
|
|
267
271
|
return;
|
|
268
272
|
}
|
|
269
273
|
detail.value = {
|
|
@@ -272,7 +276,7 @@ async function saveEdit(): Promise<void> {
|
|
|
272
276
|
body: editBody.value,
|
|
273
277
|
};
|
|
274
278
|
// Update the sidebar summary too.
|
|
275
|
-
const idx = skills.value.findIndex((
|
|
279
|
+
const idx = skills.value.findIndex((skill) => skill.name === name);
|
|
276
280
|
if (idx >= 0) {
|
|
277
281
|
skills.value[idx] = {
|
|
278
282
|
...skills.value[idx],
|
|
@@ -300,18 +304,18 @@ function runSkill(): void {
|
|
|
300
304
|
async function deleteSkill(): Promise<void> {
|
|
301
305
|
if (!detail.value || detail.value.source !== "project") return;
|
|
302
306
|
const name = detail.value.name;
|
|
303
|
-
if (!window.confirm(
|
|
307
|
+
if (!window.confirm(t("pluginManageSkills.confirmDelete", { name }))) {
|
|
304
308
|
return;
|
|
305
309
|
}
|
|
306
310
|
deleting.value = true;
|
|
307
311
|
const result = await apiDelete<unknown>(API_ROUTES.skills.remove.replace(":name", encodeURIComponent(name)));
|
|
308
312
|
deleting.value = false;
|
|
309
313
|
if (!result.ok) {
|
|
310
|
-
detailError.value = result.error || "
|
|
314
|
+
detailError.value = result.error || t("pluginManageSkills.errDeleteFailed");
|
|
311
315
|
return;
|
|
312
316
|
}
|
|
313
317
|
// Remove from the local list, advance selection, clear detail.
|
|
314
|
-
const idx = skills.value.findIndex((
|
|
318
|
+
const idx = skills.value.findIndex((skill) => skill.name === name);
|
|
315
319
|
if (idx >= 0) {
|
|
316
320
|
skills.value.splice(idx, 1);
|
|
317
321
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="text-sm">
|
|
2
|
+
<div class="p-2 text-sm">
|
|
3
3
|
<div class="font-medium text-gray-700 truncate mb-1">
|
|
4
4
|
{{ title }}
|
|
5
5
|
</div>
|
|
@@ -24,7 +24,7 @@ const hint = computed(() => {
|
|
|
24
24
|
if (sources.length === 0) return "No sources registered yet.";
|
|
25
25
|
const names = sources
|
|
26
26
|
.slice(0, 3)
|
|
27
|
-
.map((
|
|
27
|
+
.map((source: Source) => source.slug)
|
|
28
28
|
.join(", ");
|
|
29
29
|
const tail = sources.length > 3 ? ", …" : "";
|
|
30
30
|
const plural = sources.length === 1 ? "" : "s";
|