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,344 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import { startRun, streamRunEvents, type ChatMessage, type RunEvent } from '@/api/chat'
4
+ import { useAppStore } from './app'
5
+
6
+ export interface Message {
7
+ id: string
8
+ role: 'user' | 'assistant' | 'system' | 'tool'
9
+ content: string
10
+ timestamp: number
11
+ toolName?: string
12
+ toolPreview?: string
13
+ toolStatus?: 'running' | 'done' | 'error'
14
+ isStreaming?: boolean
15
+ }
16
+
17
+ interface Session {
18
+ id: string
19
+ title: string
20
+ messages: Message[]
21
+ createdAt: number
22
+ updatedAt: number
23
+ }
24
+
25
+ function uid(): string {
26
+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
27
+ }
28
+
29
+ const SESSIONS_KEY = 'hermes_chat_sessions'
30
+ const ACTIVE_SESSION_KEY = 'hermes_active_session'
31
+
32
+ function loadSessions(): Session[] {
33
+ try {
34
+ return JSON.parse(localStorage.getItem(SESSIONS_KEY) || '[]')
35
+ } catch {
36
+ return []
37
+ }
38
+ }
39
+
40
+ function saveSessions(sessions: Session[]) {
41
+ localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions))
42
+ }
43
+
44
+ function loadActiveSessionId(): string | null {
45
+ return localStorage.getItem(ACTIVE_SESSION_KEY)
46
+ }
47
+
48
+ export const useChatStore = defineStore('chat', () => {
49
+ const appStore = useAppStore()
50
+ const sessions = ref<Session[]>(loadSessions())
51
+ const activeSessionId = ref<string | null>(loadActiveSessionId())
52
+ const isStreaming = ref(false)
53
+ const abortController = ref<AbortController | null>(null)
54
+
55
+ const activeSession = ref<Session | null>(
56
+ sessions.value.find(s => s.id === activeSessionId.value) || null,
57
+ )
58
+
59
+ const messages = ref<Message[]>(activeSession.value?.messages || [])
60
+
61
+ function createSession(): Session {
62
+ const session: Session = {
63
+ id: uid(),
64
+ title: 'New Chat',
65
+ messages: [],
66
+ createdAt: Date.now(),
67
+ updatedAt: Date.now(),
68
+ }
69
+ sessions.value.unshift(session)
70
+ saveSessions(sessions.value)
71
+ return session
72
+ }
73
+
74
+ function switchSession(sessionId: string) {
75
+ activeSessionId.value = sessionId
76
+ localStorage.setItem(ACTIVE_SESSION_KEY, sessionId)
77
+ activeSession.value = sessions.value.find(s => s.id === sessionId) || null
78
+ messages.value = activeSession.value ? [...activeSession.value.messages] : []
79
+ }
80
+
81
+ function newChat() {
82
+ if (isStreaming.value) return
83
+ const session = createSession()
84
+ switchSession(session.id)
85
+ }
86
+
87
+ function deleteSession(sessionId: string) {
88
+ sessions.value = sessions.value.filter(s => s.id !== sessionId)
89
+ saveSessions(sessions.value)
90
+ if (activeSessionId.value === sessionId) {
91
+ if (sessions.value.length > 0) {
92
+ switchSession(sessions.value[0].id)
93
+ } else {
94
+ const session = createSession()
95
+ switchSession(session.id)
96
+ }
97
+ }
98
+ }
99
+
100
+ function persistMessages() {
101
+ if (!activeSession.value || !appStore.sessionPersistence) return
102
+ activeSession.value.messages = [...messages.value]
103
+ activeSession.value.updatedAt = Date.now()
104
+
105
+ if (activeSession.value.title === 'New Chat') {
106
+ const firstUser = messages.value.find(m => m.role === 'user')
107
+ if (firstUser) {
108
+ activeSession.value.title = firstUser.content.slice(0, 40) + (firstUser.content.length > 40 ? '...' : '')
109
+ }
110
+ }
111
+
112
+ const idx = sessions.value.findIndex(s => s.id === activeSession.value!.id)
113
+ if (idx !== -1) sessions.value[idx] = activeSession.value
114
+ saveSessions(sessions.value)
115
+ }
116
+
117
+ function addMessage(msg: Message) {
118
+ messages.value.push(msg)
119
+ }
120
+
121
+ function updateMessage(id: string, update: Partial<Message>) {
122
+ const idx = messages.value.findIndex(m => m.id === id)
123
+ if (idx !== -1) {
124
+ messages.value[idx] = { ...messages.value[idx], ...update }
125
+ }
126
+ }
127
+
128
+ async function sendMessage(content: string) {
129
+ if (!content.trim() || isStreaming.value) return
130
+
131
+ if (!activeSession.value) {
132
+ const session = createSession()
133
+ switchSession(session.id)
134
+ }
135
+
136
+ const userMsg: Message = {
137
+ id: uid(),
138
+ role: 'user',
139
+ content: content.trim(),
140
+ timestamp: Date.now(),
141
+ }
142
+ addMessage(userMsg)
143
+ persistMessages()
144
+
145
+ isStreaming.value = true
146
+
147
+ try {
148
+ // Build conversation history from past messages
149
+ const history: ChatMessage[] = messages.value
150
+ .filter(m => (m.role === 'user' || m.role === 'assistant') && m.content.trim())
151
+ .map(m => ({ role: m.role as 'user' | 'assistant' | 'system', content: m.content }))
152
+
153
+ const run = await startRun({
154
+ input: content.trim(),
155
+ conversation_history: history,
156
+ session_id: activeSession.value?.id,
157
+ })
158
+
159
+ const runId = (run as any).run_id || (run as any).id
160
+ if (!runId) {
161
+ addMessage({
162
+ id: uid(),
163
+ role: 'system',
164
+ content: `Error: startRun returned no run ID. Response: ${JSON.stringify(run)}`,
165
+ timestamp: Date.now(),
166
+ })
167
+ isStreaming.value = false
168
+ persistMessages()
169
+ return
170
+ }
171
+
172
+ // Listen to SSE events
173
+ abortController.value = streamRunEvents(
174
+ runId,
175
+ // onEvent
176
+ (evt: RunEvent) => {
177
+ switch (evt.event) {
178
+ case 'run.started':
179
+ // run started, nothing to render yet
180
+ break
181
+
182
+ case 'message.delta': {
183
+ // Find or create the assistant message
184
+ const last = messages.value[messages.value.length - 1]
185
+ if (last?.role === 'assistant' && last.isStreaming) {
186
+ last.content += evt.delta || ''
187
+ } else {
188
+ addMessage({
189
+ id: uid(),
190
+ role: 'assistant',
191
+ content: evt.delta || '',
192
+ timestamp: Date.now(),
193
+ isStreaming: true,
194
+ })
195
+ }
196
+ break
197
+ }
198
+
199
+ case 'tool.started': {
200
+ // Close any streaming assistant message first
201
+ const last = messages.value[messages.value.length - 1]
202
+ if (last?.isStreaming) {
203
+ updateMessage(last.id, { isStreaming: false })
204
+ }
205
+ // Add tool message
206
+ addMessage({
207
+ id: uid(),
208
+ role: 'tool',
209
+ content: '',
210
+ timestamp: Date.now(),
211
+ toolName: evt.tool || evt.name,
212
+ toolPreview: evt.preview,
213
+ toolStatus: 'running',
214
+ })
215
+ break
216
+ }
217
+
218
+ case 'tool.completed': {
219
+ // Find the running tool message and mark done
220
+ const toolMsgs = messages.value.filter(
221
+ m => m.role === 'tool' && m.toolStatus === 'running',
222
+ )
223
+ if (toolMsgs.length > 0) {
224
+ const last = toolMsgs[toolMsgs.length - 1]
225
+ updateMessage(last.id, { toolStatus: 'done' })
226
+ }
227
+ break
228
+ }
229
+
230
+ case 'run.completed':
231
+ // Close any streaming message
232
+ const lastMsg = messages.value[messages.value.length - 1]
233
+ if (lastMsg?.isStreaming) {
234
+ updateMessage(lastMsg.id, { isStreaming: false })
235
+ }
236
+ isStreaming.value = false
237
+ abortController.value = null
238
+ persistMessages()
239
+ break
240
+
241
+ case 'run.failed':
242
+ // Mark error
243
+ const lastErr = messages.value[messages.value.length - 1]
244
+ if (lastErr?.isStreaming) {
245
+ updateMessage(lastErr.id, {
246
+ isStreaming: false,
247
+ content: evt.error ? `Error: ${evt.error}` : 'Run failed',
248
+ role: 'system',
249
+ })
250
+ } else {
251
+ addMessage({
252
+ id: uid(),
253
+ role: 'system',
254
+ content: evt.error ? `Error: ${evt.error}` : 'Run failed',
255
+ timestamp: Date.now(),
256
+ })
257
+ }
258
+ // Mark any running tools as error
259
+ messages.value.forEach((m, i) => {
260
+ if (m.role === 'tool' && m.toolStatus === 'running') {
261
+ messages.value[i] = { ...m, toolStatus: 'error' }
262
+ }
263
+ })
264
+ isStreaming.value = false
265
+ abortController.value = null
266
+ persistMessages()
267
+ break
268
+ }
269
+ },
270
+ // onDone
271
+ () => {
272
+ const last = messages.value[messages.value.length - 1]
273
+ if (last?.isStreaming) {
274
+ updateMessage(last.id, { isStreaming: false })
275
+ }
276
+ isStreaming.value = false
277
+ abortController.value = null
278
+ persistMessages()
279
+ },
280
+ // onError
281
+ (err) => {
282
+ const last = messages.value[messages.value.length - 1]
283
+ if (last?.isStreaming) {
284
+ updateMessage(last.id, {
285
+ isStreaming: false,
286
+ content: `Error: ${err.message}`,
287
+ role: 'system',
288
+ })
289
+ } else {
290
+ addMessage({
291
+ id: uid(),
292
+ role: 'system',
293
+ content: `Error: ${err.message}`,
294
+ timestamp: Date.now(),
295
+ })
296
+ }
297
+ isStreaming.value = false
298
+ abortController.value = null
299
+ persistMessages()
300
+ },
301
+ )
302
+ } catch (err: any) {
303
+ addMessage({
304
+ id: uid(),
305
+ role: 'system',
306
+ content: `Error: ${err.message}`,
307
+ timestamp: Date.now(),
308
+ })
309
+ isStreaming.value = false
310
+ abortController.value = null
311
+ persistMessages()
312
+ }
313
+ }
314
+
315
+ function stopStreaming() {
316
+ abortController.value?.abort()
317
+ isStreaming.value = false
318
+ const lastMsg = messages.value[messages.value.length - 1]
319
+ if (lastMsg?.isStreaming) {
320
+ updateMessage(lastMsg.id, { isStreaming: false })
321
+ }
322
+ abortController.value = null
323
+ }
324
+
325
+ if (sessions.value.length === 0) {
326
+ const session = createSession()
327
+ switchSession(session.id)
328
+ } else if (!activeSession.value) {
329
+ switchSession(sessions.value[0].id)
330
+ }
331
+
332
+ return {
333
+ sessions,
334
+ activeSessionId,
335
+ activeSession,
336
+ messages,
337
+ isStreaming,
338
+ newChat,
339
+ switchSession,
340
+ deleteSession,
341
+ sendMessage,
342
+ stopStreaming,
343
+ }
344
+ })
@@ -0,0 +1,72 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import * as jobsApi from '@/api/jobs'
4
+ import type { Job, CreateJobRequest, UpdateJobRequest } from '@/api/jobs'
5
+
6
+ function matchId(job: Job, id: string): boolean {
7
+ return job.job_id === id || job.id === id
8
+ }
9
+
10
+ export const useJobsStore = defineStore('jobs', () => {
11
+ const jobs = ref<Job[]>([])
12
+ const loading = ref(false)
13
+
14
+ async function fetchJobs() {
15
+ loading.value = true
16
+ try {
17
+ jobs.value = await jobsApi.listJobs()
18
+ } catch (err) {
19
+ console.error('Failed to fetch jobs:', err)
20
+ } finally {
21
+ loading.value = false
22
+ }
23
+ }
24
+
25
+ async function createJob(data: CreateJobRequest): Promise<Job> {
26
+ const job = await jobsApi.createJob(data)
27
+ jobs.value.unshift(job)
28
+ return job
29
+ }
30
+
31
+ async function updateJob(jobId: string, data: UpdateJobRequest): Promise<Job> {
32
+ const job = await jobsApi.updateJob(jobId, data)
33
+ const idx = jobs.value.findIndex(j => matchId(j, jobId))
34
+ if (idx !== -1) jobs.value[idx] = job
35
+ return job
36
+ }
37
+
38
+ async function deleteJob(jobId: string) {
39
+ await jobsApi.deleteJob(jobId)
40
+ jobs.value = jobs.value.filter(j => !matchId(j, jobId))
41
+ }
42
+
43
+ async function pauseJob(jobId: string) {
44
+ const job = await jobsApi.pauseJob(jobId)
45
+ const idx = jobs.value.findIndex(j => matchId(j, jobId))
46
+ if (idx !== -1) jobs.value[idx] = job
47
+ }
48
+
49
+ async function resumeJob(jobId: string) {
50
+ const job = await jobsApi.resumeJob(jobId)
51
+ const idx = jobs.value.findIndex(j => matchId(j, jobId))
52
+ if (idx !== -1) jobs.value[idx] = job
53
+ }
54
+
55
+ async function runJob(jobId: string) {
56
+ const job = await jobsApi.runJob(jobId)
57
+ const idx = jobs.value.findIndex(j => matchId(j, jobId))
58
+ if (idx !== -1) jobs.value[idx] = job
59
+ }
60
+
61
+ return {
62
+ jobs,
63
+ loading,
64
+ fetchJobs,
65
+ createJob,
66
+ updateJob,
67
+ deleteJob,
68
+ pauseJob,
69
+ resumeJob,
70
+ runJob,
71
+ }
72
+ })
@@ -0,0 +1,60 @@
1
+ @use 'variables' as *;
2
+
3
+ *,
4
+ *::before,
5
+ *::after {
6
+ margin: 0;
7
+ padding: 0;
8
+ box-sizing: border-box;
9
+ }
10
+
11
+ html, body, #app {
12
+ height: 100%;
13
+ width: 100%;
14
+ overflow: hidden;
15
+ }
16
+
17
+ body {
18
+ font-family: $font-ui;
19
+ background-color: $bg-primary;
20
+ color: $text-primary;
21
+ font-size: 14px;
22
+ line-height: 1.6;
23
+ -webkit-font-smoothing: antialiased;
24
+ -moz-osx-font-smoothing: grayscale;
25
+ }
26
+
27
+ code, pre, .mono {
28
+ font-family: $font-code;
29
+ }
30
+
31
+ a {
32
+ color: $accent-primary;
33
+ text-decoration: none;
34
+
35
+ &:hover {
36
+ color: $accent-hover;
37
+ }
38
+ }
39
+
40
+ ::-webkit-scrollbar {
41
+ width: 6px;
42
+ height: 6px;
43
+ }
44
+
45
+ ::-webkit-scrollbar-track {
46
+ background: transparent;
47
+ }
48
+
49
+ ::-webkit-scrollbar-thumb {
50
+ background: $border-color;
51
+ border-radius: 3px;
52
+
53
+ &:hover {
54
+ background: $text-muted;
55
+ }
56
+ }
57
+
58
+ ::selection {
59
+ background: rgba($accent-primary, 0.3);
60
+ }
@@ -0,0 +1,71 @@
1
+ import type { GlobalThemeOverrides } from 'naive-ui'
2
+
3
+ export const themeOverrides: GlobalThemeOverrides = {
4
+ common: {
5
+ primaryColor: '#333333',
6
+ primaryColorHover: '#1a1a1a',
7
+ primaryColorPressed: '#000000',
8
+ primaryColorSuppl: '#333333',
9
+ bodyColor: '#fafafa',
10
+ cardColor: '#ffffff',
11
+ modalColor: '#ffffff',
12
+ popoverColor: '#ffffff',
13
+ tableColor: '#ffffff',
14
+ inputColor: '#ffffff',
15
+ actionColor: '#f0f0f0',
16
+ textColorBase: '#1a1a1a',
17
+ textColor1: '#1a1a1a',
18
+ textColor2: '#666666',
19
+ textColor3: '#999999',
20
+ dividerColor: '#e0e0e0',
21
+ borderColor: '#e0e0e0',
22
+ hoverColor: 'rgba(0, 0, 0, 0.04)',
23
+ borderRadius: '8px',
24
+ borderRadiusSmall: '6px',
25
+ fontSize: '14px',
26
+ fontSizeMedium: '14px',
27
+ heightMedium: '36px',
28
+ fontFamily: 'Inter, system-ui, -apple-system, sans-serif',
29
+ fontFamilyMono: 'JetBrains Mono, Fira Code, Consolas, monospace',
30
+ },
31
+ Layout: {
32
+ color: '#fafafa',
33
+ siderColor: '#f5f5f5',
34
+ headerColor: '#fafafa',
35
+ },
36
+ Menu: {
37
+ itemTextColorActive: '#1a1a1a',
38
+ itemTextColorActiveHover: '#1a1a1a',
39
+ itemTextColorChildActive: '#1a1a1a',
40
+ itemIconColorActive: '#1a1a1a',
41
+ itemIconColorActiveHover: '#000000',
42
+ itemColorActive: 'rgba(0, 0, 0, 0.06)',
43
+ itemColorActiveHover: 'rgba(0, 0, 0, 0.1)',
44
+ arrowColorActive: '#1a1a1a',
45
+ },
46
+ Button: {
47
+ textColorPrimary: '#ffffff',
48
+ colorPrimary: '#333333',
49
+ colorHoverPrimary: '#1a1a1a',
50
+ colorPressedPrimary: '#000000',
51
+ },
52
+ Input: {
53
+ color: '#ffffff',
54
+ colorFocus: '#ffffff',
55
+ border: '1px solid #e0e0e0',
56
+ borderHover: '1px solid #999999',
57
+ borderFocus: '1px solid #333333',
58
+ placeholderColor: '#999999',
59
+ caretColor: '#1a1a1a',
60
+ },
61
+ Card: {
62
+ color: '#ffffff',
63
+ borderColor: '#e0e0e0',
64
+ },
65
+ Modal: {
66
+ color: '#ffffff',
67
+ },
68
+ Tag: {
69
+ borderRadius: '6px',
70
+ },
71
+ }
@@ -0,0 +1,56 @@
1
+ // 黑白水墨 — Pure Ink
2
+ // 纯黑白灰,无彩色
3
+
4
+ // Backgrounds
5
+ $bg-primary: #fafafa;
6
+ $bg-secondary: #f0f0f0;
7
+ $bg-sidebar: #f5f5f5;
8
+ $bg-card: #ffffff;
9
+ $bg-card-hover: #fafafa;
10
+ $bg-input: #ffffff;
11
+
12
+ // Borders
13
+ $border-color: #e0e0e0;
14
+ $border-light: #ebebeb;
15
+
16
+ // Accent
17
+ $accent-primary: #333333;
18
+ $accent-hover: #1a1a1a;
19
+ $accent-muted: #888888;
20
+
21
+ // Text
22
+ $text-primary: #1a1a1a;
23
+ $text-secondary: #666666;
24
+ $text-muted: #999999;
25
+
26
+ // Status
27
+ $success: #2e7d32;
28
+ $error: #c62828;
29
+ $warning: #f57f17;
30
+ $info: $accent-primary;
31
+
32
+ // Message bubbles
33
+ $msg-user-bg: #e8e8e8;
34
+ $msg-assistant-bg: #f5f5f5;
35
+ $msg-system-border: #bdbdbd;
36
+
37
+ // Code
38
+ $code-bg: #f4f4f4;
39
+
40
+ // Typography
41
+ $font-ui: 'Inter', system-ui, -apple-system, sans-serif;
42
+ $font-code: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
43
+
44
+ // Layout
45
+ $sidebar-width: 240px;
46
+ $sidebar-collapsed-width: 64px;
47
+ $header-height: 56px;
48
+
49
+ // Radius
50
+ $radius-sm: 6px;
51
+ $radius-md: 10px;
52
+ $radius-lg: 14px;
53
+
54
+ // Transition
55
+ $transition-fast: 0.15s ease;
56
+ $transition-normal: 0.25s ease;
@@ -0,0 +1,25 @@
1
+ <script setup lang="ts">
2
+ import { onMounted } from 'vue'
3
+ import ChatPanel from '@/components/chat/ChatPanel.vue'
4
+ import { useAppStore } from '@/stores/app'
5
+
6
+ const appStore = useAppStore()
7
+
8
+ onMounted(() => {
9
+ appStore.loadModels()
10
+ })
11
+ </script>
12
+
13
+ <template>
14
+ <div class="chat-view">
15
+ <ChatPanel />
16
+ </div>
17
+ </template>
18
+
19
+ <style scoped lang="scss">
20
+ .chat-view {
21
+ height: 100vh;
22
+ display: flex;
23
+ flex-direction: column;
24
+ }
25
+ </style>