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.
- package/README.md +434 -0
- package/assets/logo.png +0 -0
- package/bin/hermes-web-ui.mjs +24 -0
- package/index.html +13 -0
- package/package.json +44 -0
- package/public/favicon.svg +1 -0
- package/public/icons.svg +24 -0
- package/src/App.vue +54 -0
- package/src/api/chat.ts +87 -0
- package/src/api/client.ts +44 -0
- package/src/api/jobs.ts +100 -0
- package/src/api/system.ts +25 -0
- package/src/assets/hero.png +0 -0
- package/src/assets/vite.svg +1 -0
- package/src/components/chat/ChatInput.vue +123 -0
- package/src/components/chat/ChatPanel.vue +289 -0
- package/src/components/chat/MarkdownRenderer.vue +187 -0
- package/src/components/chat/MessageItem.vue +189 -0
- package/src/components/chat/MessageList.vue +94 -0
- package/src/components/jobs/JobCard.vue +244 -0
- package/src/components/jobs/JobFormModal.vue +188 -0
- package/src/components/jobs/JobsPanel.vue +58 -0
- package/src/components/layout/AppSidebar.vue +169 -0
- package/src/composables/useKeyboard.ts +39 -0
- package/src/env.d.ts +7 -0
- package/src/main.ts +10 -0
- package/src/router/index.ts +24 -0
- package/src/stores/app.ts +66 -0
- package/src/stores/chat.ts +344 -0
- package/src/stores/jobs.ts +72 -0
- package/src/styles/global.scss +60 -0
- package/src/styles/theme.ts +71 -0
- package/src/styles/variables.scss +56 -0
- package/src/views/ChatView.vue +25 -0
- package/src/views/JobsView.vue +93 -0
- package/src/views/SettingsView.vue +257 -0
- package/tsconfig.app.json +17 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +24 -0
- package/vite.config.ts +39 -0
package/src/api/chat.ts
ADDED
|
@@ -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
|
+
}
|
package/src/api/jobs.ts
ADDED
|
@@ -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>
|