diff --git a/backend/docs/docs.go b/backend/docs/docs.go index e25feb38..3498e83a 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -12041,6 +12041,9 @@ const docTemplate = `{ "type": "string" } }, + "htmlVisualPrompt": { + "type": "boolean" + }, "model": { "type": "string", "maxLength": 128 diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index cff5b6e5..e9ddddb4 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -12034,6 +12034,9 @@ "type": "string" } }, + "htmlVisualPrompt": { + "type": "boolean" + }, "model": { "type": "string", "maxLength": 128 diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 21257a31..f5399d08 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -3407,6 +3407,8 @@ definitions: type: string maxItems: 20 type: array + htmlVisualPrompt: + type: boolean model: maxLength: 128 type: string diff --git a/backend/internal/application/conversation/service.go b/backend/internal/application/conversation/service.go index 27bb9762..0b79b142 100644 --- a/backend/internal/application/conversation/service.go +++ b/backend/internal/application/conversation/service.go @@ -123,20 +123,21 @@ type AttachmentInput struct { // SendMessageInput 定义消息发送请求。 type SendMessageInput struct { - UserID uint - ConversationID uint - RequestID string - ContentType string - Content string - PlatformModelName string - Options map[string]interface{} - ClientRunID string - FileIDs []string - SelectedToolIDs []uint - ParentMessagePublicID string - SourceMessagePublicID string - BranchReason string - Cancelable bool + UserID uint + ConversationID uint + RequestID string + ContentType string + Content string + PlatformModelName string + Options map[string]interface{} + ClientRunID string + FileIDs []string + SelectedToolIDs []uint + HTMLVisualPromptEnabled bool + ParentMessagePublicID string + SourceMessagePublicID string + BranchReason string + Cancelable bool // OnEvent 用于向调用方推送中间事件(如 rag_search),流式场景使用。 OnEvent func(eventType string, payload map[string]interface{}) error } diff --git a/backend/internal/application/conversation/service_message_send.go b/backend/internal/application/conversation/service_message_send.go index e23d7636..dea71b1f 100644 --- a/backend/internal/application/conversation/service_message_send.go +++ b/backend/internal/application/conversation/service_message_send.go @@ -407,7 +407,7 @@ func (s *Service) sendMessageInternal( // ContextAssembler 只承载真正的系统级行为指令;资料型上下文稍后进入用户 XML。 assembler := NewContextAssembler(int64(cfg.ContextMaxInputTokens)) - systemPrompt := resolveSystemPromptInjection(cfg, route) + systemPrompt := resolveMessageSystemPromptInjection(cfg, route, input.HTMLVisualPromptEnabled, input.Content) if systemPrompt.Content != "" { if systemPrompt.InlineToUser { historyMsgs = inlineSystemPromptIntoLatestUserMessage(historyMsgs, systemPrompt.Content) diff --git a/backend/internal/application/conversation/system_prompt.go b/backend/internal/application/conversation/system_prompt.go index 7ce5c8fb..5ee9a935 100644 --- a/backend/internal/application/conversation/system_prompt.go +++ b/backend/internal/application/conversation/system_prompt.go @@ -3,6 +3,7 @@ package conversation import ( "encoding/json" "strings" + "unicode" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/channel" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config" @@ -15,11 +16,104 @@ const ( systemPromptModeInlineUser = "inline_user" ) +const htmlVisualPromptInstructionZH = ` + 标题从 ## 起,子层级使用 ###;禁用 # + 使用简体中文 + 保持高信息密度和紧凑的行文 + 保持紧凑的回复格式,避免松散的内容给用户带来阅读障碍 + 代码块标注语言,优先完整可运行,复杂逻辑添加注释 + + + 纯 Markdown 的固定垂直流式结构在表达复杂逻辑时存在先天缺陷(阅读疲劳、重点不突出、缺乏真正的图表与横向排版能力)。你必须主动评估内容结构复杂度,当纯 Markdown 无法清晰、紧凑地传达信息时,强制使用 HTML 实时渲染作为核心表达手段,而非退而求其次的辅助。 + + +绝对禁止使用` + "`" + ``; +} + +function previewHead(title: string): string { + return [ + ``, + ``, + ``, + `${escapeHTML(title)}`, + artifactPreviewResetStyle(), + artifactRuntimeScript(), + ].join(""); +} + +function htmlPreviewDocument(code: string): string { + const safeHead = previewHead("Artifact Preview"); + const userHead = HEAD_BLOCK_RE.exec(code)?.[1]?.trim() ?? ""; + const bodyMatch = BODY_BLOCK_RE.exec(code); + const body = bodyMatch + ? bodyMatch[1] + : code + .replace(DOCTYPE_RE, "") + .replace(HTML_OPEN_RE, "") + .replace(HTML_CLOSE_RE, "") + .replace(HEAD_BLOCK_RE, "") + .trim(); + + return `${safeHead}${userHead}${body}`; +} + +function cssPreviewDocument(code: string): string { + return ` + + +${previewHead("CSS Preview")} + + + +
+
+

