forked from EduCraft/curriculum-project-hub
fix(hub): strip card markdown images + prefer inline ![] over send_file
Feishu interactive markdown rejects  without image_key (error 230099 empty/missing imagekey). Always mask residual image md in card builders; skip img tags with empty keys; skip inline-code examples; fetch remote images with a browser UA and without env HTTP_PROXY. Steer the agent: use  for 图文, send_file only for downloadable attachments. Release v0.0.37.
This commit is contained in:
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paradigm/hub",
|
||||
"version": "0.0.36",
|
||||
"version": "0.0.37",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -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  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),
|
||||
}];
|
||||
}
|
||||
|
||||
|
||||
@@ -296,7 +296,8 @@ function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): 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  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.",
|
||||
);
|
||||
|
||||
@@ -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<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 =
|
||||
| { 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<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> {
|
||||
if (!response.ok) return null;
|
||||
const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
|
||||
|
||||
@@ -1834,8 +1834,9 @@ async function senderAuditMetadata(rt: FeishuRuntime, openId: string): Promise<P
|
||||
function withFileDeliveryInstructions(systemPrompt: string | undefined, roleTools: readonly string[] | undefined): string | undefined {
|
||||
if (!roleToolsAllow(roleTools, "send_file")) return systemPrompt;
|
||||
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. " +
|
||||
"Do not say a file is attached or sent unless that tool returns success.";
|
||||
"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. " +
|
||||
"For 图文并茂 / inline illustrations inside your answer, do NOT use send_file. Put workspace-relative images in the final answer with markdown image syntax  (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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,12 @@ describe("outbound markdown image parsing", () => {
|
||||
);
|
||||
expect(maskMarkdownImagesForStreaming("")).toBe("【图片】");
|
||||
});
|
||||
|
||||
it("ignores markdown image examples inside inline code", () => {
|
||||
const text = "例如 `` 这样写,不会当真实图片";
|
||||
expect(findMarkdownImagesOutsideCode(text)).toEqual([]);
|
||||
expect(maskMarkdownImagesForStreaming(text)).toBe(text);
|
||||
});
|
||||
});
|
||||
|
||||
describe("materializeAnswerSegments", () => {
|
||||
|
||||
Reference in New Issue
Block a user