Skip to content

fix(markdown): resolve relative image URLs in desktop app (MUL-2463)#2913

Open
ruanjf wants to merge 3 commits into
multica-ai:mainfrom
ruanjf:agent/claude/aee57a10
Open

fix(markdown): resolve relative image URLs in desktop app (MUL-2463)#2913
ruanjf wants to merge 3 commits into
multica-ai:mainfrom
ruanjf:agent/claude/aee57a10

Conversation

@ruanjf
Copy link
Copy Markdown

@ruanjf ruanjf commented May 20, 2026

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

What does this PR do?

Related Issue

Closes #

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Refactor / code improvement (no behavior change)
  • Documentation update
  • Tests (adding or improving test coverage)
  • CI / infrastructure

Changes Made

How to Test

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots
  • I have updated relevant documentation to reflect my changes
  • If I added a new runtime / coding tool / UI tab, I synced the change to landing copy (apps/web/features/landing/i18n/) and relevant docs (apps/docs/content/docs/)
  • If this PR touches Chinese product copy, I checked it against apps/docs/content/docs/developers/conventions.zh.mdx (terminology, mixed-rule for task / issue / skill)
  • I have considered and documented any risks above
  • I will address all reviewer comments before requesting merge

AI Disclosure

AI tool used:

Prompt / approach:

Screenshots (optional)

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>
@vercel
Copy link
Copy Markdown

vercel Bot commented May 20, 2026

@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 Bohan-J changed the title fix(markdown): resolve relative image URLs in desktop app fix(markdown): resolve relative image URLs in desktop app (MUL-2463) May 20, 2026
Copy link
Copy Markdown
Collaborator

@Bohan-J Bohan-J left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

更新一下我的评审意见:经过同事 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.attachmentscomment-card.tsx 同样):

  • 下载按钮:原本走 useDownloadAttachment(attachmentId)(重新签名 / desktop 原生下载),现在因为 state.attachmentId === undefined 退化到 openByUrl(url) → 命中外链分支 → openExternal(),Electron 改为系统默认浏览器打开;
  • Preview:原本传 kind: "full", attachment: recordAttachmentPreviewModal(带 metadata),现在退化为 kind: "url" 的 URL-only 预览。

修复建议是把"决定显示用的 src"和"决定 attachment 匹配用的 url"解耦。两个方向都行:

  1. 在 markdown.tsx 里保持原始 src 传下去,把 apiBaseUrl 拼接挪到 <Attachment> / <img> 实际渲染那一层(最小改动是给 Attachment 加一个 "renderSrc",或者干脆让 ImageAttachmentView 在拿到非 http(s) 的相对 URL 时自己拼);
  2. 或者在 markdown.tsx 里先用原始 src 查一次 attachment record,命中就用 { kind: "record", attachment: record }<Attachment>(这条最干净,命中后续 record 路径完全保留,没命中再走 url-only + 拼前缀)。

两条思路都能保留 attachment record 链路。生产 S3/CDN 场景不受影响(绝对 URL 不会被改写)。

Nits(建议一并修,独立于上面的 blocker)

  1. apiBaseUrl 末尾斜杠未处理markdown.tsx:78
    apiBaseUrl = "https://api.example.com/",会拼出 //uploads/...packages/core/autopilots/webhook.ts 已经有 stripTrailingSlash,建议提到 packages/core/utils/ 之类的位置两边共用。

  2. 协议相对 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 图片源。

  3. 测试 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 放在 configStorecdnDomain 同位摆放、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>
@ruanjf
Copy link
Copy Markdown
Author

ruanjf commented May 20, 2026

@Bohan-J 已调整

Copy link
Copy Markdown
Collaborator

@Bohan-J Bohan-J left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

更新评审意见:作者在 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 也一并修了

  1. trailing slashstripTrailingSlashpackages/core/autopilots/webhook.ts 提到 packages/core/utils.ts,两边共用,markdown 拼接前会先 strip——同事 review 时提到的"提到 utils 公用"也照着做了;
  2. 协议相对 URL:判断收窄到 src.startsWith("/uploads/"),比我之前建议的 startsWith("/") && !startsWith("//") 还稳——非 uploads 的 app-relative 路径也不会被误改写;
  3. 测试名不符实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,可以合了。

Copy link
Copy Markdown
Collaborator

@Bohan-J Bohan-J left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

再过一遍代码,发现上一轮的 Approve 给早了,改为 Request Changes。trailing slash、协议相对 URL、误导性测试、record metadata 这几条都修了;但 PR 想修的「LocalStorage + 空 LOCAL_BASE_URL → desktop 渲染 /uploads/... 图片失败」这个核心场景,在最常见的 record 命中分支上其实没修好

Blocker:record 命中时 <img src> 仍是 /uploads/...

链路:

  1. server/internal/storage/local.go:149-152baseURL 为空时 LocalStorage 返回 /uploads/<key>
  2. server/internal/handler/file.go:66-67attachmentToResponse 把它原样塞进 URL / DownloadURL
  3. packages/views/common/markdown.tsx:80-83:命中 record → <Attachment kind="record" attachment={record} />
  4. packages/views/editor/attachment.tsx:98-107normalizeurl: input.attachment.url,仍是 /uploads/...
  5. packages/views/editor/attachment.tsx:282-287ImageAttachmentView 直接 <img src={src}>

packages/views/chat/components/chat-message-list.tsx:159, 214message.attachments 传进 <Markdown>,所以这是日常会命中的真实路径——而不是边角 case。结果:chat 里 ![](/uploads/x.png) 在 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),应该在 Attachmentnormalize 层把 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 处理完再合。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants