hermes-web-ui 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +434 -0
  2. package/assets/logo.png +0 -0
  3. package/bin/hermes-web-ui.mjs +24 -0
  4. package/index.html +13 -0
  5. package/package.json +44 -0
  6. package/public/favicon.svg +1 -0
  7. package/public/icons.svg +24 -0
  8. package/src/App.vue +54 -0
  9. package/src/api/chat.ts +87 -0
  10. package/src/api/client.ts +44 -0
  11. package/src/api/jobs.ts +100 -0
  12. package/src/api/system.ts +25 -0
  13. package/src/assets/hero.png +0 -0
  14. package/src/assets/vite.svg +1 -0
  15. package/src/components/chat/ChatInput.vue +123 -0
  16. package/src/components/chat/ChatPanel.vue +289 -0
  17. package/src/components/chat/MarkdownRenderer.vue +187 -0
  18. package/src/components/chat/MessageItem.vue +189 -0
  19. package/src/components/chat/MessageList.vue +94 -0
  20. package/src/components/jobs/JobCard.vue +244 -0
  21. package/src/components/jobs/JobFormModal.vue +188 -0
  22. package/src/components/jobs/JobsPanel.vue +58 -0
  23. package/src/components/layout/AppSidebar.vue +169 -0
  24. package/src/composables/useKeyboard.ts +39 -0
  25. package/src/env.d.ts +7 -0
  26. package/src/main.ts +10 -0
  27. package/src/router/index.ts +24 -0
  28. package/src/stores/app.ts +66 -0
  29. package/src/stores/chat.ts +344 -0
  30. package/src/stores/jobs.ts +72 -0
  31. package/src/styles/global.scss +60 -0
  32. package/src/styles/theme.ts +71 -0
  33. package/src/styles/variables.scss +56 -0
  34. package/src/views/ChatView.vue +25 -0
  35. package/src/views/JobsView.vue +93 -0
  36. package/src/views/SettingsView.vue +257 -0
  37. package/tsconfig.app.json +17 -0
  38. package/tsconfig.json +7 -0
  39. package/tsconfig.node.json +24 -0
  40. package/vite.config.ts +39 -0
