Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -12041,6 +12041,9 @@ const docTemplate = `{
"type": "string"
}
},
"htmlVisualPrompt": {
"type": "boolean"
},
"model": {
"type": "string",
"maxLength": 128
Expand Down
3 changes: 3 additions & 0 deletions backend/docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -12034,6 +12034,9 @@
"type": "string"
}
},
"htmlVisualPrompt": {
"type": "boolean"
},
"model": {
"type": "string",
"maxLength": 128
Expand Down
2 changes: 2 additions & 0 deletions backend/docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3407,6 +3407,8 @@ definitions:
type: string
maxItems: 20
type: array
htmlVisualPrompt:
type: boolean
model:
maxLength: 128
type: string
Expand Down
29 changes: 15 additions & 14 deletions backend/internal/application/conversation/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
139 changes: 130 additions & 9 deletions backend/internal/application/conversation/system_prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -15,24 +16,117 @@ const (
systemPromptModeInlineUser = "inline_user"
)

const htmlVisualPromptInstructionZH = `<format>
<rule>标题从 ## 起,子层级使用 ###;禁用 #</rule>
<rule>使用简体中文</rule>
<rule>保持高信息密度和紧凑的行文</rule>
<rule>保持紧凑的回复格式,避免松散的内容给用户带来阅读障碍</rule>
<rule>代码块标注语言,优先完整可运行,复杂逻辑添加注释</rule>
<rule id="html-visual">
<rationale>
纯 Markdown 的固定垂直流式结构在表达复杂逻辑时存在先天缺陷(阅读疲劳、重点不突出、缺乏真正的图表与横向排版能力)。你必须主动评估内容结构复杂度,当纯 Markdown 无法清晰、紧凑地传达信息时,强制使用 HTML 实时渲染作为核心表达手段,而非退而求其次的辅助。
</rationale>
<css-constraint>
绝对禁止使用` + "`" + `<style>` + "`" + `标签、` + "`" + `class` + "`" + `属性及伪类/伪元素。
可视化必须100%采用纯内联样式(` + "`" + `style="..."` + "`" + `),仅依赖 Flexbox 与基础盒子模型(padding/margin/border/box-shadow/背景色差)构建视觉层级。
</css-constraint>
<default-trigger>
遇到以下情形,必须放弃纯 Markdown 列表或表格的敷衍表达,主动切入 HTML 内嵌排版:
<case type="logic-graph">逻辑与结构图:流程图、架构图、状态机、树状层级、思维导图等任何包含节点与连线关系的逻辑(用 HTML/CSS 的 DOM 结构与箭头符号构建)。</case>
<case type="horizontal-layout">横向与对比排版:多维对比矩阵、优劣势对照、参数矩阵、并排展示(利用 Flex/Grid 布局实现真正的横向空间利用)。</case>
<case type="info-card">数据与信息卡片:多字段聚合展示、需要视觉分组与边框隔离的密集信息。</case>
<case type="space-optimize">空间节省:内容较多且纯垂直排列会导致严重割裂和冗长感时,利用折叠(details)、标签页等组件收拢信息。</case>
</default-trigger>
<vision-plus>
Vision+ 指令是视觉表达能力的升维,仅当用户显式声明时启用。
<capability>可用内联 HTML 绘制矢量逻辑图、结构连线、几何图形与数据图表,但仍须遵守下方红线。</capability>
<capability>可用更复杂的 CSS 特效和高级交互组件,但不得用于纯装饰目的。</capability>
<red-line>
1. HTML 片段占比不得喧宾夺主
2. 每个可视化片段必须服务于具体的信息表达需求。
3. 绝对禁止输出 !DOCTYPE/html/head/body 全量页面框架;禁止将整段回复包裹于单一 HTML 块。
4. 图形仅限:流程图、架构图、状态机、树状层级、对比矩阵、数据图表。禁止:装饰性插画、氛围图、风景、图标装饰。
5. 在采用html表达时,请同时考虑Token效率与效果的取舍,及渲染难度和错误率,不要过度设计造成效果失衡。
6. 过于复杂的html可视化内容需慎重考虑。
</red-line>
</vision-plus>
<boundary>
<constraint>永远仅输出自包含片段:只输出 div, style, script 等局部渲染标签,绝对禁止输出 !DOCTYPE, html, head, body 等全量页面框架结构,本末倒置将导致直接判错。</constraint>
<constraint>无缝嵌入正文流:HTML 片段必须像一段加粗或列表一样,自然穿插在 Markdown 文本之间,文字解释与可视化元素相互配合,禁止整段回复全量包裹于一个巨大 HTML 块中。</constraint>
</boundary>
</rule>
</format>
<require>
更积极的使用html-visual为用户提供更好的回复质量和效果
</require>`

const htmlVisualPromptInstructionEN = `<format>
<rule>Headings start from ##, child sections use ###; single # headings are forbidden.</rule>
<rule>Use English.</rule>
<rule>Keep the response information-dense and compact.</rule>
<rule>Keep the response format tight to avoid loose content that makes reading harder.</rule>
<rule>Code blocks must specify the language, prefer complete runnable examples, and add comments only for complex logic.</rule>
<rule id="html-visual">
<rationale>
Pure Markdown's fixed vertical flow has inherent limits when presenting complex logic: it can cause reading fatigue, weak emphasis, and lacks real charts or horizontal layout. You must actively evaluate structural complexity. When pure Markdown cannot communicate information clearly and compactly, use real-time rendered HTML as the core expression method instead of treating it as a fallback supplement.
</rationale>
<css-constraint>
Never use ` + "`" + `<style>` + "`" + ` tags, ` + "`" + `class` + "`" + ` attributes, pseudo-classes, or pseudo-elements.
Visual layouts must use 100% inline styles (` + "`" + `style="..."` + "`" + `), relying only on Flexbox and the basic box model (padding/margin/border/box-shadow/background color differences) to build visual hierarchy.
</css-constraint>
<default-trigger>
In the following cases, you must abandon perfunctory pure Markdown lists or tables and actively use embedded HTML layout:
<case type="logic-graph">Logic and structure diagrams: flowcharts, architecture diagrams, state machines, trees, mind maps, or any logic with nodes and relationships. Build them with HTML/CSS DOM structure and arrow symbols.</case>
<case type="horizontal-layout">Horizontal and comparison layout: multi-dimensional comparison matrices, pros/cons, parameter matrices, and side-by-side displays. Use Flex/Grid to make real use of horizontal space.</case>
<case type="info-card">Data and information cards: dense multi-field summaries that need visual grouping and border separation.</case>
<case type="space-optimize">Space saving: when content is large and pure vertical layout would become fragmented or lengthy, use details and similar compact components to collapse information.</case>
</default-trigger>
<vision-plus>
Vision+ is an upgraded visual expression instruction and is enabled only when the user explicitly requests it.
<capability>Inline HTML may draw vector logic diagrams, structural connections, geometric shapes, and data charts, while still obeying the red lines below.</capability>
<capability>More complex CSS effects and advanced interactive components may be used, but never for decoration only.</capability>
<red-line>
1. HTML fragments must not dominate the response.
2. Every visual fragment must serve a concrete information need.
3. Never output full-page frameworks such as !DOCTYPE/html/head/body, and never wrap the whole response in a single HTML block.
4. Graphics are limited to flowcharts, architecture diagrams, state machines, trees, comparison matrices, and data charts. Decorative illustrations, atmospheric scenes, landscape images, and decorative icons are forbidden.
5. When using HTML, consider the tradeoff between token efficiency, visual effect, rendering difficulty, and failure rate. Do not over-design.
6. Be cautious with overly complex HTML visualizations.
</red-line>
</vision-plus>
<boundary>
<constraint>Always output self-contained fragments only: local rendering tags such as div, style, and script are allowed, but !DOCTYPE, html, head, and body full-page framework structures are absolutely forbidden.</constraint>
<constraint>Embed seamlessly in the prose flow: HTML fragments must be naturally interleaved with Markdown like a bold sentence or list. Text explanation and visual elements should support each other. Never wrap the entire response in one huge HTML block.</constraint>
</boundary>
</rule>
</format>
<require>
Use html-visual more actively to provide better response quality and presentation.
</require>`

type systemPromptInjection struct {
Content string
InlineToUser bool
}

type systemPromptLayer struct {
title string
content string
}

type systemPromptCapabilities struct {
SupportsSystemPrompt *bool `json:"supportsSystemPrompt"`
SupportsSystemPromptSnake *bool `json:"supports_system_prompt"`
SystemPromptMode string `json:"systemPromptMode"`
SystemPromptModeSnake string `json:"system_prompt_mode"`
}

// resolveSystemPromptInjection 合并全局和模型级系统提示词,并按路由能力决定注入方式。
func resolveSystemPromptInjection(cfg config.Config, route *channel.ResolvedRoute) systemPromptInjection {
// resolveMessageSystemPromptInjection 合并平台、模型和本次请求级系统提示词,并按路由能力决定注入方式。
func resolveMessageSystemPromptInjection(cfg config.Config, route *channel.ResolvedRoute, htmlVisualPrompt bool, userContent string) systemPromptInjection {
if route == nil {
return systemPromptInjection{}
}
content := buildResolvedSystemPrompt(cfg.DefaultSystemPrompt, route.ModelSystemPrompt)
content := buildResolvedMessageSystemPrompt(cfg.DefaultSystemPrompt, route.ModelSystemPrompt, htmlVisualPrompt, userContent)
if content == "" {
return systemPromptInjection{}
}
Expand All @@ -42,16 +136,43 @@ func resolveSystemPromptInjection(cfg config.Config, route *channel.ResolvedRout
}
}

// buildResolvedSystemPrompt 把全局指令放在模型指令之前,确保平台级约束优先表达。
func buildResolvedSystemPrompt(globalPrompt string, modelPrompt string) string {
layers := []struct {
title string
content string
}{
// buildResolvedMessageSystemPrompt 把请求级输出格式指令放在全局/模型指令之后,避免覆盖更高优先级约束。
func buildResolvedMessageSystemPrompt(globalPrompt string, modelPrompt string, htmlVisualPrompt bool, userContent string) string {
layers := []systemPromptLayer{
{title: "Global instructions", content: globalPrompt},
{title: "Model instructions", content: modelPrompt},
}
if htmlVisualPrompt {
layers = append(layers, systemPromptLayer{
title: "Response format instructions",
content: selectHTMLVisualPromptInstruction(userContent),
})
}
return buildSystemPromptLayers(layers)
}

func selectHTMLVisualPromptInstruction(userContent string) string {
if shouldUseEnglishHTMLVisualPrompt(userContent) {
return htmlVisualPromptInstructionEN
}
return htmlVisualPromptInstructionZH
}

func shouldUseEnglishHTMLVisualPrompt(userContent string) bool {
hanCount := 0
latinCount := 0
for _, r := range strings.TrimSpace(userContent) {
switch {
case unicode.Is(unicode.Han, r):
hanCount++
case unicode.Is(unicode.Latin, r):
latinCount++
}
}
return hanCount == 0 && latinCount > 0
}

func buildSystemPromptLayers(layers []systemPromptLayer) string {
sections := make([]string, 0, len(layers)+1)
for _, layer := range layers {
content := strings.TrimSpace(layer.content)
Expand Down
69 changes: 59 additions & 10 deletions backend/internal/application/conversation/system_prompt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ import (
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm"
)

func TestResolveSystemPromptInjectionUsesNativeSystemPrompt(t *testing.T) {
func TestResolveMessageSystemPromptInjectionUsesNativeSystemPrompt(t *testing.T) {
route := &channel.ResolvedRoute{
Protocol: llm.AdapterOpenAIResponses,
ModelSystemPrompt: "model rule",
ModelCapabilitiesJSON: `{"supportsSystemPrompt":true}`,
}

got := resolveSystemPromptInjection(config.Config{DefaultSystemPrompt: "global rule"}, route)
got := resolveMessageSystemPromptInjection(config.Config{DefaultSystemPrompt: "global rule"}, route, false, "")
if got.Content == "" {
t.Fatal("expected system prompt content")
}
Expand All @@ -30,49 +30,98 @@ func TestResolveSystemPromptInjectionUsesNativeSystemPrompt(t *testing.T) {
}
}

func TestResolveSystemPromptInjectionFallsBackWhenCapabilitiesDisableSystemPrompt(t *testing.T) {
func TestResolveMessageSystemPromptInjectionAddsHTMLVisualPrompt(t *testing.T) {
route := &channel.ResolvedRoute{
Protocol: llm.AdapterOpenAIResponses,
}

got := resolveMessageSystemPromptInjection(config.Config{}, route, true, "解释一下泰勒展开")
if got.Content == "" {
t.Fatal("expected request-level system prompt content")
}
if got.InlineToUser {
t.Fatal("expected native system prompt")
}
for _, want := range []string{"Response format instructions", "html-visual", "使用简体中文"} {
if !strings.Contains(got.Content, want) {
t.Fatalf("expected content to contain %q, got %q", want, got.Content)
}
}
}

func TestResolveMessageSystemPromptInjectionUsesEnglishHTMLVisualPrompt(t *testing.T) {
route := &channel.ResolvedRoute{
Protocol: llm.AdapterOpenAIResponses,
}

got := resolveMessageSystemPromptInjection(config.Config{}, route, true, "Explain Taylor expansion")
if got.Content == "" {
t.Fatal("expected request-level system prompt content")
}
for _, want := range []string{"Response format instructions", "Use English.", "html-visual"} {
if !strings.Contains(got.Content, want) {
t.Fatalf("expected content to contain %q, got %q", want, got.Content)
}
}
if strings.Contains(got.Content, "使用简体中文") {
t.Fatalf("expected English prompt, got %q", got.Content)
}
}

func TestResolveMessageSystemPromptInjectionSkipsHTMLVisualPromptWhenDisabled(t *testing.T) {
route := &channel.ResolvedRoute{
Protocol: llm.AdapterOpenAIResponses,
}

got := resolveMessageSystemPromptInjection(config.Config{}, route, false, "Explain Taylor expansion")
if got.Content != "" {
t.Fatalf("expected no system prompt content, got %q", got.Content)
}
}

func TestResolveMessageSystemPromptInjectionFallsBackWhenCapabilitiesDisableSystemPrompt(t *testing.T) {
route := &channel.ResolvedRoute{
Protocol: llm.AdapterOpenAIResponses,
ModelCapabilitiesJSON: `{"supportsSystemPrompt":false}`,
}

got := resolveSystemPromptInjection(config.Config{DefaultSystemPrompt: "global rule"}, route)
got := resolveMessageSystemPromptInjection(config.Config{DefaultSystemPrompt: "global rule"}, route, false, "")
if !got.InlineToUser {
t.Fatal("expected user prompt fallback")
}
}

func TestResolveSystemPromptInjectionFallsBackWithSnakeCaseCapabilities(t *testing.T) {
func TestResolveMessageSystemPromptInjectionFallsBackWithSnakeCaseCapabilities(t *testing.T) {
route := &channel.ResolvedRoute{
Protocol: llm.AdapterOpenAIResponses,
ModelCapabilitiesJSON: `{"supports_system_prompt":false}`,
}

got := resolveSystemPromptInjection(config.Config{DefaultSystemPrompt: "global rule"}, route)
got := resolveMessageSystemPromptInjection(config.Config{DefaultSystemPrompt: "global rule"}, route, false, "")
if !got.InlineToUser {
t.Fatal("expected snake_case capability to use user prompt fallback")
}
}

func TestResolveSystemPromptInjectionFallsBackWhenModeRequestsUserPrompt(t *testing.T) {
func TestResolveMessageSystemPromptInjectionFallsBackWhenModeRequestsUserPrompt(t *testing.T) {
route := &channel.ResolvedRoute{
Protocol: llm.AdapterOpenAIResponses,
ModelCapabilitiesJSON: `{"systemPromptMode":"user"}`,
}

got := resolveSystemPromptInjection(config.Config{DefaultSystemPrompt: "global rule"}, route)
got := resolveMessageSystemPromptInjection(config.Config{DefaultSystemPrompt: "global rule"}, route, false, "")
if !got.InlineToUser {
t.Fatal("expected systemPromptMode=user to use user prompt fallback")
}
}

func TestResolveSystemPromptInjectionFallsBackForGemma(t *testing.T) {
func TestResolveMessageSystemPromptInjectionFallsBackForGemma(t *testing.T) {
route := &channel.ResolvedRoute{
PlatformModelName: "gemma-3-27b",
Protocol: llm.AdapterGoogleGenerateContent,
}

got := resolveSystemPromptInjection(config.Config{DefaultSystemPrompt: "global rule"}, route)
got := resolveMessageSystemPromptInjection(config.Config{DefaultSystemPrompt: "global rule"}, route, false, "")
if !got.InlineToUser {
t.Fatal("expected Gemma to inline system prompt into user prompt")
}
Expand Down
Loading
Loading