fix(hub): strip card markdown images + prefer inline ![] over send_file (v0.0.37) (#15)

fix card inline images v0.0.37
This commit is contained in:
2026-07-20 19:39:08 +08:00
7 changed files with 71 additions and 16 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.36", "version": "0.0.37",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.36", "version": "0.0.37",
"dependencies": { "dependencies": {
"@alicloud/credentials": "^2.4.5", "@alicloud/credentials": "^2.4.5",
"@alicloud/docmind-api20220711": "^1.4.15", "@alicloud/docmind-api20220711": "^1.4.15",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.36", "version": "0.0.37",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
+12 -5
View File
@@ -12,7 +12,7 @@
*/ */
import type { ToolUseTraceStep } from "./trace-store.js"; import type { ToolUseTraceStep } from "./trace-store.js";
import type { CardContentSegment } from "../outboundImages.js"; import { maskMarkdownImagesForStreaming, type CardContentSegment } from "../outboundImages.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -411,6 +411,7 @@ function buildAnswerElements(
let remaining = MAX_TEXT_LENGTH; let remaining = MAX_TEXT_LENGTH;
for (const segment of contentSegments) { for (const segment of contentSegments) {
if (segment.type === "image") { if (segment.type === "image") {
if (segment.imgKey.trim() === "") continue;
elements.push({ elements.push({
tag: "img", tag: "img",
img_key: segment.imgKey, img_key: segment.imgKey,
@@ -421,9 +422,13 @@ function buildAnswerElements(
continue; continue;
} }
if (segment.content === "" || remaining <= 0) continue; if (segment.content === "" || remaining <= 0) continue;
const slice = segment.content.length <= remaining // Feishu card markdown rejects ![](url) without a Feishu image_key
? segment.content // ("card contains images but no imagekey" / empty image key).
: truncateText(segment.content, remaining); const safe = maskMarkdownImagesForStreaming(segment.content);
if (safe === "" || remaining <= 0) continue;
const slice = safe.length <= remaining
? safe
: truncateText(safe, remaining);
remaining -= slice.length; remaining -= slice.length;
elements.push({ elements.push({
tag: "markdown", tag: "markdown",
@@ -433,9 +438,11 @@ function buildAnswerElements(
return elements; return elements;
} }
if (text === "") return []; if (text === "") return [];
const safe = maskMarkdownImagesForStreaming(text);
if (safe === "") return [];
return [{ return [{
tag: "markdown", tag: "markdown",
content: truncateText(text, MAX_TEXT_LENGTH), content: truncateText(safe, MAX_TEXT_LENGTH),
}]; }];
} }
+2 -1
View File
@@ -296,7 +296,8 @@ function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
const instructions: string[] = []; const instructions: string[] = [];
if (enabledTools.has("send_file")) { if (enabledTools.has("send_file")) {
instructions.push( 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.", "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.", "If a generated file path is uncertain, list or inspect the workspace first, then call send_file with the actual path.",
); );
+45 -5
View File
@@ -14,8 +14,15 @@ import {
export const FEISHU_MAX_IMAGE_BYTES = 10 * 1024 * 1024; export const FEISHU_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
export const DEFAULT_MAX_OUTBOUND_IMAGES = 10; 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 FENCED_CODE_RE = /```[\s\S]*?```/g;
const INLINE_CODE_RE = /`[^`\n]+`/g;
const IMAGE_FETCH_HEADERS: Record<string, string> = {
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 = export type CardContentSegment =
| { readonly type: "markdown"; readonly content: string } | { 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) { while ((match = FENCED_CODE_RE.exec(text)) !== null) {
ranges.push({ start: match.index, end: match.index + match[0].length }); 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; return ranges;
} }
@@ -297,11 +312,13 @@ async function fetchRemoteImage(
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15_000); const timer = setTimeout(() => controller.abort(), 15_000);
try { 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", method: "GET",
redirect: "manual", redirect: "manual",
signal: controller.signal, signal: controller.signal,
headers: { accept: "image/*,*/*;q=0.8" }, headers: IMAGE_FETCH_HEADERS,
}); });
// One safe redirect hop to another public http(s) host. // One safe redirect hop to another public http(s) host.
if (response.status >= 300 && response.status < 400) { if (response.status >= 300 && response.status < 400) {
@@ -315,11 +332,11 @@ async function fetchRemoteImage(
} }
if (redirected.protocol !== "http:" && redirected.protocol !== "https:") return null; if (redirected.protocol !== "http:" && redirected.protocol !== "https:") return null;
if (!isPublicHttpHost(redirected.hostname)) return null; if (!isPublicHttpHost(redirected.hostname)) return null;
const second = await fetchImpl(redirected, { const second = await fetchWithoutEnvProxy(fetchImpl, redirected, {
method: "GET", method: "GET",
redirect: "manual", redirect: "manual",
signal: controller.signal, signal: controller.signal,
headers: { accept: "image/*,*/*;q=0.8" }, headers: IMAGE_FETCH_HEADERS,
}); });
return readImageBody(second, maxBytes); 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<Response> {
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<Buffer | null> { async function readImageBody(response: Response, maxBytes: number): Promise<Buffer | null> {
if (!response.ok) return null; if (!response.ok) return null;
const contentType = (response.headers.get("content-type") ?? "").toLowerCase(); const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
+3 -2
View File
@@ -1834,8 +1834,9 @@ async function senderAuditMetadata(rt: FeishuRuntime, openId: string): Promise<P
function withFileDeliveryInstructions(systemPrompt: string | undefined, roleTools: readonly string[] | undefined): string | undefined { function withFileDeliveryInstructions(systemPrompt: string | undefined, roleTools: readonly string[] | undefined): string | undefined {
if (!roleToolsAllow(roleTools, "send_file")) return systemPrompt; if (!roleToolsAllow(roleTools, "send_file")) return systemPrompt;
const fileDeliveryPrompt = const fileDeliveryPrompt =
"When the user asks you to send, resend, attach, or provide a file, call the cph_hub send_file tool with the actual existing file path. " + "When the user asks for a downloadable file attachment (PDF/DOCX/ZIP/etc.), call the cph_hub send_file tool with the actual existing file path. " +
"Do not say a file is attached or sent unless that tool returns success."; "Do not say a file is attached or sent unless that tool returns success. " +
"For 图文并茂 / inline illustrations inside your answer, do NOT use send_file. Put workspace-relative images in the final answer with markdown image syntax ![alt](relative/path.png) (or a public https image URL). The platform uploads those into the Feishu card. Prefer workspace files over remote URLs.";
return systemPrompt === undefined ? fileDeliveryPrompt : `${systemPrompt}\n\n${fileDeliveryPrompt}`; return systemPrompt === undefined ? fileDeliveryPrompt : `${systemPrompt}\n\n${fileDeliveryPrompt}`;
} }
@@ -42,6 +42,12 @@ describe("outbound markdown image parsing", () => {
); );
expect(maskMarkdownImagesForStreaming("![](https://x/y.png)")).toBe("【图片】"); 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", () => { describe("materializeAnswerSegments", () => {