@@ -0,0 +1,188 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted, computed } from 'vue'
3
+ import { NModal, NForm, NFormItem, NInput, NButton, NSelect, NInputNumber, useMessage } from 'naive-ui'
4
+ import { useJobsStore } from '@/stores/jobs'
5
+
6
+ const props = defineProps<{
7
+ jobId: string | null
8
+ }>()
9
+
10
+ const emit = defineEmits<{
11
+ close: []
12
+ saved: []
13
+ }>()
14
+
15
+ const jobsStore = useJobsStore()
16
+ const message = useMessage()
17
+
18
+ const showModal = ref(true)
19
+ const loading = ref(false)
20
+
21
+ const formData = ref({
22
+ name: '',
23
+ schedule: '',
24
+ prompt: '',
25
+ deliver: 'origin',
26
+ repeat_times: null as number | null,
27
+ })
28
+
29
+ const presetValue = ref<string | null>(null)
30
+
31
+ const isEdit = computed(() => !!props.jobId)
32
+
33
+ const schedulePresets = [
34
+ { label: 'Every minute', value: '* * * * *' },
35
+ { label: 'Every 5 minutes', value: '*/5 * * * *' },
36
+ { label: 'Every hour', value: '0 * * * *' },
37
+ { label: 'Every day at 00:00', value: '0 0 * * *' },
38
+ { label: 'Every day at 09:00', value: '0 9 * * *' },
39
+ { label: 'Every Monday at 09:00', value: '0 9 * * 1' },
40
+ { label: 'Every month 1st at 09:00', value: '0 9 1 * *' },
41
+ ]
42
+
43
+ const targetOptions = [
44
+ { label: 'Origin', value: 'origin' },
45
+ { label: 'Local', value: 'local' },
46
+ ]
47
+
48
+ onMounted(async () => {
49
+ if (props.jobId) {
50
+ try {
51
+ const { getJob } = await import('@/api/jobs')
52
+ const job = await getJob(props.jobId)
53
+ formData.value = {
54
+ name: job.name,
55
+ schedule: typeof job.schedule === 'string' ? job.schedule : (job.schedule?.expr || job.schedule_display || ''),
56
+ prompt: job.prompt,
57
+ deliver: job.deliver || 'origin',
58
+ repeat_times: typeof job.repeat === 'number' ? job.repeat : (typeof job.repeat === 'object' ? job.repeat.times : null),
59
+ }
60
+ } catch (e: any) {
61
+ message.error('Failed to load job: ' + e.message)
62
+ }
63
+ }
64
+ })
65
+
66
+ async function handleSave() {
67
+ if (!formData.value.name.trim()) {
68
+ message.warning('Name is required')
69
+ return
70
+ }
71
+ if (!formData.value.schedule.trim()) {
72
+ message.warning('Schedule is required')
73
+ return
74
+ }
75
+
76
+ loading.value = true
77
+ try {
78
+ const payload = {
79
+ name: formData.value.name,
80
+ schedule: formData.value.schedule,
81
+ prompt: formData.value.prompt,
82
+ deliver: formData.value.deliver,
83
+ repeat: formData.value.repeat_times ?? undefined,
84
+ }
85
+
86
+ if (isEdit.value) {
87
+ await jobsStore.updateJob(props.jobId!, payload)
88
+ message.success('Job updated')
89
+ } else {
90
+ await jobsStore.createJob(payload)
91
+ message.success('Job created')
92
+ }
93
+ emit('saved')
94
+ } catch (e: any) {
95
+ message.error(e.message)
96
+ } finally {
97
+ loading.value = false
98
+ }
99
+ }
100
+
101
+ function handleClose() {
102
+ showModal.value = false
103
+ setTimeout(() => emit('close'), 200)
104
+ }
105
+ </script>
106
+
107
+ <template>
108
+ <NModal
109
+ v-model:show="showModal"
110
+ preset="card"
111
+ :title="isEdit ? 'Edit Job' : 'Create Job'"
112
+ :style="{ width: '520px' }"
113
+ :mask-closable="!loading"
114
+ @after-leave="emit('close')"
115
+ >
116
+ <NForm label-placement="top">
117
+ <NFormItem label="Name" required>
118
+ <NInput
119
+ v-model:value="formData.name"
120
+ placeholder="Job name"
121
+ maxlength="200"
122
+ show-count
123
+ />
124
+ </NFormItem>
125
+
126
+ <NFormItem label="Schedule (Cron Expression)" required>
127
+ <NInput
128
+ v-model:value="formData.schedule"
129
+ placeholder="e.g. 0 9 * * *"
130
+ />
131
+ </NFormItem>
132
+
133
+ <NFormItem label="Quick Presets">
134
+ <NSelect
135
+ v-model:value="presetValue"
136
+ :options="schedulePresets"
137
+ placeholder="Select a preset..."
138
+ @update:value="v => formData.schedule = v"
139
+ />
140
+ </NFormItem>
141
+
142
+ <NFormItem label="Prompt" required>
143
+ <NInput
144
+ v-model:value="formData.prompt"
145
+ type="textarea"
146
+ placeholder="The prompt to execute"
147
+ :rows="4"
148
+ maxlength="5000"
149
+ show-count
150
+ />
151
+ </NFormItem>
152
+
153
+ <NFormItem label="Deliver Target">
154
+ <NSelect
155
+ v-model:value="formData.deliver"
156
+ :options="targetOptions"
157
+ />
158
+ </NFormItem>
159
+
160
+ <NFormItem label="Repeat Count (optional)">
161
+ <NInputNumber
162
+ v-model:value="formData.repeat_times"
163
+ :min="1"
164
+ placeholder="Leave empty for infinite"
165
+ clearable
166
+ style="width: 100%"
167
+ />
168
+ </NFormItem>
169
+ </NForm>
170
+
171
+ <template #footer>
172
+ <div class="modal-footer">
173
+ <NButton @click="handleClose">Cancel</NButton>
174
+ <NButton type="primary" :loading="loading" @click="handleSave">
175
+ {{ isEdit ? 'Update' : 'Create' }}
176
+ </NButton>
177
+ </div>
178
+ </template>
179
+ </NModal>
180
+ </template>
181
+
182
+ <style scoped lang="scss">
183
+ .modal-footer {
184
+ display: flex;
185
+ justify-content: flex-end;
186
+ gap: 8px;
187
+ }
188
+ </style>
@@ -0,0 +1,58 @@
1
+ <script setup lang="ts">
2
+ import JobCard from './JobCard.vue'
3
+ import { useJobsStore } from '@/stores/jobs'
4
+
5
+ const emit = defineEmits<{
6
+ edit: [jobId: string]
7
+ }>()
8
+
9
+ const jobsStore = useJobsStore()
10
+ </script>
11
+
12
+ <template>
13
+ <div v-if="jobsStore.jobs.length === 0" class="empty-state">
14
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" class="empty-icon">
15
+ <rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
16
+ <line x1="16" y1="2" x2="16" y2="6"/>
17
+ <line x1="8" y1="2" x2="8" y2="6"/>
18
+ <line x1="3" y1="10" x2="21" y2="10"/>
19
+ </svg>
20
+ <p>No scheduled jobs yet. Create one to get started.</p>
21
+ </div>
22
+ <div v-else class="jobs-grid">
23
+ <JobCard
24
+ v-for="job in jobsStore.jobs"
25
+ :key="job.id"
26
+ :job="job"
27
+ @edit="emit('edit', job.id)"
28
+ />
29
+ </div>
30
+ </template>
31
+
32
+ <style scoped lang="scss">
33
+ @use '@/styles/variables' as *;
34
+
35
+ .empty-state {
36
+ display: flex;
37
+ flex-direction: column;
38
+ align-items: center;
39
+ justify-content: center;
40
+ height: 100%;
41
+ color: $text-muted;
42
+ gap: 12px;
43
+
44
+ .empty-icon {
45
+ opacity: 0.3;
46
+ }
47
+
48
+ p {
49
+ font-size: 14px;
50
+ }
51
+ }
52
+
53
+ .jobs-grid {
54
+ display: grid;
55
+ grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
56
+ gap: 14px;
57
+ }
58
+ </style>
@@ -0,0 +1,169 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { useRoute, useRouter } from 'vue-router'
4
+ import { useAppStore } from '@/stores/app'
5
+
6
+ const route = useRoute()
7
+ const router = useRouter()
8
+ const appStore = useAppStore()
9
+
10
+ const selectedKey = computed(() => route.name as string)
11
+
12
+ function handleNav(key: string) {
13
+ router.push({ name: key })
14
+ }
15
+ </script>
16
+
17
+ <template>
18
+ <aside class="sidebar">
19
+ <div class="sidebar-logo" @click="router.push('/')">
20
+ <img src="/assets/logo.png" alt="Hermes" class="logo-img" />
21
+ <span class="logo-text">Hermes</span>
22
+ </div>
23
+
24
+ <nav class="sidebar-nav">
25
+ <button
26
+ class="nav-item"
27
+ :class="{ active: selectedKey === 'chat' }"
28
+ @click="handleNav('chat')"
29
+ >
30
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
31
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
32
+ </svg>
33
+ <span>Chat</span>
34
+ </button>
35
+
36
+ <button
37
+ class="nav-item"
38
+ :class="{ active: selectedKey === 'jobs' }"
39
+ @click="handleNav('jobs')"
40
+ >
41
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
42
+ <rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
43
+ <line x1="16" y1="2" x2="16" y2="6" />
44
+ <line x1="8" y1="2" x2="8" y2="6" />
45
+ <line x1="3" y1="10" x2="21" y2="10" />
46
+ </svg>
47
+ <span>Jobs</span>
48
+ </button>
49
+ </nav>
50
+
51
+ <div class="sidebar-footer">
52
+ <div class="status-indicator" :class="{ connected: appStore.connected, disconnected: !appStore.connected }">
53
+ <span class="status-dot"></span>
54
+ <span class="status-text">{{ appStore.connected ? 'Connected' : 'Disconnected' }}</span>
55
+ </div>
56
+ <div class="version-info">Hermes {{ appStore.serverVersion || 'v0.1.0' }}</div>
57
+ </div>
58
+ </aside>
59
+ </template>
60
+
61
+ <style scoped lang="scss">
62
+ @use '@/styles/variables' as *;
63
+
64
+ .sidebar {
65
+ width: $sidebar-width;
66
+ height: 100vh;
67
+ background-color: $bg-sidebar;
68
+ border-right: 1px solid $border-color;
69
+ display: flex;
70
+ flex-direction: column;
71
+ padding: 20px 12px;
72
+ flex-shrink: 0;
73
+ transition: width $transition-normal;
74
+ }
75
+
76
+ .logo-img {
77
+ width: 28px;
78
+ height: 28px;
79
+ border-radius: 50%;
80
+ flex-shrink: 0;
81
+ }
82
+
83
+ .sidebar-logo {
84
+ display: flex;
85
+ align-items: center;
86
+ gap: 10px;
87
+ padding: 4px 12px 20px;
88
+ color: $text-primary;
89
+ cursor: pointer;
90
+
91
+ .logo-text {
92
+ font-size: 18px;
93
+ font-weight: 600;
94
+ letter-spacing: 0.5px;
95
+ }
96
+ }
97
+
98
+ .sidebar-nav {
99
+ flex: 1;
100
+ display: flex;
101
+ flex-direction: column;
102
+ gap: 4px;
103
+ }
104
+
105
+ .nav-item {
106
+ display: flex;
107
+ align-items: center;
108
+ gap: 10px;
109
+ padding: 10px 12px;
110
+ border: none;
111
+ background: none;
112
+ color: $text-secondary;
113
+ font-size: 14px;
114
+ border-radius: $radius-sm;
115
+ cursor: pointer;
116
+ transition: all $transition-fast;
117
+ width: 100%;
118
+ text-align: left;
119
+
120
+ &:hover {
121
+ background-color: rgba($accent-primary, 0.06);
122
+ color: $text-primary;
123
+ }
124
+
125
+ &.active {
126
+ background-color: rgba($accent-primary, 0.12);
127
+ color: $accent-primary;
128
+ }
129
+ }
130
+
131
+ .sidebar-footer {
132
+ padding-top: 16px;
133
+ border-top: 1px solid $border-color;
134
+ }
135
+
136
+ .status-indicator {
137
+ display: flex;
138
+ align-items: center;
139
+ gap: 8px;
140
+ padding: 8px 12px;
141
+ font-size: 12px;
142
+
143
+ .status-dot {
144
+ width: 8px;
145
+ height: 8px;
146
+ border-radius: 50%;
147
+ flex-shrink: 0;
148
+ }
149
+
150
+ &.connected .status-dot {
151
+ background-color: $success;
152
+ box-shadow: 0 0 6px rgba($success, 0.5);
153
+ }
154
+
155
+ &.disconnected .status-dot {
156
+ background-color: $error;
157
+ }
158
+
159
+ .status-text {
160
+ color: $text-secondary;
161
+ }
162
+ }
163
+
164
+ .version-info {
165
+ padding: 4px 12px;
166
+ font-size: 11px;
167
+ color: $text-muted;
168
+ }
169
+ </style>
@@ -0,0 +1,39 @@
1
+ import { onMounted, onUnmounted } from 'vue'
2
+ import { useRouter } from 'vue-router'
3
+ import { useChatStore } from '@/stores/chat'
4
+
5
+ export function useKeyboard() {
6
+ const router = useRouter()
7
+ const chatStore = useChatStore()
8
+
9
+ function handleKeydown(e: KeyboardEvent) {
10
+ const mod = e.ctrlKey || e.metaKey
11
+
12
+ if (mod && e.key === 'n') {
13
+ e.preventDefault()
14
+ chatStore.newChat()
15
+ }
16
+
17
+ if (mod && e.key === 'j') {
18
+ e.preventDefault()
19
+ router.push({ name: 'jobs' })
20
+ }
21
+
22
+ if (e.key === 'Escape') {
23
+ // Close any open modals — naive-ui handles this internally
24
+ const modal = document.querySelector('.n-modal-mask')
25
+ if (modal) {
26
+ const closeBtn = modal.querySelector('.n-base-close') as HTMLElement
27
+ closeBtn?.click()
28
+ }
29
+ }
30
+ }
31
+
32
+ onMounted(() => {
33
+ window.addEventListener('keydown', handleKeydown)
34
+ })
35
+
36
+ onUnmounted(() => {
37
+ window.removeEventListener('keydown', handleKeydown)
38
+ })
39
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ declare module '*.vue' {
4
+ import type { DefineComponent } from 'vue'
5
+ const component: DefineComponent<{}, {}, any>
6
+ export default component
7
+ }
package/src/main.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { createApp } from 'vue'
2
+ import { createPinia } from 'pinia'
3
+ import router from './router'
4
+ import App from './App.vue'
5
+ import './styles/global.scss'
6
+
7
+ const app = createApp(App)
8
+ app.use(createPinia())
9
+ app.use(router)
10
+ app.mount('#app')
@@ -0,0 +1,24 @@
1
+ import { createRouter, createWebHashHistory } from 'vue-router'
2
+
3
+ const router = createRouter({
4
+ history: createWebHashHistory(),
5
+ routes: [
6
+ {
7
+ path: '/',
8
+ name: 'chat',
9
+ component: () => import('@/views/ChatView.vue'),
10
+ },
11
+ {
12
+ path: '/jobs',
13
+ name: 'jobs',
14
+ component: () => import('@/views/JobsView.vue'),
15
+ },
16
+ {
17
+ path: '/settings',
18
+ name: 'settings',
19
+ redirect: '/',
20
+ },
21
+ ],
22
+ })
23
+
24
+ export default router
@@ -0,0 +1,66 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import { checkHealth, fetchModels } from '@/api/system'
4
+ import type { Model } from '@/api/system'
5
+
6
+ export const useAppStore = defineStore('app', () => {
7
+ const connected = ref(false)
8
+ const serverVersion = ref('')
9
+ const models = ref<Model[]>([])
10
+ const healthPollTimer = ref<ReturnType<typeof setInterval>>()
11
+
12
+ // Settings
13
+ const streamEnabled = ref(true)
14
+ const sessionPersistence = ref(true)
15
+ const maxTokens = ref(4096)
16
+ const selectedModel = ref('hermes-agent')
17
+
18
+ async function checkConnection() {
19
+ try {
20
+ const res = await checkHealth()
21
+ connected.value = true
22
+ if (res.version) serverVersion.value = res.version
23
+ } catch {
24
+ connected.value = false
25
+ }
26
+ }
27
+
28
+ async function loadModels() {
29
+ try {
30
+ const res = await fetchModels()
31
+ models.value = res.data || []
32
+ if (models.value.length > 0 && !models.value.find(m => m.id === selectedModel.value)) {
33
+ selectedModel.value = models.value[0].id
34
+ }
35
+ } catch {
36
+ // ignore
37
+ }
38
+ }
39
+
40
+ function startHealthPolling(interval = 30000) {
41
+ stopHealthPolling()
42
+ checkConnection()
43
+ healthPollTimer.value = setInterval(checkConnection, interval)
44
+ }
45
+
46
+ function stopHealthPolling() {
47
+ if (healthPollTimer.value) {
48
+ clearInterval(healthPollTimer.value)
49
+ healthPollTimer.value = undefined
50
+ }
51
+ }
52
+
53
+ return {
54
+ connected,
55
+ serverVersion,
56
+ models,
57
+ streamEnabled,
58
+ sessionPersistence,
59
+ maxTokens,
60
+ selectedModel,
61
+ checkConnection,
62
+ loadModels,
63
+ startHealthPolling,
64
+ stopHealthPolling,
65
+ }
66
+ })