@agent-native/core 0.101.10 → 0.101.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +6 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/a2a/client.ts +23 -1
- package/corpus/core/src/a2a/handlers.ts +65 -1
- package/corpus/core/src/a2a/server.ts +124 -0
- package/corpus/core/src/a2a/task-store.ts +253 -0
- package/corpus/core/src/a2a/types.ts +26 -0
- package/corpus/core/src/agent/production-agent.ts +5 -2
- package/corpus/core/src/mcp/builtin-tools.ts +25 -1
- package/corpus/core/src/scripts/call-agent.ts +22 -3
- package/corpus/core/src/server/agent-chat-plugin.ts +86 -1
- package/corpus/core/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +25 -0
- package/corpus/templates/content/.agents/skills/document-editing/SKILL.md +23 -0
- package/corpus/templates/content/app/components/editor/MathRenderer.tsx +43 -0
- package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +260 -4
- package/corpus/templates/content/app/components/editor/extensions/NotionExtensions.tsx +78 -0
- package/corpus/templates/content/app/global.css +53 -0
- package/corpus/templates/content/app/i18n/zh-TW.ts +13 -0
- package/corpus/templates/content/app/i18n-data.ts +163 -0
- package/corpus/templates/content/app/root.tsx +2 -0
- package/corpus/templates/content/changelog/2026-07-14-latex-equations-render-in-documents-public-pages-and-exports.md +6 -0
- package/corpus/templates/content/package.json +1 -0
- package/corpus/templates/content/shared/document-export.ts +296 -25
- package/corpus/templates/content/shared/inline-math.ts +128 -0
- package/corpus/templates/content/shared/math-rendering.ts +50 -0
- package/corpus/templates/content/shared/nfm.ts +17 -14
- package/corpus/templates/content/vite.config.ts +0 -1
- package/corpus/templates/mail/.agents/skills/email-drafts/SKILL.md +17 -0
- package/corpus/templates/mail/AGENTS.md +21 -19
- package/corpus/templates/mail/actions/create-attachment-upload.ts +49 -0
- package/corpus/templates/mail/changelog/2026-07-14-connected-agents-can-now-securely-upload-local-files-for-mai.md +6 -0
- package/corpus/templates/mail/server/handlers/media.ts +84 -61
- package/corpus/templates/mail/server/lib/attachment-upload-ticket.ts +234 -0
- package/corpus/templates/mail/server/lib/mail-connector-catalog.ts +8 -4
- package/corpus/templates/mail/server/lib/media-upload.ts +114 -0
- package/corpus/templates/mail/server/plugins/auth.ts +9 -1
- package/corpus/templates/mail/server/routes/api/media/attachment-upload/[uploadId].put.ts +1 -0
- package/dist/a2a/client.d.ts +5 -1
- package/dist/a2a/client.d.ts.map +1 -1
- package/dist/a2a/client.js +18 -1
- package/dist/a2a/client.js.map +1 -1
- package/dist/a2a/handlers.d.ts.map +1 -1
- package/dist/a2a/handlers.js +57 -8
- package/dist/a2a/handlers.js.map +1 -1
- package/dist/a2a/server.d.ts.map +1 -1
- package/dist/a2a/server.js +87 -1
- package/dist/a2a/server.js.map +1 -1
- package/dist/a2a/task-store.d.ts +27 -0
- package/dist/a2a/task-store.d.ts.map +1 -1
- package/dist/a2a/task-store.js +216 -0
- package/dist/a2a/task-store.js.map +1 -1
- package/dist/a2a/types.d.ts +24 -0
- package/dist/a2a/types.d.ts.map +1 -1
- package/dist/a2a/types.js.map +1 -1
- package/dist/agent/production-agent.d.ts +1 -0
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +5 -2
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/mcp/builtin-tools.d.ts.map +1 -1
- package/dist/mcp/builtin-tools.js +22 -3
- package/dist/mcp/builtin-tools.js.map +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +5 -5
- package/dist/resources/handlers.d.ts +3 -3
- package/dist/scripts/call-agent.d.ts +1 -1
- package/dist/scripts/call-agent.d.ts.map +1 -1
- package/dist/scripts/call-agent.js +19 -1
- package/dist/scripts/call-agent.js.map +1 -1
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +71 -2
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +25 -0
- package/package.json +1 -1
- package/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +25 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
export const MAX_INLINE_MATH_LENGTH = 1_000;
|
|
2
|
+
|
|
3
|
+
export type InlineMathSyntax = "plain" | "github";
|
|
4
|
+
|
|
5
|
+
export interface InlineMathMatch {
|
|
6
|
+
from: number;
|
|
7
|
+
to: number;
|
|
8
|
+
latex: string;
|
|
9
|
+
syntax: InlineMathSyntax;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isEscapedInlineDelimiter(text: string, index: number): boolean {
|
|
13
|
+
let backslashes = 0;
|
|
14
|
+
for (let cursor = index - 1; cursor >= 0 && text[cursor] === "\\"; cursor--) {
|
|
15
|
+
backslashes += 1;
|
|
16
|
+
}
|
|
17
|
+
return backslashes % 2 === 1;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function matchGithubInlineMath(
|
|
21
|
+
text: string,
|
|
22
|
+
start: number,
|
|
23
|
+
): InlineMathMatch | null {
|
|
24
|
+
if (!text.startsWith("$`", start)) return null;
|
|
25
|
+
|
|
26
|
+
const close = text.indexOf("`$", start + 2);
|
|
27
|
+
if (close === -1 || close - (start + 2) > MAX_INLINE_MATH_LENGTH) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
from: start,
|
|
33
|
+
to: close + 2,
|
|
34
|
+
latex: text.slice(start + 2, close),
|
|
35
|
+
syntax: "github",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function matchPlainInlineMath(
|
|
40
|
+
text: string,
|
|
41
|
+
start: number,
|
|
42
|
+
): InlineMathMatch | null {
|
|
43
|
+
if (
|
|
44
|
+
text[start] !== "$" ||
|
|
45
|
+
text[start + 1] === "$" ||
|
|
46
|
+
text[start + 1] === "`" ||
|
|
47
|
+
!text[start + 1] ||
|
|
48
|
+
/\s/.test(text[start + 1])
|
|
49
|
+
) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const searchLimit = Math.min(text.length, start + MAX_INLINE_MATH_LENGTH + 2);
|
|
54
|
+
for (let close = start + 1; close < searchLimit; close++) {
|
|
55
|
+
const char = text[close];
|
|
56
|
+
if (char === "\n" || char === "\r") return null;
|
|
57
|
+
if (
|
|
58
|
+
char !== "$" ||
|
|
59
|
+
isEscapedInlineDelimiter(text, close) ||
|
|
60
|
+
text[close - 1] === "$" ||
|
|
61
|
+
text[close + 1] === "$" ||
|
|
62
|
+
/\s/.test(text[close - 1]) ||
|
|
63
|
+
/\d/.test(text[close + 1] || "")
|
|
64
|
+
) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
from: start,
|
|
70
|
+
to: close + 1,
|
|
71
|
+
latex: text.slice(start + 1, close),
|
|
72
|
+
syntax: "plain",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function matchInlineMathAt(
|
|
80
|
+
text: string,
|
|
81
|
+
start: number,
|
|
82
|
+
): InlineMathMatch | null {
|
|
83
|
+
if (text[start] !== "$" || isEscapedInlineDelimiter(text, start)) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
matchGithubInlineMath(text, start) ?? matchPlainInlineMath(text, start)
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function findTrailingPlainInlineMath(
|
|
93
|
+
text: string,
|
|
94
|
+
): InlineMathMatch | null {
|
|
95
|
+
if (!text.endsWith("$") || text.endsWith("$$")) return null;
|
|
96
|
+
|
|
97
|
+
for (let start = text.length - 2; start >= 0; start--) {
|
|
98
|
+
if (text[start] !== "$") continue;
|
|
99
|
+
const match = matchInlineMathAt(text, start);
|
|
100
|
+
if (
|
|
101
|
+
match?.syntax === "plain" &&
|
|
102
|
+
match.to === text.length &&
|
|
103
|
+
!hasUnclosedCodeSpan(text.slice(0, start))
|
|
104
|
+
) {
|
|
105
|
+
return match;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function hasUnclosedCodeSpan(text: string): boolean {
|
|
113
|
+
let openRun = 0;
|
|
114
|
+
|
|
115
|
+
for (let cursor = 0; cursor < text.length; cursor++) {
|
|
116
|
+
if (text[cursor] !== "`" || isEscapedInlineDelimiter(text, cursor)) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let run = 1;
|
|
121
|
+
while (text[cursor + run] === "`") run += 1;
|
|
122
|
+
if (openRun === 0) openRun = run;
|
|
123
|
+
else if (openRun === run) openRun = 0;
|
|
124
|
+
cursor += run - 1;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return openRun !== 0;
|
|
128
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import katex, { type KatexOptions } from "katex";
|
|
2
|
+
|
|
3
|
+
export const KATEX_VERSION = "0.17.0";
|
|
4
|
+
export const KATEX_STYLESHEET_URL = `https://cdn.jsdelivr.net/npm/katex@${KATEX_VERSION}/dist/katex.min.css`;
|
|
5
|
+
|
|
6
|
+
export interface MathRenderSuccess {
|
|
7
|
+
ok: true;
|
|
8
|
+
html: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MathRenderFailure {
|
|
12
|
+
ok: false;
|
|
13
|
+
error: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type MathRenderResult = MathRenderSuccess | MathRenderFailure;
|
|
17
|
+
|
|
18
|
+
export function mathRenderOptions(displayMode: boolean): KatexOptions {
|
|
19
|
+
return {
|
|
20
|
+
displayMode,
|
|
21
|
+
maxExpand: 1_000,
|
|
22
|
+
output: "htmlAndMathml",
|
|
23
|
+
strict: "warn",
|
|
24
|
+
throwOnError: true,
|
|
25
|
+
trust: false,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function mathRenderErrorMessage(error: unknown): string {
|
|
30
|
+
if (error instanceof Error && error.message.trim()) return error.message;
|
|
31
|
+
return "This equation could not be rendered.";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function renderMathToHtml(
|
|
35
|
+
latex: string,
|
|
36
|
+
displayMode: boolean,
|
|
37
|
+
): MathRenderResult {
|
|
38
|
+
if (!latex.trim()) {
|
|
39
|
+
return { ok: false, error: "This equation is empty." };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
return {
|
|
44
|
+
ok: true,
|
|
45
|
+
html: katex.renderToString(latex, mathRenderOptions(displayMode)),
|
|
46
|
+
};
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return { ok: false, error: mathRenderErrorMessage(error) };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
* exposing simple string props for the editor preview layer.
|
|
42
42
|
*/
|
|
43
43
|
|
|
44
|
+
import { matchInlineMathAt } from "./inline-math.js";
|
|
44
45
|
import { isRegistryBlockTag, registryBlockSpecByTag } from "./nfm-registry.js";
|
|
45
46
|
|
|
46
47
|
// ── Shared PM JSON types ────────────────────────────────────────────
|
|
@@ -279,7 +280,7 @@ function serializeInlineAtom(node: PMNode): string {
|
|
|
279
280
|
attrs = {};
|
|
280
281
|
}
|
|
281
282
|
if (tagName === "math") {
|
|
282
|
-
return "
|
|
283
|
+
return "$" + (label || attrs.latex || "") + "$";
|
|
283
284
|
}
|
|
284
285
|
const attrEntries = Object.entries(attrs).filter(([k]) => k !== "latex");
|
|
285
286
|
const attrStr = serializeAttrs(attrEntries);
|
|
@@ -446,19 +447,21 @@ function parseInline(input: string): PMNode[] {
|
|
|
446
447
|
continue;
|
|
447
448
|
}
|
|
448
449
|
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
}
|
|
450
|
+
// Canonical inline math is $...$; GitHub's $`...`$ form remains a
|
|
451
|
+
// backwards-compatible input alias and canonicalizes on the next write.
|
|
452
|
+
const inlineMath = ch === "$" ? matchInlineMathAt(input, i) : null;
|
|
453
|
+
if (inlineMath) {
|
|
454
|
+
flush();
|
|
455
|
+
out.push({
|
|
456
|
+
type: "notionInlineAtom",
|
|
457
|
+
attrs: {
|
|
458
|
+
tagName: "math",
|
|
459
|
+
attrsJson: "{}",
|
|
460
|
+
label: inlineMath.latex,
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
i = inlineMath.to;
|
|
464
|
+
continue;
|
|
462
465
|
}
|
|
463
466
|
|
|
464
467
|
// Inline code `...` — CommonMark-style variable-length delimiter: the
|
|
@@ -121,6 +121,23 @@ const drafts = await listAppState("compose-");
|
|
|
121
121
|
|
|
122
122
|
The `send-email` action accepts an optional `attachments` array. Each entry must reference a file that was previously uploaded via the media-upload endpoint (`/api/media/upload`). Pass the server-side `filename` (the key returned by the upload endpoint, e.g. `abc123.pdf`), and optionally `originalName` (display name for the recipient) and `mimeType`. The attachment plumbing resolves the file from `data/uploads/` or from the configured file-storage URL recorded by the upload endpoint, then includes it as a MIME multipart attachment in the outgoing Gmail message. Do not store or paste attachment bytes, base64, or `data:` URLs in draft state/settings; files are never sent speculatively — only attach what the user has explicitly provided and confirmed.
|
|
123
123
|
|
|
124
|
+
### Local files from an MCP host
|
|
125
|
+
|
|
126
|
+
A hosted Mail app cannot read a path on the MCP host's local filesystem. Use
|
|
127
|
+
`create-attachment-upload` with the file's display name. It returns a
|
|
128
|
+
five-minute, owner-bound `uploadUrl`, a `PUT` method, and the attachment handle
|
|
129
|
+
Mail expects. From the local host, upload the raw bytes directly:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
curl --fail-with-body --request PUT --header '<Authorization header>' --data-binary @/absolute/path/to/report.pdf '<uploadUrl>'
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Use the response's `attachment` object in the draft/send request. The upload
|
|
136
|
+
Authorization header is a short-lived bearer credential: do not log, persist,
|
|
137
|
+
reuse, or share it. Creating or consuming it does not send mail. Keep `send-email` behind its
|
|
138
|
+
normal explicit-intent and human-approval gate; for drafting, pass the handle
|
|
139
|
+
through `ask_app` or open the returned compose deep link.
|
|
140
|
+
|
|
124
141
|
Example:
|
|
125
142
|
```json
|
|
126
143
|
{
|
|
@@ -60,8 +60,9 @@ needed.
|
|
|
60
60
|
- Never send mail unless the user explicitly asks to send. Draft or queue
|
|
61
61
|
review by default. `send-email` has `needsApproval: true` — it is the
|
|
62
62
|
canonical, intentionally rare use of the human-in-the-loop gate in this
|
|
63
|
-
framework
|
|
64
|
-
|
|
63
|
+
framework. An authenticated A2A caller may carry the user's exact chat
|
|
64
|
+
authorization for one matching send; otherwise the loop pauses for approval.
|
|
65
|
+
Drafting and queueing are unaffected.
|
|
65
66
|
- When drafting, first read `get-mail-settings` for signature and writing
|
|
66
67
|
style. Use `signature` exactly when present — draft-writing paths that build
|
|
67
68
|
a `compose-*` entry (`manage-draft`, `open-queued-draft`) call
|
|
@@ -93,23 +94,24 @@ needed.
|
|
|
93
94
|
|
|
94
95
|
## Action Map
|
|
95
96
|
|
|
96
|
-
| Action | Purpose
|
|
97
|
-
| -------------------------------------------------------------------------------------------------------------------------------------- |
|
|
98
|
-
| `search-emails` / `list-emails` | Query across Gmail or local-fallback data by view/query.
|
|
99
|
-
| `get-email` / `get-thread` | Full body/metadata for one message or thread.
|
|
100
|
-
| `find-contact` | Resolve a name/partial address to a real email.
|
|
101
|
-
| `get-hubspot-contact` | CRM contact + deals + tickets by email (HubSpot only).
|
|
102
|
-
| `
|
|
103
|
-
| `
|
|
104
|
-
| `
|
|
105
|
-
| `
|
|
106
|
-
| `
|
|
107
|
-
| `manage-
|
|
108
|
-
| `
|
|
109
|
-
| `
|
|
110
|
-
| `
|
|
111
|
-
| `
|
|
112
|
-
| `
|
|
97
|
+
| Action | Purpose |
|
|
98
|
+
| -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
|
99
|
+
| `search-emails` / `list-emails` | Query across Gmail or local-fallback data by view/query. |
|
|
100
|
+
| `get-email` / `get-thread` | Full body/metadata for one message or thread. |
|
|
101
|
+
| `find-contact` | Resolve a name/partial address to a real email. |
|
|
102
|
+
| `get-hubspot-contact` | CRM contact + deals + tickets by email (HubSpot only). |
|
|
103
|
+
| `create-attachment-upload` | Mint a five-minute, owner-bound raw-byte upload URL for one local attachment. |
|
|
104
|
+
| `manage-draft` | Create/update/delete a `compose-{id}` draft (signature-aware). |
|
|
105
|
+
| `send-email` | Real send. `needsApproval: true`; exact authenticated chat grants run once, otherwise it pauses for approval. |
|
|
106
|
+
| `queue-email-draft` / `list-queued-drafts` / `update-queued-draft` / `open-queued-draft` / `send-queued-drafts` | Teammate/Slack draft-review workflow — see `draft-queue`. |
|
|
107
|
+
| `mark-read` / `mark-thread-read` / `star-email` / `archive-email` / `unarchive-email` / `trash-email` / `untrash-email` / `move-email` | Per-message or per-thread state changes; most call `refresh-list` internally. |
|
|
108
|
+
| `manage-gmail-filters` | Provider-native Gmail filters (create/replace/delete). |
|
|
109
|
+
| `manage-automations` / `trigger-automations` | Natural-language inbox automation rules. |
|
|
110
|
+
| `respond-calendar-invite` | Accept/decline/tentative an invite found in Mail. |
|
|
111
|
+
| `get-mail-settings` / `update-mail-settings` / `import-gmail-signature` | Signature and writing-style settings. |
|
|
112
|
+
| `manage-snippets` | List/create/update/delete saved reply snippets insertable from the compose slash menu. |
|
|
113
|
+
| `get-tracking` | Open/click stats for a previously sent, tracked message. |
|
|
114
|
+
| `provider-api-catalog` / `provider-api-docs` / `provider-api-request` | Raw Gmail, Calendar, or HubSpot API calls beyond the canned actions. |
|
|
113
115
|
|
|
114
116
|
## Application State
|
|
115
117
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { defineAction } from "@agent-native/core/action";
|
|
2
|
+
import {
|
|
3
|
+
getAppProductionUrl,
|
|
4
|
+
getRequestUserEmail,
|
|
5
|
+
withConfiguredAppBasePath,
|
|
6
|
+
} from "@agent-native/core/server";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
import { createAttachmentUploadTicket } from "../server/lib/attachment-upload-ticket.js";
|
|
10
|
+
|
|
11
|
+
const originalNameSchema = z
|
|
12
|
+
.string()
|
|
13
|
+
.trim()
|
|
14
|
+
.min(1)
|
|
15
|
+
.max(255)
|
|
16
|
+
.refine(
|
|
17
|
+
(value) => !/[\\/\0\r\n]/.test(value),
|
|
18
|
+
"Filename must not contain path separators or control characters",
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
export default defineAction({
|
|
22
|
+
description:
|
|
23
|
+
"Create a short-lived authenticated Mail upload URL for one local attachment. Upload the file bytes with HTTP PUT, then pass the returned attachment handle to ask_app so Mail can include it in a draft or approved send. Creating the URL does not send email.",
|
|
24
|
+
schema: z.object({
|
|
25
|
+
originalName: originalNameSchema.describe(
|
|
26
|
+
"Display filename, including its extension (for example, report.png)",
|
|
27
|
+
),
|
|
28
|
+
}),
|
|
29
|
+
run: async ({ originalName }) => {
|
|
30
|
+
const ownerEmail = getRequestUserEmail();
|
|
31
|
+
if (!ownerEmail) throw new Error("Authentication required");
|
|
32
|
+
const ticket = await createAttachmentUploadTicket(ownerEmail, originalName);
|
|
33
|
+
const appUrl = withConfiguredAppBasePath(getAppProductionUrl());
|
|
34
|
+
const uploadUrl = `${appUrl}/api/media/attachment-upload/${encodeURIComponent(ticket.uploadId)}`;
|
|
35
|
+
return {
|
|
36
|
+
method: "PUT" as const,
|
|
37
|
+
uploadUrl,
|
|
38
|
+
headers: { Authorization: `Bearer ${ticket.token}` },
|
|
39
|
+
expiresAt: new Date(ticket.expiresAt).toISOString(),
|
|
40
|
+
maxBytes: 10 * 1024 * 1024,
|
|
41
|
+
attachment: {
|
|
42
|
+
filename: ticket.filename,
|
|
43
|
+
originalName: ticket.originalName,
|
|
44
|
+
mimeType: ticket.mimeType,
|
|
45
|
+
},
|
|
46
|
+
next: "PUT the raw local file bytes to uploadUrl with the returned Authorization header before passing attachment to ask_app. Do not paste base64 into chat or action arguments.",
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
|
-
import { uploadFile } from "@agent-native/core/file-upload";
|
|
5
4
|
import { streamFile, getSession } from "@agent-native/core/server";
|
|
6
5
|
import {
|
|
7
6
|
defineEventHandler,
|
|
7
|
+
getHeader,
|
|
8
8
|
getQuery,
|
|
9
9
|
readRawBody,
|
|
10
10
|
getRouterParam,
|
|
@@ -15,9 +15,20 @@ import {
|
|
|
15
15
|
} from "h3";
|
|
16
16
|
import { nanoid } from "nanoid";
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
claimAttachmentUploadTicket,
|
|
20
|
+
verifyAttachmentUploadTicket,
|
|
21
|
+
} from "../lib/attachment-upload-ticket.js";
|
|
22
|
+
import {
|
|
23
|
+
MAX_UPLOAD_BYTES,
|
|
24
|
+
MediaStorageSetupError,
|
|
25
|
+
mimeTypeForUpload,
|
|
26
|
+
storeMediaUpload,
|
|
27
|
+
uploadsDirectory,
|
|
28
|
+
} from "../lib/media-upload.js";
|
|
29
|
+
import { getStoredUpload } from "../lib/upload-store.js";
|
|
19
30
|
|
|
20
|
-
const UPLOADS_DIR =
|
|
31
|
+
const UPLOADS_DIR = uploadsDirectory();
|
|
21
32
|
|
|
22
33
|
// Ensure uploads directory exists (guarded for edge runtimes without filesystem)
|
|
23
34
|
try {
|
|
@@ -28,26 +39,6 @@ try {
|
|
|
28
39
|
// Edge runtime (e.g. Cloudflare Workers) — no local filesystem
|
|
29
40
|
}
|
|
30
41
|
|
|
31
|
-
const MIME_MAP: Record<string, string> = {
|
|
32
|
-
".png": "image/png",
|
|
33
|
-
".jpg": "image/jpeg",
|
|
34
|
-
".jpeg": "image/jpeg",
|
|
35
|
-
".gif": "image/gif",
|
|
36
|
-
".webp": "image/webp",
|
|
37
|
-
".svg": "image/svg+xml",
|
|
38
|
-
".pdf": "application/pdf",
|
|
39
|
-
".txt": "text/plain",
|
|
40
|
-
".csv": "text/csv",
|
|
41
|
-
".doc": "application/msword",
|
|
42
|
-
".docx":
|
|
43
|
-
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
44
|
-
".xls": "application/vnd.ms-excel",
|
|
45
|
-
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
46
|
-
".zip": "application/zip",
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
50
|
-
|
|
51
42
|
export const uploadMedia = defineEventHandler(async (event: H3Event) => {
|
|
52
43
|
const session = await getSession(event).catch(() => null);
|
|
53
44
|
if (!session?.email) {
|
|
@@ -69,51 +60,83 @@ export const uploadMedia = defineEventHandler(async (event: H3Event) => {
|
|
|
69
60
|
|
|
70
61
|
const originalName = (getQuery(event).filename as string) || "upload";
|
|
71
62
|
const ext = path.extname(originalName).toLowerCase() || ".bin";
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const payload = {
|
|
77
|
-
url: `/api/media/${id}`,
|
|
78
|
-
filename: id,
|
|
63
|
+
return await storeMediaUpload({
|
|
64
|
+
ownerEmail: session.email,
|
|
65
|
+
data: body instanceof Uint8Array ? body : new Uint8Array(body),
|
|
66
|
+
filename: nanoid(12) + ext,
|
|
79
67
|
originalName,
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
68
|
+
});
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if (err instanceof MediaStorageSetupError) {
|
|
71
|
+
setResponseStatus(event, 503);
|
|
72
|
+
return {
|
|
73
|
+
error: err.message,
|
|
74
|
+
storageSetupRequired: true,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
console.error("[media] Upload failed:", err);
|
|
78
|
+
setResponseStatus(event, 500);
|
|
79
|
+
return { error: "Upload failed" };
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
export const uploadAttachmentWithTicket = defineEventHandler(
|
|
84
|
+
async (event: H3Event) => {
|
|
85
|
+
const uploadId = getRouterParam(event, "uploadId") as string;
|
|
86
|
+
const authorization = getHeader(event, "authorization") || "";
|
|
87
|
+
const token = authorization.startsWith("Bearer ")
|
|
88
|
+
? authorization.slice("Bearer ".length).trim()
|
|
89
|
+
: "";
|
|
90
|
+
// Reject an invalid bearer before buffering request bytes. The later
|
|
91
|
+
// atomic claim remains the concurrency boundary immediately before the
|
|
92
|
+
// storage side effect.
|
|
93
|
+
const verified = await verifyAttachmentUploadTicket(uploadId, token);
|
|
94
|
+
if (!verified) {
|
|
95
|
+
setResponseStatus(event, 401);
|
|
96
|
+
return { error: "Invalid or expired attachment upload URL" };
|
|
97
|
+
}
|
|
98
|
+
const body = await readRawBody(event, false);
|
|
99
|
+
if (!body || !body.byteLength) {
|
|
100
|
+
setResponseStatus(event, 400);
|
|
101
|
+
return { error: "No file data" };
|
|
102
|
+
}
|
|
103
|
+
if (body.byteLength > MAX_UPLOAD_BYTES) {
|
|
104
|
+
setResponseStatus(event, 413);
|
|
105
|
+
return { error: "File too large (max 10 MB)" };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const claimed = await claimAttachmentUploadTicket(uploadId, token);
|
|
109
|
+
if (!claimed) {
|
|
110
|
+
setResponseStatus(event, 401);
|
|
111
|
+
return { error: "Invalid or expired attachment upload URL" };
|
|
112
|
+
}
|
|
83
113
|
|
|
84
114
|
try {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const uploaded = await uploadFile({
|
|
115
|
+
const uploaded = await storeMediaUpload({
|
|
116
|
+
ownerEmail: claimed.ownerEmail,
|
|
88
117
|
data: body instanceof Uint8Array ? body : new Uint8Array(body),
|
|
89
|
-
filename:
|
|
90
|
-
|
|
91
|
-
ownerEmail: session.email,
|
|
92
|
-
recordAsset: false,
|
|
118
|
+
filename: claimed.ticket.filename,
|
|
119
|
+
originalName: claimed.ticket.originalName,
|
|
93
120
|
});
|
|
94
|
-
|
|
121
|
+
return {
|
|
122
|
+
...uploaded,
|
|
123
|
+
attachment: {
|
|
124
|
+
filename: uploaded.filename,
|
|
125
|
+
originalName: uploaded.originalName,
|
|
126
|
+
mimeType: uploaded.mimeType,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
} catch (err) {
|
|
130
|
+
if (err instanceof MediaStorageSetupError) {
|
|
95
131
|
setResponseStatus(event, 503);
|
|
96
|
-
return {
|
|
97
|
-
error:
|
|
98
|
-
"File storage is not configured. Connect Builder.io or another upload provider before attaching files in hosted environments.",
|
|
99
|
-
storageSetupRequired: true,
|
|
100
|
-
};
|
|
132
|
+
return { error: err.message, storageSetupRequired: true };
|
|
101
133
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
createdAt: Date.now(),
|
|
106
|
-
});
|
|
107
|
-
return { ...payload, url: uploaded.url, provider: uploaded.provider };
|
|
134
|
+
console.error("[media] Ticketed attachment upload failed:", err);
|
|
135
|
+
setResponseStatus(event, 500);
|
|
136
|
+
return { error: "Upload failed" };
|
|
108
137
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
} catch (err) {
|
|
112
|
-
console.error("[media] Upload failed:", err);
|
|
113
|
-
setResponseStatus(event, 500);
|
|
114
|
-
return { error: "Upload failed" };
|
|
115
|
-
}
|
|
116
|
-
});
|
|
138
|
+
},
|
|
139
|
+
);
|
|
117
140
|
|
|
118
141
|
export const serveMedia = defineEventHandler(async (event: H3Event) => {
|
|
119
142
|
const filename = getRouterParam(event, "filename") as string;
|
|
@@ -161,7 +184,7 @@ export const serveMedia = defineEventHandler(async (event: H3Event) => {
|
|
|
161
184
|
return Buffer.from(stored.dataBase64, "base64");
|
|
162
185
|
}
|
|
163
186
|
|
|
164
|
-
const mimeType =
|
|
187
|
+
const mimeType = mimeTypeForUpload(filename);
|
|
165
188
|
|
|
166
189
|
setResponseHeader(event, "Content-Type", mimeType);
|
|
167
190
|
setResponseHeader(
|