commit cd58797f4c21d771ed75cea0fdf369c51604bcc1 Author: ekko Date: Sat Apr 11 15:59:14 2026 +0800 init: hermes-web-ui v0.1.0 Hermes Agent Web 管理面板,支持对话交互和定时任务管理。 Co-Authored-By: Claude Opus 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a931ded --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +package-lock.json +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/README.md b/README.md new file mode 100644 index 0000000..8b00da8 --- /dev/null +++ b/README.md @@ -0,0 +1,434 @@ +# Hermes UI + +Hermes Agent 的 Web 管理面板,用于对话交互和定时任务管理。 + +## 技术栈 + +- **Vue 3** — Composition API + ` + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..1ac9b61 --- /dev/null +++ b/package.json @@ -0,0 +1,44 @@ +{ + "name": "hermes-web-ui", + "version": "0.1.0", + "type": "module", + "bin": { + "hermes-web-ui": "./bin/hermes-web-ui.mjs" + }, + "scripts": { + "start": "vite --host --port 8648", + "dev": "vite --host", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "files": [ + "bin/", + "index.html", + "public/", + "assets/", + "src/", + "vite.config.ts", + "tsconfig.json", + "tsconfig.app.json", + "tsconfig.node.json", + "package.json" + ], + "dependencies": { + "highlight.js": "^11.11.1", + "markdown-it": "^14.1.1", + "naive-ui": "^2.44.1", + "pinia": "^3.0.4", + "vue": "^3.5.32", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@types/markdown-it": "^14.1.2", + "@types/node": "^24.12.2", + "@vitejs/plugin-vue": "^6.0.5", + "@vue/tsconfig": "^0.9.1", + "sass": "^1.99.0", + "typescript": "~6.0.2", + "vite": "^8.0.4", + "vue-tsc": "^3.2.6" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons.svg b/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..6a89bbf --- /dev/null +++ b/src/App.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/src/api/chat.ts b/src/api/chat.ts new file mode 100644 index 0000000..4e5476c --- /dev/null +++ b/src/api/chat.ts @@ -0,0 +1,87 @@ +import { request, getBaseUrlValue } from './client' + +export interface ChatMessage { + role: 'user' | 'assistant' | 'system' + content: string +} + +export interface StartRunRequest { + input: string | ChatMessage[] + instructions?: string + conversation_history?: ChatMessage[] + session_id?: string +} + +export interface StartRunResponse { + run_id: string + status: string +} + +// SSE event types from /v1/runs/{id}/events +export interface RunEvent { + event: string + run_id?: string + delta?: string + tool?: string + name?: string + preview?: string + timestamp?: number + error?: string +} + +export async function startRun(body: StartRunRequest): Promise { + return request('/v1/runs', { + method: 'POST', + body: JSON.stringify(body), + }) +} + +export function streamRunEvents( + runId: string, + onEvent: (event: RunEvent) => void, + onDone: () => void, + onError: (err: Error) => void, +) { + const baseUrl = getBaseUrlValue() + const url = `${baseUrl}/v1/runs/${runId}/events` + + let closed = false + const source = new EventSource(url) + + source.onmessage = (e) => { + if (closed) return + try { + const parsed = JSON.parse(e.data) + onEvent(parsed) + + if (parsed.event === 'run.completed' || parsed.event === 'run.failed') { + closed = true + source.close() + onDone() + } + } catch { + onEvent({ event: 'message', delta: e.data }) + } + } + + source.onerror = () => { + if (closed) return + closed = true + source.close() + onError(new Error('SSE connection error')) + } + + // Return AbortController-compatible object + return { + abort: () => { + if (!closed) { + closed = true + source.close() + } + }, + } as unknown as AbortController +} + +export async function fetchModels(): Promise<{ data: Array<{ id: string }> }> { + return request('/v1/models') +} diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..eca2dd2 --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,44 @@ +const DEFAULT_BASE_URL = '' + +function getBaseUrl(): string { + return localStorage.getItem('hermes_server_url') || DEFAULT_BASE_URL +} + +function getApiKey(): string { + return localStorage.getItem('hermes_api_key') || '' +} + +export function setServerUrl(url: string) { + localStorage.setItem('hermes_server_url', url) +} + +export function setApiKey(key: string) { + localStorage.setItem('hermes_api_key', key) +} + +export async function request(path: string, options: RequestInit = {}): Promise { + const base = getBaseUrl() + const url = `${base}${path}` + const headers: Record = { + 'Content-Type': 'application/json', + ...options.headers as Record, + } + + const apiKey = getApiKey() + if (apiKey) { + headers['Authorization'] = `Bearer ${apiKey}` + } + + const res = await fetch(url, { ...options, headers }) + + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`API Error ${res.status}: ${text || res.statusText}`) + } + + return res.json() +} + +export function getBaseUrlValue(): string { + return getBaseUrl() +} diff --git a/src/api/jobs.ts b/src/api/jobs.ts new file mode 100644 index 0000000..d9a4a0e --- /dev/null +++ b/src/api/jobs.ts @@ -0,0 +1,100 @@ +import { request } from './client' + +export interface Job { + job_id: string + id: string + name: string + prompt: string + prompt_preview?: string + skills: string[] + skill: string | null + model: string | null + provider: string | null + base_url: string | null + script: string | null + schedule: string | { kind: string; expr: string; display: string } + schedule_display: string + repeat: string | { times: number | null; completed: number } + enabled: boolean + state: string + paused_at: string | null + paused_reason: string | null + created_at: string + next_run_at: string | null + last_run_at: string | null + last_status: string | null + last_error: string | null + deliver: string + origin: { + platform: string + chat_id: string + chat_name: string + thread_id: string | null + } | null + last_delivery_error: string | null +} + +export interface CreateJobRequest { + name: string + schedule: string + prompt?: string + deliver?: string + skills?: string[] + repeat?: number +} + +export interface UpdateJobRequest { + name?: string + schedule?: string + prompt?: string + deliver?: string + skills?: string[] + skill?: string + repeat?: number + enabled?: boolean +} + +function unwrap(res: { job: Job }): Job { + return res.job +} + +export async function listJobs(): Promise { + const res = await request<{ jobs: Job[] }>('/api/jobs?include_disabled=true') + return res.jobs +} + +export async function getJob(jobId: string): Promise { + return unwrap(await request<{ job: Job }>(`/api/jobs/${jobId}`)) +} + +export async function createJob(data: CreateJobRequest): Promise { + return unwrap(await request<{ job: Job }>('/api/jobs', { + method: 'POST', + body: JSON.stringify(data), + })) +} + +export async function updateJob(jobId: string, data: UpdateJobRequest): Promise { + return unwrap(await request<{ job: Job }>(`/api/jobs/${jobId}`, { + method: 'PATCH', + body: JSON.stringify(data), + })) +} + +export async function deleteJob(jobId: string): Promise<{ ok: boolean }> { + return request<{ ok: boolean }>(`/api/jobs/${jobId}`, { + method: 'DELETE', + }) +} + +export async function pauseJob(jobId: string): Promise { + return unwrap(await request<{ job: Job }>(`/api/jobs/${jobId}/pause`, { method: 'POST' })) +} + +export async function resumeJob(jobId: string): Promise { + return unwrap(await request<{ job: Job }>(`/api/jobs/${jobId}/resume`, { method: 'POST' })) +} + +export async function runJob(jobId: string): Promise { + return unwrap(await request<{ job: Job }>(`/api/jobs/${jobId}/run`, { method: 'POST' })) +} diff --git a/src/api/system.ts b/src/api/system.ts new file mode 100644 index 0000000..342b077 --- /dev/null +++ b/src/api/system.ts @@ -0,0 +1,25 @@ +import { request } from './client' + +export interface HealthResponse { + status: string + version?: string +} + +export interface Model { + id: string + object: string + owned_by: string +} + +export interface ModelsResponse { + object: string + data: Model[] +} + +export async function checkHealth(): Promise { + return request('/health') +} + +export async function fetchModels(): Promise { + return request('/v1/models') +} diff --git a/src/assets/hero.png b/src/assets/hero.png new file mode 100644 index 0000000..cc51a3d Binary files /dev/null and b/src/assets/hero.png differ diff --git a/src/assets/vite.svg b/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/src/components/chat/ChatInput.vue b/src/components/chat/ChatInput.vue new file mode 100644 index 0000000..9f4c6cd --- /dev/null +++ b/src/components/chat/ChatInput.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/src/components/chat/ChatPanel.vue b/src/components/chat/ChatPanel.vue new file mode 100644 index 0000000..15b7dd4 --- /dev/null +++ b/src/components/chat/ChatPanel.vue @@ -0,0 +1,289 @@ + + + + + diff --git a/src/components/chat/MarkdownRenderer.vue b/src/components/chat/MarkdownRenderer.vue new file mode 100644 index 0000000..58eed3b --- /dev/null +++ b/src/components/chat/MarkdownRenderer.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/src/components/chat/MessageItem.vue b/src/components/chat/MessageItem.vue new file mode 100644 index 0000000..de7a528 --- /dev/null +++ b/src/components/chat/MessageItem.vue @@ -0,0 +1,189 @@ + + + + + diff --git a/src/components/chat/MessageList.vue b/src/components/chat/MessageList.vue new file mode 100644 index 0000000..99688b1 --- /dev/null +++ b/src/components/chat/MessageList.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/src/components/jobs/JobCard.vue b/src/components/jobs/JobCard.vue new file mode 100644 index 0000000..d3a1584 --- /dev/null +++ b/src/components/jobs/JobCard.vue @@ -0,0 +1,244 @@ + + + + + diff --git a/src/components/jobs/JobFormModal.vue b/src/components/jobs/JobFormModal.vue new file mode 100644 index 0000000..f279b8f --- /dev/null +++ b/src/components/jobs/JobFormModal.vue @@ -0,0 +1,188 @@ + + + + + diff --git a/src/components/jobs/JobsPanel.vue b/src/components/jobs/JobsPanel.vue new file mode 100644 index 0000000..b9f46c8 --- /dev/null +++ b/src/components/jobs/JobsPanel.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/src/components/layout/AppSidebar.vue b/src/components/layout/AppSidebar.vue new file mode 100644 index 0000000..9343b6d --- /dev/null +++ b/src/components/layout/AppSidebar.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/src/composables/useKeyboard.ts b/src/composables/useKeyboard.ts new file mode 100644 index 0000000..ed85785 --- /dev/null +++ b/src/composables/useKeyboard.ts @@ -0,0 +1,39 @@ +import { onMounted, onUnmounted } from 'vue' +import { useRouter } from 'vue-router' +import { useChatStore } from '@/stores/chat' + +export function useKeyboard() { + const router = useRouter() + const chatStore = useChatStore() + + function handleKeydown(e: KeyboardEvent) { + const mod = e.ctrlKey || e.metaKey + + if (mod && e.key === 'n') { + e.preventDefault() + chatStore.newChat() + } + + if (mod && e.key === 'j') { + e.preventDefault() + router.push({ name: 'jobs' }) + } + + if (e.key === 'Escape') { + // Close any open modals — naive-ui handles this internally + const modal = document.querySelector('.n-modal-mask') + if (modal) { + const closeBtn = modal.querySelector('.n-base-close') as HTMLElement + closeBtn?.click() + } + } + } + + onMounted(() => { + window.addEventListener('keydown', handleKeydown) + }) + + onUnmounted(() => { + window.removeEventListener('keydown', handleKeydown) + }) +} diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..323c78a --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..2573c34 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,10 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import router from './router' +import App from './App.vue' +import './styles/global.scss' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.mount('#app') diff --git a/src/router/index.ts b/src/router/index.ts new file mode 100644 index 0000000..f853ec5 --- /dev/null +++ b/src/router/index.ts @@ -0,0 +1,24 @@ +import { createRouter, createWebHashHistory } from 'vue-router' + +const router = createRouter({ + history: createWebHashHistory(), + routes: [ + { + path: '/', + name: 'chat', + component: () => import('@/views/ChatView.vue'), + }, + { + path: '/jobs', + name: 'jobs', + component: () => import('@/views/JobsView.vue'), + }, + { + path: '/settings', + name: 'settings', + redirect: '/', + }, + ], +}) + +export default router diff --git a/src/stores/app.ts b/src/stores/app.ts new file mode 100644 index 0000000..358949f --- /dev/null +++ b/src/stores/app.ts @@ -0,0 +1,66 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { checkHealth, fetchModels } from '@/api/system' +import type { Model } from '@/api/system' + +export const useAppStore = defineStore('app', () => { + const connected = ref(false) + const serverVersion = ref('') + const models = ref([]) + const healthPollTimer = ref>() + + // Settings + const streamEnabled = ref(true) + const sessionPersistence = ref(true) + const maxTokens = ref(4096) + const selectedModel = ref('hermes-agent') + + async function checkConnection() { + try { + const res = await checkHealth() + connected.value = true + if (res.version) serverVersion.value = res.version + } catch { + connected.value = false + } + } + + async function loadModels() { + try { + const res = await fetchModels() + models.value = res.data || [] + if (models.value.length > 0 && !models.value.find(m => m.id === selectedModel.value)) { + selectedModel.value = models.value[0].id + } + } catch { + // ignore + } + } + + function startHealthPolling(interval = 30000) { + stopHealthPolling() + checkConnection() + healthPollTimer.value = setInterval(checkConnection, interval) + } + + function stopHealthPolling() { + if (healthPollTimer.value) { + clearInterval(healthPollTimer.value) + healthPollTimer.value = undefined + } + } + + return { + connected, + serverVersion, + models, + streamEnabled, + sessionPersistence, + maxTokens, + selectedModel, + checkConnection, + loadModels, + startHealthPolling, + stopHealthPolling, + } +}) diff --git a/src/stores/chat.ts b/src/stores/chat.ts new file mode 100644 index 0000000..067ecb1 --- /dev/null +++ b/src/stores/chat.ts @@ -0,0 +1,344 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { startRun, streamRunEvents, type ChatMessage, type RunEvent } from '@/api/chat' +import { useAppStore } from './app' + +export interface Message { + id: string + role: 'user' | 'assistant' | 'system' | 'tool' + content: string + timestamp: number + toolName?: string + toolPreview?: string + toolStatus?: 'running' | 'done' | 'error' + isStreaming?: boolean +} + +interface Session { + id: string + title: string + messages: Message[] + createdAt: number + updatedAt: number +} + +function uid(): string { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 8) +} + +const SESSIONS_KEY = 'hermes_chat_sessions' +const ACTIVE_SESSION_KEY = 'hermes_active_session' + +function loadSessions(): Session[] { + try { + return JSON.parse(localStorage.getItem(SESSIONS_KEY) || '[]') + } catch { + return [] + } +} + +function saveSessions(sessions: Session[]) { + localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions)) +} + +function loadActiveSessionId(): string | null { + return localStorage.getItem(ACTIVE_SESSION_KEY) +} + +export const useChatStore = defineStore('chat', () => { + const appStore = useAppStore() + const sessions = ref(loadSessions()) + const activeSessionId = ref(loadActiveSessionId()) + const isStreaming = ref(false) + const abortController = ref(null) + + const activeSession = ref( + sessions.value.find(s => s.id === activeSessionId.value) || null, + ) + + const messages = ref(activeSession.value?.messages || []) + + function createSession(): Session { + const session: Session = { + id: uid(), + title: 'New Chat', + messages: [], + createdAt: Date.now(), + updatedAt: Date.now(), + } + sessions.value.unshift(session) + saveSessions(sessions.value) + return session + } + + function switchSession(sessionId: string) { + activeSessionId.value = sessionId + localStorage.setItem(ACTIVE_SESSION_KEY, sessionId) + activeSession.value = sessions.value.find(s => s.id === sessionId) || null + messages.value = activeSession.value ? [...activeSession.value.messages] : [] + } + + function newChat() { + if (isStreaming.value) return + const session = createSession() + switchSession(session.id) + } + + function deleteSession(sessionId: string) { + sessions.value = sessions.value.filter(s => s.id !== sessionId) + saveSessions(sessions.value) + if (activeSessionId.value === sessionId) { + if (sessions.value.length > 0) { + switchSession(sessions.value[0].id) + } else { + const session = createSession() + switchSession(session.id) + } + } + } + + function persistMessages() { + if (!activeSession.value || !appStore.sessionPersistence) return + activeSession.value.messages = [...messages.value] + activeSession.value.updatedAt = Date.now() + + if (activeSession.value.title === 'New Chat') { + const firstUser = messages.value.find(m => m.role === 'user') + if (firstUser) { + activeSession.value.title = firstUser.content.slice(0, 40) + (firstUser.content.length > 40 ? '...' : '') + } + } + + const idx = sessions.value.findIndex(s => s.id === activeSession.value!.id) + if (idx !== -1) sessions.value[idx] = activeSession.value + saveSessions(sessions.value) + } + + function addMessage(msg: Message) { + messages.value.push(msg) + } + + function updateMessage(id: string, update: Partial) { + const idx = messages.value.findIndex(m => m.id === id) + if (idx !== -1) { + messages.value[idx] = { ...messages.value[idx], ...update } + } + } + + async function sendMessage(content: string) { + if (!content.trim() || isStreaming.value) return + + if (!activeSession.value) { + const session = createSession() + switchSession(session.id) + } + + const userMsg: Message = { + id: uid(), + role: 'user', + content: content.trim(), + timestamp: Date.now(), + } + addMessage(userMsg) + persistMessages() + + isStreaming.value = true + + try { + // Build conversation history from past messages + const history: ChatMessage[] = messages.value + .filter(m => (m.role === 'user' || m.role === 'assistant') && m.content.trim()) + .map(m => ({ role: m.role as 'user' | 'assistant' | 'system', content: m.content })) + + const run = await startRun({ + input: content.trim(), + conversation_history: history, + session_id: activeSession.value?.id, + }) + + const runId = (run as any).run_id || (run as any).id + if (!runId) { + addMessage({ + id: uid(), + role: 'system', + content: `Error: startRun returned no run ID. Response: ${JSON.stringify(run)}`, + timestamp: Date.now(), + }) + isStreaming.value = false + persistMessages() + return + } + + // Listen to SSE events + abortController.value = streamRunEvents( + runId, + // onEvent + (evt: RunEvent) => { + switch (evt.event) { + case 'run.started': + // run started, nothing to render yet + break + + case 'message.delta': { + // Find or create the assistant message + const last = messages.value[messages.value.length - 1] + if (last?.role === 'assistant' && last.isStreaming) { + last.content += evt.delta || '' + } else { + addMessage({ + id: uid(), + role: 'assistant', + content: evt.delta || '', + timestamp: Date.now(), + isStreaming: true, + }) + } + break + } + + case 'tool.started': { + // Close any streaming assistant message first + const last = messages.value[messages.value.length - 1] + if (last?.isStreaming) { + updateMessage(last.id, { isStreaming: false }) + } + // Add tool message + addMessage({ + id: uid(), + role: 'tool', + content: '', + timestamp: Date.now(), + toolName: evt.tool || evt.name, + toolPreview: evt.preview, + toolStatus: 'running', + }) + break + } + + case 'tool.completed': { + // Find the running tool message and mark done + const toolMsgs = messages.value.filter( + m => m.role === 'tool' && m.toolStatus === 'running', + ) + if (toolMsgs.length > 0) { + const last = toolMsgs[toolMsgs.length - 1] + updateMessage(last.id, { toolStatus: 'done' }) + } + break + } + + case 'run.completed': + // Close any streaming message + const lastMsg = messages.value[messages.value.length - 1] + if (lastMsg?.isStreaming) { + updateMessage(lastMsg.id, { isStreaming: false }) + } + isStreaming.value = false + abortController.value = null + persistMessages() + break + + case 'run.failed': + // Mark error + const lastErr = messages.value[messages.value.length - 1] + if (lastErr?.isStreaming) { + updateMessage(lastErr.id, { + isStreaming: false, + content: evt.error ? `Error: ${evt.error}` : 'Run failed', + role: 'system', + }) + } else { + addMessage({ + id: uid(), + role: 'system', + content: evt.error ? `Error: ${evt.error}` : 'Run failed', + timestamp: Date.now(), + }) + } + // Mark any running tools as error + messages.value.forEach((m, i) => { + if (m.role === 'tool' && m.toolStatus === 'running') { + messages.value[i] = { ...m, toolStatus: 'error' } + } + }) + isStreaming.value = false + abortController.value = null + persistMessages() + break + } + }, + // onDone + () => { + const last = messages.value[messages.value.length - 1] + if (last?.isStreaming) { + updateMessage(last.id, { isStreaming: false }) + } + isStreaming.value = false + abortController.value = null + persistMessages() + }, + // onError + (err) => { + const last = messages.value[messages.value.length - 1] + if (last?.isStreaming) { + updateMessage(last.id, { + isStreaming: false, + content: `Error: ${err.message}`, + role: 'system', + }) + } else { + addMessage({ + id: uid(), + role: 'system', + content: `Error: ${err.message}`, + timestamp: Date.now(), + }) + } + isStreaming.value = false + abortController.value = null + persistMessages() + }, + ) + } catch (err: any) { + addMessage({ + id: uid(), + role: 'system', + content: `Error: ${err.message}`, + timestamp: Date.now(), + }) + isStreaming.value = false + abortController.value = null + persistMessages() + } + } + + function stopStreaming() { + abortController.value?.abort() + isStreaming.value = false + const lastMsg = messages.value[messages.value.length - 1] + if (lastMsg?.isStreaming) { + updateMessage(lastMsg.id, { isStreaming: false }) + } + abortController.value = null + } + + if (sessions.value.length === 0) { + const session = createSession() + switchSession(session.id) + } else if (!activeSession.value) { + switchSession(sessions.value[0].id) + } + + return { + sessions, + activeSessionId, + activeSession, + messages, + isStreaming, + newChat, + switchSession, + deleteSession, + sendMessage, + stopStreaming, + } +}) diff --git a/src/stores/jobs.ts b/src/stores/jobs.ts new file mode 100644 index 0000000..14fe54b --- /dev/null +++ b/src/stores/jobs.ts @@ -0,0 +1,72 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import * as jobsApi from '@/api/jobs' +import type { Job, CreateJobRequest, UpdateJobRequest } from '@/api/jobs' + +function matchId(job: Job, id: string): boolean { + return job.job_id === id || job.id === id +} + +export const useJobsStore = defineStore('jobs', () => { + const jobs = ref([]) + const loading = ref(false) + + async function fetchJobs() { + loading.value = true + try { + jobs.value = await jobsApi.listJobs() + } catch (err) { + console.error('Failed to fetch jobs:', err) + } finally { + loading.value = false + } + } + + async function createJob(data: CreateJobRequest): Promise { + const job = await jobsApi.createJob(data) + jobs.value.unshift(job) + return job + } + + async function updateJob(jobId: string, data: UpdateJobRequest): Promise { + const job = await jobsApi.updateJob(jobId, data) + const idx = jobs.value.findIndex(j => matchId(j, jobId)) + if (idx !== -1) jobs.value[idx] = job + return job + } + + async function deleteJob(jobId: string) { + await jobsApi.deleteJob(jobId) + jobs.value = jobs.value.filter(j => !matchId(j, jobId)) + } + + async function pauseJob(jobId: string) { + const job = await jobsApi.pauseJob(jobId) + const idx = jobs.value.findIndex(j => matchId(j, jobId)) + if (idx !== -1) jobs.value[idx] = job + } + + async function resumeJob(jobId: string) { + const job = await jobsApi.resumeJob(jobId) + const idx = jobs.value.findIndex(j => matchId(j, jobId)) + if (idx !== -1) jobs.value[idx] = job + } + + async function runJob(jobId: string) { + const job = await jobsApi.runJob(jobId) + const idx = jobs.value.findIndex(j => matchId(j, jobId)) + if (idx !== -1) jobs.value[idx] = job + } + + return { + jobs, + loading, + fetchJobs, + createJob, + updateJob, + deleteJob, + pauseJob, + resumeJob, + runJob, + } +}) diff --git a/src/styles/global.scss b/src/styles/global.scss new file mode 100644 index 0000000..42bddee --- /dev/null +++ b/src/styles/global.scss @@ -0,0 +1,60 @@ +@use 'variables' as *; + +*, +*::before, +*::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body, #app { + height: 100%; + width: 100%; + overflow: hidden; +} + +body { + font-family: $font-ui; + background-color: $bg-primary; + color: $text-primary; + font-size: 14px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code, pre, .mono { + font-family: $font-code; +} + +a { + color: $accent-primary; + text-decoration: none; + + &:hover { + color: $accent-hover; + } +} + +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: $border-color; + border-radius: 3px; + + &:hover { + background: $text-muted; + } +} + +::selection { + background: rgba($accent-primary, 0.3); +} diff --git a/src/styles/theme.ts b/src/styles/theme.ts new file mode 100644 index 0000000..75b63ac --- /dev/null +++ b/src/styles/theme.ts @@ -0,0 +1,71 @@ +import type { GlobalThemeOverrides } from 'naive-ui' + +export const themeOverrides: GlobalThemeOverrides = { + common: { + primaryColor: '#333333', + primaryColorHover: '#1a1a1a', + primaryColorPressed: '#000000', + primaryColorSuppl: '#333333', + bodyColor: '#fafafa', + cardColor: '#ffffff', + modalColor: '#ffffff', + popoverColor: '#ffffff', + tableColor: '#ffffff', + inputColor: '#ffffff', + actionColor: '#f0f0f0', + textColorBase: '#1a1a1a', + textColor1: '#1a1a1a', + textColor2: '#666666', + textColor3: '#999999', + dividerColor: '#e0e0e0', + borderColor: '#e0e0e0', + hoverColor: 'rgba(0, 0, 0, 0.04)', + borderRadius: '8px', + borderRadiusSmall: '6px', + fontSize: '14px', + fontSizeMedium: '14px', + heightMedium: '36px', + fontFamily: 'Inter, system-ui, -apple-system, sans-serif', + fontFamilyMono: 'JetBrains Mono, Fira Code, Consolas, monospace', + }, + Layout: { + color: '#fafafa', + siderColor: '#f5f5f5', + headerColor: '#fafafa', + }, + Menu: { + itemTextColorActive: '#1a1a1a', + itemTextColorActiveHover: '#1a1a1a', + itemTextColorChildActive: '#1a1a1a', + itemIconColorActive: '#1a1a1a', + itemIconColorActiveHover: '#000000', + itemColorActive: 'rgba(0, 0, 0, 0.06)', + itemColorActiveHover: 'rgba(0, 0, 0, 0.1)', + arrowColorActive: '#1a1a1a', + }, + Button: { + textColorPrimary: '#ffffff', + colorPrimary: '#333333', + colorHoverPrimary: '#1a1a1a', + colorPressedPrimary: '#000000', + }, + Input: { + color: '#ffffff', + colorFocus: '#ffffff', + border: '1px solid #e0e0e0', + borderHover: '1px solid #999999', + borderFocus: '1px solid #333333', + placeholderColor: '#999999', + caretColor: '#1a1a1a', + }, + Card: { + color: '#ffffff', + borderColor: '#e0e0e0', + }, + Modal: { + color: '#ffffff', + }, + Tag: { + borderRadius: '6px', + }, +} diff --git a/src/styles/variables.scss b/src/styles/variables.scss new file mode 100644 index 0000000..81d3b39 --- /dev/null +++ b/src/styles/variables.scss @@ -0,0 +1,56 @@ +// 黑白水墨 — Pure Ink +// 纯黑白灰,无彩色 + +// Backgrounds +$bg-primary: #fafafa; +$bg-secondary: #f0f0f0; +$bg-sidebar: #f5f5f5; +$bg-card: #ffffff; +$bg-card-hover: #fafafa; +$bg-input: #ffffff; + +// Borders +$border-color: #e0e0e0; +$border-light: #ebebeb; + +// Accent +$accent-primary: #333333; +$accent-hover: #1a1a1a; +$accent-muted: #888888; + +// Text +$text-primary: #1a1a1a; +$text-secondary: #666666; +$text-muted: #999999; + +// Status +$success: #2e7d32; +$error: #c62828; +$warning: #f57f17; +$info: $accent-primary; + +// Message bubbles +$msg-user-bg: #e8e8e8; +$msg-assistant-bg: #f5f5f5; +$msg-system-border: #bdbdbd; + +// Code +$code-bg: #f4f4f4; + +// Typography +$font-ui: 'Inter', system-ui, -apple-system, sans-serif; +$font-code: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + +// Layout +$sidebar-width: 240px; +$sidebar-collapsed-width: 64px; +$header-height: 56px; + +// Radius +$radius-sm: 6px; +$radius-md: 10px; +$radius-lg: 14px; + +// Transition +$transition-fast: 0.15s ease; +$transition-normal: 0.25s ease; diff --git a/src/views/ChatView.vue b/src/views/ChatView.vue new file mode 100644 index 0000000..12f9ae6 --- /dev/null +++ b/src/views/ChatView.vue @@ -0,0 +1,25 @@ + + + + + diff --git a/src/views/JobsView.vue b/src/views/JobsView.vue new file mode 100644 index 0000000..1a49736 --- /dev/null +++ b/src/views/JobsView.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/src/views/SettingsView.vue b/src/views/SettingsView.vue new file mode 100644 index 0000000..aceb3a7 --- /dev/null +++ b/src/views/SettingsView.vue @@ -0,0 +1,257 @@ + + + + + diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..d7fb81f --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,17 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "types": ["vite/client"], + "ignoreDeprecations": "6.0", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..d3c52ea --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..9961cb4 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,39 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import type { ProxyOptions } from 'vite' +import { resolve } from 'path' + +function createProxyConfig(): ProxyOptions { + return { + target: 'http://127.0.0.1:8642', + changeOrigin: true, + configure: (proxy) => { + proxy.on('proxyReq', (proxyReq) => { + proxyReq.removeHeader('origin') + proxyReq.removeHeader('referer') + }) + // Disable response buffering for SSE streaming + proxy.on('proxyRes', (proxyRes) => { + proxyRes.headers['cache-control'] = 'no-cache' + proxyRes.headers['x-accel-buffering'] = 'no' + }) + }, + } +} + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + server: { + compress: false, + proxy: { + '/api': createProxyConfig(), + '/v1': createProxyConfig(), + '/health': createProxyConfig(), + }, + }, +})