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,87 @@
1
+ import { request, getBaseUrlValue } from './client'
2
+
3
+ export interface ChatMessage {
4
+ role: 'user' | 'assistant' | 'system'
5
+ content: string
6
+ }
7
+
8
+ export interface StartRunRequest {
9
+ input: string | ChatMessage[]
10
+ instructions?: string
11
+ conversation_history?: ChatMessage[]
12
+ session_id?: string
13
+ }
14
+
15
+ export interface StartRunResponse {
16
+ run_id: string
17
+ status: string
18
+ }
19
+
20
+ // SSE event types from /v1/runs/{id}/events
21
+ export interface RunEvent {
22
+ event: string
23
+ run_id?: string
24
+ delta?: string
25
+ tool?: string
26
+ name?: string
27
+ preview?: string
28
+ timestamp?: number
29
+ error?: string
30
+ }
31
+
32
+ export async function startRun(body: StartRunRequest): Promise<StartRunResponse> {
33
+ return request<StartRunResponse>('/v1/runs', {
34
+ method: 'POST',
35
+ body: JSON.stringify(body),
36
+ })
37
+ }
38
+
39
+ export function streamRunEvents(
40
+ runId: string,
41
+ onEvent: (event: RunEvent) => void,
42
+ onDone: () => void,
43
+ onError: (err: Error) => void,
44
+ ) {
45
+ const baseUrl = getBaseUrlValue()
46
+ const url = `${baseUrl}/v1/runs/${runId}/events`
47
+
48
+ let closed = false
49
+ const source = new EventSource(url)
50
+
51
+ source.onmessage = (e) => {
52
+ if (closed) return
53
+ try {
54
+ const parsed = JSON.parse(e.data)
55
+ onEvent(parsed)
56
+
57
+ if (parsed.event === 'run.completed' || parsed.event === 'run.failed') {
58
+ closed = true
59
+ source.close()
60
+ onDone()
61
+ }
62
+ } catch {
63
+ onEvent({ event: 'message', delta: e.data })
64
+ }
65
+ }
66
+
67
+ source.onerror = () => {
68
+ if (closed) return
69
+ closed = true
70
+ source.close()
71
+ onError(new Error('SSE connection error'))
72
+ }
73
+
74
+ // Return AbortController-compatible object
75
+ return {
76
+ abort: () => {
77
+ if (!closed) {
78
+ closed = true
79
+ source.close()
80
+ }
81
+ },
82
+ } as unknown as AbortController
83
+ }
84
+
85
+ export async function fetchModels(): Promise<{ data: Array<{ id: string }> }> {
86
+ return request('/v1/models')
87
+ }
@@ -0,0 +1,44 @@
1
+ const DEFAULT_BASE_URL = ''
2
+
3
+ function getBaseUrl(): string {
4
+ return localStorage.getItem('hermes_server_url') || DEFAULT_BASE_URL
5
+ }
6
+
7
+ function getApiKey(): string {
8
+ return localStorage.getItem('hermes_api_key') || ''
9
+ }
10
+
11
+ export function setServerUrl(url: string) {
12
+ localStorage.setItem('hermes_server_url', url)
13
+ }
14
+
15
+ export function setApiKey(key: string) {
16
+ localStorage.setItem('hermes_api_key', key)
17
+ }
18
+
19
+ export async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
20
+ const base = getBaseUrl()
21
+ const url = `${base}${path}`
22
+ const headers: Record<string, string> = {
23
+ 'Content-Type': 'application/json',
24
+ ...options.headers as Record<string, string>,
25
+ }
26
+
27
+ const apiKey = getApiKey()
28
+ if (apiKey) {
29
+ headers['Authorization'] = `Bearer ${apiKey}`
30
+ }
31
+
32
+ const res = await fetch(url, { ...options, headers })
33
+
34
+ if (!res.ok) {
35
+ const text = await res.text().catch(() => '')
36
+ throw new Error(`API Error ${res.status}: ${text || res.statusText}`)
37
+ }
38
+
39
+ return res.json()
40
+ }
41
+
42
+ export function getBaseUrlValue(): string {
43
+ return getBaseUrl()
44
+ }
@@ -0,0 +1,100 @@
1
+ import { request } from './client'
2
+
3
+ export interface Job {
4
+ job_id: string
5
+ id: string
6
+ name: string
7
+ prompt: string
8
+ prompt_preview?: string
9
+ skills: string[]
10
+ skill: string | null
11
+ model: string | null
12
+ provider: string | null
13
+ base_url: string | null
14
+ script: string | null
15
+ schedule: string | { kind: string; expr: string; display: string }
16
+ schedule_display: string
17
+ repeat: string | { times: number | null; completed: number }
18
+ enabled: boolean
19
+ state: string
20
+ paused_at: string | null
21
+ paused_reason: string | null
22
+ created_at: string
23
+ next_run_at: string | null
24
+ last_run_at: string | null
25
+ last_status: string | null
26
+ last_error: string | null
27
+ deliver: string
28
+ origin: {
29
+ platform: string
30
+ chat_id: string
31
+ chat_name: string
32
+ thread_id: string | null
33
+ } | null
34
+ last_delivery_error: string | null
35
+ }
36
+
37
+ export interface CreateJobRequest {
38
+ name: string
39
+ schedule: string
40
+ prompt?: string
41
+ deliver?: string
42
+ skills?: string[]
43
+ repeat?: number
44
+ }
45
+
46
+ export interface UpdateJobRequest {
47
+ name?: string
48
+ schedule?: string
49
+ prompt?: string
50
+ deliver?: string
51
+ skills?: string[]
52
+ skill?: string
53
+ repeat?: number
54
+ enabled?: boolean
55
+ }
56
+
57
+ function unwrap(res: { job: Job }): Job {
58
+ return res.job
59
+ }
60
+
61
+ export async function listJobs(): Promise<Job[]> {
62
+ const res = await request<{ jobs: Job[] }>('/api/jobs?include_disabled=true')
63
+ return res.jobs
64
+ }
65
+
66
+ export async function getJob(jobId: string): Promise<Job> {
67
+ return unwrap(await request<{ job: Job }>(`/api/jobs/${jobId}`))
68
+ }
69
+
70
+ export async function createJob(data: CreateJobRequest): Promise<Job> {
71
+ return unwrap(await request<{ job: Job }>('/api/jobs', {
72
+ method: 'POST',
73
+ body: JSON.stringify(data),
74
+ }))
75
+ }
76
+
77
+ export async function updateJob(jobId: string, data: UpdateJobRequest): Promise<Job> {
78
+ return unwrap(await request<{ job: Job }>(`/api/jobs/${jobId}`, {
79
+ method: 'PATCH',
80
+ body: JSON.stringify(data),
81
+ }))
82
+ }
83
+
84
+ export async function deleteJob(jobId: string): Promise<{ ok: boolean }> {
85
+ return request<{ ok: boolean }>(`/api/jobs/${jobId}`, {
86
+ method: 'DELETE',
87
+ })
88
+ }
89
+
90
+ export async function pauseJob(jobId: string): Promise<Job> {
91
+ return unwrap(await request<{ job: Job }>(`/api/jobs/${jobId}/pause`, { method: 'POST' }))
92
+ }
93
+
94
+ export async function resumeJob(jobId: string): Promise<Job> {
95
+ return unwrap(await request<{ job: Job }>(`/api/jobs/${jobId}/resume`, { method: 'POST' }))
96
+ }
97
+
98
+ export async function runJob(jobId: string): Promise<Job> {
99
+ return unwrap(await request<{ job: Job }>(`/api/jobs/${jobId}/run`, { method: 'POST' }))
100
+ }
@@ -0,0 +1,25 @@
1
+ import { request } from './client'
2
+
3
+ export interface HealthResponse {
4
+ status: string
5
+ version?: string
6
+ }
7
+
8
+ export interface Model {
9
+ id: string
10
+ object: string
11
+ owned_by: string
12
+ }
13
+
14
+ export interface ModelsResponse {
15
+ object: string
16
+ data: Model[]
17
+ }
18
+
19
+ export async function checkHealth(): Promise<HealthResponse> {
20
+ return request<HealthResponse>('/health')
21
+ }
22
+
23
+ export async function fetchModels(): Promise<ModelsResponse> {
24
+ return request<ModelsResponse>('/v1/models')
25
+ }
Binary file
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="29.961" y="-17.13" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="37.754" y="3.055" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="37.754" y="3.055" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-13.43" y="-22.082" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="34.321" y="-37.644" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="38.847" y="-10.552" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-15.081" y="6.78" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-15.081" y="6.78" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="22.45" y="-1.645" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="32.919" y="11.36" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter></defs></svg>
@@ -0,0 +1,123 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ import { NButton } from 'naive-ui'
4
+ import { useChatStore } from '@/stores/chat'
5
+
6
+ const chatStore = useChatStore()
7
+ const inputText = ref('')
8
+ const textareaRef = ref<HTMLTextAreaElement>()
9
+
10
+ function handleSend() {
11
+ const text = inputText.value.trim()
12
+ if (!text) return
13
+
14
+ chatStore.sendMessage(text)
15
+ inputText.value = ''
16
+
17
+ // Reset textarea height
18
+ if (textareaRef.value) {
19
+ textareaRef.value.style.height = 'auto'
20
+ }
21
+ }
22
+
23
+ function handleKeydown(e: KeyboardEvent) {
24
+ if (e.key === 'Enter' && !e.shiftKey) {
25
+ e.preventDefault()
26
+ handleSend()
27
+ }
28
+ }
29
+
30
+ function handleInput(e: Event) {
31
+ const el = e.target as HTMLTextAreaElement
32
+ el.style.height = 'auto'
33
+ el.style.height = Math.min(el.scrollHeight, 100) + 'px'
34
+ }
35
+ </script>
36
+
37
+ <template>
38
+ <div class="chat-input-area">
39
+ <div class="input-wrapper">
40
+ <textarea
41
+ ref="textareaRef"
42
+ v-model="inputText"
43
+ class="input-textarea"
44
+ placeholder="Type a message... (Enter to send, Shift+Enter for new line)"
45
+ rows="1"
46
+ @keydown="handleKeydown"
47
+ @input="handleInput"
48
+ ></textarea>
49
+ <div class="input-actions">
50
+ <NButton
51
+ v-if="chatStore.isStreaming"
52
+ size="small"
53
+ type="error"
54
+ @click="chatStore.stopStreaming()"
55
+ >
56
+ Stop
57
+ </NButton>
58
+ <NButton
59
+ size="small"
60
+ type="primary"
61
+ :disabled="!inputText.trim() || chatStore.isStreaming"
62
+ @click="handleSend"
63
+ >
64
+ <template #icon>
65
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
66
+ </template>
67
+ Send
68
+ </NButton>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ </template>
73
+
74
+ <style scoped lang="scss">
75
+ @use '@/styles/variables' as *;
76
+
77
+ .chat-input-area {
78
+ padding: 12px 20px 16px;
79
+ border-top: 1px solid $border-color;
80
+ flex-shrink: 0;
81
+ }
82
+
83
+ .input-wrapper {
84
+ display: flex;
85
+ align-items: center;
86
+ gap: 10px;
87
+ background-color: $bg-input;
88
+ border: 1px solid $border-color;
89
+ border-radius: $radius-md;
90
+ padding: 10px 12px;
91
+ transition: border-color $transition-fast;
92
+
93
+ &:focus-within {
94
+ border-color: $accent-primary;
95
+ }
96
+ }
97
+
98
+ .input-textarea {
99
+ flex: 1;
100
+ background: none;
101
+ border: none;
102
+ outline: none;
103
+ color: $text-primary;
104
+ font-family: $font-ui;
105
+ font-size: 14px;
106
+ line-height: 1.5;
107
+ resize: none;
108
+ max-height: 100px;
109
+ min-height: 20px;
110
+ overflow-y: auto;
111
+
112
+ &::placeholder {
113
+ color: $text-muted;
114
+ }
115
+ }
116
+
117
+ .input-actions {
118
+ display: flex;
119
+ gap: 6px;
120
+ flex-shrink: 0;
121
+ align-items: center;
122
+ }
123
+ </style>
@@ -0,0 +1,289 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed } from 'vue'
3
+ import { NButton, NTooltip, NPopconfirm, useMessage } from 'naive-ui'
4
+ import MessageList from './MessageList.vue'
5
+ import ChatInput from './ChatInput.vue'
6
+ import { useChatStore } from '@/stores/chat'
7
+ import { useAppStore } from '@/stores/app'
8
+
9
+ const chatStore = useChatStore()
10
+ const appStore = useAppStore()
11
+ const message = useMessage()
12
+
13
+ const showSessions = ref(true)
14
+
15
+ const sortedSessions = computed(() => {
16
+ return [...chatStore.sessions].sort((a, b) => b.updatedAt - a.updatedAt)
17
+ })
18
+
19
+ const activeSessionLabel = computed(() =>
20
+ chatStore.activeSession?.title || 'New Chat',
21
+ )
22
+
23
+ function handleNewChat() {
24
+ chatStore.newChat()
25
+ }
26
+
27
+ function copySessionId() {
28
+ if (chatStore.activeSessionId) {
29
+ navigator.clipboard.writeText(chatStore.activeSessionId)
30
+ message.success('Copied')
31
+ }
32
+ }
33
+
34
+ function handleDeleteSession(id: string) {
35
+ chatStore.deleteSession(id)
36
+ message.success('Session deleted')
37
+ }
38
+
39
+ function formatTime(ts: number) {
40
+ const d = new Date(ts)
41
+ const now = new Date()
42
+ const isToday = d.toDateString() === now.toDateString()
43
+ if (isToday) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
44
+ return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
45
+ }
46
+ </script>
47
+
48
+ <template>
49
+ <div class="chat-panel">
50
+ <!-- Session List -->
51
+ <aside class="session-list" :class="{ collapsed: !showSessions }">
52
+ <div class="session-list-header">
53
+ <span v-if="showSessions" class="session-list-title">Sessions</span>
54
+ <NButton quaternary size="tiny" @click="handleNewChat" circle>
55
+ <template #icon>
56
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
57
+ </template>
58
+ </NButton>
59
+ </div>
60
+ <div v-if="showSessions" class="session-items">
61
+ <button
62
+ v-for="s in sortedSessions"
63
+ :key="s.id"
64
+ class="session-item"
65
+ :class="{ active: s.id === chatStore.activeSessionId }"
66
+ @click="chatStore.switchSession(s.id)"
67
+ >
68
+ <div class="session-item-content">
69
+ <span class="session-item-title">{{ s.title }}</span>
70
+ <span class="session-item-time">{{ formatTime(s.updatedAt) }}</span>
71
+ </div>
72
+ <NPopconfirm
73
+ v-if="s.id !== chatStore.activeSessionId || sortedSessions.length > 1"
74
+ @positive-click="handleDeleteSession(s.id)"
75
+ >
76
+ <template #trigger>
77
+ <button class="session-item-delete" @click.stop>
78
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
79
+ </button>
80
+ </template>
81
+ Delete this session?
82
+ </NPopconfirm>
83
+ </button>
84
+ </div>
85
+ </aside>
86
+
87
+ <!-- Chat Area -->
88
+ <div class="chat-main">
89
+ <header class="chat-header">
90
+ <div class="header-left">
91
+ <NButton quaternary size="small" @click="showSessions = !showSessions" circle>
92
+ <template #icon>
93
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
94
+ </template>
95
+ </NButton>
96
+ <span class="header-session-title">{{ activeSessionLabel }}</span>
97
+ <span class="model-badge">{{ appStore.selectedModel }}</span>
98
+ </div>
99
+ <div class="header-actions">
100
+ <NTooltip trigger="hover">
101
+ <template #trigger>
102
+ <NButton quaternary size="small" @click="copySessionId" circle>
103
+ <template #icon>
104
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
105
+ </template>
106
+ </NButton>
107
+ </template>
108
+ Copy Session ID
109
+ </NTooltip>
110
+ <NButton size="small" @click="handleNewChat">
111
+ <template #icon>
112
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
113
+ </template>
114
+ New Chat
115
+ </NButton>
116
+ </div>
117
+ </header>
118
+
119
+ <MessageList />
120
+ <ChatInput />
121
+ </div>
122
+ </div>
123
+ </template>
124
+
125
+ <style scoped lang="scss">
126
+ @use '@/styles/variables' as *;
127
+
128
+ .chat-panel {
129
+ display: flex;
130
+ height: 100%;
131
+ }
132
+
133
+ .session-list {
134
+ width: 220px;
135
+ border-right: 1px solid $border-color;
136
+ display: flex;
137
+ flex-direction: column;
138
+ flex-shrink: 0;
139
+ transition: width $transition-normal, opacity $transition-normal;
140
+ overflow: hidden;
141
+
142
+ &.collapsed {
143
+ width: 0;
144
+ border-right: none;
145
+ opacity: 0;
146
+ pointer-events: none;
147
+ }
148
+ }
149
+
150
+ .session-list-header {
151
+ display: flex;
152
+ align-items: center;
153
+ justify-content: space-between;
154
+ padding: 12px;
155
+ flex-shrink: 0;
156
+ }
157
+
158
+ .session-list-title {
159
+ font-size: 12px;
160
+ font-weight: 600;
161
+ color: $text-muted;
162
+ text-transform: uppercase;
163
+ letter-spacing: 0.5px;
164
+ }
165
+
166
+ .session-items {
167
+ flex: 1;
168
+ overflow-y: auto;
169
+ padding: 0 6px 12px;
170
+ }
171
+
172
+ .session-item {
173
+ display: flex;
174
+ align-items: center;
175
+ justify-content: space-between;
176
+ width: 100%;
177
+ padding: 8px 10px;
178
+ border: none;
179
+ background: none;
180
+ border-radius: $radius-sm;
181
+ cursor: pointer;
182
+ text-align: left;
183
+ color: $text-secondary;
184
+ transition: all $transition-fast;
185
+ margin-bottom: 2px;
186
+
187
+ &:hover {
188
+ background: rgba($accent-primary, 0.06);
189
+ color: $text-primary;
190
+
191
+ .session-item-delete {
192
+ opacity: 1;
193
+ }
194
+ }
195
+
196
+ &.active {
197
+ background: rgba($accent-primary, 0.1);
198
+ color: $text-primary;
199
+ font-weight: 500;
200
+ }
201
+ }
202
+
203
+ .session-item-content {
204
+ flex: 1;
205
+ overflow: hidden;
206
+ }
207
+
208
+ .session-item-title {
209
+ display: block;
210
+ font-size: 13px;
211
+ white-space: nowrap;
212
+ overflow: hidden;
213
+ text-overflow: ellipsis;
214
+ }
215
+
216
+ .session-item-time {
217
+ display: block;
218
+ font-size: 11px;
219
+ color: $text-muted;
220
+ margin-top: 2px;
221
+ }
222
+
223
+ .session-item-delete {
224
+ flex-shrink: 0;
225
+ opacity: 0;
226
+ padding: 2px;
227
+ border: none;
228
+ background: none;
229
+ color: $text-muted;
230
+ cursor: pointer;
231
+ border-radius: 3px;
232
+ transition: all $transition-fast;
233
+
234
+ &:hover {
235
+ color: $error;
236
+ background: rgba($error, 0.1);
237
+ }
238
+ }
239
+
240
+ .chat-main {
241
+ flex: 1;
242
+ display: flex;
243
+ flex-direction: column;
244
+ overflow: hidden;
245
+ min-width: 0;
246
+ }
247
+
248
+ .chat-header {
249
+ display: flex;
250
+ align-items: center;
251
+ justify-content: space-between;
252
+ padding: 12px 16px;
253
+ border-bottom: 1px solid $border-color;
254
+ flex-shrink: 0;
255
+ }
256
+
257
+ .header-left {
258
+ display: flex;
259
+ align-items: center;
260
+ gap: 8px;
261
+ overflow: hidden;
262
+ }
263
+
264
+ .header-session-title {
265
+ font-size: 14px;
266
+ font-weight: 500;
267
+ color: $text-primary;
268
+ white-space: nowrap;
269
+ overflow: hidden;
270
+ text-overflow: ellipsis;
271
+ }
272
+
273
+ .model-badge {
274
+ font-size: 11px;
275
+ color: $text-muted;
276
+ background: rgba($accent-primary, 0.1);
277
+ padding: 2px 8px;
278
+ border-radius: 10px;
279
+ border: 1px solid $border-color;
280
+ flex-shrink: 0;
281
+ }
282
+
283
+ .header-actions {
284
+ display: flex;
285
+ align-items: center;
286
+ gap: 4px;
287
+ flex-shrink: 0;
288
+ }
289
+ </style>