fix(markdown): resolve relative image URLs in desktop app (MUL-2463)#2913
fix(markdown): resolve relative image URLs in desktop app (MUL-2463)#2913ruanjf wants to merge 3 commits into
Conversation
Images embedded in markdown with relative paths (/uploads/...) rendered correctly in the browser (which resolves them against window.location.origin) but failed in the Electron desktop app where no base URL context exists. - Add `apiBaseUrl` field and `setApiBaseUrl` to `configStore` - Initialise `apiBaseUrl` in `initCore` before creating the ApiClient - Move `renderImage` into the `Markdown` component so it can read `apiBaseUrl` from the config store and prepend it to any path that starts with `/`, producing an absolute URL the Electron renderer can fetch Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
|
@ruanjiefeng is attempting to deploy a commit to the IndexLabs Team on Vercel. A member of the Team first needs to authorize it. |
…solution Covers the fix from be713f6: - configStore: default value, setApiBaseUrl stores/overwrites, does not affect other fields - Markdown renderImage: relative paths get apiBaseUrl prepended; absolute, data: URLs and empty apiBaseUrl cases pass through unchanged Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
Bohan-J
left a comment
There was a problem hiding this comment.
更新一下我的评审意见:经过同事 review 后再过一遍代码,我把之前 "Merge with nits" 的结论改为 Request Changes。修复方向(让 desktop 能加载 /uploads/... 这类 server-relative 图片)是对的,但当前实现会在恰好这次想修的场景里引入一个对附件链路的回归,建议先调一下再合。
❗ Blocker:URL 改写破坏 attachment record 匹配
packages/views/common/markdown.tsx:78 这一行把 src 提前改写成绝对 URL,然后把 resolvedSrc 传给 <Attachment kind="url" url={resolvedSrc} ...>:
const resolvedSrc = src.startsWith("/") ? `${apiBaseUrl}${src}` : src;
return <AttachmentRenderer attachment={{ kind: "url", url: resolvedSrc, ... }} />;Attachment 内部会调 useAttachmentDownloadResolver().resolveAttachment(input.url),而它的实现是严格相等匹配(packages/views/editor/attachment-download-context.tsx:45):
return attachments.find((a) => a.url === url);在这个 PR 想修的 本地 LocalStorage + 空 LOCAL_BASE_URL 场景下,后端写进 chat message / comment 的 attachment.url 是 /uploads/<key>(server/internal/storage/local.go:152),markdown 里的 src 同样是 /uploads/<key>。改写后 src 变成 https://api.../uploads/<key>,但 attachments[].url 仍是 /uploads/<key>——精确匹配失败,record / attachmentId 全丢。
具体回归(chat 和 comment 都走这条链路,chat-message-list.tsx:159 / :214 / :407 / :421 / :482 都传了 message.attachments,comment-card.tsx 同样):
- 下载按钮:原本走
useDownloadAttachment(attachmentId)(重新签名 / desktop 原生下载),现在因为state.attachmentId === undefined退化到openByUrl(url)→ 命中外链分支 →openExternal(),Electron 改为系统默认浏览器打开; - Preview:原本传
kind: "full", attachment: record进AttachmentPreviewModal(带 metadata),现在退化为kind: "url"的 URL-only 预览。
修复建议是把"决定显示用的 src"和"决定 attachment 匹配用的 url"解耦。两个方向都行:
- 在 markdown.tsx 里保持原始 src 传下去,把
apiBaseUrl拼接挪到<Attachment>/<img>实际渲染那一层(最小改动是给Attachment加一个 "renderSrc",或者干脆让ImageAttachmentView在拿到非 http(s) 的相对 URL 时自己拼); - 或者在 markdown.tsx 里先用原始 src 查一次 attachment record,命中就用
{ kind: "record", attachment: record }进<Attachment>(这条最干净,命中后续 record 路径完全保留,没命中再走 url-only + 拼前缀)。
两条思路都能保留 attachment record 链路。生产 S3/CDN 场景不受影响(绝对 URL 不会被改写)。
Nits(建议一并修,独立于上面的 blocker)
-
apiBaseUrl末尾斜杠未处理 —markdown.tsx:78
若apiBaseUrl = "https://api.example.com/",会拼出//uploads/...。packages/core/autopilots/webhook.ts已经有stripTrailingSlash,建议提到packages/core/utils/之类的位置两边共用。 -
协议相对 URL(
//host/foo.png)会被破坏 — 同上一行
//cdn.example.com/foo.png也以/开头,会被错误改写成https://api.example.com//cdn.example.com/foo.png。一行收窄:const isAppRelative = src.startsWith("/") && !src.startsWith("//"); const resolvedSrc = isAppRelative ? `${apiBaseUrl}${src}` : src;
再窄一些可以只对
/uploads/前缀做改写——目前仓库里也只有这一种 server-relative 图片源。 -
测试
passes the alt text through as filename名不符实 —markdown.test.tsx:108-114
测试名说要验证 alt 透传为 filename,但 mock 写死了<img alt="">,断言只剩expect(node).not.toBeNull()。要么让 mock 把filename通过data-filename之类的属性露出来真做断言,要么删掉这条用例——保留它反而误导人,让以后改这块代码的人以为已经被覆盖了。
好的部分(保留)
configStore单测(默认值、覆盖、不影响cdnDomain)扎实,写法跟现有约定一致;- 把
apiBaseUrl放在configStore和cdnDomain同位摆放、view 层组件通过useConfigStore取——架构上是对的; useCallback的依赖数组写对了;initCore里在创建ApiClient之前setApiBaseUrl,时序细节做对了;- CI 全绿(frontend + backend)。
总结:修一下 blocker 让 attachment record 链路保留,顺手处理一下 trailing slash / 协议相对 URL / 测试名问题,就可以合了。
…mages - Look up attachment record by original src before rewriting URL, so the download/re-signing/preview chain stays intact when src matches - Narrow URL rewriting to /uploads/ prefix only (avoids breaking protocol-relative URLs like //cdn.host/...) - Strip trailing slash from apiBaseUrl before concatenation - Move stripTrailingSlash to packages/core/utils and share with webhook.ts - Fix tests: add record-match, trailing-slash, protocol-relative cases; make the "passes alt through as filename" assertion meaningful Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
|
@Bohan-J 已调整 |
Bohan-J
left a comment
There was a problem hiding this comment.
更新评审意见:作者在 c3f2f44 把上一轮所有问题都改掉了,改为 Approve。
Blocker 已解决
packages/views/common/markdown.tsx 新的 renderImage 走我之前提的第二种思路——先用原始 src 查 attachment record,命中走 { kind: "record" },没命中再拼 apiBaseUrl 走 url-only:
const record = attachments?.find((a) => a.url === src);
if (record) {
return <AttachmentRenderer attachment={{ kind: "record", attachment: record }} />;
}
const resolvedSrc = src.startsWith("/uploads/")
? `${stripTrailingSlash(apiBaseUrl)}${src}`
: src;这条路径上 attachment.url === "/uploads/<key>" 的精确匹配会先命中,下载按钮 / preview / 重新签名链路完整保留;只有"markdown 里写了 src 但 message.attachments 里没有对应 record"的情况才走拼前缀,desktop 也能正常加载。回归被堵住了。
Nits 也一并修了
- trailing slash:
stripTrailingSlash从packages/core/autopilots/webhook.ts提到packages/core/utils.ts,两边共用,markdown 拼接前会先 strip——同事 review 时提到的"提到 utils 公用"也照着做了; - 协议相对 URL:判断收窄到
src.startsWith("/uploads/"),比我之前建议的startsWith("/") && !startsWith("//")还稳——非 uploads 的 app-relative 路径也不会被误改写; - 测试名不符实:
passes the alt text through as filename现在真断言了data-filename="diagram",不再是空壳子。
新增覆盖也不错
markdown.test.tsx 增加了两条关键用例:
uses attachment record when src matches a known attachment URL— 验证命中 record 时走kind: "record"、src 不被改写;falls back to url-only path when src does not match any attachment record— 验证 fallback 时走 url + prefix。
正好是这次修复要保护的两条核心分支。trailing-slash、protocol-relative、data:、空 apiBaseUrl 也都各有一条用例。
其它
- CI 全绿(backend + frontend + installer x2);
configStore/core-provider.tsx改动跟上一轮一致,没有引入新结构改动;- 没有改后端、不会影响 schema 或现有 storage 行为;
- 生产 S3/CDN 路径不受影响(绝对 URL 不会进任何改写分支)。
LGTM,可以合了。
Bohan-J
left a comment
There was a problem hiding this comment.
再过一遍代码,发现上一轮的 Approve 给早了,改为 Request Changes。trailing slash、协议相对 URL、误导性测试、record metadata 这几条都修了;但 PR 想修的「LocalStorage + 空 LOCAL_BASE_URL → desktop 渲染 /uploads/... 图片失败」这个核心场景,在最常见的 record 命中分支上其实没修好。
Blocker:record 命中时 <img src> 仍是 /uploads/...
链路:
server/internal/storage/local.go:149-152:baseURL为空时LocalStorage返回/uploads/<key>。server/internal/handler/file.go:66-67:attachmentToResponse把它原样塞进URL/DownloadURL。packages/views/common/markdown.tsx:80-83:命中 record →<Attachment kind="record" attachment={record} />。packages/views/editor/attachment.tsx:98-107:normalize里url: input.attachment.url,仍是/uploads/...。packages/views/editor/attachment.tsx:282-287:ImageAttachmentView直接<img src={src}>。
packages/views/chat/components/chat-message-list.tsx:159, 214 把 message.attachments 传进 <Markdown>,所以这是日常会命中的真实路径——而不是边角 case。结果:chat 里  在 LocalStorage 自部署 + desktop 的组合下,record 命中分支仍然渲染成 <img src="/uploads/x.png">,Electron 一样 404。
测试把坏行为锁死了
packages/views/common/markdown.test.tsx:194-202 在 record 命中时显式断言 src === "/uploads/image.png"。这等于把 bug 固化成预期行为,未来真正修这一段时这条测试会反过来挡路。
修复方向
不该在 markdown.tsx 里二选一(要么保 record metadata 要么 absolutize URL),应该在 Attachment 的 normalize 层把 server-relative URL 统一拼上 apiBaseUrl,让 record metadata(attachmentId / record)和「能被 Electron 实际加载的展示 URL」都保留。或者在 AttachmentInput 里增加一个 displayUrl/resolved URL 字段,由调用方传入。对应测试应改成:record 命中时既保留 data-record-id,又断言 src === "https://api.example.com/uploads/..."。
CI 状态目前是绿的——绿不代表场景被覆盖了,只是说明没有更基础的回归。建议先把 normalize 层 absolutize 处理完再合。
Images embedded in markdown with relative paths (/uploads/...) rendered correctly in the browser (which resolves them against window.location.origin) but failed in the Electron desktop app where no base URL context exists.
apiBaseUrlfield andsetApiBaseUrltoconfigStoreapiBaseUrlininitCorebefore creating the ApiClientrenderImageinto theMarkdowncomponent so it can readapiBaseUrlfrom the config store and prepend it to any path that starts with/, producing an absolute URL the Electron renderer can fetchWhat does this PR do?
Related Issue
Closes #
Type of Change
Changes Made
How to Test
Checklist
apps/web/features/landing/i18n/) and relevant docs (apps/docs/content/docs/)apps/docs/content/docs/developers/conventions.zh.mdx(terminology, mixed-rule fortask/issue/skill)AI Disclosure
AI tool used:
Prompt / approach:
Screenshots (optional)