diff --git a/docs/AI_OUTPUT_FORMAT_GUIDE.md b/docs/AI_OUTPUT_FORMAT_GUIDE.md new file mode 100644 index 00000000..cc9913dd --- /dev/null +++ b/docs/AI_OUTPUT_FORMAT_GUIDE.md @@ -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` diff --git a/packages/client/src/api/hermes/download.ts b/packages/client/src/api/hermes/download.ts index 7b6f3a9c..b53d319f 100644 --- a/packages/client/src/api/hermes/download.ts +++ b/packages/client/src/api/hermes/download.ts @@ -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()}` diff --git a/packages/client/src/components/hermes/chat/MarkdownRenderer.vue b/packages/client/src/components/hermes/chat/MarkdownRenderer.vue index 9454f840..a6850e3b 100644 --- a/packages/client/src/components/hermes/chat/MarkdownRenderer.vue +++ b/packages/client/src/components/hermes/chat/MarkdownRenderer.vue @@ -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(null) const componentId = `hermes-mermaid-${Math.random().toString(36).slice(2)}` +const previewUrl = ref(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 filename or filename + html = html.replace(/([^<]+)<\/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 `
+ + +
` + } + + // Other files: render as file card + return `
+ + + + + ${fileName} + + + + + +
` + }) + 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 { 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 { diff --git a/packages/server/src/lib/llm-prompt.ts b/packages/server/src/lib/llm-prompt.ts new file mode 100644 index 00000000..c45c5bb9 --- /dev/null +++ b/packages/server/src/lib/llm-prompt.ts @@ -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'); +} + diff --git a/packages/server/src/services/hermes/chat-run-socket.ts b/packages/server/src/services/hermes/chat-run-socket.ts index 6e366dd8..9988a0d3 100644 --- a/packages/server/src/services/hermes/chat-run-socket.ts +++ b/packages/server/src/services/hermes/chat-run-socket.ts @@ -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 = { 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 diff --git a/packages/server/src/services/hermes/context-engine/prompt.ts b/packages/server/src/services/hermes/context-engine/prompt.ts index fc0afb51..5c07ae56 100644 --- a/packages/server/src/services/hermes/context-engine/prompt.ts +++ b/packages/server/src/services/hermes/context-engine/prompt.ts @@ -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() + + 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 ───────────────────────────────── diff --git a/packages/server/src/services/hermes/file-provider.ts b/packages/server/src/services/hermes/file-provider.ts index dad3b2db..7a550f1f 100644 --- a/packages/server/src/services/hermes/file-provider.ts +++ b/packages/server/src/services/hermes/file-provider.ts @@ -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 diff --git a/packages/server/src/services/hermes/group-chat/agent-clients.ts b/packages/server/src/services/hermes/group-chat/agent-clients.ts index 0d09ce8a..5364adb7 100644 --- a/packages/server/src/services/hermes/group-chat/agent-clients.ts +++ b/packages/server/src/services/hermes/group-chat/agent-clients.ts @@ -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)