DEEIX Artifact

+

Preview Surface

+

Generated CSS is applied to this isolated document.

+
+ + +
+
+
CardSample content
+
Metric128
+
+
+
+ +`; +} + +function javascriptPreviewDocument(code: string): string { + return ` + + +${previewHead("JavaScript Preview")} + + + +
+ + + + +`; +} + +export function buildArtifactPreviewDocument(kind: ArtifactPreviewKind, code: string): string { + if (kind === "css") return cssPreviewDocument(code); + if (kind === "javascript") return javascriptPreviewDocument(code); + return htmlPreviewDocument(code); +} + +export function resolveArtifactDownloadName(kind: ArtifactPreviewKind): string { + if (kind === "css") return "artifact-css-preview.html"; + if (kind === "javascript") return "artifact-js-preview.html"; + return "artifact-preview.html"; +} + +export function downloadArtifactHTML(fileName: string, value: string): void { + const blob = new Blob([value], { type: "text/html;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +export function extractArtifactsFromContent( + message: Pick, +): ChatArtifact[] { + const content = message.content; + const artifacts: ChatArtifact[] = []; + const lines = content.split(/\r?\n/); + const stableMessageID = artifactStableMessageID(message); + const runID = message.runID?.trim() || undefined; + let openMarker = ""; + let language = ""; + let codeLines: string[] = []; + let blockIndex = 0; + + const pushArtifact = (code: string, complete: boolean) => { + const kind = resolveArtifactPreviewKind(language, code); + if (!kind || !code.trim()) { + return; + } + artifacts.push({ + id: `${stableMessageID}:artifact:${blockIndex}`, + messageID: message.publicID, + messageKey: message.key, + runID, + blockIndex, + kind, + language, + code, + complete, + streaming: Boolean(message.isStreaming), + updatedAt: message.updatedAt, + }); + blockIndex += 1; + }; + + for (const line of lines) { + if (!openMarker) { + const openMatch = line.match(FENCE_OPEN_RE); + if (!openMatch) { + continue; + } + openMarker = openMatch[1] ?? ""; + language = parseFenceLanguage(openMatch[2] ?? ""); + codeLines = []; + continue; + } + + if (isFenceClose(line, openMarker)) { + pushArtifact(codeLines.join("\n"), true); + openMarker = ""; + language = ""; + codeLines = []; + continue; + } + + codeLines.push(line); + } + + if (openMarker && message.isStreaming) { + pushArtifact(codeLines.join("\n"), false); + } + + if (artifacts.length === 0) { + const kind = resolveArtifactPreviewKind("", content); + if (kind && content.trim()) { + artifacts.push({ + id: `${stableMessageID}:artifact:0`, + messageID: message.publicID, + messageKey: message.key, + runID, + blockIndex: 0, + kind, + language: kind, + code: content, + complete: !message.isStreaming, + streaming: Boolean(message.isStreaming), + updatedAt: message.updatedAt, + }); + } + } + + return artifacts; +} + +export function extractArtifactsFromMessages(messages: ChatAreaMessage[]): ChatArtifact[] { + return messages.flatMap((message) => (message.role === "assistant" ? extractArtifactsFromContent(message) : [])); +} diff --git a/frontend/i18n/messages/en-US/chat.json b/frontend/i18n/messages/en-US/chat.json index 4d2fc7ff..bf0b2134 100644 --- a/frontend/i18n/messages/en-US/chat.json +++ b/frontend/i18n/messages/en-US/chat.json @@ -102,11 +102,42 @@ "collapse": "Collapse", "expand": "Expand {count} lines" }, + "artifact": { + "openPreview": "Preview", + "title": "Artifact Preview", + "description": "Render this code block inside an isolated sandbox.", + "preview": "Preview", + "source": "Source", + "copySource": "Copy source", + "sourceCopied": "Source copied", + "copyFailed": "Could not copy source", + "downloadHtml": "Download HTML", + "empty": "This code block is empty and cannot be previewed yet.", + "security": "The preview runs in an isolated sandbox with external network, forms, popups, parent page access, and storage access restricted." + }, "thinking": { "active": "Thinking", "done": "Thinking complete" } }, + "artifacts": { + "title": "Artifact", + "previewTitle": "Artifact preview", + "live": "Live", + "complete": "Complete", + "draft": "Draft", + "selectArtifact": "Select artifact", + "preview": "Preview", + "source": "Source", + "copySource": "Copy source", + "sourceCopied": "Source copied", + "copyFailed": "Could not copy source", + "downloadHtml": "Download HTML", + "close": "Close", + "resize": "Resize Artifact", + "empty": "This artifact is empty and cannot be previewed yet.", + "security": "The preview runs in an isolated sandbox with external network, forms, popups, parent page access, and storage access restricted." + }, "messages": { "debugRequest": "Request", "debugResponse": "Response", @@ -296,6 +327,9 @@ "mcpServerLimitHint": "Max {limit}", "mcpToolLimitTitle": "Tool limit reached", "mcpToolLimitDescription": "A message can use up to {limit} MCP tools. Clear some tools before selecting more.", + "htmlVisualPrompt": "Visual layout prompt", + "htmlVisualPromptEnabled": "Visual layout prompt is enabled. This turn will inject safe inline HTML formatting rules when useful.", + "htmlVisualPromptDisabled": "Enable the visual layout prompt to guide safe inline HTML for complex structures.", "pauseGeneration": "Pause generation", "voiceInput": "Voice input", "cancelVoiceInput": "Cancel voice input", diff --git a/frontend/i18n/messages/zh-CN/chat.json b/frontend/i18n/messages/zh-CN/chat.json index 9fb149b3..0b65023f 100644 --- a/frontend/i18n/messages/zh-CN/chat.json +++ b/frontend/i18n/messages/zh-CN/chat.json @@ -102,11 +102,42 @@ "collapse": "折叠", "expand": "展开 {count} 行" }, + "artifact": { + "openPreview": "预览", + "title": "Artifact 预览", + "description": "在隔离沙箱中渲染当前代码块。", + "preview": "预览", + "source": "源码", + "copySource": "复制源码", + "sourceCopied": "源码已复制", + "copyFailed": "源码复制失败", + "downloadHtml": "下载 HTML", + "empty": "当前代码块为空,暂无法预览。", + "security": "预览运行在隔离沙箱中,外部网络、表单、弹窗、父页面访问和存储访问均受限制。" + }, "thinking": { "active": "正在思考", "done": "思考完成" } }, + "artifacts": { + "title": "Artifact", + "previewTitle": "Artifact 预览", + "live": "实时", + "complete": "完成", + "draft": "草稿", + "selectArtifact": "选择 Artifact", + "preview": "预览", + "source": "源码", + "copySource": "复制源码", + "sourceCopied": "源码已复制", + "copyFailed": "源码复制失败", + "downloadHtml": "下载 HTML", + "close": "关闭", + "resize": "调整 Artifact 宽度", + "empty": "当前 Artifact 为空,暂无法预览。", + "security": "预览运行在隔离沙箱中,外部网络、表单、弹窗、父页面访问和存储访问均受限制。" + }, "messages": { "debugRequest": "请求", "debugResponse": "响应", @@ -296,6 +327,9 @@ "mcpServerLimitHint": "最多 {limit} 个", "mcpToolLimitTitle": "工具数量已达上限", "mcpToolLimitDescription": "单次消息最多选择 {limit} 个 MCP 工具,请减少选择后再继续。", + "htmlVisualPrompt": "视觉排版提示", + "htmlVisualPromptEnabled": "已启用视觉排版提示,本轮会在适合时注入安全内联 HTML 表达规范。", + "htmlVisualPromptDisabled": "启用视觉排版提示,在复杂结构中引导模型使用安全内联 HTML。", "pauseGeneration": "暂停生成", "voiceInput": "语音输入", "cancelVoiceInput": "取消语音输入", diff --git a/frontend/shared/api/conversation.types.ts b/frontend/shared/api/conversation.types.ts index a8b70f36..060966a7 100644 --- a/frontend/shared/api/conversation.types.ts +++ b/frontend/shared/api/conversation.types.ts @@ -373,6 +373,7 @@ export type SendMessageRequest = { clientRunID?: string; fileIDs?: string[]; selectedToolIDs?: number[]; + htmlVisualPrompt?: boolean; parentMessagePublicID?: string; sourceMessagePublicID?: string; branchReason?: "default" | "retry" | "edit";