diff --git a/hub/package-lock.json b/hub/package-lock.json index 384f9ce..2e36f8b 100644 --- a/hub/package-lock.json +++ b/hub/package-lock.json @@ -1,12 +1,12 @@ { "name": "@paradigm/hub", - "version": "0.0.36", + "version": "0.0.37", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@paradigm/hub", - "version": "0.0.36", + "version": "0.0.37", "dependencies": { "@alicloud/credentials": "^2.4.5", "@alicloud/docmind-api20220711": "^1.4.15", diff --git a/hub/package.json b/hub/package.json index f507746..bdd4f18 100644 --- a/hub/package.json +++ b/hub/package.json @@ -1,6 +1,6 @@ { "name": "@paradigm/hub", - "version": "0.0.36", + "version": "0.0.37", "private": true, "type": "module", "engines": { diff --git a/hub/src/feishu/card/builder.ts b/hub/src/feishu/card/builder.ts index 54b33bf..99a837a 100644 --- a/hub/src/feishu/card/builder.ts +++ b/hub/src/feishu/card/builder.ts @@ -12,7 +12,7 @@ */ import type { ToolUseTraceStep } from "./trace-store.js"; -import type { CardContentSegment } from "../outboundImages.js"; +import { maskMarkdownImagesForStreaming, type CardContentSegment } from "../outboundImages.js"; // --------------------------------------------------------------------------- // Types @@ -411,6 +411,7 @@ function buildAnswerElements( let remaining = MAX_TEXT_LENGTH; for (const segment of contentSegments) { if (segment.type === "image") { + if (segment.imgKey.trim() === "") continue; elements.push({ tag: "img", img_key: segment.imgKey, @@ -421,9 +422,13 @@ function buildAnswerElements( continue; } if (segment.content === "" || remaining <= 0) continue; - const slice = segment.content.length <= remaining - ? segment.content - : truncateText(segment.content, remaining); + // Feishu card markdown rejects ![](url) without a Feishu image_key + // ("card contains images but no imagekey" / empty image key). + const safe = maskMarkdownImagesForStreaming(segment.content); + if (safe === "" || remaining <= 0) continue; + const slice = safe.length <= remaining + ? safe + : truncateText(safe, remaining); remaining -= slice.length; elements.push({ tag: "markdown", @@ -433,9 +438,11 @@ function buildAnswerElements( return elements; } if (text === "") return []; + const safe = maskMarkdownImagesForStreaming(text); + if (safe === "") return []; return [{ tag: "markdown", - content: truncateText(text, MAX_TEXT_LENGTH), + content: truncateText(safe, MAX_TEXT_LENGTH), }]; } diff --git a/hub/src/feishu/fileDeliveryTool.ts b/hub/src/feishu/fileDeliveryTool.ts index b4ea7d5..58a86c6 100644 --- a/hub/src/feishu/fileDeliveryTool.ts +++ b/hub/src/feishu/fileDeliveryTool.ts @@ -296,7 +296,8 @@ function mcpInstructions(enabledTools: ReadonlySet): string { const instructions: string[] = []; if (enabledTools.has("send_file")) { instructions.push( - "Use send_file when the user asks to receive, resend, download, or attach a file.", + "Use send_file only for downloadable attachments the user should save (PDF, DOCX, ZIP, etc.).", + "For inline 图文 answers, put ![alt](workspace-relative-path) in the final assistant text instead of send_file; the hub embeds those images in the reply card.", "Do not claim a file was sent unless send_file returns success.", "If a generated file path is uncertain, list or inspect the workspace first, then call send_file with the actual path.", ); diff --git a/hub/src/feishu/outboundImages.ts b/hub/src/feishu/outboundImages.ts index a4efb33..e9d2784 100644 --- a/hub/src/feishu/outboundImages.ts +++ b/hub/src/feishu/outboundImages.ts @@ -14,8 +14,15 @@ import { export const FEISHU_MAX_IMAGE_BYTES = 10 * 1024 * 1024; export const DEFAULT_MAX_OUTBOUND_IMAGES = 10; -const IMAGE_MARKDOWN_RE = /!\[([^\]\n]*)\]\(([^)\n]+)\)/g; +const IMAGE_MARKDOWN_RE = /!\[([^\]\n]*)\]\(([^)\n]*)\)/g; const FENCED_CODE_RE = /```[\s\S]*?```/g; +const INLINE_CODE_RE = /`[^`\n]+`/g; +const IMAGE_FETCH_HEADERS: Record = { + accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", + // Some CDNs (incl. Wikimedia) reject bare programmatic clients with 400 HTML. + "user-agent": + "Mozilla/5.0 (compatible; EducraftHub/1.0; +https://educraft.paradigm-edu.net) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", +}; export type CardContentSegment = | { readonly type: "markdown"; readonly content: string } @@ -261,6 +268,14 @@ function blockedRanges(text: string): Array<{ start: number; end: number }> { while ((match = FENCED_CODE_RE.exec(text)) !== null) { ranges.push({ start: match.index, end: match.index + match[0].length }); } + INLINE_CODE_RE.lastIndex = 0; + while ((match = INLINE_CODE_RE.exec(text)) !== null) { + const start = match.index; + const end = start + match[0].length; + // Skip inline spans fully inside a fence already recorded above. + if (ranges.some((range) => start >= range.start && end <= range.end)) continue; + ranges.push({ start, end }); + } return ranges; } @@ -297,11 +312,13 @@ async function fetchRemoteImage( const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 15_000); try { - const response = await fetchImpl(url, { + // Direct fetch: host env may enable NODE_USE_ENV_PROXY; several image CDNs + // reject or rewrite traffic through shared egress proxies. + const response = await fetchWithoutEnvProxy(fetchImpl, url, { method: "GET", redirect: "manual", signal: controller.signal, - headers: { accept: "image/*,*/*;q=0.8" }, + headers: IMAGE_FETCH_HEADERS, }); // One safe redirect hop to another public http(s) host. if (response.status >= 300 && response.status < 400) { @@ -315,11 +332,11 @@ async function fetchRemoteImage( } if (redirected.protocol !== "http:" && redirected.protocol !== "https:") return null; if (!isPublicHttpHost(redirected.hostname)) return null; - const second = await fetchImpl(redirected, { + const second = await fetchWithoutEnvProxy(fetchImpl, redirected, { method: "GET", redirect: "manual", signal: controller.signal, - headers: { accept: "image/*,*/*;q=0.8" }, + headers: IMAGE_FETCH_HEADERS, }); return readImageBody(second, maxBytes); } @@ -329,6 +346,29 @@ async function fetchRemoteImage( } } +/** + * Fetch without inheriting HTTP(S)_PROXY from the process env for one call. + * Restores env immediately so unrelated concurrent work keeps proxy settings. + */ +async function fetchWithoutEnvProxy( + fetchImpl: typeof fetch, + url: URL, + init: RequestInit, +): Promise { + const proxyKeys = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"] as const; + const saved: Array<[string, string | undefined]> = proxyKeys.map((key) => [key, process.env[key]]); + try { + for (const key of proxyKeys) delete process.env[key]; + return await fetchImpl(url, init); + } finally { + for (const [key, value] of saved) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + + async function readImageBody(response: Response, maxBytes: number): Promise { if (!response.ok) return null; const contentType = (response.headers.get("content-type") ?? "").toLowerCase(); diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index d5eb40a..ff26ce4 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -1834,8 +1834,9 @@ async function senderAuditMetadata(rt: FeishuRuntime, openId: string): Promise

{ ); expect(maskMarkdownImagesForStreaming("![](https://x/y.png)")).toBe("【图片】"); }); + + it("ignores markdown image examples inside inline code", () => { + const text = "例如 `![](images/img_5.png)` 这样写,不会当真实图片"; + expect(findMarkdownImagesOutsideCode(text)).toEqual([]); + expect(maskMarkdownImagesForStreaming(text)).toBe(text); + }); }); describe("materializeAnswerSegments", () => {