Skip to content

Commit 60c1e2d

Browse files
committed
feat(rss): enhance media handling in RSS generation and rendering
1 parent be8abac commit 60c1e2d

7 files changed

Lines changed: 188 additions & 17 deletions

File tree

internal/model/embedding/embedding.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@ func (EchoEmbedding) TableName() string { return "echo_embeddings" }
3131

3232
// SearchResult 是一次向量检索命中的结果(含内容快照与距离)。
3333
//
34-
// Files / Extension 是命中 Echo 的图片附件与扩展分享,仅在检索命中后回查填充(见 copilot.enrichHits),
35-
// 用于前端在引用来源里展示缩略图与扩展类型标签——只随 SSE/会话给前端,不进向量索引、也不喂模型。
34+
// Files / Extension 是命中 Echo 的媒体附件(图片/视频/音频)与扩展分享,仅在检索命中后回查填充
35+
// (见 copilot.enrichHits),用于前端在引用来源里展示缩略图与类型标志——只随 SSE/会话给前端,
36+
// 不进向量索引、也不喂模型。
3637
type SearchResult struct {
3738
EchoID string `json:"echo_id"`
3839
Content string `json:"content"`

internal/service/common/common.go

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/lin-snow/ech0/internal/cache"
1717
commonModel "github.com/lin-snow/ech0/internal/model/common"
1818
userModel "github.com/lin-snow/ech0/internal/model/user"
19+
"github.com/lin-snow/ech0/internal/storage"
1920
"github.com/lin-snow/ech0/internal/util/egress"
2021
mdUtil "github.com/lin-snow/ech0/internal/util/md"
2122
timezoneUtil "github.com/lin-snow/ech0/internal/util/timezone"
@@ -109,15 +110,36 @@ func (s *CommonService) GenerateRSS(ctx *gin.Context) (string, error) {
109110
title := msg.Username + " - " + createdAt.Format("2006-01-02")
110111

111112
if len(msg.EchoFiles) > 0 {
112-
var imageContent []byte
113+
var mediaContent []byte
113114
for _, ef := range msg.EchoFiles {
114-
imageContent = fmt.Appendf(
115-
imageContent,
116-
"<img src=\"%s\" alt=\"Image\" style=\"max-width:100%%;height:auto;\" />",
117-
ef.File.URL,
118-
)
115+
if ef.File.URL == "" {
116+
continue
117+
}
118+
// URL 进属性、文件名进链接文本都是可能来自 external 的用户可控字段,进入
119+
// <summary type="html"> 前必须做 HTML 实体转义,阻断订阅器二次解码触发的
120+
// stored XSS(与下方标签转义同一注入类,GHSA-3v85-fqvh-7rxf)。
121+
url := stdhtml.EscapeString(ef.File.URL)
122+
switch storage.NormalizeCategory(ef.File.Category) {
123+
case storage.CategoryImage:
124+
mediaContent = fmt.Appendf(mediaContent,
125+
"<img src=\"%s\" alt=\"Image\" style=\"max-width:100%%;height:auto;\" />", url)
126+
case storage.CategoryVideo:
127+
// 内嵌 <a> 兜底:RSS 阅读器若剥离 <video> 标签,仍退化成可点链接,不丢内容。
128+
mediaContent = fmt.Appendf(mediaContent,
129+
"<video controls src=\"%s\" style=\"max-width:100%%;\"><a href=\"%s\">打开视频</a></video>", url, url)
130+
case storage.CategoryAudio:
131+
mediaContent = fmt.Appendf(mediaContent,
132+
"<audio controls src=\"%s\"><a href=\"%s\">打开音频</a></audio>", url, url)
133+
default:
134+
// pdf / document / file / markdown:给一个可点的下载链接。
135+
name := stdhtml.EscapeString(ef.File.Name)
136+
if name == "" {
137+
name = "下载文件"
138+
}
139+
mediaContent = fmt.Appendf(mediaContent, "<p>📎 <a href=\"%s\">%s</a></p>", url, name)
140+
}
119141
}
120-
renderedContent = append(imageContent, renderedContent...)
142+
renderedContent = append(mediaContent, renderedContent...)
121143
}
122144

123145
if len(msg.Tags) > 0 {

internal/service/common/common_rss_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,84 @@ func TestGenerateRSS_RendersEchoImages(t *testing.T) {
165165
assert.Contains(t, atom, "look at this")
166166
}
167167

168+
// TestGenerateRSS_RendersMediaByCategory 附件按 Category 分流渲染:
169+
// video → <video controls>、audio → <audio controls>(均内嵌 <a> 链接兜底),
170+
// 其它类型(pdf/file)→ 📎 下载链接;image 仍是 <img>。
171+
func TestGenerateRSS_RendersMediaByCategory(t *testing.T) {
172+
repo := commonmock.NewMockCommonRepository(t)
173+
svc := commonService.NewCommonService(repo, newFakeCache())
174+
175+
echos := []echoModel.Echo{
176+
{
177+
ID: "echo-media",
178+
Username: "carol",
179+
Content: "mixed media",
180+
CreatedAt: time.Now().UTC().Unix(),
181+
EchoFiles: []echoModel.EchoFile{
182+
{File: fileModel.File{Category: "image", URL: "http://example.com/files/pic.png"}},
183+
{File: fileModel.File{Category: "video", URL: "http://example.com/files/clip.mp4"}},
184+
{File: fileModel.File{Category: "audio", URL: "http://example.com/files/song.mp3"}},
185+
{File: fileModel.File{Category: "pdf", URL: "http://example.com/files/doc.pdf", Name: "doc.pdf"}},
186+
},
187+
},
188+
}
189+
190+
repo.EXPECT().GetAllEchos(mock.Anything, false).Return(echos, nil).Once()
191+
repo.EXPECT().TrackRSSCacheKey(mock.Anything).Return().Once()
192+
193+
ctx := newRSSContext(t, "example.com")
194+
atom, err := svc.GenerateRSS(ctx)
195+
require.NoError(t, err)
196+
197+
// summary 内容会被 feeds 库再做一层 XML 转义(< → &lt;," → &#34;),故断言转义后的形态。
198+
// image → <img>
199+
assert.Contains(t, atom, `&lt;img src=&#34;http://example.com/files/pic.png&#34;`, "图片应渲染为 <img>")
200+
// video → <video controls> 且内嵌 <a> 链接兜底
201+
assert.Contains(t, atom, `&lt;video controls src=&#34;http://example.com/files/clip.mp4&#34;`, "视频应渲染为 <video controls>")
202+
assert.Contains(t, atom, `&lt;a href=&#34;http://example.com/files/clip.mp4&#34;&gt;打开视频&lt;/a&gt;`, "视频应内嵌链接兜底")
203+
// audio → <audio controls> 且内嵌 <a> 链接兜底
204+
assert.Contains(t, atom, `&lt;audio controls src=&#34;http://example.com/files/song.mp3&#34;`, "音频应渲染为 <audio controls>")
205+
assert.Contains(t, atom, `&lt;a href=&#34;http://example.com/files/song.mp3&#34;&gt;打开音频&lt;/a&gt;`, "音频应内嵌链接兜底")
206+
// pdf/其它 → 📎 下载链接(文件名作为链接文本)
207+
assert.Contains(t, atom, `&lt;a href=&#34;http://example.com/files/doc.pdf&#34;&gt;doc.pdf&lt;/a&gt;`, "普通文件应渲染为下载链接")
208+
}
209+
210+
// TestGenerateRSS_MediaFieldEscaping 绑定 GHSA-3v85-fqvh-7rxf 同类注入:
211+
// external 附件的 URL / 文件名是用户可控字段,进入 <summary type="html"> 前必须 HTML 实体转义,
212+
// 不得让原始引号/尖括号突破属性或标签上下文。
213+
func TestGenerateRSS_MediaFieldEscaping(t *testing.T) {
214+
repo := commonmock.NewMockCommonRepository(t)
215+
svc := commonService.NewCommonService(repo, newFakeCache())
216+
217+
echos := []echoModel.Echo{
218+
{
219+
ID: "echo-evil",
220+
Username: "mallory",
221+
Content: "benign",
222+
CreatedAt: time.Now().UTC().Unix(),
223+
EchoFiles: []echoModel.EchoFile{
224+
{File: fileModel.File{
225+
Category: "file",
226+
URL: `http://x/"><script>alert(1)</script>`,
227+
Name: `<script>alert(2)</script>`,
228+
}},
229+
},
230+
},
231+
}
232+
233+
repo.EXPECT().GetAllEchos(mock.Anything, false).Return(echos, nil).Once()
234+
repo.EXPECT().TrackRSSCacheKey(mock.Anything).Return().Once()
235+
236+
ctx := newRSSContext(t, "example.com")
237+
atom, err := svc.GenerateRSS(ctx)
238+
require.NoError(t, err)
239+
240+
// 不得出现由 URL/文件名注入的原始 <script>。
241+
assert.NotContains(t, atom, "<script>", "URL/文件名注入的原始 script 标签不得出现")
242+
// 单层转义形态(&lt;script&gt;)也不应出现——须先 HTML 实体转义再经 Atom 的 XML 序列化,呈双层转义。
243+
assert.NotContains(t, atom, "&lt;script&gt;", "媒体字段必须先做 HTML 实体转义,杜绝单层转义形态")
244+
}
245+
168246
// TestGenerateRSS_ReadThrough 读穿透:相同 host 第二次调用命中缓存,不再回源仓库。
169247
func TestGenerateRSS_ReadThrough(t *testing.T) {
170248
repo := commonmock.NewMockCommonRepository(t)

internal/service/copilot/enrich.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const maxImageBytes = 5 << 20 // 5MB
2424

2525
// enrichHits 按命中顺序(最相关在前)回查 Echo(GetEchoById 带缓存),一次加载取齐三样:
2626
// - exts:每条命中的 Extension 渲染文本(音乐/网站/位置等分享,常开,喂给模型理解);
27-
// - results[i].Files:命中 Echo 的图片附件元数据(常开,随 SSE 给前端展示缩略图,仅 URL 不含字节);
27+
// - results[i].Files:命中 Echo 的媒体附件元数据(图片/视频/音频,常开,随 SSE 给前端展示,仅元数据不含字节);
2828
// - images:配图的 base64 ImagePart,仅 multimodal 开启时收集,累计到 maxChatImages 即止(喂模型)。
2929
//
3030
// 不存进 embedding 索引、只在检索命中后回查,向量库保持纯文本干净。读取失败静默跳过(best-effort)。
@@ -47,12 +47,14 @@ func (s *CopilotService) enrichHits(
4747

4848
var files []fileModel.File
4949
for _, ef := range echo.EchoFiles {
50-
if !storage.NormalizeCategory(ef.File.Category).IsImageLike() {
51-
continue
50+
cat := storage.NormalizeCategory(ef.File.Category)
51+
// 展示用:图片/视频/音频都带给前端(sources 里按类型展示缩略图或类型标志)。
52+
switch cat {
53+
case storage.CategoryImage, storage.CategoryVideo, storage.CategoryAudio:
54+
files = append(files, ef.File) // 整条 File(含 storage_type/key/url/category 等)
5255
}
53-
files = append(files, ef.File) // 前端展示用:整条 File(含 storage_type/key/url 等)
54-
// 多模态:再把图片字节读成 base64 喂给模型(受 maxChatImages 上限约束)。
55-
if multimodal && s.storage != nil && len(images) < maxChatImages {
56+
// 多模态:仅图片读成 base64 喂给模型(受 maxChatImages 上限约束);视频/音频不入模型。
57+
if cat.IsImageLike() && multimodal && s.storage != nil && len(images) < maxChatImages {
5658
if part, ok := s.loadImagePart(ctx, ef.File); ok {
5759
images = append(images, part)
5860
}

internal/service/copilot/enrich_extra_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,61 @@ package service
55

66
import (
77
"context"
8+
"reflect"
89
"testing"
910

1011
echoModel "github.com/lin-snow/ech0/internal/model/echo"
12+
embeddingModel "github.com/lin-snow/ech0/internal/model/embedding"
1113
fileModel "github.com/lin-snow/ech0/internal/model/file"
1214
settingModel "github.com/lin-snow/ech0/internal/model/setting"
1315
"github.com/lin-snow/ech0/internal/storage"
1416
)
1517

18+
// enrichEchoSvc 是 EchoService 的测试替身,只覆写 GetEchoById 返回带指定附件的 Echo,
19+
// 其余方法未实现(嵌入 nil 接口,未调用即不 panic)。enrichHits 只用到 GetEchoById。
20+
type enrichEchoSvc struct {
21+
EchoService
22+
echo *echoModel.Echo
23+
}
24+
25+
func (f *enrichEchoSvc) GetEchoById(_ context.Context, _ string) (*echoModel.Echo, error) {
26+
return f.echo, nil
27+
}
28+
29+
// enrichHits:命中回查时图片/视频/音频都进 results.Files(供前端展示),pdf/file 等非媒体排除;
30+
// 多模态关闭时不产出 base64 图片。锁住「sources 带媒体类型标志、但不把非媒体塞给前端」的契约。
31+
func TestEnrichHits_MediaCategoriesToFiles(t *testing.T) {
32+
echoFile := func(cat string) fileModel.EchoFile {
33+
return fileModel.EchoFile{File: fileModel.File{Category: cat, URL: "https://f/" + cat}}
34+
}
35+
svc := &enrichEchoSvc{echo: &echoModel.Echo{
36+
ID: "e1",
37+
EchoFiles: []fileModel.EchoFile{
38+
echoFile(string(storage.CategoryImage)),
39+
echoFile(string(storage.CategoryVideo)),
40+
echoFile(string(storage.CategoryAudio)),
41+
echoFile(string(storage.CategoryPDF)),
42+
echoFile(string(storage.CategoryFile)),
43+
},
44+
}}
45+
s := &CopilotService{echoService: svc}
46+
47+
results := []embeddingModel.SearchResult{{EchoID: "e1"}}
48+
_, images := s.enrichHits(context.Background(), results, false)
49+
50+
gotCats := make([]string, 0, len(results[0].Files))
51+
for _, f := range results[0].Files {
52+
gotCats = append(gotCats, f.Category)
53+
}
54+
want := []string{"image", "video", "audio"}
55+
if !reflect.DeepEqual(gotCats, want) {
56+
t.Fatalf("Files 类别 = %v, want %v(应带图片/视频/音频,排除 pdf/file)", gotCats, want)
57+
}
58+
if len(images) != 0 {
59+
t.Fatalf("多模态关闭时不应产出 base64 图片,got %d", len(images))
60+
}
61+
}
62+
1663
// formatExtension:覆盖各扩展类型的渲染分支与缺字段/空值降级。
1764
func TestFormatExtension(t *testing.T) {
1865
ext := func(typ string, kv map[string]any) *echoModel.EchoExtension {

web/src/typings/chat.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ declare namespace App {
1212
username: string
1313
echo_created: number
1414
distance: number
15-
// 命中 Echo 的图片附件(后端回查填充,整条 File,供前端展示缩略图)
15+
// 命中 Echo 的媒体附件(图片/视频/音频,后端回查填充,整条 File,供前端展示缩略图/类型标志
1616
files?: ChatSourceFile[]
1717
// 命中 Echo 的扩展分享(音乐/网站/位置…),仅用于在来源里展示一个类型标签
1818
extension?: Ech0.EchoExtension

web/src/views/chat/modules/ChatSources.vue

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
<span aria-hidden="true">{{ src.ext.icon }}</span>
1616
{{ src.ext.label }}
1717
</span>
18+
<!-- 媒体类型标志:视频/音频附件用图标 + 类型标签展示(图片走下方缩略图) -->
19+
<span v-for="badge in src.mediaBadges" :key="badge.label" class="sources__ext">
20+
<span aria-hidden="true">{{ badge.icon }}</span>
21+
{{ badge.label }}
22+
</span>
1823
</button>
1924
<!-- 命中 Echo 的配图缩略图:复用 getImageUrl 解析 local/S3/external 直链 -->
2025
<div v-if="src.images.length" class="sources__thumbs">
@@ -67,6 +72,13 @@ type DisplaySource = {
6772
empty: boolean
6873
images: App.Api.Ech0.FileObject[]
6974
ext?: { icon: string; label: string }
75+
mediaBadges: { icon: string; label: string }[]
76+
}
77+
78+
// 媒体类型 → 图标 emoji + i18n 标签 key(视频/音频;图片走缩略图不进这里)
79+
const MEDIA_META: Record<string, { icon: string; labelKey: string }> = {
80+
video: { icon: '🎬', labelKey: 'editor.mediaTypeVideo' },
81+
audio: { icon: '🎵', labelKey: 'editor.mediaTypeAudio' },
7082
}
7183
7284
// Extension 类型 → 图标 emoji + i18n 标签 key(复用编辑器里既有的扩展类型文案)
@@ -96,7 +108,16 @@ const displaySources = computed<DisplaySource[]>(() =>
96108
.map((f) => ({ ...f, echo_id: src.echo_id }))
97109
const meta = src.extension ? EXT_META[src.extension.type] : undefined
98110
const ext = meta ? { icon: meta.icon, label: t(meta.labelKey) } : undefined
99-
return { echoId: src.echo_id, day, text, empty, images, ext }
111+
// 视频/音频类型标志:按类型去重(每条 Echo 至多各一个),图片不进这里
112+
const mediaTypes = new Set<string>()
113+
for (const f of src.files ?? []) {
114+
if (f.category && f.category in MEDIA_META) mediaTypes.add(f.category)
115+
}
116+
const mediaBadges = [...mediaTypes].map((c) => ({
117+
icon: MEDIA_META[c].icon,
118+
label: t(MEDIA_META[c].labelKey),
119+
}))
120+
return { echoId: src.echo_id, day, text, empty, images, ext, mediaBadges }
100121
}),
101122
)
102123

0 commit comments

Comments
 (0)