feat: add attachment upload UI and local file upload endpoint
- Add attachment button, file picker, and preview area to ChatInput - Render image/file attachments in user message bubbles (MessageItem) - Add Attachment type and attachments field to Message interface - Add POST /__upload endpoint to both Vite dev server and production server for saving files to temp directory and returning local file paths - Translate README to English Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,23 +1,23 @@
|
|||||||
# Hermes UI
|
# Hermes Web UI
|
||||||
|
|
||||||
Hermes Agent 的 Web 管理面板,用于对话交互和定时任务管理。
|
Web dashboard for [Hermes Agent](https://github.com/EKKOLearnAI/hermes-agent) — chat interaction and scheduled job management.
|
||||||
|
|
||||||
## 技术栈
|
## Tech Stack
|
||||||
|
|
||||||
- **Vue 3** — Composition API + `<script setup>`
|
- **Vue 3** — Composition API + `<script setup>`
|
||||||
- **TypeScript**
|
- **TypeScript**
|
||||||
- **Vite** — 构建工具
|
- **Vite** — Build tool
|
||||||
- **Naive UI** — 组件库
|
- **Naive UI** — Component library
|
||||||
- **Pinia** — 状态管理
|
- **Pinia** — State management
|
||||||
- **Vue Router** — 路由(Hash 模式)
|
- **Vue Router** — Routing (Hash mode)
|
||||||
- **SCSS** — 样式预处理
|
- **SCSS** — Style preprocessor
|
||||||
- **markdown-it** + **highlight.js** — Markdown 渲染与代码高亮
|
- **markdown-it** + **highlight.js** — Markdown rendering and code highlighting
|
||||||
|
|
||||||
## 快速开始
|
## Getting Started
|
||||||
|
|
||||||
### 1. 配置 API Server
|
### 1. Configure API Server
|
||||||
|
|
||||||
编辑 `~/.hermes/config.yaml`,启用 API Server:
|
Edit `~/.hermes/config.yaml` and enable the API Server:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
platforms:
|
platforms:
|
||||||
@@ -29,115 +29,116 @@ platforms:
|
|||||||
cors_origins: "*"
|
cors_origins: "*"
|
||||||
```
|
```
|
||||||
|
|
||||||
重启 Gateway 使配置生效:
|
Restart the Gateway to apply changes:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hermes gateway restart
|
hermes gateway restart
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 安装并启动
|
### 2. Install and Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 全局安装
|
# Global install
|
||||||
npm install -g hermes-web-ui
|
npm install -g hermes-web-ui
|
||||||
|
|
||||||
# 启动 Web 面板(默认 http://localhost:8648)
|
# Start the web dashboard (default http://localhost:8648)
|
||||||
hermes-web-ui start
|
hermes-web-ui start
|
||||||
```
|
```
|
||||||
|
|
||||||
### 开发模式
|
### Development Mode
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 克隆项目后
|
git clone https://github.com/EKKOLearnAI/hermes-web-ui.git
|
||||||
|
cd hermes-web-ui
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## 项目结构
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── api/
|
├── api/
|
||||||
│ ├── client.ts # HTTP 请求封装(fetch + Bearer Auth)
|
│ ├── client.ts # HTTP client (fetch + Bearer Auth)
|
||||||
│ ├── chat.ts # 对话 API(startRun + SSE 事件流)
|
│ ├── chat.ts # Chat API (startRun + SSE event stream)
|
||||||
│ ├── jobs.ts # 定时任务 CRUD
|
│ ├── jobs.ts # Scheduled job CRUD
|
||||||
│ └── system.ts # 健康检查、模型列表
|
│ └── system.ts # Health check, model list
|
||||||
├── stores/
|
├── stores/
|
||||||
│ ├── app.ts # 全局状态(连接状态、版本、模型)
|
│ ├── app.ts # Global state (connection, version, models)
|
||||||
│ ├── chat.ts # 对话状态(消息、会话、流式输出)
|
│ ├── chat.ts # Chat state (messages, sessions, streaming)
|
||||||
│ └── jobs.ts # 任务状态(列表、CRUD 操作)
|
│ └── jobs.ts # Job state (list, CRUD operations)
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── layout/
|
│ ├── layout/
|
||||||
│ │ └── AppSidebar.vue # 侧边栏导航
|
│ │ └── AppSidebar.vue # Sidebar navigation
|
||||||
│ ├── chat/
|
│ ├── chat/
|
||||||
│ │ ├── ChatPanel.vue # 对话面板(会话列表 + 聊天区域)
|
│ │ ├── ChatPanel.vue # Chat panel (session list + chat area)
|
||||||
│ │ ├── MessageList.vue # 消息列表(自动滚动、加载动画)
|
│ │ ├── MessageList.vue # Message list (auto-scroll, loading animation)
|
||||||
│ │ ├── MessageItem.vue # 单条消息(用户/AI/工具/系统)
|
│ │ ├── MessageItem.vue # Single message (user/AI/tool/system)
|
||||||
│ │ ├── ChatInput.vue # 输入框(Ctrl+Enter 发送)
|
│ │ ├── ChatInput.vue # Input box (Enter to send, Shift+Enter for newline)
|
||||||
│ │ └── MarkdownRenderer.vue # Markdown 渲染(代码高亮、复制)
|
│ │ └── MarkdownRenderer.vue # Markdown renderer (code highlighting, copy)
|
||||||
│ └── jobs/
|
│ └── jobs/
|
||||||
│ ├── JobsPanel.vue # 任务面板
|
│ ├── JobsPanel.vue # Job panel
|
||||||
│ ├── JobCard.vue # 任务卡片
|
│ ├── JobCard.vue # Job card
|
||||||
│ └── JobFormModal.vue # 创建/编辑任务弹窗
|
│ └── JobFormModal.vue # Create/edit job modal
|
||||||
├── views/
|
├── views/
|
||||||
│ ├── ChatView.vue # 对话页
|
│ ├── ChatView.vue # Chat page
|
||||||
│ └── JobsView.vue # 任务页
|
│ └── JobsView.vue # Jobs page
|
||||||
├── router/
|
├── router/
|
||||||
│ └── index.ts # 路由配置
|
│ └── index.ts # Router configuration
|
||||||
├── styles/
|
├── styles/
|
||||||
│ ├── variables.scss # SCSS 设计变量
|
│ ├── variables.scss # SCSS design tokens
|
||||||
│ ├── global.scss # 全局样式
|
│ ├── global.scss # Global styles
|
||||||
│ └── theme.ts # Naive UI 主题覆盖
|
│ └── theme.ts # Naive UI theme overrides
|
||||||
├── composables/
|
├── composables/
|
||||||
│ └── useKeyboard.ts # 键盘快捷键
|
│ └── useKeyboard.ts # Keyboard shortcuts
|
||||||
└── main.ts # 应用入口
|
└── main.ts # App entry point
|
||||||
```
|
```
|
||||||
|
|
||||||
## 功能特性
|
## Features
|
||||||
|
|
||||||
### 对话(Chat)
|
### Chat
|
||||||
|
|
||||||
- 基于 `/v1/runs` + `/v1/runs/{id}/events` 的异步 Run + SSE 事件流
|
- Async Run + SSE event stream via `/v1/runs` + `/v1/runs/{id}/events`
|
||||||
- 实时流式输出,工具调用进度可视化
|
- Real-time streaming output with tool call progress visualization
|
||||||
- 多会话管理,会话历史持久化到 localStorage
|
- Multi-session management with localStorage persistence
|
||||||
- Markdown 渲染,代码块语法高亮与一键复制
|
- Markdown rendering with syntax highlighting and one-click code copy
|
||||||
|
|
||||||
### 定时任务(Jobs)
|
### Scheduled Jobs
|
||||||
|
|
||||||
- 任务列表查看(含暂停/禁用任务)
|
- Job list view (including paused/disabled jobs)
|
||||||
- 创建、编辑、删除任务
|
- Create, edit, and delete jobs
|
||||||
- 暂停/恢复任务
|
- Pause and resume jobs
|
||||||
- 立即触发任务执行
|
- Trigger immediate job execution
|
||||||
- Cron 表达式快速预设
|
- Cron expression quick presets
|
||||||
|
|
||||||
### 其他
|
### Other
|
||||||
|
|
||||||
- 连接状态实时检测(30s 轮询)
|
- Real-time connection status monitoring (30s polling)
|
||||||
- 纯黑白主题
|
- Minimalist black-and-white theme
|
||||||
- 键盘快捷键支持
|
- Keyboard shortcuts (Ctrl+N for new chat, Ctrl+J for jobs)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API 接口文档
|
## API Reference
|
||||||
|
|
||||||
Base URL: `http://127.0.0.1:8642`
|
Base URL: `http://127.0.0.1:8642`
|
||||||
|
|
||||||
### 认证
|
### Authentication
|
||||||
|
|
||||||
除 `/health` 外,所有接口支持 Bearer Token 认证(如果服务端配置了 `key`):
|
All endpoints except `/health` support Bearer Token authentication (if `key` is configured on the server):
|
||||||
|
|
||||||
```
|
```
|
||||||
Authorization: Bearer <your-api-key>
|
Authorization: Bearer <your-api-key>
|
||||||
```
|
```
|
||||||
|
|
||||||
未配置 key 时所有请求放行。
|
When no key is configured, all requests are allowed without authentication.
|
||||||
|
|
||||||
### 通用错误格式
|
### Error Format
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"error": {
|
"error": {
|
||||||
"message": "错误描述",
|
"message": "Error description",
|
||||||
"type": "invalid_request_error",
|
"type": "invalid_request_error",
|
||||||
"param": null,
|
"param": null,
|
||||||
"code": "invalid_api_key"
|
"code": "invalid_api_key"
|
||||||
@@ -145,23 +146,23 @@ Authorization: Bearer <your-api-key>
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| 状态码 | 说明 |
|
| Status Code | Description |
|
||||||
|--------|------|
|
|-------------|-------------|
|
||||||
| 200 | 成功 |
|
| 200 | Success |
|
||||||
| 400 | 请求参数错误 |
|
| 400 | Bad request |
|
||||||
| 401 | API Key 无效 |
|
| 401 | Invalid API key |
|
||||||
| 404 | 资源不存在 |
|
| 404 | Not found |
|
||||||
| 413 | 请求体过大(上限 1MB) |
|
| 413 | Request body too large (max 1MB) |
|
||||||
| 429 | 并发超限(最大 10 个 Run) |
|
| 429 | Concurrent run limit exceeded (max 10 runs) |
|
||||||
| 500 | 服务器内部错误 |
|
| 500 | Internal server error |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 1. 健康检查
|
### 1. Health Check
|
||||||
|
|
||||||
**GET** `/health` 或 `/v1/health`
|
**GET** `/health` or `/v1/health`
|
||||||
|
|
||||||
无需认证。
|
No authentication required.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"status": "ok", "platform": "hermes-agent"}
|
{"status": "ok", "platform": "hermes-agent"}
|
||||||
@@ -169,7 +170,7 @@ Authorization: Bearer <your-api-key>
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2. 模型列表
|
### 2. Model List
|
||||||
|
|
||||||
**GET** `/v1/models`
|
**GET** `/v1/models`
|
||||||
|
|
||||||
@@ -189,56 +190,56 @@ Authorization: Bearer <your-api-key>
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3. Chat Completions(OpenAI 兼容)
|
### 3. Chat Completions (OpenAI Compatible)
|
||||||
|
|
||||||
**POST** `/v1/chat/completions`
|
**POST** `/v1/chat/completions`
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
| Field | Type | Required | Description |
|
||||||
|------|------|------|------|
|
|-------|------|----------|-------------|
|
||||||
| messages | array | Y | 消息数组,格式同 OpenAI |
|
| messages | array | Y | Message array, same format as OpenAI |
|
||||||
| stream | boolean | N | 是否流式返回,默认 false |
|
| stream | boolean | N | Enable streaming, default false |
|
||||||
| model | string | N | 模型名,默认 "hermes-agent" |
|
| model | string | N | Model name, default "hermes-agent" |
|
||||||
|
|
||||||
可选 Header: `X-Hermes-Session-Id` 指定会话 ID。
|
Optional header: `X-Hermes-Session-Id` to specify a session ID.
|
||||||
|
|
||||||
**stream=false 响应:**
|
**stream=false response:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "chatcmpl-xxxxx",
|
"id": "chatcmpl-xxxxx",
|
||||||
"object": "chat.completion",
|
"object": "chat.completion",
|
||||||
"created": 1744348800,
|
"created": 1744348800,
|
||||||
"model": "hermes-agent",
|
"model": "hermes-agent",
|
||||||
"choices": [{"index": 0, "message": {"role": "assistant", "content": "回复内容"}, "finish_reason": "stop"}],
|
"choices": [{"index": 0, "message": {"role": "assistant", "content": "Response content"}, "finish_reason": "stop"}],
|
||||||
"usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150}
|
"usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**stream=true 响应:** SSE 流(`Content-Type: text/event-stream`)
|
**stream=true response:** SSE stream (`Content-Type: text/event-stream`)
|
||||||
```
|
```
|
||||||
data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"你"},"index":0}]}
|
data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"Hello"},"index":0}]}
|
||||||
data: [DONE]
|
data: [DONE]
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 4. Responses(有状态链式对话)
|
### 4. Responses (Stateful Chained Conversations)
|
||||||
|
|
||||||
**POST** `/v1/responses`
|
**POST** `/v1/responses`
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
| Field | Type | Required | Description |
|
||||||
|------|------|------|------|
|
|-------|------|----------|-------------|
|
||||||
| input | string / array | Y | 用户输入 |
|
| input | string / array | Y | User input |
|
||||||
| instructions | string | N | 系统指令 |
|
| instructions | string | N | System instructions |
|
||||||
| previous_response_id | string | N | 链式对话的上一次响应 ID |
|
| previous_response_id | string | N | Previous response ID for chained conversation |
|
||||||
| conversation | string | N | 会话名称,自动链式到最新响应 |
|
| conversation | string | N | Conversation name, auto-chains to latest response |
|
||||||
| conversation_history | array | N | 显式传入对话历史 |
|
| conversation_history | array | N | Explicit conversation history |
|
||||||
| store | boolean | N | 是否存储响应,默认 true |
|
| store | boolean | N | Whether to store the response, default true |
|
||||||
| truncation | string | N | 设为 "auto" 自动截断历史到 100 条 |
|
| truncation | string | N | Set to "auto" to truncate history to 100 messages |
|
||||||
| model | string | N | 模型名 |
|
| model | string | N | Model name |
|
||||||
|
|
||||||
> `conversation` 和 `previous_response_id` 互斥。
|
> `conversation` and `previous_response_id` are mutually exclusive.
|
||||||
|
|
||||||
可选 Header: `Idempotency-Key` 幂等键。
|
Optional header: `Idempotency-Key` for idempotency.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -246,18 +247,18 @@ data: [DONE]
|
|||||||
"object": "response",
|
"object": "response",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"created_at": 1744348800,
|
"created_at": 1744348800,
|
||||||
"output": [{"type": "message", "role": "assistant", "content": "回复内容"}],
|
"output": [{"type": "message", "role": "assistant", "content": "Response content"}],
|
||||||
"usage": {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150}
|
"usage": {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5. 获取 / 删除存储的响应
|
### 5. Get / Delete Stored Responses
|
||||||
|
|
||||||
**GET** `/v1/responses/{response_id}` — 获取存储的响应
|
**GET** `/v1/responses/{response_id}` — Get a stored response
|
||||||
|
|
||||||
**DELETE** `/v1/responses/{response_id}` — 删除存储的响应
|
**DELETE** `/v1/responses/{response_id}` — Delete a stored response
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"id": "resp_xxx", "object": "response", "deleted": true}
|
{"id": "resp_xxx", "object": "response", "deleted": true}
|
||||||
@@ -265,17 +266,17 @@ data: [DONE]
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 6. 启动异步 Run
|
### 6. Start Async Run
|
||||||
|
|
||||||
**POST** `/v1/runs`
|
**POST** `/v1/runs`
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
| Field | Type | Required | Description |
|
||||||
|------|------|------|------|
|
|-------|------|----------|-------------|
|
||||||
| input | string / array | Y | 用户输入 |
|
| input | string / array | Y | User input |
|
||||||
| instructions | string | N | 系统指令 |
|
| instructions | string | N | System instructions |
|
||||||
| previous_response_id | string | N | 链式对话 ID |
|
| previous_response_id | string | N | Chained conversation ID |
|
||||||
| conversation_history | array | N | 对话历史 |
|
| conversation_history | array | N | Conversation history |
|
||||||
| session_id | string | N | 会话 ID,默认使用 run_id |
|
| session_id | string | N | Session ID, defaults to run_id |
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"run_id": "run_xxx", "status": "started"}
|
{"run_id": "run_xxx", "status": "started"}
|
||||||
@@ -283,36 +284,36 @@ data: [DONE]
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 7. SSE 事件流
|
### 7. SSE Event Stream
|
||||||
|
|
||||||
**GET** `/v1/runs/{run_id}/events`
|
**GET** `/v1/runs/{run_id}/events`
|
||||||
|
|
||||||
`Content-Type: text/event-stream`
|
`Content-Type: text/event-stream`
|
||||||
|
|
||||||
**事件类型:**
|
**Event types:**
|
||||||
|
|
||||||
| 事件 | 说明 |
|
| Event | Description |
|
||||||
|------|------|
|
|-------|-------------|
|
||||||
| `run.started` | Run 开始 |
|
| `run.started` | Run started |
|
||||||
| `message.delta` | 消息内容片段(字段 `delta`) |
|
| `message.delta` | Message content fragment (field `delta`) |
|
||||||
| `tool.started` | 工具调用开始(字段 `tool`、`preview`) |
|
| `tool.started` | Tool call started (fields `tool`, `preview`) |
|
||||||
| `tool.completed` | 工具调用完成(字段 `tool`、`duration`) |
|
| `tool.completed` | Tool call completed (fields `tool`, `duration`) |
|
||||||
| `run.completed` | Run 完成(字段 `output`、`usage`) |
|
| `run.completed` | Run completed (fields `output`, `usage`) |
|
||||||
| `run.failed` | Run 失败(字段 `error`) |
|
| `run.failed` | Run failed (field `error`) |
|
||||||
|
|
||||||
示例:
|
Example:
|
||||||
```
|
```
|
||||||
data: {"event":"message.delta","run_id":"run_xxx","delta":"你好","timestamp":...}
|
data: {"event":"message.delta","run_id":"run_xxx","delta":"Hello","timestamp":...}
|
||||||
data: {"event":"tool.started","run_id":"run_xxx","tool":"browser_navigate","preview":"https://...","timestamp":...}
|
data: {"event":"tool.started","run_id":"run_xxx","tool":"browser_navigate","preview":"https://...","timestamp":...}
|
||||||
data: {"event":"tool.completed","run_id":"run_xxx","tool":"browser_navigate","duration":3.8,"timestamp":...}
|
data: {"event":"tool.completed","run_id":"run_xxx","tool":"browser_navigate","duration":3.8,"timestamp":...}
|
||||||
data: {"event":"run.completed","run_id":"run_xxx","output":"完整回复","usage":{"input_tokens":100,"output_tokens":50,"total_tokens":150}}
|
data: {"event":"run.completed","run_id":"run_xxx","output":"Full response","usage":{"input_tokens":100,"output_tokens":50,"total_tokens":150}}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 8. 定时任务
|
### 8. Scheduled Jobs
|
||||||
|
|
||||||
#### 列出任务
|
#### List Jobs
|
||||||
|
|
||||||
**GET** `/api/jobs?include_disabled=true`
|
**GET** `/api/jobs?include_disabled=true`
|
||||||
|
|
||||||
@@ -321,7 +322,7 @@ data: {"event":"run.completed","run_id":"run_xxx","output":"完整回复","usage
|
|||||||
"jobs": [
|
"jobs": [
|
||||||
{
|
{
|
||||||
"job_id": "61a5eb0baeb9",
|
"job_id": "61a5eb0baeb9",
|
||||||
"name": "任务名",
|
"name": "Job name",
|
||||||
"schedule": "0 9 * * *",
|
"schedule": "0 9 * * *",
|
||||||
"repeat": "forever",
|
"repeat": "forever",
|
||||||
"deliver": "origin",
|
"deliver": "origin",
|
||||||
@@ -337,32 +338,32 @@ data: {"event":"run.completed","run_id":"run_xxx","output":"完整回复","usage
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 创建任务
|
#### Create Job
|
||||||
|
|
||||||
**POST** `/api/jobs`
|
**POST** `/api/jobs`
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
| Field | Type | Required | Description |
|
||||||
|------|------|------|------|
|
|-------|------|----------|-------------|
|
||||||
| name | string | Y | 任务名称(最大 200 字符) |
|
| name | string | Y | Job name (max 200 characters) |
|
||||||
| schedule | string | Y | Cron 表达式 |
|
| schedule | string | Y | Cron expression |
|
||||||
| prompt | string | N | 任务 prompt |
|
| prompt | string | N | Job prompt |
|
||||||
| deliver | string | N | 投递目标(origin / local / telegram / discord) |
|
| deliver | string | N | Delivery target (origin / local / telegram / discord) |
|
||||||
| skills | array | N | skill 名称数组 |
|
| skills | array | N | Skill name array |
|
||||||
| repeat | integer | N | 重复次数,不传表示永久 |
|
| repeat | integer | N | Repeat count, omit for indefinite |
|
||||||
|
|
||||||
响应包裹在 `{"job": {...}}` 中。
|
Response is wrapped in `{"job": {...}}`.
|
||||||
|
|
||||||
#### 查看任务详情
|
#### Get Job Detail
|
||||||
|
|
||||||
**GET** `/api/jobs/{job_id}`
|
**GET** `/api/jobs/{job_id}`
|
||||||
|
|
||||||
#### 更新任务
|
#### Update Job
|
||||||
|
|
||||||
**PATCH** `/api/jobs/{job_id}`
|
**PATCH** `/api/jobs/{job_id}`
|
||||||
|
|
||||||
可更新字段:`name`、`schedule`、`prompt`、`deliver`、`skills`、`repeat`、`enabled`
|
Updatable fields: `name`, `schedule`, `prompt`, `deliver`, `skills`, `repeat`, `enabled`
|
||||||
|
|
||||||
#### 删除任务
|
#### Delete Job
|
||||||
|
|
||||||
**DELETE** `/api/jobs/{job_id}`
|
**DELETE** `/api/jobs/{job_id}`
|
||||||
|
|
||||||
@@ -370,7 +371,7 @@ data: {"event":"run.completed","run_id":"run_xxx","output":"完整回复","usage
|
|||||||
{"ok": true}
|
{"ok": true}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 暂停任务
|
#### Pause Job
|
||||||
|
|
||||||
**POST** `/api/jobs/{job_id}/pause`
|
**POST** `/api/jobs/{job_id}/pause`
|
||||||
|
|
||||||
@@ -378,7 +379,7 @@ data: {"event":"run.completed","run_id":"run_xxx","output":"完整回复","usage
|
|||||||
{"job": {"job_id": "xxx", "enabled": false, "state": "paused", ...}}
|
{"job": {"job_id": "xxx", "enabled": false, "state": "paused", ...}}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 恢复任务
|
#### Resume Job
|
||||||
|
|
||||||
**POST** `/api/jobs/{job_id}/resume`
|
**POST** `/api/jobs/{job_id}/resume`
|
||||||
|
|
||||||
@@ -386,7 +387,7 @@ data: {"event":"run.completed","run_id":"run_xxx","output":"完整回复","usage
|
|||||||
{"job": {"job_id": "xxx", "enabled": true, "state": "scheduled", ...}}
|
{"job": {"job_id": "xxx", "enabled": true, "state": "scheduled", ...}}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 立即触发任务
|
#### Trigger Job Now
|
||||||
|
|
||||||
**POST** `/api/jobs/{job_id}/run`
|
**POST** `/api/jobs/{job_id}/run`
|
||||||
|
|
||||||
@@ -396,39 +397,43 @@ data: {"event":"run.completed","run_id":"run_xxx","output":"完整回复","usage
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 快速测试
|
## Quick Test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 健康检查
|
# Health check
|
||||||
curl http://127.0.0.1:8642/health
|
curl http://127.0.0.1:8642/health
|
||||||
|
|
||||||
# 模型列表
|
# Model list
|
||||||
curl http://127.0.0.1:8642/v1/models
|
curl http://127.0.0.1:8642/v1/models
|
||||||
|
|
||||||
# Chat Completions
|
# Chat Completions
|
||||||
curl -X POST http://127.0.0.1:8642/v1/chat/completions \
|
curl -X POST http://127.0.0.1:8642/v1/chat/completions \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"messages":[{"role":"user","content":"你好"}]}'
|
-d '{"messages":[{"role":"user","content":"Hello"}]}'
|
||||||
|
|
||||||
# 启动异步 Run
|
# Start async Run
|
||||||
curl -X POST http://127.0.0.1:8642/v1/runs \
|
curl -X POST http://127.0.0.1:8642/v1/runs \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"input":"你好"}'
|
-d '{"input":"Hello"}'
|
||||||
|
|
||||||
# 监听 Run 事件流
|
# Listen to Run event stream
|
||||||
curl http://127.0.0.1:8642/v1/runs/{run_id}/events
|
curl http://127.0.0.1:8642/v1/runs/{run_id}/events
|
||||||
|
|
||||||
# 列出任务(含已暂停)
|
# List jobs (including disabled)
|
||||||
curl "http://127.0.0.1:8642/api/jobs?include_disabled=true"
|
curl "http://127.0.0.1:8642/api/jobs?include_disabled=true"
|
||||||
|
|
||||||
# 创建任务
|
# Create job
|
||||||
curl -X POST http://127.0.0.1:8642/api/jobs \
|
curl -X POST http://127.0.0.1:8642/api/jobs \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"name":"测试任务","schedule":"0 9 * * *","prompt":"执行测试"}'
|
-d '{"name":"Test Job","schedule":"0 9 * * *","prompt":"Run test"}'
|
||||||
|
|
||||||
# 暂停 / 恢复 / 触发 / 删除
|
# Pause / Resume / Trigger / Delete
|
||||||
curl -X POST http://127.0.0.1:8642/api/jobs/{job_id}/pause
|
curl -X POST http://127.0.0.1:8642/api/jobs/{job_id}/pause
|
||||||
curl -X POST http://127.0.0.1:8642/api/jobs/{job_id}/resume
|
curl -X POST http://127.0.0.1:8642/api/jobs/{job_id}/resume
|
||||||
curl -X POST http://127.0.0.1:8642/api/jobs/{job_id}/run
|
curl -X POST http://127.0.0.1:8642/api/jobs/{job_id}/run
|
||||||
curl -X DELETE http://127.0.0.1:8642/api/jobs/{job_id}
|
curl -X DELETE http://127.0.0.1:8642/api/jobs/{job_id}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](./LICENSE)
|
||||||
|
|||||||
+62
-2
@@ -2,12 +2,15 @@
|
|||||||
import { createServer as createViteServer } from 'http'
|
import { createServer as createViteServer } from 'http'
|
||||||
import { resolve, dirname, join } from 'path'
|
import { resolve, dirname, join } from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
import { readFile, stat, readdir } from 'fs/promises'
|
import { readFile, stat, readdir, writeFile, mkdir } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { randomBytes } from 'crypto'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const distDir = resolve(__dirname, '..', 'dist')
|
const distDir = resolve(__dirname, '..', 'dist')
|
||||||
const API_TARGET = 'http://127.0.0.1:8642'
|
const API_TARGET = 'http://127.0.0.1:8642'
|
||||||
const DEFAULT_PORT = 8648
|
const DEFAULT_PORT = 8648
|
||||||
|
const UPLOAD_DIR = join(tmpdir(), 'hermes-uploads')
|
||||||
|
|
||||||
const MIME_TYPES = {
|
const MIME_TYPES = {
|
||||||
'.html': 'text/html',
|
'.html': 'text/html',
|
||||||
@@ -26,6 +29,10 @@ function getMimeType(filePath) {
|
|||||||
return MIME_TYPES[ext] || 'application/octet-stream'
|
return MIME_TYPES[ext] || 'application/octet-stream'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureUploadDir() {
|
||||||
|
await mkdir(UPLOAD_DIR, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
async function serveStatic(reqPath, res) {
|
async function serveStatic(reqPath, res) {
|
||||||
let filePath = join(distDir, reqPath)
|
let filePath = join(distDir, reqPath)
|
||||||
try {
|
try {
|
||||||
@@ -50,6 +57,57 @@ async function serveStatic(reqPath, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleUpload(req, res) {
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
res.writeHead(405, { 'Content-Type': 'application/json' })
|
||||||
|
res.end(JSON.stringify({ error: 'Method not allowed' }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = req.headers['content-type'] || ''
|
||||||
|
if (!contentType.startsWith('multipart/form-data')) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' })
|
||||||
|
res.end(JSON.stringify({ error: 'Expected multipart/form-data' }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureUploadDir()
|
||||||
|
const chunks = []
|
||||||
|
for await (const chunk of req) chunks.push(chunk)
|
||||||
|
const body = Buffer.concat(chunks).toString()
|
||||||
|
|
||||||
|
const boundary = '--' + contentType.split('boundary=')[1]
|
||||||
|
const parts = body.split(boundary).slice(1, -1)
|
||||||
|
|
||||||
|
const results = []
|
||||||
|
for (const part of parts) {
|
||||||
|
const headerEnd = part.indexOf('\r\n\r\n')
|
||||||
|
if (headerEnd === -1) continue
|
||||||
|
const header = part.substring(0, headerEnd)
|
||||||
|
const data = part.substring(headerEnd + 4, part.length - 2)
|
||||||
|
|
||||||
|
const nameMatch = header.match(/name="([^"]+)"/)
|
||||||
|
const filenameMatch = header.match(/filename="([^"]+)"/)
|
||||||
|
if (!nameMatch || !filenameMatch) continue
|
||||||
|
|
||||||
|
const filename = filenameMatch[1]
|
||||||
|
const ext = filename.includes('.') ? '.' + filename.split('.').pop() : ''
|
||||||
|
const savedName = randomBytes(8).toString('hex') + ext
|
||||||
|
const savedPath = join(UPLOAD_DIR, savedName)
|
||||||
|
|
||||||
|
await writeFile(savedPath, Buffer.from(data, 'binary'))
|
||||||
|
results.push({ name: filename, path: savedPath })
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||||
|
res.end(JSON.stringify({ files: results }))
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' })
|
||||||
|
res.end(JSON.stringify({ error: err.message }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function proxyRequest(req, res, reqPath) {
|
async function proxyRequest(req, res, reqPath) {
|
||||||
const url = `${API_TARGET}${reqPath}`
|
const url = `${API_TARGET}${reqPath}`
|
||||||
const headers = { ...req.headers, host: '127.0.0.1:8642' }
|
const headers = { ...req.headers, host: '127.0.0.1:8642' }
|
||||||
@@ -115,7 +173,9 @@ const port = parseInt(process.argv[2] && !isNaN(process.argv[2]) ? process.argv[
|
|||||||
createViteServer(async (req, res) => {
|
createViteServer(async (req, res) => {
|
||||||
const reqPath = req.url.split('?')[0]
|
const reqPath = req.url.split('?')[0]
|
||||||
|
|
||||||
if (reqPath.startsWith('/api/') || reqPath.startsWith('/v1/') || reqPath === '/health' || reqPath.startsWith('/health')) {
|
if (reqPath === '/__upload') {
|
||||||
|
await handleUpload(req, res)
|
||||||
|
} else if (reqPath.startsWith('/api/') || reqPath.startsWith('/v1/') || reqPath === '/health' || reqPath.startsWith('/health')) {
|
||||||
await proxyRequest(req, res, reqPath)
|
await proxyRequest(req, res, reqPath)
|
||||||
} else {
|
} else {
|
||||||
await serveStatic(reqPath, res)
|
await serveStatic(reqPath, res)
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { NButton } from 'naive-ui'
|
import { NButton, NTooltip } from 'naive-ui'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chat'
|
||||||
|
import type { Attachment } from '@/stores/chat'
|
||||||
|
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
const inputText = ref('')
|
const inputText = ref('')
|
||||||
const textareaRef = ref<HTMLTextAreaElement>()
|
const textareaRef = ref<HTMLTextAreaElement>()
|
||||||
|
const fileInputRef = ref<HTMLInputElement>()
|
||||||
|
const attachments = ref<Attachment[]>([])
|
||||||
|
|
||||||
|
const canSend = computed(() => inputText.value.trim() || attachments.value.length > 0)
|
||||||
|
|
||||||
function handleSend() {
|
function handleSend() {
|
||||||
const text = inputText.value.trim()
|
const text = inputText.value.trim()
|
||||||
if (!text) return
|
if (!text && attachments.value.length === 0) return
|
||||||
|
|
||||||
chatStore.sendMessage(text)
|
chatStore.sendMessage(text, attachments.value.length > 0 ? attachments.value : undefined)
|
||||||
inputText.value = ''
|
inputText.value = ''
|
||||||
|
attachments.value = []
|
||||||
|
|
||||||
// Reset textarea height
|
// Reset textarea height
|
||||||
if (textareaRef.value) {
|
if (textareaRef.value) {
|
||||||
@@ -32,11 +38,86 @@ function handleInput(e: Event) {
|
|||||||
el.style.height = 'auto'
|
el.style.height = 'auto'
|
||||||
el.style.height = Math.min(el.scrollHeight, 100) + 'px'
|
el.style.height = Math.min(el.scrollHeight, 100) + 'px'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAttachClick() {
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileChange(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
const files = input.files
|
||||||
|
if (!files) return
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
|
||||||
|
const url = URL.createObjectURL(file)
|
||||||
|
attachments.value.push({
|
||||||
|
id,
|
||||||
|
name: file.name,
|
||||||
|
type: file.type,
|
||||||
|
size: file.size,
|
||||||
|
url,
|
||||||
|
file,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset input so the same file can be re-selected
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAttachment(id: string) {
|
||||||
|
const idx = attachments.value.findIndex(a => a.id === id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
URL.revokeObjectURL(attachments.value[idx].url)
|
||||||
|
attachments.value.splice(idx, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return bytes + ' B'
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImage(type: string): boolean {
|
||||||
|
return type.startsWith('image/')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="chat-input-area">
|
<div class="chat-input-area">
|
||||||
|
<!-- Attachment previews -->
|
||||||
|
<div v-if="attachments.length > 0" class="attachment-previews">
|
||||||
|
<div
|
||||||
|
v-for="att in attachments"
|
||||||
|
:key="att.id"
|
||||||
|
class="attachment-preview"
|
||||||
|
:class="{ image: isImage(att.type) }"
|
||||||
|
>
|
||||||
|
<template v-if="isImage(att.type)">
|
||||||
|
<img :src="att.url" :alt="att.name" class="attachment-thumb" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="attachment-file">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||||
|
<span class="file-name">{{ att.name }}</span>
|
||||||
|
<span class="file-size">{{ formatSize(att.size) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<button class="attachment-remove" @click="removeAttachment(att.id)">
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="input-wrapper">
|
<div class="input-wrapper">
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
class="file-input-hidden"
|
||||||
|
@change="handleFileChange"
|
||||||
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
ref="textareaRef"
|
ref="textareaRef"
|
||||||
v-model="inputText"
|
v-model="inputText"
|
||||||
@@ -47,6 +128,16 @@ function handleInput(e: Event) {
|
|||||||
@input="handleInput"
|
@input="handleInput"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="input-actions">
|
<div class="input-actions">
|
||||||
|
<!-- <NTooltip trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<NButton quaternary size="small" @click="handleAttachClick" circle>
|
||||||
|
<template #icon>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||||
|
</template>
|
||||||
|
</NButton>
|
||||||
|
</template>
|
||||||
|
Attach files
|
||||||
|
</NTooltip> -->
|
||||||
<NButton
|
<NButton
|
||||||
v-if="chatStore.isStreaming"
|
v-if="chatStore.isStreaming"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -58,7 +149,7 @@ function handleInput(e: Event) {
|
|||||||
<NButton
|
<NButton
|
||||||
size="small"
|
size="small"
|
||||||
type="primary"
|
type="primary"
|
||||||
:disabled="!inputText.trim() || chatStore.isStreaming"
|
:disabled="!canSend || chatStore.isStreaming"
|
||||||
@click="handleSend"
|
@click="handleSend"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
@@ -80,6 +171,83 @@ function handleInput(e: Event) {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.attachment-previews {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-preview {
|
||||||
|
position: relative;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: $bg-secondary;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
|
||||||
|
&.image {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-thumb {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-file {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
min-width: 80px;
|
||||||
|
max-width: 140px;
|
||||||
|
color: $text-secondary;
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size {
|
||||||
|
font-size: 10px;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity $transition-fast;
|
||||||
|
|
||||||
|
.attachment-preview:hover & {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.input-wrapper {
|
.input-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -11,6 +11,18 @@ const timeStr = computed(() => {
|
|||||||
const d = new Date(props.message.timestamp)
|
const d = new Date(props.message.timestamp)
|
||||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function isImage(type: string): boolean {
|
||||||
|
return type.startsWith('image/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return bytes + ' B'
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAttachments = computed(() => (props.message.attachments?.length ?? 0) > 0)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -27,6 +39,25 @@ const timeStr = computed(() => {
|
|||||||
<img v-if="message.role === 'assistant'" src="/assets/logo.png" alt="Hermes" class="msg-avatar" />
|
<img v-if="message.role === 'assistant'" src="/assets/logo.png" alt="Hermes" class="msg-avatar" />
|
||||||
<div class="msg-content" :class="message.role">
|
<div class="msg-content" :class="message.role">
|
||||||
<div class="message-bubble" :class="{ system: isSystem }">
|
<div class="message-bubble" :class="{ system: isSystem }">
|
||||||
|
<div v-if="hasAttachments" class="msg-attachments">
|
||||||
|
<div
|
||||||
|
v-for="att in message.attachments"
|
||||||
|
:key="att.id"
|
||||||
|
class="msg-attachment"
|
||||||
|
:class="{ image: isImage(att.type) }"
|
||||||
|
>
|
||||||
|
<template v-if="isImage(att.type) && att.url">
|
||||||
|
<img :src="att.url" :alt="att.name" class="msg-attachment-thumb" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="msg-attachment-file">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||||
|
<span class="att-name">{{ att.name }}</span>
|
||||||
|
<span class="att-size">{{ formatSize(att.size) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<MarkdownRenderer v-if="message.content" :content="message.content" />
|
<MarkdownRenderer v-if="message.content" :content="message.content" />
|
||||||
<span v-if="message.isStreaming" class="streaming-cursor"></span>
|
<span v-if="message.isStreaming" class="streaming-cursor"></span>
|
||||||
<div v-if="message.isStreaming && !message.content" class="streaming-dots">
|
<div v-if="message.isStreaming && !message.content" class="streaming-dots">
|
||||||
@@ -123,6 +154,53 @@ const timeStr = computed(() => {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.msg-attachments {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-attachment {
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: rgba(0, 0, 0, 0.04);
|
||||||
|
border: 1px solid $border-light;
|
||||||
|
|
||||||
|
&.image {
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-attachment-thumb {
|
||||||
|
display: block;
|
||||||
|
max-width: 200px;
|
||||||
|
max-height: 160px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-attachment-file {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-secondary;
|
||||||
|
|
||||||
|
.att-name {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-size {
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: 11px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.message-time {
|
.message-time {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: $text-muted;
|
color: $text-muted;
|
||||||
|
|||||||
+46
-5
@@ -3,6 +3,15 @@ import { ref } from 'vue'
|
|||||||
import { startRun, streamRunEvents, type ChatMessage, type RunEvent } from '@/api/chat'
|
import { startRun, streamRunEvents, type ChatMessage, type RunEvent } from '@/api/chat'
|
||||||
import { useAppStore } from './app'
|
import { useAppStore } from './app'
|
||||||
|
|
||||||
|
export interface Attachment {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
size: number
|
||||||
|
url: string
|
||||||
|
file?: File
|
||||||
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: string
|
id: string
|
||||||
role: 'user' | 'assistant' | 'system' | 'tool'
|
role: 'user' | 'assistant' | 'system' | 'tool'
|
||||||
@@ -12,6 +21,7 @@ export interface Message {
|
|||||||
toolPreview?: string
|
toolPreview?: string
|
||||||
toolStatus?: 'running' | 'done' | 'error'
|
toolStatus?: 'running' | 'done' | 'error'
|
||||||
isStreaming?: boolean
|
isStreaming?: boolean
|
||||||
|
attachments?: Attachment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Session {
|
interface Session {
|
||||||
@@ -26,6 +36,18 @@ function uid(): string {
|
|||||||
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
|
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadFiles(attachments: Attachment[]): Promise<{ name: string; path: string }[]> {
|
||||||
|
if (attachments.length === 0) return []
|
||||||
|
const formData = new FormData()
|
||||||
|
for (const att of attachments) {
|
||||||
|
if (att.file) formData.append('file', att.file, att.name)
|
||||||
|
}
|
||||||
|
const res = await fetch('/__upload', { method: 'POST', body: formData })
|
||||||
|
if (!res.ok) throw new Error(`Upload failed: ${res.status}`)
|
||||||
|
const data = await res.json() as { files: { name: string; path: string }[] }
|
||||||
|
return data.files
|
||||||
|
}
|
||||||
|
|
||||||
const SESSIONS_KEY = 'hermes_chat_sessions'
|
const SESSIONS_KEY = 'hermes_chat_sessions'
|
||||||
const ACTIVE_SESSION_KEY = 'hermes_active_session'
|
const ACTIVE_SESSION_KEY = 'hermes_active_session'
|
||||||
|
|
||||||
@@ -97,15 +119,25 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripNonSerializable(msgs: Message[]): Message[] {
|
||||||
|
return msgs.map(m => ({
|
||||||
|
...m,
|
||||||
|
attachments: m.attachments?.map(a => ({ ...a, file: undefined, url: '' })),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
function persistMessages() {
|
function persistMessages() {
|
||||||
if (!activeSession.value || !appStore.sessionPersistence) return
|
if (!activeSession.value || !appStore.sessionPersistence) return
|
||||||
activeSession.value.messages = [...messages.value]
|
activeSession.value.messages = stripNonSerializable(messages.value)
|
||||||
activeSession.value.updatedAt = Date.now()
|
activeSession.value.updatedAt = Date.now()
|
||||||
|
|
||||||
if (activeSession.value.title === 'New Chat') {
|
if (activeSession.value.title === 'New Chat') {
|
||||||
const firstUser = messages.value.find(m => m.role === 'user')
|
const firstUser = messages.value.find(m => m.role === 'user')
|
||||||
if (firstUser) {
|
if (firstUser) {
|
||||||
activeSession.value.title = firstUser.content.slice(0, 40) + (firstUser.content.length > 40 ? '...' : '')
|
const title = firstUser.attachments?.length
|
||||||
|
? firstUser.attachments.map(a => a.name).join(', ')
|
||||||
|
: firstUser.content
|
||||||
|
activeSession.value.title = title.slice(0, 40) + (title.length > 40 ? '...' : '')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,8 +157,8 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMessage(content: string) {
|
async function sendMessage(content: string, attachments?: Attachment[]) {
|
||||||
if (!content.trim() || isStreaming.value) return
|
if ((!content.trim() && !(attachments && attachments.length > 0)) || isStreaming.value) return
|
||||||
|
|
||||||
if (!activeSession.value) {
|
if (!activeSession.value) {
|
||||||
const session = createSession()
|
const session = createSession()
|
||||||
@@ -138,6 +170,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
role: 'user',
|
role: 'user',
|
||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
attachments: attachments && attachments.length > 0 ? attachments : undefined,
|
||||||
}
|
}
|
||||||
addMessage(userMsg)
|
addMessage(userMsg)
|
||||||
persistMessages()
|
persistMessages()
|
||||||
@@ -150,8 +183,16 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
.filter(m => (m.role === 'user' || m.role === 'assistant') && m.content.trim())
|
.filter(m => (m.role === 'user' || m.role === 'assistant') && m.content.trim())
|
||||||
.map(m => ({ role: m.role as 'user' | 'assistant' | 'system', content: m.content }))
|
.map(m => ({ role: m.role as 'user' | 'assistant' | 'system', content: m.content }))
|
||||||
|
|
||||||
|
// Upload attachments and build input with file paths
|
||||||
|
let inputText = content.trim()
|
||||||
|
if (attachments && attachments.length > 0) {
|
||||||
|
const uploaded = await uploadFiles(attachments)
|
||||||
|
const pathParts = uploaded.map(f => `[File: ${f.name}](${f.path})`)
|
||||||
|
inputText = inputText ? inputText + '\n\n' + pathParts.join('\n') : pathParts.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
const run = await startRun({
|
const run = await startRun({
|
||||||
input: content.trim(),
|
input: inputText,
|
||||||
conversation_history: history,
|
conversation_history: history,
|
||||||
session_id: activeSession.value?.id,
|
session_id: activeSession.value?.id,
|
||||||
})
|
})
|
||||||
|
|||||||
+71
-1
@@ -2,6 +2,10 @@ import { defineConfig } from 'vite'
|
|||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import type { ProxyOptions } from 'vite'
|
import type { ProxyOptions } from 'vite'
|
||||||
import { resolve } from 'path'
|
import { resolve } from 'path'
|
||||||
|
import type { IncomingMessage, ServerResponse } from 'http'
|
||||||
|
import { mkdir, writeFile } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { randomBytes } from 'crypto'
|
||||||
|
|
||||||
function createProxyConfig(): ProxyOptions {
|
function createProxyConfig(): ProxyOptions {
|
||||||
return {
|
return {
|
||||||
@@ -21,8 +25,74 @@ function createProxyConfig(): ProxyOptions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UPLOAD_DIR = resolve(tmpdir(), 'hermes-uploads')
|
||||||
|
|
||||||
|
async function handleUpload(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
res.writeHead(405, { 'Content-Type': 'application/json' })
|
||||||
|
res.end(JSON.stringify({ error: 'Method not allowed' }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = req.headers['content-type'] || ''
|
||||||
|
if (!contentType.startsWith('multipart/form-data')) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' })
|
||||||
|
res.end(JSON.stringify({ error: 'Expected multipart/form-data' }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(UPLOAD_DIR, { recursive: true })
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
for await (const chunk of req) chunks.push(chunk)
|
||||||
|
const body = Buffer.concat(chunks).toString()
|
||||||
|
|
||||||
|
const boundary = '--' + contentType.split('boundary=')[1]
|
||||||
|
const parts = body.split(boundary).slice(1, -1)
|
||||||
|
|
||||||
|
const results: { name: string; path: string }[] = []
|
||||||
|
for (const part of parts) {
|
||||||
|
const headerEnd = part.indexOf('\r\n\r\n')
|
||||||
|
if (headerEnd === -1) continue
|
||||||
|
const header = part.substring(0, headerEnd)
|
||||||
|
const data = part.substring(headerEnd + 4, part.length - 2)
|
||||||
|
|
||||||
|
const filenameMatch = header.match(/filename="([^"]+)"/)
|
||||||
|
if (!filenameMatch) continue
|
||||||
|
|
||||||
|
const filename = filenameMatch[1]
|
||||||
|
const ext = filename.includes('.') ? '.' + filename.split('.').pop() : ''
|
||||||
|
const savedName = randomBytes(8).toString('hex') + ext
|
||||||
|
const savedPath = resolve(UPLOAD_DIR, savedName)
|
||||||
|
|
||||||
|
await writeFile(savedPath, Buffer.from(data, 'binary'))
|
||||||
|
results.push({ name: filename, path: savedPath })
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||||
|
res.end(JSON.stringify({ files: results }))
|
||||||
|
} catch (err: any) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' })
|
||||||
|
res.end(JSON.stringify({ error: err.message }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
{
|
||||||
|
name: 'upload-middleware',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use((req, res, next) => {
|
||||||
|
if (req.url?.startsWith('/__upload')) {
|
||||||
|
handleUpload(req as any, res as any)
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': resolve(__dirname, 'src'),
|
'@': resolve(__dirname, 'src'),
|
||||||
|
|||||||
Reference in New Issue
Block a user