Skip to content

Commit b1607f5

Browse files
authored
Merge pull request #48 from DEEIX-AI/artifact
feat: implement artifact preview functionality in chat component
2 parents f7076ec + f0e1bc8 commit b1607f5

28 files changed

Lines changed: 2075 additions & 148 deletions

backend/docs/docs.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12041,6 +12041,9 @@ const docTemplate = `{
1204112041
"type": "string"
1204212042
}
1204312043
},
12044+
"htmlVisualPrompt": {
12045+
"type": "boolean"
12046+
},
1204412047
"model": {
1204512048
"type": "string",
1204612049
"maxLength": 128

backend/docs/swagger.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12034,6 +12034,9 @@
1203412034
"type": "string"
1203512035
}
1203612036
},
12037+
"htmlVisualPrompt": {
12038+
"type": "boolean"
12039+
},
1203712040
"model": {
1203812041
"type": "string",
1203912042
"maxLength": 128

backend/docs/swagger.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3407,6 +3407,8 @@ definitions:
34073407
type: string
34083408
maxItems: 20
34093409
type: array
3410+
htmlVisualPrompt:
3411+
type: boolean
34103412
model:
34113413
maxLength: 128
34123414
type: string

backend/internal/application/conversation/service.go

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -123,20 +123,21 @@ type AttachmentInput struct {
123123

124124
// SendMessageInput 定义消息发送请求。
125125
type SendMessageInput struct {
126-
UserID uint
127-
ConversationID uint
128-
RequestID string
129-
ContentType string
130-
Content string
131-
PlatformModelName string
132-
Options map[string]interface{}
133-
ClientRunID string
134-
FileIDs []string
135-
SelectedToolIDs []uint
136-
ParentMessagePublicID string
137-
SourceMessagePublicID string
138-
BranchReason string
139-
Cancelable bool
126+
UserID uint
127+
ConversationID uint
128+
RequestID string
129+
ContentType string
130+
Content string
131+
PlatformModelName string
132+
Options map[string]interface{}
133+
ClientRunID string
134+
FileIDs []string
135+
SelectedToolIDs []uint
136+
HTMLVisualPromptEnabled bool
137+
ParentMessagePublicID string
138+
SourceMessagePublicID string
139+
BranchReason string
140+
Cancelable bool
140141
// OnEvent 用于向调用方推送中间事件(如 rag_search),流式场景使用。
141142
OnEvent func(eventType string, payload map[string]interface{}) error
142143
}

backend/internal/application/conversation/service_message_send.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,7 @@ func (s *Service) sendMessageInternal(
407407

408408
// ContextAssembler 只承载真正的系统级行为指令;资料型上下文稍后进入用户 XML。
409409
assembler := NewContextAssembler(int64(cfg.ContextMaxInputTokens))
410-
systemPrompt := resolveSystemPromptInjection(cfg, route)
410+
systemPrompt := resolveMessageSystemPromptInjection(cfg, route, input.HTMLVisualPromptEnabled, input.Content)
411411
if systemPrompt.Content != "" {
412412
if systemPrompt.InlineToUser {
413413
historyMsgs = inlineSystemPromptIntoLatestUserMessage(historyMsgs, systemPrompt.Content)

backend/internal/application/conversation/system_prompt.go

Lines changed: 130 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package conversation
33
import (
44
"encoding/json"
55
"strings"
6+
"unicode"
67

78
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/channel"
89
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config"
@@ -15,24 +16,117 @@ const (
1516
systemPromptModeInlineUser = "inline_user"
1617
)
1718

19+
const htmlVisualPromptInstructionZH = `<format>
20+
<rule>标题从 ## 起,子层级使用 ###;禁用 #</rule>
21+
<rule>使用简体中文</rule>
22+
<rule>保持高信息密度和紧凑的行文</rule>
23+
<rule>保持紧凑的回复格式,避免松散的内容给用户带来阅读障碍</rule>
24+
<rule>代码块标注语言,优先完整可运行,复杂逻辑添加注释</rule>
25+
<rule id="html-visual">
26+
<rationale>
27+
纯 Markdown 的固定垂直流式结构在表达复杂逻辑时存在先天缺陷(阅读疲劳、重点不突出、缺乏真正的图表与横向排版能力)。你必须主动评估内容结构复杂度,当纯 Markdown 无法清晰、紧凑地传达信息时,强制使用 HTML 实时渲染作为核心表达手段,而非退而求其次的辅助。
28+
</rationale>
29+
<css-constraint>
30+
绝对禁止使用` + "`" + `<style>` + "`" + `标签、` + "`" + `class` + "`" + `属性及伪类/伪元素。
31+
可视化必须100%采用纯内联样式(` + "`" + `style="..."` + "`" + `),仅依赖 Flexbox 与基础盒子模型(padding/margin/border/box-shadow/背景色差)构建视觉层级。
32+
</css-constraint>
33+
<default-trigger>
34+
遇到以下情形,必须放弃纯 Markdown 列表或表格的敷衍表达,主动切入 HTML 内嵌排版:
35+
<case type="logic-graph">逻辑与结构图:流程图、架构图、状态机、树状层级、思维导图等任何包含节点与连线关系的逻辑(用 HTML/CSS 的 DOM 结构与箭头符号构建)。</case>
36+
<case type="horizontal-layout">横向与对比排版:多维对比矩阵、优劣势对照、参数矩阵、并排展示(利用 Flex/Grid 布局实现真正的横向空间利用)。</case>
37+
<case type="info-card">数据与信息卡片:多字段聚合展示、需要视觉分组与边框隔离的密集信息。</case>
38+
<case type="space-optimize">空间节省:内容较多且纯垂直排列会导致严重割裂和冗长感时,利用折叠(details)、标签页等组件收拢信息。</case>
39+
</default-trigger>
40+
<vision-plus>
41+
Vision+ 指令是视觉表达能力的升维,仅当用户显式声明时启用。
42+
<capability>可用内联 HTML 绘制矢量逻辑图、结构连线、几何图形与数据图表,但仍须遵守下方红线。</capability>
43+
<capability>可用更复杂的 CSS 特效和高级交互组件,但不得用于纯装饰目的。</capability>
44+
<red-line>
45+
1. HTML 片段占比不得喧宾夺主
46+
2. 每个可视化片段必须服务于具体的信息表达需求。
47+
3. 绝对禁止输出 !DOCTYPE/html/head/body 全量页面框架;禁止将整段回复包裹于单一 HTML 块。
48+
4. 图形仅限:流程图、架构图、状态机、树状层级、对比矩阵、数据图表。禁止:装饰性插画、氛围图、风景、图标装饰。
49+
5. 在采用html表达时,请同时考虑Token效率与效果的取舍,及渲染难度和错误率,不要过度设计造成效果失衡。
50+
6. 过于复杂的html可视化内容需慎重考虑。
51+
</red-line>
52+
</vision-plus>
53+
<boundary>
54+
<constraint>永远仅输出自包含片段:只输出 div, style, script 等局部渲染标签,绝对禁止输出 !DOCTYPE, html, head, body 等全量页面框架结构,本末倒置将导致直接判错。</constraint>
55+
<constraint>无缝嵌入正文流:HTML 片段必须像一段加粗或列表一样,自然穿插在 Markdown 文本之间,文字解释与可视化元素相互配合,禁止整段回复全量包裹于一个巨大 HTML 块中。</constraint>
56+
</boundary>
57+
</rule>
58+
</format>
59+
<require>
60+
更积极的使用html-visual为用户提供更好的回复质量和效果
61+
</require>`
62+
63+
const htmlVisualPromptInstructionEN = `<format>
64+
<rule>Headings start from ##, child sections use ###; single # headings are forbidden.</rule>
65+
<rule>Use English.</rule>
66+
<rule>Keep the response information-dense and compact.</rule>
67+
<rule>Keep the response format tight to avoid loose content that makes reading harder.</rule>
68+
<rule>Code blocks must specify the language, prefer complete runnable examples, and add comments only for complex logic.</rule>
69+
<rule id="html-visual">
70+
<rationale>
71+
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.
72+
</rationale>
73+
<css-constraint>
74+
Never use ` + "`" + `<style>` + "`" + ` tags, ` + "`" + `class` + "`" + ` attributes, pseudo-classes, or pseudo-elements.
75+
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.
76+
</css-constraint>
77+
<default-trigger>
78+
In the following cases, you must abandon perfunctory pure Markdown lists or tables and actively use embedded HTML layout:
79+
<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>
80+
<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>
81+
<case type="info-card">Data and information cards: dense multi-field summaries that need visual grouping and border separation.</case>
82+
<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>
83+
</default-trigger>
84+
<vision-plus>
85+
Vision+ is an upgraded visual expression instruction and is enabled only when the user explicitly requests it.
86+
<capability>Inline HTML may draw vector logic diagrams, structural connections, geometric shapes, and data charts, while still obeying the red lines below.</capability>
87+
<capability>More complex CSS effects and advanced interactive components may be used, but never for decoration only.</capability>
88+
<red-line>
89+
1. HTML fragments must not dominate the response.
90+
2. Every visual fragment must serve a concrete information need.
91+
3. Never output full-page frameworks such as !DOCTYPE/html/head/body, and never wrap the whole response in a single HTML block.
92+
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.
93+
5. When using HTML, consider the tradeoff between token efficiency, visual effect, rendering difficulty, and failure rate. Do not over-design.
94+
6. Be cautious with overly complex HTML visualizations.
95+
</red-line>
96+
</vision-plus>
97+
<boundary>
98+
<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>
99+
<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>
100+
</boundary>
101+
</rule>
102+
</format>
103+
<require>
104+
Use html-visual more actively to provide better response quality and presentation.
105+
</require>`
106+
18107
type systemPromptInjection struct {
19108
Content string
20109
InlineToUser bool
21110
}
22111

112+
type systemPromptLayer struct {
113+
title string
114+
content string
115+
}
116+
23117
type systemPromptCapabilities struct {
24118
SupportsSystemPrompt *bool `json:"supportsSystemPrompt"`
25119
SupportsSystemPromptSnake *bool `json:"supports_system_prompt"`
26120
SystemPromptMode string `json:"systemPromptMode"`
27121
SystemPromptModeSnake string `json:"system_prompt_mode"`
28122
}
29123

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

45-
// buildResolvedSystemPrompt 把全局指令放在模型指令之前,确保平台级约束优先表达。
46-
func buildResolvedSystemPrompt(globalPrompt string, modelPrompt string) string {
47-
layers := []struct {
48-
title string
49-
content string
50-
}{
139+
// buildResolvedMessageSystemPrompt 把请求级输出格式指令放在全局/模型指令之后,避免覆盖更高优先级约束。
140+
func buildResolvedMessageSystemPrompt(globalPrompt string, modelPrompt string, htmlVisualPrompt bool, userContent string) string {
141+
layers := []systemPromptLayer{
51142
{title: "Global instructions", content: globalPrompt},
52143
{title: "Model instructions", content: modelPrompt},
53144
}
145+
if htmlVisualPrompt {
146+
layers = append(layers, systemPromptLayer{
147+
title: "Response format instructions",
148+
content: selectHTMLVisualPromptInstruction(userContent),
149+
})
150+
}
151+
return buildSystemPromptLayers(layers)
152+
}
153+
154+
func selectHTMLVisualPromptInstruction(userContent string) string {
155+
if shouldUseEnglishHTMLVisualPrompt(userContent) {
156+
return htmlVisualPromptInstructionEN
157+
}
158+
return htmlVisualPromptInstructionZH
159+
}
160+
161+
func shouldUseEnglishHTMLVisualPrompt(userContent string) bool {
162+
hanCount := 0
163+
latinCount := 0
164+
for _, r := range strings.TrimSpace(userContent) {
165+
switch {
166+
case unicode.Is(unicode.Han, r):
167+
hanCount++
168+
case unicode.Is(unicode.Latin, r):
169+
latinCount++
170+
}
171+
}
172+
return hanCount == 0 && latinCount > 0
173+
}
54174

175+
func buildSystemPromptLayers(layers []systemPromptLayer) string {
55176
sections := make([]string, 0, len(layers)+1)
56177
for _, layer := range layers {
57178
content := strings.TrimSpace(layer.content)

backend/internal/application/conversation/system_prompt_test.go

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@ import (
99
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm"
1010
)
1111

12-
func TestResolveSystemPromptInjectionUsesNativeSystemPrompt(t *testing.T) {
12+
func TestResolveMessageSystemPromptInjectionUsesNativeSystemPrompt(t *testing.T) {
1313
route := &channel.ResolvedRoute{
1414
Protocol: llm.AdapterOpenAIResponses,
1515
ModelSystemPrompt: "model rule",
1616
ModelCapabilitiesJSON: `{"supportsSystemPrompt":true}`,
1717
}
1818

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

33-
func TestResolveSystemPromptInjectionFallsBackWhenCapabilitiesDisableSystemPrompt(t *testing.T) {
33+
func TestResolveMessageSystemPromptInjectionAddsHTMLVisualPrompt(t *testing.T) {
34+
route := &channel.ResolvedRoute{
35+
Protocol: llm.AdapterOpenAIResponses,
36+
}
37+
38+
got := resolveMessageSystemPromptInjection(config.Config{}, route, true, "解释一下泰勒展开")
39+
if got.Content == "" {
40+
t.Fatal("expected request-level system prompt content")
41+
}
42+
if got.InlineToUser {
43+
t.Fatal("expected native system prompt")
44+
}
45+
for _, want := range []string{"Response format instructions", "html-visual", "使用简体中文"} {
46+
if !strings.Contains(got.Content, want) {
47+
t.Fatalf("expected content to contain %q, got %q", want, got.Content)
48+
}
49+
}
50+
}
51+
52+
func TestResolveMessageSystemPromptInjectionUsesEnglishHTMLVisualPrompt(t *testing.T) {
53+
route := &channel.ResolvedRoute{
54+
Protocol: llm.AdapterOpenAIResponses,
55+
}
56+
57+
got := resolveMessageSystemPromptInjection(config.Config{}, route, true, "Explain Taylor expansion")
58+
if got.Content == "" {
59+
t.Fatal("expected request-level system prompt content")
60+
}
61+
for _, want := range []string{"Response format instructions", "Use English.", "html-visual"} {
62+
if !strings.Contains(got.Content, want) {
63+
t.Fatalf("expected content to contain %q, got %q", want, got.Content)
64+
}
65+
}
66+
if strings.Contains(got.Content, "使用简体中文") {
67+
t.Fatalf("expected English prompt, got %q", got.Content)
68+
}
69+
}
70+
71+
func TestResolveMessageSystemPromptInjectionSkipsHTMLVisualPromptWhenDisabled(t *testing.T) {
72+
route := &channel.ResolvedRoute{
73+
Protocol: llm.AdapterOpenAIResponses,
74+
}
75+
76+
got := resolveMessageSystemPromptInjection(config.Config{}, route, false, "Explain Taylor expansion")
77+
if got.Content != "" {
78+
t.Fatalf("expected no system prompt content, got %q", got.Content)
79+
}
80+
}
81+
82+
func TestResolveMessageSystemPromptInjectionFallsBackWhenCapabilitiesDisableSystemPrompt(t *testing.T) {
3483
route := &channel.ResolvedRoute{
3584
Protocol: llm.AdapterOpenAIResponses,
3685
ModelCapabilitiesJSON: `{"supportsSystemPrompt":false}`,
3786
}
3887

39-
got := resolveSystemPromptInjection(config.Config{DefaultSystemPrompt: "global rule"}, route)
88+
got := resolveMessageSystemPromptInjection(config.Config{DefaultSystemPrompt: "global rule"}, route, false, "")
4089
if !got.InlineToUser {
4190
t.Fatal("expected user prompt fallback")
4291
}
4392
}
4493

45-
func TestResolveSystemPromptInjectionFallsBackWithSnakeCaseCapabilities(t *testing.T) {
94+
func TestResolveMessageSystemPromptInjectionFallsBackWithSnakeCaseCapabilities(t *testing.T) {
4695
route := &channel.ResolvedRoute{
4796
Protocol: llm.AdapterOpenAIResponses,
4897
ModelCapabilitiesJSON: `{"supports_system_prompt":false}`,
4998
}
5099

51-
got := resolveSystemPromptInjection(config.Config{DefaultSystemPrompt: "global rule"}, route)
100+
got := resolveMessageSystemPromptInjection(config.Config{DefaultSystemPrompt: "global rule"}, route, false, "")
52101
if !got.InlineToUser {
53102
t.Fatal("expected snake_case capability to use user prompt fallback")
54103
}
55104
}
56105

57-
func TestResolveSystemPromptInjectionFallsBackWhenModeRequestsUserPrompt(t *testing.T) {
106+
func TestResolveMessageSystemPromptInjectionFallsBackWhenModeRequestsUserPrompt(t *testing.T) {
58107
route := &channel.ResolvedRoute{
59108
Protocol: llm.AdapterOpenAIResponses,
60109
ModelCapabilitiesJSON: `{"systemPromptMode":"user"}`,
61110
}
62111

63-
got := resolveSystemPromptInjection(config.Config{DefaultSystemPrompt: "global rule"}, route)
112+
got := resolveMessageSystemPromptInjection(config.Config{DefaultSystemPrompt: "global rule"}, route, false, "")
64113
if !got.InlineToUser {
65114
t.Fatal("expected systemPromptMode=user to use user prompt fallback")
66115
}
67116
}
68117

69-
func TestResolveSystemPromptInjectionFallsBackForGemma(t *testing.T) {
118+
func TestResolveMessageSystemPromptInjectionFallsBackForGemma(t *testing.T) {
70119
route := &channel.ResolvedRoute{
71120
PlatformModelName: "gemma-3-27b",
72121
Protocol: llm.AdapterGoogleGenerateContent,
73122
}
74123

75-
got := resolveSystemPromptInjection(config.Config{DefaultSystemPrompt: "global rule"}, route)
124+
got := resolveMessageSystemPromptInjection(config.Config{DefaultSystemPrompt: "global rule"}, route, false, "")
76125
if !got.InlineToUser {
77126
t.Fatal("expected Gemma to inline system prompt into user prompt")
78127
}

0 commit comments

Comments
 (0)