feat: Media rendering enhancements and group chat optimizations (#444)

* fix: add missing i18n key and unify session data source (#408)

- Add `chat.sessionNotFound` translation key to all 8 locales
- Fix history page data source inconsistency:
  - Change `getHermesSession` to prioritize database over CLI
  - Now consistent with `listHermesSessions` behavior
  - Prevents "session in list but detail not found" issue
- Update CI workflow to trigger on base branch PRs
- Remove debug log from sessions-db

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: filter special characters and emoji in speech playback (#409)

- Update extractReadableText to filter special characters like *#
- Only keep common punctuation marks for speech synthesis
- Remove emoji, symbols, and special unicode characters
- Improve text-to-speech readability

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add drawer panel with mobile sidebar support and customizable button (#412)

* feat: add drawer panel with mobile sidebar support

- Add DrawerPanel component with Terminal and Files tabs
- Extract TerminalPanel and FilesPanel from existing views
- Add mobile sidebar toggle functionality with overlay
- Add rainbow breathing light effect to drawer button
- Remove Tools section from AppSidebar (Terminal/Files entries)
- Add i18n support for drawer and file tree
- Optimize mobile button layout and spacing
- Fix z-index hierarchy for proper layering
- Add responsive sidebar behavior (PC: always visible, Mobile: toggle)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: customize drawer button with arc rainbow border

- Change drawer button to semi-circle shape贴着右边
- Add arrow icon pointing left (向左箭头)
- Add rainbow border from top to bottom through semi-circle arc
- Slow down animation from 4s to 8s for smoother effect
- Move drawer button wrapper to messages area only (not贯穿header和input)
- Add semi-transparent accent color background to button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: resolve profile switching state sync issue (#414) (#415)

* fix: resolve profile switching state sync issue (#414)

Fix bug where switching to a different profile would still show the
old profile name in the UI and prevent switching back to default.

Root cause:
- Frontend relied entirely on fetchProfiles() return value to set
  activeProfileName
- Backend Hermes CLI may return stale active flag due to timing
  issues between profile use and profile list commands
- This caused frontend to display wrong profile and prevented
  switching back to default

Solution:
- Immediately set activeProfileName when switchProfile API succeeds
- Don't rely solely on listProfiles() result which may have stale data
- Use activeProfileName instead of activeProfile?.name in ProfileSelector

Changes:
- profiles store: Set activeProfileName immediately after successful switch
- ProfileSelector: Use activeProfileName computed property
- Add test to verify activeProfileName updates on switch

Fixes #414

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refine: improve error handling for profile switching failures

Add proper error handling for edge cases:
- If fetchProfiles() fails after successful switch, keep the updated
  activeProfileName (don't let fetchProfiles failure undo the switch)
- Add test cases to verify:
  1. API failure doesn't change state
  2. fetchProfiles failure doesn't affect successful switch

This ensures the UI remains consistent even when profile list refresh
fails after a successful profile switch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refine: add rollback mechanism for profile switching verification

Add backend verification after profile switch:
- Save old activeProfileName before setting new value
- After fetchProfiles, verify backend reports expected active profile
- If backend reports different profile, rollback frontend state and return false
- This handles edge case where API returns 200 but backend didn't actually switch

Test cases:
-  Normal switch: updates and verifies successfully
-  API failure: doesn't change state
-  fetchProfiles failure: assumes success (API returned 200)
-  Backend verification fails: rolls back to old profile

This ensures frontend state always matches backend reality, even in
edge cases where hermes profile use succeeded but gateway/cleanup
steps failed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refine: add user feedback for profile operations

Improve user experience with success/error messages:
- ProfileSelector: Add error message when switch fails
- ProfileCard: Add success message before reload on switch
- ProfileSelector: Use async/await for better error handling
- ProfileCard: Add 500ms delay before reload to show success message

Before: Silent failures, no feedback
After: Clear success/error messages for all operations

Example feedback:
- Success: "已切换到配置 qinghe"
- Failure: "切换配置失败,网关可能需要手动重启"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: update frontend changelog for v0.5.7 (#419)

* docs: update frontend changelog for v0.5.7

- Update changelog.ts with v0.5.7 release date and changes
- Add i18n translation keys for all languages (en, zh, de, es, fr, ja, ko, pt)
- Include v0.5.7 changelog entries:
  - Optimize context compression and session sync
  - Add startup delays to prevent database race conditions

Changes:
- packages/client/src/data/changelog.ts: Update v0.5.7 entry
- packages/client/src/i18n/locales/*.ts: Add changelog translation section

This enables the changelog modal in the UI to display v0.5.7 release notes.

* feat: add v0.5.7 changelog translations to all supported languages

Add new_0_5_7_1, new_0_5_7_2, and new_0_5_7_3 changelog entries to all
locale files (en, zh, de, es, fr, ja, ko, pt) with proper translations
for each language.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: remove duplicate changelog sections causing syntax errors

Remove duplicate changelog object sections that were causing TypeScript
syntax errors in all locale files (en, zh, de, es, fr, ja, ko, pt).
The actual changelog entries are already correctly placed in the main
changelog section of each file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add v0.5.8 changelog and fix profile parsing issue

Add v0.5.8 changelog entries based on PRs merged since v0.5.7:
- Drawer panel with mobile sidebar support (#412)
- Profile switching state sync fix (#414)
- Speech playback special character filtering (#409)
- Missing i18n key and session data source unification (#408)
- Vite build optimization for faster Docker builds (#403)

Also fix issue #417: Profile names with long hyphenated names fail
to parse in profile list regex. Change \s{2,} to \s+ to handle
compressed column spacing when profile names are long.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: remove enter key submit from profile creation and rename modals

Remove @keyup.enter handlers from NInput components in:
- ProfileCreateModal: prevent accidental profile creation when pressing enter
- ProfileRenameModal: prevent accidental profile rename when pressing enter

Users must now explicitly click the confirm button to submit, preventing
unintended profile operations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: allow free text input for profile names

Remove frontend character filtering from profile creation and rename
modals. Users can now input any characters including spaces and
uppercase letters to test backend Hermes CLI validation.

Changes:
- ProfileCreateModal: Remove toLowerCase() and character filtering
- ProfileRenameModal: Remove toLowerCase() and character filtering
- Use v-model:value binding instead of :value with @input

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: improve error handling for profile creation

Display backend error messages when profile creation fails instead of
generic "failed" message. This helps users understand why their
profile name was rejected (e.g., invalid characters).

Changes:
- API layer: Capture and return error messages from backend
- ProfileCreateModal: Display specific error message from backend

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add profile name validation with i18n support

Add client-side validation for profile names to prevent invalid input
before sending to backend. Only lowercase letters, numbers, underscores,
and hyphens are allowed.

Changes:
- ProfileCreateModal: Add input validation with real-time feedback
- ProfileRenameModal: Add input validation with real-time feedback
- Add nameValidation i18n key for all 8 languages
- Filter invalid characters on input and show warning message

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: revert profile parsing regex changes

Revert the regex changes in hermes-cli.ts and gateway-manager.ts
back to requiring \s{2,} (at least 2 spaces). Since frontend now
validates profile names to only allow lowercase letters, numbers,
underscores, and hyphens, the relaxed regex is no longer needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: revert profile parsing regex changes

Revert the regex changes in gateway-manager.ts and hermes-cli.ts
back to requiring \s{2,} (at least 2 spaces). Since frontend now
validates profile names to only allow lowercase letters, numbers,
underscores, and hyphens, the relaxed regex is no longer needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: remove tooltip from drawer button

Remove the NTooltip wrapper from the floating drawer button.
The "Terminal & Files" tooltip is no longer shown on hover.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* Update assets images (#421)

Updated two asset images in the client package.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: bump version to 0.5.8

Release v0.5.8

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: improve profile list parsing to handle long profile names (#425)

Fixed issue #423 where long profile names caused parsing failures.

Changes:
- gateway-manager.ts: Use `.+?` instead of `\S+` to match profile names, allowing names that overflow table column width
- hermes-cli.ts: Use `\s+` instead of `\s{2,}` for first delimiter to handle cases where long profile names reduce spacing to 1 space

The regex now correctly parses profile output even when profile names are long enough to compress table formatting, ensuring all profiles appear in the UI regardless of name length.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add GitHub issue templates

Add structured issue templates to guide users when submitting issues:
- Bug Report template with version info, reproduction steps, and environment details
- Feature Request template with problem statement, solution, and priority
- General Issue template for questions that don't fit other categories
- Config to enable blank issues and provide contact links to documentation and discussions

Templates use YAML forms for better structure and validation of required fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: unify profile management across the application (#432)

This commit addresses long-standing profile inconsistency issues by establishing
`~/.hermes/active_profile` file as the single source of truth for all profile
operations throughout the application.

## Changes

### Backend (Server)

**1. profiles.ts - Enhanced profile switching**
- Switch from CLI polling to direct file verification (Hermes CLI writes synchronously)
- Verify `active_profile` file with quick retry (max 2 attempts × 100ms = 300ms)
- Update GatewayManager only after file verification succeeds
- Add comprehensive logging for debugging

**2. profiles.ts - Authoritative API responses**
- Override CLI's active flag with `active_profile` file in `list()` endpoint
- Add warning when CLI output differs from file (detects inconsistencies)
- Ensures API responses always match actual runtime state

**3. jobs.ts - Use authoritative profile source**
- `resolveProfile()` falls back to `getActiveProfileName()` when no profile in request
- Ensures jobs operate on correct profile even if frontend doesn't specify

**4. cron-history.ts - Fix run history to respect active profile**
- Changed from fixed `~/.hermes/cron/output/` to `getActiveProfileDir()/cron/output/`
- Run history now correctly switches with profile (e.g., `~/.hermes/profiles/hermes/cron/output/`)

**5. proxy-handler.ts - Add fallback to authoritative source**
- If no profile in request headers/query, read from `getActiveProfileName()`
- Prevents proxy from using wrong default profile

### Frontend (Client)

**1. api/client.ts - Simplified profile resolution**
- Prioritize `useProfilesStore().activeProfileName` over localStorage
- localStorage fallback only for early initialization

**2. api/hermes/chat.ts - Consistent profile resolution**
- Same pattern: store first, localStorage fallback only during init

**3. stores/session-browser-prefs.ts - Clean up fallback logic**
- Prioritize store, remove redundant localStorage read

## Problem Solved

Previously, multiple components had different ways of determining the active profile:
- CLI output (◆ marker) - could be stale
- GatewayManager memory - startup cache only
- localStorage - frontend cache
- Various fallbacks scattered across codebase

This caused inconsistencies where:
- Frontend showed one profile but API used another
- Jobs ran on wrong profile
- Run history displayed wrong data
- Profile switches appeared to fail (but actually succeeded)

## Solution

All components now derive the active profile from the same authoritative source:
- `~/.hermes/active_profile` file (written synchronously by `hermes profile use`)
- `getActiveProfileName()` function (reads the file)
- Single source of truth = no inconsistencies

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: add v0.5.9 changelog entries (#434)

- Add unified profile management across the application
- Add GitHub issue and pull request templates

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: Enhance Markdown Media Rendering (Image/Video/File Support) (#438)

* feat: enhance markdown media rendering with image, video, and file support

- Add image display with thumbnail preview (200x160px) and click-to-fullscreen
- Add video playback support for .mp4 and .webm formats with HTML5 player
- Add file card UI for downloads with icon and filename
- Convert local file paths (/tmp/*) to download URLs with auth token
- Add AI output format guidelines system prompt (llm-prompt.ts)
- Increase max download file size from 100MB to 200MB
- Add documentation for AI output format constraints

This enables AI agents to return images, videos, and files using standard
Markdown syntax, which the frontend renders as interactive media elements
instead of plain text links.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: resolve unused parameter TypeScript errors in MarkdownRenderer

Use underscore prefix for unused match parameters in replace callbacks

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: optimize group chat prompts and fix media handling (#439)

Group Chat Prompt Improvements:
- Add AI_OUTPUT_FORMAT_GUIDELINES to group chat system prompts
- Fix duplicate member issue in room member list (deduplicate by name)
- Handle empty agentDescription with default fallback
- Add rule for sending files to users using proper format

Chat Run Socket Integration:
- Integrate getSystemPrompt() into chat-run-socket.ts
- Append media format guidelines to all chat instructions
- Ensure consistent format enforcement across chat and group chat

Media Format Guidelines:
- Simplify "注意事项" section (remove frontend implementation details)
- Add "发送文件给用户" section with clear examples
- Update video format description to mention embedded player

URL Encoding Fix:
- Fix double URL encoding in download.ts (decode first, then encode)
- Prevent %25E8... double-encoded paths, now correctly %E8...

This ensures AI agents in both private chat and group chat follow
consistent media formatting rules when returning images, videos, and files.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-05-04 19:48:40 +08:00
committed by GitHub
parent 46bc2cf12e
commit b9f9e62179
8 changed files with 458 additions and 21 deletions
+143
View File
@@ -0,0 +1,143 @@
# AI 输出格式规范
为了让前端正确渲染大模型返回的图片、文件等内容,需要在系统提示词中定义以下格式约束。
## 系统提示词模板
将以下内容添加到你的系统提示词或 Agent 配置中:
```
当你的回复中包含图片或文件引用时,请遵循以下格式规范:
## 图片格式
使用 Markdown 图片语法,路径必须是本地绝对路径(以 / 开头):
`
![图片描述](/tmp/screenshot.png)
`
示例:
`
![Sub2API Dashboard](/tmp/sub2api-dashboard.png)
`
## 文件链接格式
使用 Markdown 链接语法,路径必须是本地绝对路径(以 / 开头):
[文件名](/tmp/report.pdf)
示例:
[下载报告](/tmp/monthly-report.pdf)
## 注意事项
1. 图片和文件路径必须以 / 开头的绝对路径
2. 图片会自动显示在对话中
3. 文件链接点击后会自动下载
4. 不要使用相对路径(如 ./file.png
5. 不要使用 http:// 或 https:// 开头的远程链接表示本地文件
```
## 完整示例
### 推荐:添加到 Hermes 配置文件
在你的 Hermes 配置文件(`~/.hermes/config.yaml` 或项目配置)中添加:
```yaml
agents:
your_agent_name:
system_prompt: |
你是一个智能助手。
当你的回复中包含图片或文件引用时,请遵循以下格式规范:
## 图片格式
使用 Markdown 图片语法:
![图片描述](/tmp/screenshot.png)
## 文件链接格式
使用 Markdown 链接语法:
[文件名](/tmp/report.pdf)
## 注意事项
- 图片和文件路径必须以 / 开头的绝对路径
- 图片会自动显示在对话中
- 文件链接点击后会自动下载
```
### 或者:在 Web UI 中配置
1. 打开 Hermes Web UI
2. 进入 **Settings****Model Settings**
3.**System Instructions****Agent Instructions** 中添加上述提示词内容
## 支持的内容格式
| 类型 | Markdown 语法 | 前端渲染效果 |
|------|--------------|------------|
| 🖼️ 图片 | `
![描述](/tmp/file.png)
` | 自动显示图片,通过 download 接口加载 |
| 📄 文件下载 | `[文件名](/tmp/file.pdf)` | 可点击链接,点击后下载文件 |
| 🔗 外部链接 | `[文本](https://example.com)` | 在新标签页打开 |
| 💻 代码块 | ` ```language\ncode\n``` ` | 语法高亮显示,支持一键复制 |
## 调试技巧
如果图片或文件没有正确显示,检查:
1. **路径格式**:确保路径以 `/` 开头(如 `/tmp/file.png`
2. **Markdown 语法**:确保使用正确的 Markdown 语法
3. **文件存在**:确保文件确实存在于服务器上
4. **下载接口**:检查 `/api/hermes/download` 接口是否正常工作
## 示例对话
### 正确格式 ✅
**用户**:帮我截个屏
**AI**
```
截图成功!这是当前页面的截图:
![Screenshot](//tmp/screenshot-20250104-143020.png)
```
### 错误格式 ❌
**AI**
```
截图保存在 /tmp/screenshot.png
```
→ 这不会显示图片,因为没有使用 Markdown 图片语法
## 高级用法
### ContentBlock 格式
如果需要更复杂的消息结构,可以使用 JSON ContentBlock 格式:
```json
[
{
"type": "text",
"text": "这是分析结果:"
},
{
"type": "image",
"name": "screenshot.png",
"path": "/tmp/screenshot.png",
"media_type": "image/png"
},
{
"type": "file",
"name": "report.pdf",
"path": "/tmp/report.pdf",
"media_type": "application/pdf"
}
]
```
前端会自动解析并渲染这种格式。
## 相关文件
- 前端渲染逻辑:`packages/client/src/components/hermes/chat/MarkdownRenderer.vue`
- 下载接口:`packages/server/src/controllers/hermes/sessions.ts`
- API 层:`packages/client/src/api/hermes/download.ts`
+8 -2
View File
@@ -6,8 +6,14 @@ import { getApiKey, getBaseUrlValue } from '../client'
*/
export function getDownloadUrl(filePath: string, fileName?: string): string {
const base = getBaseUrlValue()
const params = new URLSearchParams({ path: filePath })
if (fileName) params.set('name', fileName)
// Decode the path first in case it's already encoded (e.g., from AI responses)
// URLSearchParams will encode it again, so we need to start with decoded text
const decodedPath = decodeURIComponent(filePath)
const params = new URLSearchParams({ path: decodedPath })
if (fileName) {
const decodedName = decodeURIComponent(fileName)
params.set('name', decodedName)
}
const token = getApiKey()
if (token) params.set('token', token)
return `${base}/api/hermes/download?${params.toString()}`
@@ -14,7 +14,7 @@ import {
isMermaidFence,
renderMermaidPlaceholder,
} from './mermaidRenderer'
import { downloadFile } from '@/api/hermes/download'
import { downloadFile, getDownloadUrl } from '@/api/hermes/download'
const props = withDefaults(defineProps<{
content: string
@@ -52,11 +52,65 @@ md.renderer.rules.fence = (tokens, idx, options, env, self) => {
const markdownBody = ref<HTMLElement | null>(null)
const componentId = `hermes-mermaid-${Math.random().toString(36).slice(2)}`
const previewUrl = ref<string | null>(null)
let renderGeneration = 0
let unmounted = false
const renderedHtml = computed(() => {
let html = md.render(repairNestedMarkdownFences(props.content))
// Replace image src paths with download URLs
// Replace both src="/path" and src='/path' formats
html = html.replace(/src="\/([^"]+)"/g, (_match, path) => {
const originalPath = '/' + path
const downloadUrl = getDownloadUrl(originalPath)
return `src="${downloadUrl}"`
})
html = html.replace(/src='\/([^']+)'/g, (_match, path) => {
const originalPath = '/' + path
const downloadUrl = getDownloadUrl(originalPath)
return `src='${downloadUrl}'`
})
// Replace local file links with file card UI or video player
// Match <a href="/tmp/file.pdf">filename</a> or <a href="/tmp/video.mp4">filename</a>
html = html.replace(/<a href="(\/[^"]+)">([^<]+)<\/a>/g, (match, path, filename) => {
// Only replace local file paths (starting with /)
if (!path.startsWith('/')) return match
const fileName = filename.trim()
const ext = path.split('.').pop()?.toLowerCase()
// Video files: render as video player
if (ext === 'mp4' || ext === 'webm') {
const downloadUrl = getDownloadUrl(path)
return `<div class="markdown-video-container">
<video class="markdown-video" controls preload="metadata" src="${downloadUrl}"></video>
<div class="markdown-video-footer">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
<span class="att-name">${fileName}</span>
</div>
</div>`
}
// Other files: render as file card
return `<div class="markdown-file-card" data-path="${path}" data-filename="${fileName}" title="${t('download.downloadFile')}">
<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">${fileName}</span>
<svg class="att-download-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</div>`
})
if (props.mentionNames && props.mentionNames.length > 0) {
const escaped = props.mentionNames.map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
const re = new RegExp(`(?<=[\\s>]|^)@(${escaped.join('|')})(?=\\s|$)`, 'gi')
@@ -210,8 +264,33 @@ async function handleMarkdownClick(event: MouseEvent): Promise<void> {
return
}
// Handle file path link clicks for download
const target = event.target as HTMLElement
// Handle image clicks for preview
const img = target.closest('img') as HTMLImageElement | null
if (img) {
event.preventDefault()
previewUrl.value = img.src
return
}
// Handle file card clicks for download
const fileCard = target.closest('.markdown-file-card') as HTMLElement | null
if (fileCard) {
event.preventDefault()
event.stopPropagation()
const path = fileCard.getAttribute('data-path')
const fileName = fileCard.getAttribute('data-filename')
if (path) {
message.info(t('download.downloading'))
downloadFile(path, fileName || undefined).catch((err: Error) => {
message.error(err.message || t('download.downloadFailed'))
})
}
return
}
// Handle file path link clicks for download
const link = target.closest('a') as HTMLAnchorElement | null
if (!link) return
@@ -242,6 +321,11 @@ async function handleMarkdownClick(event: MouseEvent): Promise<void> {
<template>
<div ref="markdownBody" class="markdown-body" v-html="renderedHtml" @click="handleMarkdownClick"></div>
<Teleport to="body">
<div v-if="previewUrl" class="image-preview-overlay" @click.self="previewUrl = null">
<img :src="previewUrl" class="image-preview-img" @click="previewUrl = null" />
</div>
</Teleport>
</template>
<style lang="scss">
@@ -291,6 +375,86 @@ async function handleMarkdownClick(event: MouseEvent): Promise<void> {
}
}
img {
display: block;
max-width: 200px;
max-height: 160px;
object-fit: contain;
cursor: pointer;
border-radius: 4px;
margin: 8px 0;
}
.markdown-video-container {
margin: 12px 0;
border-radius: $radius-sm;
overflow: hidden;
background: #000;
border: 1px solid $border-color;
}
.markdown-video {
display: block;
width: 100%;
max-width: 640px;
max-height: 480px;
object-fit: contain;
}
.markdown-video-footer {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.85);
color: #fff;
font-size: 12px;
.att-name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.markdown-file-card {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
font-size: 12px;
color: $text-secondary;
background-color: rgba(0, 0, 0, 0.04);
border: 1px solid $border-light;
border-radius: $radius-sm;
margin: 8px 0;
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.08);
border-color: $border-color;
}
.att-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 160px;
}
.att-download-icon {
flex-shrink: 0;
opacity: 0.6;
transition: opacity 0.15s ease;
}
&:hover .att-download-icon {
opacity: 1;
}
}
blockquote {
margin: 8px 0;
padding: 4px 12px;
@@ -364,4 +528,23 @@ async function handleMarkdownClick(event: MouseEvent): Promise<void> {
justify-content: center;
}
}
.image-preview-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.image-preview-img {
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
border-radius: 4px;
cursor: pointer;
}
</style>
+77
View File
@@ -0,0 +1,77 @@
/**
* LLM System Prompts and Instructions
*
* This module contains system prompts and format guidelines for LLM agents.
* These prompts ensure that AI outputs are correctly rendered by the frontend.
*/
/**
* System prompt for AI output format guidelines
* Add this to your agent's system prompt to ensure proper formatting
*/
export const AI_OUTPUT_FORMAT_GUIDELINES = `
#
##
使 Markdown /
\`\`\`
![](/tmp/screenshot.png)
\`\`\`
\`\`\`
![Sub2API Dashboard](/tmp/sub2api-dashboard.png)
\`\`\`
##
使 Markdown / mp4, webm
\`\`\`
[](/tmp/recording.mp4)
\`\`\`
\`\`\`
[](/tmp/screen-recording.mp4)
[](/tmp/demo.webm)
\`\`\`
640x480
##
使 Markdown /
\`\`\`
[](/tmp/report.pdf)
\`\`\`
\`\`\`
[](/tmp/monthly-report.pdf)
\`\`\`
##
1. 使 /
2.
3. .mp4, .webm
##
"发给我""发送给我""传给我"使
- \`![描述](/path/to/image.png)\`
- \`[视频名](/path/to/video.mp4)\`
- \`[文件名](/path/to/file.pdf)\`
`;
/**
* Get the complete system prompt with format guidelines
* @param customPrompt - Optional custom system prompt to prepend
* @returns Complete system prompt string
*/
export function getSystemPrompt(customPrompt?: string): string {
const parts: string[] = [];
if (customPrompt) {
parts.push(customPrompt);
}
parts.push(AI_OUTPUT_FORMAT_GUIDELINES);
return parts.join('\n\n');
}
@@ -12,6 +12,7 @@ import type { Server, Socket } from 'socket.io'
import { EventSource } from 'eventsource'
import { setRunSession } from '../../routes/hermes/proxy-handler'
import { updateUsage } from '../../db/hermes/usage-store'
import { getSystemPrompt } from '../../lib/llm-prompt'
import {
getSession,
getSessionDetail,
@@ -479,15 +480,19 @@ export class ChatRunSocket {
const body: Record<string, any> = { input }
if (hermesSessionId) body.session_id = hermesSessionId
if (model) body.model = model
if (instructions) body.instructions = instructions
if (instructions) {
body.instructions = `${getSystemPrompt()}\n${instructions}`
} else {
body.instructions = getSystemPrompt()
}
// Inject workspace context if set for this session
if (session_id) {
const sessionRow = getSession(session_id)
if (sessionRow?.workspace) {
const workspaceCtx = `[Current working directory: ${sessionRow.workspace}]`
body.instructions = body.instructions
? `${workspaceCtx}\n${body.instructions}`
: workspaceCtx
? `\n${workspaceCtx}\n${body.instructions}`
: `\n${workspaceCtx}`
}
}
// Build conversation_history from DB if session_id is provided
@@ -1,6 +1,7 @@
// ─── Agent Identity Instructions ────────────────────────────
import type { MemberInfo } from './types'
import { getSystemPrompt } from '../../../lib/llm-prompt'
interface AgentInstructionsParams {
agentName: string
@@ -11,20 +12,41 @@ interface AgentInstructionsParams {
}
export function buildAgentInstructions(params: AgentInstructionsParams): string {
// Deduplicate members by name (primary key) to avoid duplicate roles
// If multiple entries have the same name, prefer the one with description
const uniqueMembersMap = new Map<string, MemberInfo>()
for (const m of params.members) {
const existing = uniqueMembersMap.get(m.name)
// Prefer entries with description
if (!existing || (m.description && !existing.description)) {
uniqueMembersMap.set(m.name, m)
}
}
const uniqueMembers = Array.from(uniqueMembersMap.values())
let memberSection: string
if (params.members.length > 0) {
memberSection = params.members
if (uniqueMembers.length > 0) {
memberSection = uniqueMembers
.map(m => m.description ? `- ${m.name}: ${m.description}` : `- ${m.name}`)
.join('\n')
} else if (params.memberNames.length > 0) {
memberSection = params.memberNames.map(n => `- ${n}`).join('\n')
// Deduplicate member names as well
const uniqueNames = Array.from(new Set(params.memberNames))
memberSection = uniqueNames.map(n => `- ${n}`).join('\n')
} else {
memberSection = '- 未知'
}
return `你是"${params.agentName}",群聊房间"${params.roomName}"中的 AI 助手。
// Handle empty agent description
const roleDescription = params.agentDescription?.trim()
? params.agentDescription
: '专业的 AI 助手,随时准备协助解决问题。'
${params.agentDescription}
const basePrompt = `你是"${params.agentName}",群聊房间"${params.roomName}"中的 AI 助手。
${roleDescription}
${memberSection}
@@ -38,6 +60,8 @@ ${memberSection}
-
- agent 使 @名字
- @任何人`
return getSystemPrompt(basePrompt)
}
// ─── Summarization Prompts ─────────────────────────────────
@@ -9,8 +9,8 @@ import { getActiveProfileDir, getActiveEnvPath } from './hermes-profile'
const execFileAsync = promisify(execFile)
// Max download file size (default 100MB)
const MAX_DOWNLOAD_SIZE = parseInt(process.env.MAX_DOWNLOAD_SIZE || '', 10) || 100 * 1024 * 1024
// Max download file size (default 200MB)
const MAX_DOWNLOAD_SIZE = parseInt(process.env.MAX_DOWNLOAD_SIZE || '', 10) || 200 * 1024 * 1024
// Backend command timeout (default 30s)
const BACKEND_TIMEOUT = 30_000
@@ -289,7 +289,6 @@ class AgentClient {
// Strip @mention from input — agent already knows it was mentioned
const input = msg.content.replace(new RegExp(`@${this.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'gi'), '').trim() || msg.content
// Start a run on Hermes gateway
const runRes = await fetch(`${upstream}/v1/runs`, {
method: 'POST',
@@ -338,13 +337,13 @@ class AgentClient {
// Use Authorization header instead of query parameter for better compatibility
const eventSourceInit: any = apiKey ? {
fetch: (url: string, init: any = {}) => fetch(url, {
...init,
headers: {
...(init.headers || {}),
Authorization: `Bearer ${apiKey}`,
},
...init,
headers: {
...(init.headers || {}),
Authorization: `Bearer ${apiKey}`,
},
}),
} : {}
} : {}
// @ts-ignore - eventsource library types are too strict
const source = new EventSource(eventsUrl.toString(), eventSourceInit)