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",
|
"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
@@ -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,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  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),
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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  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.",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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  (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("")).toBe("【图片】");
|
expect(maskMarkdownImagesForStreaming("")).toBe("【图片】");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ignores markdown image examples inside inline code", () => {
|
||||||
|
const text = "例如 `` 这样写,不会当真实图片";
|
||||||
|
expect(findMarkdownImagesOutsideCode(text)).toEqual([]);
|
||||||
|
expect(maskMarkdownImagesForStreaming(text)).toBe(text);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("materializeAnswerSegments", () => {
|
describe("materializeAnswerSegments", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user