mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 03:30:48 +08:00
feat(channel): unify deliverable outbound and WeChat attachments
Require deliverable:true for all channel attachments including images; extend save_deliverable_attachment with attachment_id; add WeChat proactive attachment delivery; inject channel file rules via system prompt instead of repeating on every user message. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
dc5da21f5a
commit
cbe68a54cd
14 changed files with 440 additions and 125 deletions
|
|
@ -566,28 +566,18 @@ def _rows_since_last_user_message(rows: list[Any]) -> list[Any]:
|
|||
return list(rows[last_user_idx + 1 :])
|
||||
|
||||
|
||||
_CHANNEL_DELIVERABLE_ATTACHMENT_TYPES = frozenset(
|
||||
{"image_ref", "video_ref", "image", "input_image", "image_url"}
|
||||
)
|
||||
_CHANNEL_EXPLICIT_DELIVERABLE_TYPES = frozenset({"binary_ref", "text_ref"})
|
||||
|
||||
|
||||
def _is_channel_deliverable_attachment(att: dict[str, Any]) -> bool:
|
||||
"""Channel outbound only sends attachments explicitly marked deliverable=true."""
|
||||
if not isinstance(att, dict):
|
||||
return False
|
||||
t = str(att.get("type") or "").strip().lower()
|
||||
if t in _CHANNEL_DELIVERABLE_ATTACHMENT_TYPES:
|
||||
return True
|
||||
if t in _CHANNEL_EXPLICIT_DELIVERABLE_TYPES:
|
||||
return att.get("deliverable") is True
|
||||
return False
|
||||
return att.get("deliverable") is True
|
||||
|
||||
|
||||
def _collect_recent_tool_attachments(*, store: Any, session_id: str) -> list[dict[str, Any]]:
|
||||
"""Fallback for channel delivery: reuse tool media produced during the current user turn only.
|
||||
|
||||
Avoids re-sending images from earlier conversation turns when the latest assistant row has no attachments.
|
||||
Visual media is always eligible; documents need explicit deliverable=true on the attachment ref.
|
||||
Avoids re-sending attachments from earlier conversation turns when the latest assistant row has none.
|
||||
All attachment types (image, video, document) require explicit deliverable=true.
|
||||
"""
|
||||
sid = str(session_id or "").strip()
|
||||
if not sid:
|
||||
|
|
@ -1083,11 +1073,6 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|||
if dispatch_lang in {"zh", "en"}
|
||||
else resolve_runtime_lang(store=store, user_text=user_text)
|
||||
)
|
||||
if str(inbound.channel or "").strip().lower() in {"whatsapp", "wechat", "weixin"}:
|
||||
from runtime.orchestration.group_ingest import build_channel_file_delivery_instruction
|
||||
|
||||
ch_hint = build_channel_file_delivery_instruction(lang=lang)
|
||||
user_text = f"{ch_hint}\n{user_text}" if user_text else ch_hint
|
||||
gw = OclawGateway(store=store)
|
||||
msg = StandardMessage(
|
||||
session_id=str(session_id),
|
||||
|
|
|
|||
|
|
@ -467,6 +467,17 @@ class OclawGateway:
|
|||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_channel_delivery_channel(msg: StandardMessage) -> bool:
|
||||
ch = str(getattr(msg, "channel", "") or "").strip().lower()
|
||||
return ch in {"whatsapp", "wechat", "weixin"}
|
||||
|
||||
@staticmethod
|
||||
def _channel_file_delivery_system_hint(lang: str) -> str:
|
||||
from runtime.orchestration.group_ingest import build_channel_file_delivery_instruction
|
||||
|
||||
return str(build_channel_file_delivery_instruction(lang=lang) or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _tabular_query_system_hint(lang: str) -> str:
|
||||
limits = OclawGateway._tabular_limits_from_config()
|
||||
|
|
@ -1159,6 +1170,10 @@ class OclawGateway:
|
|||
if model is None or tools is None:
|
||||
raise RuntimeError("executor missing model/tools")
|
||||
sys_prompt = system_prompt_override or str(getattr(selected_executor, "system_prompt", "") or "")
|
||||
if self._is_channel_delivery_channel(msg):
|
||||
ch_hint = self._channel_file_delivery_system_hint(lang)
|
||||
if ch_hint:
|
||||
sys_prompt = f"{sys_prompt}\n\n{ch_hint}".strip()
|
||||
if self._has_tabular_ref_attachments(msg):
|
||||
sys_prompt = f"{sys_prompt}\n\n{self._tabular_query_system_hint(lang)}".strip()
|
||||
if self._has_text_ref_attachments(msg):
|
||||
|
|
|
|||
|
|
@ -235,7 +235,10 @@ async function flushWeixinDbOutbound(args: {
|
|||
const toUser = String(item.chat_id || "").trim();
|
||||
const text = String(item.text || "").trim();
|
||||
const msgId = String(item.id || "").trim();
|
||||
if (!toUser || !text || !msgId) continue;
|
||||
const mediaPath = String(item.media_path || item.mediaPath || "").trim();
|
||||
const attachments = Array.isArray(item.attachments) ? (item.attachments as Json[]) : [];
|
||||
if (!toUser || !msgId) continue;
|
||||
if (!text && !mediaPath && !attachments.length) continue;
|
||||
const contextToken = String(
|
||||
(item.context_token as string) ||
|
||||
args.modules.getContextToken(args.accountId, toUser) ||
|
||||
|
|
@ -247,14 +250,20 @@ async function flushWeixinDbOutbound(args: {
|
|||
continue;
|
||||
}
|
||||
try {
|
||||
await args.modules.sendMessageWeixin({
|
||||
to: toUser,
|
||||
text,
|
||||
opts: {
|
||||
baseUrl: args.cloudBaseUrl,
|
||||
token: args.token,
|
||||
contextToken,
|
||||
await deliverWeixinReply(args.modules, {
|
||||
reply: {
|
||||
chat_id: toUser,
|
||||
text,
|
||||
media_path: mediaPath,
|
||||
attachments,
|
||||
context_token: contextToken,
|
||||
},
|
||||
defaultTo: toUser,
|
||||
cloudBaseUrl: args.cloudBaseUrl,
|
||||
token: args.token,
|
||||
accountId: args.accountId,
|
||||
defaultContextToken: contextToken,
|
||||
userContextTokens: args.userContextTokens,
|
||||
});
|
||||
await postLocalJson(args.token, "weixin/outbound/ack", { id: msgId, ok: true }, 8000);
|
||||
log(`db proactive reply sent: id=${msgId} to=${toUser} textLen=${text.length}`);
|
||||
|
|
@ -307,7 +316,10 @@ async function flushLocalProactiveReplies(args: {
|
|||
for (const r of msgs) {
|
||||
const toUser = String(r.chat_id || "").trim();
|
||||
const text = String(r.text || "").trim();
|
||||
if (!toUser || !text) continue;
|
||||
const mediaPath = String(r.media_path || r.mediaPath || "").trim();
|
||||
const attachments = Array.isArray(r.attachments) ? (r.attachments as Json[]) : [];
|
||||
if (!toUser) continue;
|
||||
if (!text && !mediaPath && !attachments.length) continue;
|
||||
const contextToken = String(
|
||||
(r.context_token as string) ||
|
||||
args.modules.getContextToken(args.accountId, toUser) ||
|
||||
|
|
@ -320,14 +332,20 @@ async function flushLocalProactiveReplies(args: {
|
|||
continue;
|
||||
}
|
||||
try {
|
||||
await args.modules.sendMessageWeixin({
|
||||
to: toUser,
|
||||
text,
|
||||
opts: {
|
||||
baseUrl: args.cloudBaseUrl,
|
||||
token: args.token,
|
||||
contextToken,
|
||||
await deliverWeixinReply(args.modules, {
|
||||
reply: {
|
||||
chat_id: toUser,
|
||||
text,
|
||||
media_path: mediaPath,
|
||||
attachments,
|
||||
context_token: contextToken,
|
||||
},
|
||||
defaultTo: toUser,
|
||||
cloudBaseUrl: args.cloudBaseUrl,
|
||||
token: args.token,
|
||||
accountId: args.accountId,
|
||||
defaultContextToken: contextToken,
|
||||
userContextTokens: args.userContextTokens,
|
||||
});
|
||||
log(`proactive reply sent: to=${toUser} textLen=${text.length}`);
|
||||
} catch (err) {
|
||||
|
|
@ -450,16 +468,45 @@ function pickDownloadableMedia(msg: Json): Json | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
function buildAttachmentsFromMedia(mediaOpts: Json): Json[] {
|
||||
function attachmentNameFromMediaItem(item: Json | null, kind: string): string {
|
||||
if (!item || typeof item !== "object") return "";
|
||||
if (kind === "file") {
|
||||
const fileItem = item.file_item;
|
||||
if (fileItem && typeof fileItem === "object") {
|
||||
return String((fileItem as Json).file_name || (fileItem as Json).filename || (fileItem as Json).name || "").trim();
|
||||
}
|
||||
}
|
||||
if (kind === "image") {
|
||||
const imageItem = item.image_item;
|
||||
if (imageItem && typeof imageItem === "object") {
|
||||
return String((imageItem as Json).file_name || (imageItem as Json).filename || "").trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildAttachmentsFromMedia(mediaOpts: Json, mediaItem?: Json | null): Json[] {
|
||||
const out: Json[] = [];
|
||||
const pushIf = (key: string, mimeKey: string, kind: string): void => {
|
||||
const filePath = String(mediaOpts[key] || "").trim();
|
||||
if (!filePath) return;
|
||||
out.push({
|
||||
const name = String(
|
||||
mediaOpts.fileOriginalName ||
|
||||
mediaOpts.originalFilename ||
|
||||
mediaOpts.file_name ||
|
||||
attachmentNameFromMediaItem(mediaItem || null, kind) ||
|
||||
"",
|
||||
).trim();
|
||||
const att: Json = {
|
||||
kind,
|
||||
local_path: filePath,
|
||||
media_type: String(mediaOpts[mimeKey] || "").trim(),
|
||||
});
|
||||
};
|
||||
if (name) {
|
||||
att.name = name;
|
||||
att.filename = name;
|
||||
}
|
||||
out.push(att);
|
||||
};
|
||||
pushIf("decryptedPicPath", "", "image");
|
||||
pushIf("decryptedVideoPath", "", "video");
|
||||
|
|
@ -468,6 +515,70 @@ function buildAttachmentsFromMedia(mediaOpts: Json): Json[] {
|
|||
return out;
|
||||
}
|
||||
|
||||
async function deliverWeixinReply(
|
||||
modules: OfficialModules,
|
||||
params: {
|
||||
reply: Json;
|
||||
defaultTo: string;
|
||||
cloudBaseUrl: string;
|
||||
token: string;
|
||||
accountId: string;
|
||||
defaultContextToken?: string;
|
||||
userContextTokens?: TokenMap;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const reply = params.reply;
|
||||
const text = String(reply.text || "").trim();
|
||||
const mediaPath = String(reply.media_path || reply.mediaPath || "").trim();
|
||||
const mediaUrl = String(reply.media_url || reply.mediaUrl || "").trim();
|
||||
const deliverTo = String(reply.chat_id || reply.to || params.defaultTo).trim() || params.defaultTo;
|
||||
const replyContextToken = String(
|
||||
reply.context_token ||
|
||||
modules.getContextToken(params.accountId, deliverTo) ||
|
||||
params.defaultContextToken ||
|
||||
params.userContextTokens?.[deliverTo] ||
|
||||
"",
|
||||
).trim();
|
||||
const replyAtts = Array.isArray(reply.attachments) ? (reply.attachments as Json[]) : [];
|
||||
let inlineMediaPath = "";
|
||||
for (const att of replyAtts) {
|
||||
if (!att || typeof att !== "object") continue;
|
||||
const decoded = _decodeReplyBase64Attachment(att);
|
||||
if (decoded?.filePath) {
|
||||
inlineMediaPath = decoded.filePath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (mediaPath || mediaUrl || inlineMediaPath) {
|
||||
const filePath = mediaPath || mediaUrl || inlineMediaPath;
|
||||
await modules.sendWeixinMediaFile({
|
||||
filePath,
|
||||
to: deliverTo,
|
||||
text,
|
||||
opts: {
|
||||
baseUrl: params.cloudBaseUrl,
|
||||
token: params.token,
|
||||
contextToken: replyContextToken || undefined,
|
||||
},
|
||||
cdnBaseUrl: DEFAULT_CDN_BASE_URL,
|
||||
});
|
||||
log(`reply media sent to=${deliverTo} textLen=${text.length}`);
|
||||
return true;
|
||||
}
|
||||
if (!text) return false;
|
||||
await modules.sendMessageWeixin({
|
||||
to: deliverTo,
|
||||
text,
|
||||
opts: {
|
||||
baseUrl: params.cloudBaseUrl,
|
||||
token: params.token,
|
||||
contextToken: replyContextToken || undefined,
|
||||
},
|
||||
});
|
||||
log(`reply text sent to=${deliverTo} textLen=${text.length}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function _decodeReplyBase64Attachment(att: Json): { filePath: string; mime: string } | null {
|
||||
const b64Raw = String(
|
||||
att.data_base64 || att.media_base64 || att.image_base64 || att.video_base64 || att.audio_base64 || att.data || "",
|
||||
|
|
@ -551,7 +662,8 @@ async function handleInboundMessage(
|
|||
}
|
||||
const ctx = modules.weixinMessageToMsgContext(params.full, params.accountId, mediaOpts);
|
||||
const bodyText = String((ctx as Json).Body || "").trim();
|
||||
const attachCount = buildAttachmentsFromMedia(mediaOpts).length;
|
||||
const inboundAttachments = buildAttachmentsFromMedia(mediaOpts, mediaItem);
|
||||
const attachCount = inboundAttachments.length;
|
||||
log(`native call bodyLen=${bodyText.length} attachments=${attachCount}`);
|
||||
let replies: Json[] = [];
|
||||
const tNative0 = Date.now();
|
||||
|
|
@ -560,7 +672,7 @@ async function handleInboundMessage(
|
|||
channel: "wechat",
|
||||
account_id: params.accountId,
|
||||
ctx,
|
||||
attachments: buildAttachmentsFromMedia(mediaOpts),
|
||||
attachments: inboundAttachments,
|
||||
metadata: {
|
||||
source: "weixin_official_native",
|
||||
raw: {
|
||||
|
|
@ -586,50 +698,15 @@ async function handleInboundMessage(
|
|||
return params.localCursor;
|
||||
}
|
||||
for (const reply of replies) {
|
||||
const text = String(reply.text || "").trim();
|
||||
const mediaPath = String(reply.media_path || reply.mediaPath || "").trim();
|
||||
const mediaUrl = String(reply.media_url || reply.mediaUrl || "").trim();
|
||||
const deliverTo = String(reply.chat_id || reply.to || fromUser).trim() || fromUser;
|
||||
const replyContextToken = String(
|
||||
reply.context_token || modules.getContextToken(params.accountId, deliverTo) || contextToken || "",
|
||||
).trim();
|
||||
const replyAtts = Array.isArray(reply.attachments) ? (reply.attachments as Json[]) : [];
|
||||
let inlineMediaPath = "";
|
||||
for (const att of replyAtts) {
|
||||
if (!att || typeof att !== "object") continue;
|
||||
const decoded = _decodeReplyBase64Attachment(att);
|
||||
if (decoded?.filePath) {
|
||||
inlineMediaPath = decoded.filePath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (mediaPath || mediaUrl || inlineMediaPath) {
|
||||
const filePath = mediaPath || mediaUrl || inlineMediaPath;
|
||||
await modules.sendWeixinMediaFile({
|
||||
filePath,
|
||||
to: deliverTo,
|
||||
text,
|
||||
opts: {
|
||||
baseUrl: params.cloudBaseUrl,
|
||||
token: params.token,
|
||||
contextToken: replyContextToken || undefined,
|
||||
},
|
||||
cdnBaseUrl: DEFAULT_CDN_BASE_URL,
|
||||
});
|
||||
log(`reply media sent to=${deliverTo} textLen=${text.length}`);
|
||||
continue;
|
||||
}
|
||||
if (!text) continue;
|
||||
await modules.sendMessageWeixin({
|
||||
to: deliverTo,
|
||||
text,
|
||||
opts: {
|
||||
baseUrl: params.cloudBaseUrl,
|
||||
token: params.token,
|
||||
contextToken: replyContextToken || undefined,
|
||||
},
|
||||
await deliverWeixinReply(modules, {
|
||||
reply,
|
||||
defaultTo: fromUser,
|
||||
cloudBaseUrl: params.cloudBaseUrl,
|
||||
token: params.token,
|
||||
accountId: params.accountId,
|
||||
defaultContextToken: contextToken || undefined,
|
||||
userContextTokens: params.userContextTokens,
|
||||
});
|
||||
log(`reply text sent to=${deliverTo} textLen=${text.length}`);
|
||||
}
|
||||
return params.localCursor;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -373,14 +373,14 @@ def build_group_focus_instruction(*, lang: str = "zh") -> str:
|
|||
def build_channel_file_delivery_instruction(*, lang: str = "zh") -> str:
|
||||
if str(lang or "").strip().lower().startswith("en"):
|
||||
return (
|
||||
"[Channel rule: to send a generated file back to the user on WhatsApp/WeChat, "
|
||||
"call save_deliverable_attachment after creating the file. write_file or run_command alone "
|
||||
"does not attach files to the outbound message.]"
|
||||
"[Channel rule: to send any generated attachment back to the user on WhatsApp/WeChat "
|
||||
"(documents, images, videos), call save_deliverable_attachment with path or attachment_id. "
|
||||
"write_file, run_command, and image generation alone do not attach files to the outbound message.]"
|
||||
)
|
||||
return (
|
||||
"[渠道规则:若要把生成的文件发回用户(WhatsApp/微信),"
|
||||
"在 write_file 或 run_command 生成文件后必须调用 save_deliverable_attachment;"
|
||||
"仅 write_file 不会随消息发送附件。]"
|
||||
"[渠道规则:若要把生成的附件发回用户(WhatsApp/微信,含文档/图片/视频),"
|
||||
"必须调用 save_deliverable_attachment(path 或 attachment_id);"
|
||||
"write_file、run_command、生图工具 alone 不会随消息发送附件。]"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,11 +8,23 @@ from runtime.orchestration.group_ingest import is_nonsend_channel_reply_text, sh
|
|||
from runtime.scheduler.session_resolver import parse_delivery_json
|
||||
|
||||
|
||||
def _encode_weixin_outbound_source(*, context_token: str) -> str:
|
||||
return json.dumps(
|
||||
{"kind": "scheduled_job", "context_token": str(context_token or "").strip()},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
def _encode_weixin_outbound_source(
|
||||
*,
|
||||
context_token: str,
|
||||
attachments: list[dict[str, Any]] | None = None,
|
||||
media_path: str | None = None,
|
||||
) -> str:
|
||||
payload: dict[str, Any] = {
|
||||
"kind": "scheduled_job",
|
||||
"context_token": str(context_token or "").strip(),
|
||||
}
|
||||
atts = [a for a in (attachments or []) if isinstance(a, dict)]
|
||||
if atts:
|
||||
payload["attachments"] = atts
|
||||
mp = str(media_path or "").strip()
|
||||
if mp:
|
||||
payload["media_path"] = mp
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
def _decode_weixin_outbound_source(raw: str) -> dict[str, Any]:
|
||||
|
|
@ -35,6 +47,8 @@ def enqueue_weixin_reply(
|
|||
context_token: str = "",
|
||||
store: Any = None,
|
||||
tenant_id: str = "",
|
||||
attachments: list[dict[str, Any]] | None = None,
|
||||
media_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
from runtime.scheduler.weixin_delivery import normalize_weixin_channel
|
||||
|
||||
|
|
@ -57,7 +71,11 @@ def enqueue_weixin_reply(
|
|||
text=str(text or ""),
|
||||
tenant_id=str(tenant_id or ""),
|
||||
account_id=str(account_id or "").strip(),
|
||||
source=_encode_weixin_outbound_source(context_token=ctx_tok),
|
||||
source=_encode_weixin_outbound_source(
|
||||
context_token=ctx_tok,
|
||||
attachments=attachments,
|
||||
media_path=media_path,
|
||||
),
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
|
|
@ -84,6 +102,8 @@ def enqueue_weixin_reply(
|
|||
chat_id=str(chat_id or "").strip(),
|
||||
text=text,
|
||||
context_token=ctx_tok,
|
||||
attachments=attachments,
|
||||
media_path=media_path,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
|
|
|
|||
|
|
@ -18,13 +18,32 @@ def _guess_mime(path: Path, override: str) -> str:
|
|||
|
||||
def save_deliverable_attachment_tool() -> ToolSpec:
|
||||
def _handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
raw = str(args.get("path") or "").strip().strip('"').strip("'")
|
||||
if not raw:
|
||||
return {"ok": False, "error": "path_required"}
|
||||
attachment_id = str(args.get("attachment_id") or "").strip().lower()
|
||||
raw_path = str(args.get("path") or "").strip().strip('"').strip("'")
|
||||
display_name = str(args.get("name") or "").strip()
|
||||
mime_override = str(args.get("mime") or "").strip()
|
||||
|
||||
if attachment_id:
|
||||
store = AttachmentAssetStore()
|
||||
meta = store.get_meta(attachment_id)
|
||||
if meta is None:
|
||||
return {"ok": False, "error": "attachment_not_found", "attachment_id": attachment_id}
|
||||
name = display_name or meta.name
|
||||
return {
|
||||
"ok": True,
|
||||
"attachment_id": meta.attachment_id,
|
||||
"name": name,
|
||||
"mime": mime_override or meta.mime,
|
||||
"bytes": meta.bytes,
|
||||
"width": meta.width,
|
||||
"height": meta.height,
|
||||
"deliverable": True,
|
||||
}
|
||||
|
||||
if not raw_path:
|
||||
return {"ok": False, "error": "path_or_attachment_id_required"}
|
||||
try:
|
||||
p = resolve_workspace_path(raw)
|
||||
p = resolve_workspace_path(raw_path)
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
if not p.exists() or not p.is_file():
|
||||
|
|
@ -48,9 +67,11 @@ def save_deliverable_attachment_tool() -> ToolSpec:
|
|||
return ToolSpec(
|
||||
name="save_deliverable_attachment",
|
||||
description=(
|
||||
"Register a workspace file for outbound channel delivery (WhatsApp/WeChat). "
|
||||
"Call this after generating a file with write_file or run_command when the user should receive it as an attachment. "
|
||||
"write_file alone does not send files to messaging channels."
|
||||
"Mark an attachment for outbound channel delivery (WhatsApp/WeChat). "
|
||||
"Required before the user receives any generated file, image, or video in a messaging channel. "
|
||||
"Use path for workspace files (after write_file or run_command), or attachment_id for assets "
|
||||
"already in the attachment store (e.g. after cloudflare_image_generate). "
|
||||
"Generating content alone does not send attachments to the channel."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
|
|
@ -59,16 +80,19 @@ def save_deliverable_attachment_tool() -> ToolSpec:
|
|||
"type": "string",
|
||||
"description": "Workspace file path (relative to workspace root or allowed absolute path).",
|
||||
},
|
||||
"attachment_id": {
|
||||
"type": "string",
|
||||
"description": "Existing attachment_id from image/video generation tools.",
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Optional download filename shown to the user.",
|
||||
},
|
||||
"mime": {
|
||||
"type": "string",
|
||||
"description": "Optional MIME type override (e.g. application/vnd.openxmlformats-officedocument.spreadsheetml.sheet).",
|
||||
"description": "Optional MIME type override.",
|
||||
},
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
handler=_handler,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue