mulmoclaude 0.3.0 → 0.5.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-DiKaqnKs.js +5 -0
- package/client/assets/{index-eHWB79u5.js → index-C94GcmNa.js} +203 -198
- package/client/assets/index-CY-WpQUm.css +2 -0
- package/client/assets/{index.es-D4YyL_Dg-BfRHLTZV.js → index.es-D4YyL_Dg-5ipqh8Pe.js} +5 -5
- package/client/assets/material-symbols-outlined-NzYEeyps.woff2 +0 -0
- package/client/index.html +2 -4
- package/package.json +17 -15
- package/server/agent/attachmentConverter.ts +2 -2
- package/server/agent/backend/claude-code.ts +170 -0
- package/server/agent/backend/index.ts +14 -0
- package/server/agent/backend/types.ts +65 -0
- package/server/agent/index.ts +31 -159
- package/server/agent/mcp-server.ts +88 -10
- package/server/agent/mcp-tools/index.ts +8 -7
- package/server/agent/mcp-tools/notify.ts +76 -0
- package/server/agent/mcp-tools/x.ts +12 -2
- package/server/agent/plugin-names.ts +10 -4
- package/server/agent/prompt.ts +187 -26
- package/server/agent/resumeFailover.ts +5 -5
- package/server/agent/sandboxMounts.ts +3 -3
- package/server/api/auth/bearerAuth.ts +3 -3
- package/server/api/auth/token.ts +2 -2
- package/server/api/routes/agent.ts +99 -4
- package/server/api/routes/chart.ts +13 -0
- package/server/api/routes/chat-index.ts +2 -1
- package/server/api/routes/config.ts +35 -8
- package/server/api/routes/files.ts +75 -24
- package/server/api/routes/html.ts +15 -2
- package/server/api/routes/image.ts +75 -20
- package/server/api/routes/mulmo-script.ts +33 -31
- package/server/api/routes/news.ts +146 -0
- package/server/api/routes/notifications.ts +58 -2
- package/server/api/routes/pdf.ts +2 -2
- package/server/api/routes/plugins.ts +73 -91
- package/server/api/routes/presentHtml.ts +9 -0
- package/server/api/routes/roles.ts +12 -2
- package/server/api/routes/scheduler.ts +20 -11
- package/server/api/routes/schedulerTasks.ts +58 -21
- package/server/api/routes/sessions.ts +15 -4
- package/server/api/routes/sessionsCursor.ts +4 -4
- package/server/api/routes/skills.ts +26 -5
- package/server/api/routes/sources.ts +8 -7
- package/server/api/routes/todos.ts +30 -0
- package/server/api/routes/todosColumnsHandlers.ts +13 -27
- package/server/api/routes/todosHandlers.ts +1 -1
- package/server/api/routes/todosItemsHandlers.ts +14 -14
- package/server/api/routes/wiki/frontmatter.ts +86 -0
- package/server/api/routes/wiki.ts +335 -75
- package/server/api/sandboxStatus.ts +1 -1
- package/server/events/notifications.ts +32 -8
- package/server/events/pub-sub/index.ts +3 -3
- package/server/events/relay-client.ts +26 -16
- package/server/events/resolveRelayBridgeOptions.ts +125 -0
- package/server/index.ts +72 -49
- package/server/system/config.ts +5 -5
- package/server/system/credentials.ts +7 -5
- package/server/system/env.ts +15 -5
- package/server/system/macosNotify.ts +152 -0
- package/server/utils/errors.ts +11 -2
- package/server/utils/fetch.ts +54 -0
- package/server/utils/files/atomic.ts +18 -17
- package/server/utils/files/image-store.ts +19 -13
- package/server/utils/files/journal-io.ts +2 -2
- package/server/utils/files/json.ts +5 -5
- package/server/utils/files/markdown-image-fill.ts +131 -0
- package/server/utils/files/markdown-store.ts +22 -6
- package/server/utils/files/naming.ts +20 -10
- package/server/utils/files/reference-dirs-io.ts +3 -3
- package/server/utils/files/roles-io.ts +4 -4
- package/server/utils/files/safe.ts +14 -14
- package/server/utils/files/scheduler-overrides-io.ts +2 -2
- package/server/utils/files/spreadsheet-store.ts +15 -10
- package/server/utils/files/workspace-io.ts +12 -12
- package/server/utils/gemini.ts +30 -4
- package/server/utils/gitignore.ts +9 -9
- package/server/utils/id.ts +40 -8
- package/server/utils/json.ts +5 -5
- package/server/utils/logBackgroundError.ts +12 -3
- package/server/utils/logPreview.ts +24 -0
- 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/promptMeta.ts +32 -0
- package/server/utils/request.ts +12 -6
- package/server/utils/slug.ts +65 -4
- package/server/utils/spawn.ts +1 -1
- package/server/utils/types.ts +2 -2
- package/server/workspace/chat-index/index.ts +1 -1
- package/server/workspace/chat-index/summarizer.ts +5 -5
- package/server/workspace/custom-dirs.ts +5 -5
- package/server/workspace/helps/gemini.md +57 -0
- package/server/workspace/helps/index.md +2 -1
- package/server/workspace/helps/sources.md +42 -0
- package/server/workspace/helps/wiki.md +40 -5
- package/server/workspace/journal/archivist-cli.ts +121 -0
- package/server/workspace/journal/{archivist.ts → archivist-schemas.ts} +12 -120
- package/server/workspace/journal/dailyPass.ts +78 -38
- package/server/workspace/journal/diff.ts +2 -2
- package/server/workspace/journal/index.ts +56 -5
- package/server/workspace/journal/memoryExtractor.ts +1 -1
- package/server/workspace/journal/optimizationPass.ts +4 -5
- package/server/workspace/journal/paths.ts +8 -24
- package/server/workspace/journal/state.ts +18 -8
- package/server/workspace/news/reader.ts +248 -0
- package/server/workspace/paths.ts +4 -3
- package/server/workspace/reference-dirs.ts +3 -3
- package/server/workspace/skills/parser.ts +6 -6
- package/server/workspace/skills/scheduler.ts +5 -4
- package/server/workspace/skills/user-tasks.ts +3 -2
- package/server/workspace/skills/writer.ts +3 -3
- package/server/workspace/sources/arxivDiscovery.ts +2 -2
- package/server/workspace/sources/classifier.ts +1 -1
- package/server/workspace/sources/fetchers/rss.ts +5 -5
- package/server/workspace/sources/fetchers/rssParser.ts +4 -4
- package/server/workspace/sources/interests.ts +3 -3
- package/server/workspace/sources/paths.ts +6 -6
- package/server/workspace/sources/pipeline/fetch.ts +59 -13
- package/server/workspace/sources/pipeline/index.ts +59 -7
- package/server/workspace/sources/pipeline/notify.ts +13 -5
- package/server/workspace/sources/pipeline/plan.ts +11 -9
- package/server/workspace/sources/pipeline/summarize.ts +1 -1
- package/server/workspace/sources/pipeline/write.ts +5 -5
- package/server/workspace/sources/rateLimiter.ts +1 -1
- package/server/workspace/sources/sourceState.ts +9 -4
- package/server/workspace/sources/types.ts +9 -0
- package/server/workspace/sources/urls.ts +1 -1
- package/server/workspace/tool-trace/classify.ts +4 -4
- package/server/workspace/workspace.ts +7 -7
- package/src/App.vue +477 -251
- package/src/components/CanvasViewToggle.vue +12 -10
- package/src/components/ChatInput.vue +112 -105
- package/src/components/FileContentHeader.vue +10 -7
- package/src/components/FileContentRenderer.vue +37 -10
- package/src/components/FileTree.vue +34 -4
- package/src/components/FileTreePane.vue +32 -27
- package/src/components/FilesView.vue +5 -3
- package/src/components/FilterChip.vue +22 -0
- package/src/components/LockStatusPopup.vue +19 -13
- package/src/components/NewsView.vue +252 -0
- package/src/components/NotificationBell.vue +35 -9
- package/src/components/NotificationToast.vue +4 -1
- package/src/components/PageChatComposer.vue +101 -0
- package/src/components/PluginLauncher.vue +36 -62
- package/src/components/RightSidebar.vue +13 -10
- package/src/components/RoleSelector.vue +3 -2
- package/src/components/SessionHeaderControls.vue +63 -0
- package/src/components/SessionHistoryExpandButton.vue +30 -0
- package/src/components/SessionHistoryPanel.vue +64 -93
- package/src/components/SessionHistoryToggleButton.vue +40 -0
- package/src/components/SessionRoleIcon.vue +72 -0
- package/src/components/SessionSidebar.vue +96 -0
- package/src/components/SessionTabBar.vue +44 -51
- package/src/components/SettingsMcpTab.vue +361 -52
- package/src/components/SettingsModal.vue +203 -72
- package/src/components/SettingsReferenceDirsTab.vue +72 -51
- package/src/components/SettingsWorkspaceDirsTab.vue +74 -51
- package/src/components/SidebarHeader.vue +50 -16
- package/src/components/SourcesManager.vue +900 -0
- package/src/components/SourcesView.vue +45 -0
- package/src/components/StackView.vue +84 -48
- package/src/components/SuggestionsPanel.vue +25 -36
- package/src/components/SystemFileBanner.vue +106 -0
- package/src/components/ThinkingIndicator.vue +41 -0
- package/src/components/TodoExplorer.vue +72 -22
- package/src/components/todo/TodoAddDialog.vue +17 -12
- package/src/components/todo/TodoEditDialog.vue +7 -2
- package/src/components/todo/TodoEditPanel.vue +15 -10
- package/src/components/todo/TodoKanbanView.vue +16 -6
- package/src/components/todo/TodoListView.vue +14 -3
- package/src/components/todo/TodoTableView.vue +36 -5
- package/src/composables/favicon/conditions.ts +76 -0
- package/src/composables/favicon/resolveColor.ts +93 -0
- package/src/composables/favicon/types.ts +61 -0
- package/src/composables/useAppApi.ts +23 -0
- package/src/composables/useChatScroll.ts +5 -5
- package/src/composables/useCurrentRole.ts +32 -0
- package/src/composables/useDynamicFavicon.ts +174 -58
- package/src/composables/useEventListeners.ts +7 -12
- package/src/composables/useFaviconState.ts +93 -12
- package/src/composables/useFileSelection.ts +25 -6
- package/src/composables/useHealth.ts +76 -7
- package/src/composables/useLayoutMode.ts +27 -0
- package/src/composables/useNewsItems.ts +38 -0
- package/src/composables/useNewsReadState.ts +75 -0
- package/src/composables/useNotifications.ts +76 -13
- package/src/composables/usePendingCalls.ts +11 -1
- package/src/composables/useRoles.ts +6 -10
- package/src/composables/useRunElapsed.ts +80 -0
- package/src/composables/useSessionDerived.ts +21 -5
- package/src/composables/useSessionHistory.ts +7 -17
- package/src/composables/useSidePanelVisible.ts +25 -0
- package/src/composables/useViewLayout.ts +16 -37
- package/src/config/apiRoutes.ts +19 -6
- package/src/config/historyFilters.ts +30 -0
- package/src/config/mcpCatalog.ts +285 -0
- package/src/config/mcpTypes.ts +26 -0
- package/src/config/roles.ts +19 -51
- package/src/config/systemFileDescriptors.ts +170 -0
- package/src/config/toolNames.ts +6 -1
- package/src/config/workspacePaths.ts +1 -0
- package/src/index.css +14 -0
- package/src/lang/de.ts +706 -0
- package/src/lang/en.ts +726 -0
- package/src/lang/es.ts +712 -0
- package/src/lang/fr.ts +704 -0
- package/src/lang/ja.ts +707 -0
- package/src/lang/ko.ts +709 -0
- package/src/lang/pt-BR.ts +702 -0
- package/src/lang/zh.ts +705 -0
- package/src/lib/vue-i18n.ts +97 -0
- package/src/main.ts +3 -0
- package/src/plugins/canvas/View.vue +104 -186
- package/src/plugins/canvas/definition.ts +0 -8
- package/src/plugins/canvas/index.ts +3 -2
- package/src/plugins/chart/Preview.vue +1 -1
- package/src/plugins/chart/View.vue +9 -4
- package/src/plugins/chart/index.ts +3 -2
- package/src/plugins/editImage/index.ts +3 -2
- package/src/plugins/generateImage/index.ts +3 -2
- package/src/plugins/manageRoles/Preview.vue +4 -1
- package/src/plugins/manageRoles/View.vue +67 -46
- package/src/plugins/manageRoles/index.ts +3 -2
- package/src/plugins/manageSkills/Preview.vue +8 -3
- package/src/plugins/manageSkills/View.vue +39 -34
- package/src/plugins/manageSkills/index.ts +3 -2
- package/src/plugins/manageSource/Preview.vue +1 -1
- package/src/plugins/manageSource/View.vue +3 -687
- package/src/plugins/manageSource/index.ts +3 -2
- package/src/plugins/markdown/Preview.vue +1 -1
- package/src/plugins/markdown/View.vue +164 -73
- package/src/plugins/markdown/definition.ts +6 -4
- package/src/plugins/markdown/index.ts +3 -2
- package/src/plugins/presentForm/Preview.vue +99 -0
- package/src/plugins/presentForm/View.vue +675 -0
- package/src/plugins/presentForm/definition.ts +127 -0
- package/src/plugins/presentForm/index.ts +18 -0
- package/src/plugins/presentForm/plugin.ts +94 -0
- package/src/plugins/presentForm/types.ts +109 -0
- package/src/plugins/presentHtml/Preview.vue +1 -1
- package/src/plugins/presentHtml/View.vue +7 -4
- package/src/plugins/presentHtml/index.ts +3 -2
- package/src/plugins/presentMulmoScript/Preview.vue +1 -1
- package/src/plugins/presentMulmoScript/View.vue +36 -26
- package/src/plugins/presentMulmoScript/index.ts +3 -2
- package/src/plugins/scheduler/AutomationsPreview.vue +37 -0
- package/src/plugins/scheduler/AutomationsView.vue +23 -0
- package/src/plugins/scheduler/CalendarView.vue +23 -0
- package/src/plugins/scheduler/LegacySchedulerView.vue +32 -0
- package/src/plugins/scheduler/Preview.vue +7 -4
- package/src/plugins/scheduler/TasksTab.vue +119 -28
- package/src/plugins/scheduler/View.vue +75 -32
- package/src/plugins/scheduler/automationsDefinition.ts +58 -0
- package/src/plugins/scheduler/calendarDefinition.ts +46 -0
- package/src/plugins/scheduler/formatSchedule.ts +93 -0
- package/src/plugins/scheduler/index.ts +68 -14
- package/src/plugins/scheduler/legacyShape.ts +34 -0
- package/src/plugins/spreadsheet/Preview.vue +9 -5
- package/src/plugins/spreadsheet/View.vue +43 -57
- package/src/plugins/spreadsheet/engine/responseDecoder.ts +2 -1
- package/src/plugins/spreadsheet/index.ts +3 -2
- package/src/plugins/textResponse/Preview.vue +15 -58
- package/src/plugins/textResponse/View.vue +42 -45
- package/src/plugins/textResponse/utils.ts +25 -0
- package/src/plugins/todo/Preview.vue +11 -6
- package/src/plugins/todo/View.vue +27 -13
- package/src/plugins/todo/composables/useTodos.ts +3 -1
- package/src/plugins/todo/index.ts +3 -2
- package/src/plugins/ui-image/ImagePreview.vue +6 -3
- package/src/plugins/ui-image/ImageView.vue +7 -4
- package/src/plugins/wiki/Preview.vue +5 -2
- package/src/plugins/wiki/View.vue +539 -92
- package/src/plugins/wiki/index.ts +5 -2
- package/src/plugins/wiki/route.ts +121 -0
- package/src/router/guards.ts +43 -24
- package/src/router/index.ts +53 -26
- package/src/router/pageRoutes.ts +23 -0
- package/src/tools/index.ts +12 -5
- package/src/tools/legacyPluginNames.ts +13 -0
- package/src/types/notification.ts +31 -6
- package/src/types/vue-i18n.d.ts +20 -0
- package/src/utils/agent/eventDispatch.ts +3 -6
- package/src/utils/agent/formatElapsed.ts +37 -0
- package/src/utils/agent/request.ts +22 -1
- package/src/utils/canvas/layoutMode.ts +26 -0
- package/src/utils/canvas/sidePanelVisible.ts +19 -0
- package/src/utils/dom/scrollIntoViewByTestId.ts +38 -0
- package/src/utils/errors.ts +9 -2
- package/src/utils/files/filename.ts +24 -0
- package/src/utils/filesPreview/schedulerPreview.ts +9 -3
- package/src/utils/id.ts +18 -0
- package/src/utils/image/cacheBust.ts +16 -0
- package/src/utils/image/resolve.ts +16 -0
- package/src/utils/markdown/taskList.ts +175 -0
- package/src/utils/mcp/interpolateSpec.ts +97 -0
- package/src/utils/notification/dispatch.ts +51 -15
- package/src/utils/path/workspaceLinkRouter.ts +99 -0
- package/src/utils/session/mergeSessions.ts +5 -0
- package/src/utils/sources/filter.ts +69 -0
- package/src/vite-env.d.ts +9 -0
- package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
- package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
- package/client/assets/index-Bm70FDU2.css +0 -1
- package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
- package/server/workspace/journal/linkRewrite.ts +0 -4
- package/src/components/ToolResultsPanel.vue +0 -77
- package/src/composables/useCanvasViewMode.ts +0 -121
- package/src/plugins/scheduler/definition.ts +0 -57
- package/src/utils/canvas/viewMode.ts +0 -46
- package/src/utils/role/plugins.ts +0 -12
- package/src/utils/session/seedRoleDefault.ts +0 -35
- /package/client/assets/{purify.es-Fx1Nqyry-PeS5RUhs.js → purify.es-Fx1Nqyry-BwJECkqS.js} +0 -0
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="w-full h-full overflow-y-auto p-8">
|
|
3
|
+
<div v-if="formData" class="max-w-3xl w-full mx-auto">
|
|
4
|
+
<!-- Form Title -->
|
|
5
|
+
<h2 v-if="formData.title" class="text-gray-900 text-3xl font-bold mb-4 text-center">
|
|
6
|
+
{{ formData.title }}
|
|
7
|
+
</h2>
|
|
8
|
+
|
|
9
|
+
<!-- Form Description -->
|
|
10
|
+
<p v-if="formData.description" class="text-gray-600 text-center mb-8 text-lg">
|
|
11
|
+
{{ formData.description }}
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<!-- Error Summary -->
|
|
15
|
+
<div v-if="showErrorSummary && fieldErrors.size > 0" class="bg-red-50 border-2 border-red-500 rounded-lg p-4 mb-6" role="alert">
|
|
16
|
+
<h3 class="text-red-800 font-semibold mb-2 flex items-center gap-2">
|
|
17
|
+
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
|
18
|
+
<path
|
|
19
|
+
fill-rule="evenodd"
|
|
20
|
+
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
|
21
|
+
clip-rule="evenodd"
|
|
22
|
+
/>
|
|
23
|
+
</svg>
|
|
24
|
+
{{ t("pluginPresentForm.errorSummary") }}
|
|
25
|
+
</h3>
|
|
26
|
+
<ul class="text-red-700 space-y-1">
|
|
27
|
+
<li v-for="[fieldId, error] in fieldErrors" :key="fieldId">
|
|
28
|
+
<a :href="`#${fieldId}`" @click.prevent="focusField(fieldId)" class="hover:underline cursor-pointer">
|
|
29
|
+
{{ error.message }}
|
|
30
|
+
</a>
|
|
31
|
+
</li>
|
|
32
|
+
</ul>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<!-- Form Fields -->
|
|
36
|
+
<form @submit.prevent="handleSubmit" class="space-y-6">
|
|
37
|
+
<div
|
|
38
|
+
v-for="field in formData.fields"
|
|
39
|
+
:key="field.id"
|
|
40
|
+
:id="field.id"
|
|
41
|
+
class="form-field"
|
|
42
|
+
:class="{ 'has-error': hasError(field.id) && touched.has(field.id) }"
|
|
43
|
+
>
|
|
44
|
+
<!-- Field Label -->
|
|
45
|
+
<label
|
|
46
|
+
:for="`input-${field.id}`"
|
|
47
|
+
class="block text-gray-800 font-semibold mb-2"
|
|
48
|
+
:class="{
|
|
49
|
+
'text-red-600': hasError(field.id) && touched.has(field.id),
|
|
50
|
+
}"
|
|
51
|
+
>
|
|
52
|
+
{{ field.label }}
|
|
53
|
+
<span v-if="field.required" class="text-red-500 ml-1" aria-label="required">{{ t("pluginPresentForm.requiredMarker") }}</span>
|
|
54
|
+
</label>
|
|
55
|
+
|
|
56
|
+
<!-- Field Description -->
|
|
57
|
+
<p v-if="field.description" class="text-gray-600 text-sm mb-2">
|
|
58
|
+
{{ field.description }}
|
|
59
|
+
</p>
|
|
60
|
+
|
|
61
|
+
<!-- Text Field -->
|
|
62
|
+
<input
|
|
63
|
+
v-if="field.type === 'text'"
|
|
64
|
+
:id="`input-${field.id}`"
|
|
65
|
+
v-model="formValues[field.id]"
|
|
66
|
+
type="text"
|
|
67
|
+
:placeholder="field.placeholder"
|
|
68
|
+
:aria-invalid="hasError(field.id) && touched.has(field.id)"
|
|
69
|
+
:aria-describedby="hasError(field.id) && touched.has(field.id) ? `${field.id}-error` : undefined"
|
|
70
|
+
@blur="handleBlur(field.id)"
|
|
71
|
+
@input="handleInput(field.id)"
|
|
72
|
+
class="w-full px-4 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
|
|
73
|
+
:class="{
|
|
74
|
+
'border-red-500 focus:ring-red-500': hasError(field.id) && touched.has(field.id),
|
|
75
|
+
'border-gray-300': !hasError(field.id) || !touched.has(field.id),
|
|
76
|
+
}"
|
|
77
|
+
/>
|
|
78
|
+
|
|
79
|
+
<!-- Textarea Field -->
|
|
80
|
+
<textarea
|
|
81
|
+
v-else-if="field.type === 'textarea'"
|
|
82
|
+
:id="`input-${field.id}`"
|
|
83
|
+
v-model="formValues[field.id]"
|
|
84
|
+
:placeholder="field.placeholder"
|
|
85
|
+
:rows="field.rows || 4"
|
|
86
|
+
:aria-invalid="hasError(field.id) && touched.has(field.id)"
|
|
87
|
+
:aria-describedby="hasError(field.id) && touched.has(field.id) ? `${field.id}-error` : undefined"
|
|
88
|
+
@blur="handleBlur(field.id)"
|
|
89
|
+
@input="handleInput(field.id)"
|
|
90
|
+
class="w-full px-4 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors resize-y"
|
|
91
|
+
:class="{
|
|
92
|
+
'border-red-500 focus:ring-red-500': hasError(field.id) && touched.has(field.id),
|
|
93
|
+
'border-gray-300': !hasError(field.id) || !touched.has(field.id),
|
|
94
|
+
}"
|
|
95
|
+
/>
|
|
96
|
+
|
|
97
|
+
<!-- Number Field -->
|
|
98
|
+
<input
|
|
99
|
+
v-else-if="field.type === 'number'"
|
|
100
|
+
:id="`input-${field.id}`"
|
|
101
|
+
v-model.number="formValues[field.id]"
|
|
102
|
+
type="number"
|
|
103
|
+
:min="field.min"
|
|
104
|
+
:max="field.max"
|
|
105
|
+
:step="field.step"
|
|
106
|
+
:aria-invalid="hasError(field.id) && touched.has(field.id)"
|
|
107
|
+
:aria-describedby="hasError(field.id) && touched.has(field.id) ? `${field.id}-error` : undefined"
|
|
108
|
+
@blur="handleBlur(field.id)"
|
|
109
|
+
@input="handleInput(field.id)"
|
|
110
|
+
class="w-full px-4 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
|
|
111
|
+
:class="{
|
|
112
|
+
'border-red-500 focus:ring-red-500': hasError(field.id) && touched.has(field.id),
|
|
113
|
+
'border-gray-300': !hasError(field.id) || !touched.has(field.id),
|
|
114
|
+
}"
|
|
115
|
+
/>
|
|
116
|
+
|
|
117
|
+
<!-- Date Field -->
|
|
118
|
+
<input
|
|
119
|
+
v-else-if="field.type === 'date'"
|
|
120
|
+
:id="`input-${field.id}`"
|
|
121
|
+
v-model="formValues[field.id]"
|
|
122
|
+
type="date"
|
|
123
|
+
:min="field.minDate"
|
|
124
|
+
:max="field.maxDate"
|
|
125
|
+
:aria-invalid="hasError(field.id) && touched.has(field.id)"
|
|
126
|
+
:aria-describedby="hasError(field.id) && touched.has(field.id) ? `${field.id}-error` : undefined"
|
|
127
|
+
@blur="handleBlur(field.id)"
|
|
128
|
+
@change="handleInput(field.id)"
|
|
129
|
+
class="w-full px-4 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
|
|
130
|
+
:class="{
|
|
131
|
+
'border-red-500 focus:ring-red-500': hasError(field.id) && touched.has(field.id),
|
|
132
|
+
'border-gray-300': !hasError(field.id) || !touched.has(field.id),
|
|
133
|
+
}"
|
|
134
|
+
/>
|
|
135
|
+
|
|
136
|
+
<!-- Time Field -->
|
|
137
|
+
<input
|
|
138
|
+
v-else-if="field.type === 'time'"
|
|
139
|
+
:id="`input-${field.id}`"
|
|
140
|
+
v-model="formValues[field.id]"
|
|
141
|
+
type="time"
|
|
142
|
+
:aria-invalid="hasError(field.id) && touched.has(field.id)"
|
|
143
|
+
:aria-describedby="hasError(field.id) && touched.has(field.id) ? `${field.id}-error` : undefined"
|
|
144
|
+
@blur="handleBlur(field.id)"
|
|
145
|
+
@change="handleInput(field.id)"
|
|
146
|
+
class="w-full px-4 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
|
|
147
|
+
:class="{
|
|
148
|
+
'border-red-500 focus:ring-red-500': hasError(field.id) && touched.has(field.id),
|
|
149
|
+
'border-gray-300': !hasError(field.id) || !touched.has(field.id),
|
|
150
|
+
}"
|
|
151
|
+
/>
|
|
152
|
+
|
|
153
|
+
<!-- Radio Field -->
|
|
154
|
+
<div
|
|
155
|
+
v-else-if="field.type === 'radio'"
|
|
156
|
+
class="space-y-2"
|
|
157
|
+
role="radiogroup"
|
|
158
|
+
:aria-invalid="hasError(field.id) && touched.has(field.id)"
|
|
159
|
+
:aria-describedby="hasError(field.id) && touched.has(field.id) ? `${field.id}-error` : undefined"
|
|
160
|
+
>
|
|
161
|
+
<label
|
|
162
|
+
v-for="(choice, index) in field.choices"
|
|
163
|
+
:key="index"
|
|
164
|
+
class="flex items-center p-3 border-2 border-gray-300 rounded-lg cursor-pointer transition-all hover:bg-gray-50"
|
|
165
|
+
:class="{
|
|
166
|
+
'border-blue-500 bg-blue-50': formValues[field.id] === index,
|
|
167
|
+
'border-gray-300': formValues[field.id] !== index,
|
|
168
|
+
}"
|
|
169
|
+
>
|
|
170
|
+
<input
|
|
171
|
+
type="radio"
|
|
172
|
+
:name="field.id"
|
|
173
|
+
:value="index"
|
|
174
|
+
v-model="formValues[field.id]"
|
|
175
|
+
@change="handleInput(field.id)"
|
|
176
|
+
@blur="handleBlur(field.id)"
|
|
177
|
+
class="mr-3 h-4 w-4 flex-shrink-0"
|
|
178
|
+
/>
|
|
179
|
+
<span class="text-gray-800">{{ choice }}</span>
|
|
180
|
+
</label>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<!-- Dropdown Field -->
|
|
184
|
+
<select
|
|
185
|
+
v-else-if="field.type === 'dropdown'"
|
|
186
|
+
:id="`input-${field.id}`"
|
|
187
|
+
v-model="formValues[field.id]"
|
|
188
|
+
:aria-invalid="hasError(field.id) && touched.has(field.id)"
|
|
189
|
+
:aria-describedby="hasError(field.id) && touched.has(field.id) ? `${field.id}-error` : undefined"
|
|
190
|
+
@blur="handleBlur(field.id)"
|
|
191
|
+
@change="handleInput(field.id)"
|
|
192
|
+
class="w-full px-4 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors bg-white"
|
|
193
|
+
:class="{
|
|
194
|
+
'border-red-500 focus:ring-red-500': hasError(field.id) && touched.has(field.id),
|
|
195
|
+
'border-gray-300': !hasError(field.id) || !touched.has(field.id),
|
|
196
|
+
}"
|
|
197
|
+
>
|
|
198
|
+
<option :value="null" disabled>{{ t("pluginPresentForm.selectOption") }}</option>
|
|
199
|
+
<option v-for="(choice, index) in field.choices" :key="index" :value="index">
|
|
200
|
+
{{ choice }}
|
|
201
|
+
</option>
|
|
202
|
+
</select>
|
|
203
|
+
|
|
204
|
+
<!-- Checkbox Field -->
|
|
205
|
+
<div
|
|
206
|
+
v-else-if="field.type === 'checkbox'"
|
|
207
|
+
class="space-y-2"
|
|
208
|
+
role="group"
|
|
209
|
+
:aria-invalid="hasError(field.id) && touched.has(field.id)"
|
|
210
|
+
:aria-describedby="hasError(field.id) && touched.has(field.id) ? `${field.id}-error` : undefined"
|
|
211
|
+
>
|
|
212
|
+
<label
|
|
213
|
+
v-for="(choice, index) in field.choices"
|
|
214
|
+
:key="index"
|
|
215
|
+
class="flex items-center p-3 border-2 border-gray-300 rounded-lg cursor-pointer transition-all hover:bg-gray-50"
|
|
216
|
+
:class="{
|
|
217
|
+
'border-blue-500 bg-blue-50': (formValues[field.id] || []).includes(index),
|
|
218
|
+
'border-gray-300': !(formValues[field.id] || []).includes(index),
|
|
219
|
+
}"
|
|
220
|
+
>
|
|
221
|
+
<input
|
|
222
|
+
type="checkbox"
|
|
223
|
+
:value="index"
|
|
224
|
+
v-model="formValues[field.id]"
|
|
225
|
+
@change="handleInput(field.id)"
|
|
226
|
+
@blur="handleBlur(field.id)"
|
|
227
|
+
class="mr-3 h-4 w-4 flex-shrink-0"
|
|
228
|
+
/>
|
|
229
|
+
<span class="text-gray-800">{{ choice }}</span>
|
|
230
|
+
</label>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<!-- Error Message -->
|
|
234
|
+
<div
|
|
235
|
+
v-if="hasError(field.id) && touched.has(field.id)"
|
|
236
|
+
:id="`${field.id}-error`"
|
|
237
|
+
class="flex items-center gap-2 mt-2 text-red-600 text-sm"
|
|
238
|
+
role="alert"
|
|
239
|
+
>
|
|
240
|
+
<svg class="w-4 h-4 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
|
241
|
+
<path
|
|
242
|
+
fill-rule="evenodd"
|
|
243
|
+
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
|
244
|
+
clip-rule="evenodd"
|
|
245
|
+
/>
|
|
246
|
+
</svg>
|
|
247
|
+
{{ fieldErrors.get(field.id)?.message }}
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
<!-- Character Count (for text/textarea with maxLength) -->
|
|
251
|
+
<div
|
|
252
|
+
v-if="showCharCount(field)"
|
|
253
|
+
class="text-sm mt-2"
|
|
254
|
+
:class="{
|
|
255
|
+
'text-amber-600 font-semibold': isNearLimit(field),
|
|
256
|
+
'text-gray-500': !isNearLimit(field),
|
|
257
|
+
}"
|
|
258
|
+
>
|
|
259
|
+
<template v-if="field.maxLength">
|
|
260
|
+
{{ t("pluginPresentForm.charactersCount", { current: (formValues[field.id] || "").length, max: field.maxLength }) }}
|
|
261
|
+
</template>
|
|
262
|
+
<template v-else>
|
|
263
|
+
{{ t("pluginPresentForm.charactersCountNoMax", { current: (formValues[field.id] || "").length }) }}
|
|
264
|
+
</template>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<!-- Submit Button -->
|
|
269
|
+
<div class="mt-8 flex justify-center">
|
|
270
|
+
<button
|
|
271
|
+
type="submit"
|
|
272
|
+
:disabled="submitted"
|
|
273
|
+
:class="submitted ? 'bg-green-600 cursor-default' : 'bg-blue-600 hover:bg-blue-700'"
|
|
274
|
+
class="px-8 py-3 rounded-lg text-white font-semibold text-lg transition-colors"
|
|
275
|
+
>
|
|
276
|
+
{{ submitted ? t("pluginPresentForm.submitted") : t("pluginPresentForm.submit") }}
|
|
277
|
+
</button>
|
|
278
|
+
</div>
|
|
279
|
+
|
|
280
|
+
<!-- Progress Indicator -->
|
|
281
|
+
<div class="mt-4 text-center text-gray-600 text-sm">
|
|
282
|
+
{{ t("pluginPresentForm.progress", { filled: filledRequiredCount, total: requiredFieldsCount }) }}
|
|
283
|
+
</div>
|
|
284
|
+
</form>
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
</template>
|
|
288
|
+
|
|
289
|
+
<script setup lang="ts">
|
|
290
|
+
import { ref, watch, computed } from "vue";
|
|
291
|
+
import { useI18n } from "vue-i18n";
|
|
292
|
+
import type { ToolResult } from "gui-chat-protocol";
|
|
293
|
+
import type { FormData, FormField, TextField, TextareaField, NumberField, DateField, CheckboxField } from "./types";
|
|
294
|
+
|
|
295
|
+
const { t } = useI18n();
|
|
296
|
+
|
|
297
|
+
interface FieldError {
|
|
298
|
+
fieldId: string;
|
|
299
|
+
message: string;
|
|
300
|
+
type: "required" | "format" | "range" | "pattern" | "custom";
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
interface FormViewState {
|
|
304
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
305
|
+
userResponses: Record<string, any>;
|
|
306
|
+
touched: string[];
|
|
307
|
+
submitted?: boolean;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const props = defineProps<{
|
|
311
|
+
selectedResult: ToolResult | null;
|
|
312
|
+
sendTextMessage: (text?: string) => void;
|
|
313
|
+
}>();
|
|
314
|
+
|
|
315
|
+
const emit = defineEmits<{
|
|
316
|
+
updateResult: [result: ToolResult];
|
|
317
|
+
}>();
|
|
318
|
+
|
|
319
|
+
const formData = ref<FormData | null>(null);
|
|
320
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
321
|
+
const formValues = ref<Record<string, any>>({});
|
|
322
|
+
const touched = ref<Set<string>>(new Set());
|
|
323
|
+
const fieldErrors = ref<Map<string, FieldError>>(new Map());
|
|
324
|
+
const submitted = ref<boolean>(false);
|
|
325
|
+
const showErrorSummary = ref<boolean>(false);
|
|
326
|
+
const isRestoring = ref<boolean>(false);
|
|
327
|
+
|
|
328
|
+
function isFreshResult(newResult: ToolResult, oldResult: ToolResult | null | undefined): boolean {
|
|
329
|
+
return !oldResult || !oldResult.jsonData || oldResult.uuid !== newResult.uuid || oldResult.jsonData !== newResult.jsonData;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function restoreViewState(state: FormViewState): void {
|
|
333
|
+
if (state.userResponses) Object.assign(formValues.value, state.userResponses);
|
|
334
|
+
if (state.touched) {
|
|
335
|
+
touched.value = new Set(state.touched);
|
|
336
|
+
state.touched.forEach((fieldId) => validateField(fieldId));
|
|
337
|
+
}
|
|
338
|
+
if (state.submitted !== undefined) submitted.value = state.submitted;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function applyNewResult(newResult: ToolResult): void {
|
|
342
|
+
isRestoring.value = true;
|
|
343
|
+
formData.value = newResult.jsonData as FormData;
|
|
344
|
+
formValues.value = {};
|
|
345
|
+
formData.value.fields.forEach((field) => {
|
|
346
|
+
formValues.value[field.id] = getDefaultValue(field);
|
|
347
|
+
});
|
|
348
|
+
if (newResult.viewState) restoreViewState(newResult.viewState as unknown as FormViewState);
|
|
349
|
+
isRestoring.value = false;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Initialize form data and restore state
|
|
353
|
+
watch(
|
|
354
|
+
() => props.selectedResult,
|
|
355
|
+
(newResult, oldResult) => {
|
|
356
|
+
if (!newResult || newResult.toolName !== "presentForm" || !newResult.jsonData) return;
|
|
357
|
+
if (!isFreshResult(newResult, oldResult)) return;
|
|
358
|
+
applyNewResult(newResult);
|
|
359
|
+
},
|
|
360
|
+
{ immediate: true },
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
// Save state to viewState - watch all state changes together
|
|
364
|
+
watch(
|
|
365
|
+
[formValues, touched, submitted],
|
|
366
|
+
() => {
|
|
367
|
+
if (isRestoring.value || !props.selectedResult) return;
|
|
368
|
+
|
|
369
|
+
const updatedResult: ToolResult = {
|
|
370
|
+
...props.selectedResult,
|
|
371
|
+
viewState: {
|
|
372
|
+
userResponses: { ...formValues.value },
|
|
373
|
+
touched: Array.from(touched.value),
|
|
374
|
+
submitted: submitted.value,
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
emit("updateResult", updatedResult);
|
|
378
|
+
},
|
|
379
|
+
{ deep: true },
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
383
|
+
function convertProvidedDefault(field: FormField, provided: any): any {
|
|
384
|
+
if (field.type === "radio" || field.type === "dropdown") {
|
|
385
|
+
const choiceIndex = field.choices.indexOf(provided as string);
|
|
386
|
+
return choiceIndex !== -1 ? choiceIndex : null;
|
|
387
|
+
}
|
|
388
|
+
if (field.type === "checkbox") {
|
|
389
|
+
if (!Array.isArray(provided)) return [];
|
|
390
|
+
return provided.map((val: string) => field.choices.indexOf(val)).filter((idx: number) => idx !== -1);
|
|
391
|
+
}
|
|
392
|
+
return provided;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
396
|
+
function emptyValueForField(field: FormField): any {
|
|
397
|
+
if (field.type === "text" || field.type === "textarea" || field.type === "date" || field.type === "time") return "";
|
|
398
|
+
if (field.type === "number") return (field as NumberField).min !== undefined ? (field as NumberField).min : 0;
|
|
399
|
+
if (field.type === "checkbox") return [];
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
404
|
+
function getDefaultValue(field: FormField): any {
|
|
405
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
406
|
+
const provided = (field as FormField & { defaultValue?: any }).defaultValue;
|
|
407
|
+
if (provided !== undefined) return convertProvidedDefault(field, provided);
|
|
408
|
+
return emptyValueForField(field);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
412
|
+
function isEmpty(value: any): boolean {
|
|
413
|
+
if (value === null || value === undefined) return true;
|
|
414
|
+
if (typeof value === "string") return value.trim() === "";
|
|
415
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// 254 = RFC 5321 maximum address length. indexOf-based check avoids
|
|
420
|
+
// the chained `[^\s@]+` regex pattern that sonarjs flags as
|
|
421
|
+
// vulnerable to super-linear backtracking.
|
|
422
|
+
const EMAIL_MAX_LENGTH = 254;
|
|
423
|
+
function isValidEmail(email: string): boolean {
|
|
424
|
+
if (!email || email.length > EMAIL_MAX_LENGTH) return false;
|
|
425
|
+
const atIndex = email.indexOf("@");
|
|
426
|
+
if (atIndex <= 0 || atIndex !== email.lastIndexOf("@")) return false;
|
|
427
|
+
const local = email.slice(0, atIndex);
|
|
428
|
+
const domain = email.slice(atIndex + 1);
|
|
429
|
+
if (/\s/.test(local) || /\s/.test(domain)) return false;
|
|
430
|
+
const dot = domain.lastIndexOf(".");
|
|
431
|
+
return dot > 0 && dot < domain.length - 1;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function isValidUrl(url: string): boolean {
|
|
435
|
+
try {
|
|
436
|
+
new URL(url);
|
|
437
|
+
return true;
|
|
438
|
+
} catch {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function isValidPhone(phone: string): boolean {
|
|
444
|
+
const phoneRegex = /^[\d\s\-+()]+$/;
|
|
445
|
+
return phoneRegex.test(phone) && phone.replace(/\D/g, "").length >= 10;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function validateText(field: TextField, value: string): string | null {
|
|
449
|
+
if (field.validation === "email" && !isValidEmail(value)) return "Please enter a valid email address";
|
|
450
|
+
if (field.validation === "url" && !isValidUrl(value)) return "Please enter a valid URL";
|
|
451
|
+
if (field.validation === "phone" && !isValidPhone(value)) return "Please enter a valid phone number";
|
|
452
|
+
if (typeof field.validation === "string" && !["email", "url", "phone"].includes(field.validation)) {
|
|
453
|
+
try {
|
|
454
|
+
if (!new RegExp(field.validation).test(value)) return `${field.label} format is invalid`;
|
|
455
|
+
} catch {
|
|
456
|
+
console.warn(`Invalid regex pattern: ${field.validation}`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function validateTextarea(field: TextareaField, value: string): string | null {
|
|
463
|
+
if (field.minLength && value.length < field.minLength) {
|
|
464
|
+
return `Must be at least ${field.minLength} characters (currently ${value.length})`;
|
|
465
|
+
}
|
|
466
|
+
if (field.maxLength && value.length > field.maxLength) {
|
|
467
|
+
return `Must be no more than ${field.maxLength} characters (currently ${value.length})`;
|
|
468
|
+
}
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function validateNumber(field: NumberField, value: number): string | null {
|
|
473
|
+
if (field.min !== undefined && value < field.min) return `Must be at least ${field.min}`;
|
|
474
|
+
if (field.max !== undefined && value > field.max) return `Must be no more than ${field.max}`;
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function validateDate(field: DateField, value: string): string | null {
|
|
479
|
+
if (field.minDate && value < field.minDate) return `Date must be on or after ${field.minDate}`;
|
|
480
|
+
if (field.maxDate && value > field.maxDate) return `Date must be on or before ${field.maxDate}`;
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function validateCheckbox(field: CheckboxField, value: number[]): string | null {
|
|
485
|
+
const selectedCount = value?.length || 0;
|
|
486
|
+
if (field.minSelections && selectedCount < field.minSelections) {
|
|
487
|
+
return `Please select at least ${field.minSelections} option${field.minSelections > 1 ? "s" : ""}`;
|
|
488
|
+
}
|
|
489
|
+
if (field.maxSelections && selectedCount > field.maxSelections) {
|
|
490
|
+
return `Please select no more than ${field.maxSelections} option${field.maxSelections > 1 ? "s" : ""}`;
|
|
491
|
+
}
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
496
|
+
function getErrorMessage(field: FormField, value: any): string | null {
|
|
497
|
+
if (field.required && isEmpty(value)) return `${field.label} is required`;
|
|
498
|
+
if (isEmpty(value)) return null;
|
|
499
|
+
if (field.type === "text") return validateText(field as TextField, value);
|
|
500
|
+
if (field.type === "textarea") return validateTextarea(field as TextareaField, value);
|
|
501
|
+
if (field.type === "number") return validateNumber(field as NumberField, value);
|
|
502
|
+
if (field.type === "date") return validateDate(field as DateField, value);
|
|
503
|
+
if (field.type === "checkbox") return validateCheckbox(field as CheckboxField, value);
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function validateField(fieldId: string): boolean {
|
|
508
|
+
const field = formData.value?.fields.find((candidate) => candidate.id === fieldId);
|
|
509
|
+
if (!field) return true;
|
|
510
|
+
|
|
511
|
+
const value = formValues.value[fieldId];
|
|
512
|
+
const errorMessage = getErrorMessage(field, value);
|
|
513
|
+
|
|
514
|
+
if (errorMessage) {
|
|
515
|
+
fieldErrors.value.set(fieldId, {
|
|
516
|
+
fieldId,
|
|
517
|
+
message: errorMessage,
|
|
518
|
+
type: "custom",
|
|
519
|
+
});
|
|
520
|
+
return false;
|
|
521
|
+
} else {
|
|
522
|
+
fieldErrors.value.delete(fieldId);
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function handleBlur(fieldId: string): void {
|
|
528
|
+
touched.value.add(fieldId);
|
|
529
|
+
validateField(fieldId);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function handleInput(fieldId: string): void {
|
|
533
|
+
// Real-time validation for fields that are already touched
|
|
534
|
+
if (touched.value.has(fieldId)) {
|
|
535
|
+
validateField(fieldId);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function hasError(fieldId: string): boolean {
|
|
540
|
+
return fieldErrors.value.has(fieldId);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function focusField(fieldId: string): void {
|
|
544
|
+
const element = document.getElementById(`input-${fieldId}`);
|
|
545
|
+
if (element) {
|
|
546
|
+
element.focus();
|
|
547
|
+
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function showCharCount(field: FormField): boolean {
|
|
552
|
+
return (field.type === "text" || field.type === "textarea") && (field as TextField | TextareaField).maxLength !== undefined;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function isNearLimit(field: FormField): boolean {
|
|
556
|
+
if (field.type !== "text" && field.type !== "textarea") return false;
|
|
557
|
+
const maxLength = (field as TextField | TextareaField).maxLength;
|
|
558
|
+
if (!maxLength) return false;
|
|
559
|
+
const currentLength = (formValues.value[field.id] || "").length;
|
|
560
|
+
return currentLength / maxLength > 0.9;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const requiredFieldsCount = computed(() => {
|
|
564
|
+
return formData.value?.fields.filter((field) => field.required).length || 0;
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
const filledRequiredCount = computed(() => {
|
|
568
|
+
if (!formData.value) return 0;
|
|
569
|
+
return formData.value.fields.filter((field) => field.required && !isEmpty(formValues.value[field.id])).length;
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
function handleSubmit(): void {
|
|
573
|
+
if (submitted.value) return;
|
|
574
|
+
|
|
575
|
+
// Mark all fields as touched
|
|
576
|
+
formData.value?.fields.forEach((field) => {
|
|
577
|
+
touched.value.add(field.id);
|
|
578
|
+
validateField(field.id);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
// Check for errors
|
|
582
|
+
if (fieldErrors.value.size > 0) {
|
|
583
|
+
showErrorSummary.value = true;
|
|
584
|
+
// Focus first error field
|
|
585
|
+
const firstErrorFieldId = Array.from(fieldErrors.value.keys())[0];
|
|
586
|
+
focusField(firstErrorFieldId);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Build a markdown bullet list of `- {label}: {value}` lines so the
|
|
591
|
+
// chat history reads naturally for a human while still being trivial
|
|
592
|
+
// for the LLM to parse. Empty values become "(none)". Titles, labels,
|
|
593
|
+
// and choice strings are passed through singleLine() to strip any
|
|
594
|
+
// newlines they might contain — without that, a label like
|
|
595
|
+
// "Address (street\nand number)" would shatter the bullet structure.
|
|
596
|
+
const lines: string[] = [];
|
|
597
|
+
if (formData.value?.title) lines.push(`**${singleLine(formData.value.title)}**`, "");
|
|
598
|
+
formData.value?.fields.forEach((field) => {
|
|
599
|
+
lines.push(`- ${singleLine(field.label)}: ${renderValue(field, formValues.value[field.id])}`);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
submitted.value = true;
|
|
603
|
+
props.sendTextMessage(lines.join("\n"));
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Indent every line except the first by 2 spaces — under markdown
|
|
607
|
+
// rules, indented continuations stay attached to the preceding bullet
|
|
608
|
+
// instead of starting a new top-level item. Without this, a textarea
|
|
609
|
+
// value containing newlines would inject phantom bullets into the
|
|
610
|
+
// chat-side payload that the LLM could mis-parse.
|
|
611
|
+
function indentContinuation(text: string): string {
|
|
612
|
+
return text.replace(/\n/g, "\n ");
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Collapse any newline (and surrounding whitespace) into a single
|
|
616
|
+
// space. Used on titles, labels, and choice strings (LLM-authored
|
|
617
|
+
// fields) so they can't smuggle a newline into the markdown bullet
|
|
618
|
+
// structure and reshape the payload. Free-form user input
|
|
619
|
+
// (text/textarea values) uses indentContinuation instead — it
|
|
620
|
+
// preserves multi-line values. Implemented via split-trim-join (not a
|
|
621
|
+
// global regex) to avoid sonarjs's slow-regex flag on `\s*\n\s*`.
|
|
622
|
+
function singleLine(text: string): string {
|
|
623
|
+
return text
|
|
624
|
+
.split(/\r?\n/)
|
|
625
|
+
.map((segment) => segment.trim())
|
|
626
|
+
.filter((segment) => segment.length > 0)
|
|
627
|
+
.join(" ");
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
631
|
+
function renderValue(field: FormField, value: any): string {
|
|
632
|
+
const empty = "(none)";
|
|
633
|
+
if (field.type === "radio" || field.type === "dropdown") {
|
|
634
|
+
return value !== null && value !== undefined ? singleLine(field.choices[value]) : empty;
|
|
635
|
+
}
|
|
636
|
+
// Checkbox values render as a nested bullet sublist rather than a
|
|
637
|
+
// comma-joined string. Comma-joining is ambiguous when a choice label
|
|
638
|
+
// itself contains a comma; the sublist is unambiguous and survives
|
|
639
|
+
// round-trip parsing.
|
|
640
|
+
if (field.type === "checkbox") {
|
|
641
|
+
const items: string[] = (value || []).map((idx: number) => singleLine(field.choices[idx]));
|
|
642
|
+
if (items.length === 0) return empty;
|
|
643
|
+
return "\n - " + items.join("\n - ");
|
|
644
|
+
}
|
|
645
|
+
if (typeof value === "string") {
|
|
646
|
+
const trimmed = value.trim();
|
|
647
|
+
return trimmed === "" ? empty : indentContinuation(trimmed);
|
|
648
|
+
}
|
|
649
|
+
if (value === null || value === undefined) return empty;
|
|
650
|
+
return String(value);
|
|
651
|
+
}
|
|
652
|
+
</script>
|
|
653
|
+
|
|
654
|
+
<style scoped>
|
|
655
|
+
.form-field {
|
|
656
|
+
transition: all 0.2s ease;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
.form-field.has-error {
|
|
660
|
+
animation: shake 0.3s ease;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
@keyframes shake {
|
|
664
|
+
0%,
|
|
665
|
+
100% {
|
|
666
|
+
transform: translateX(0);
|
|
667
|
+
}
|
|
668
|
+
25% {
|
|
669
|
+
transform: translateX(-5px);
|
|
670
|
+
}
|
|
671
|
+
75% {
|
|
672
|
+
transform: translateX(5px);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
</style>
|