akanjs 3.0.0-alpha.38 → 3.0.0-alpha.39

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.
@@ -54,6 +54,10 @@ export const baseDictionary = serviceDictionary(["en", "ko"])
54
54
  agentClear: ["Clear conversation", "대화 비우기"],
55
55
  agentQuestion: ["The agent needs your decision", "에이전트가 결정을 요청합니다"],
56
56
  agentAnswer: ["Type your answer...", "답변을 입력하세요..."],
57
+ agentAttach: ["Attach a file", "파일 첨부"],
58
+ agentAttachRemove: ["Remove attachment", "첨부 제거"],
59
+ agentAttachTooLarge: ["{name} is too large to attach.", "{name}은(는) 용량이 너무 커서 첨부할 수 없습니다."],
60
+ agentAttachUnsupported: ["{name} cannot be attached here.", "{name}은(는) 여기에 첨부할 수 없습니다."],
57
61
  agentContinue: ["This is taking a while. Keep going?", "시간이 걸리고 있습니다. 계속할까요?"],
58
62
  agentKeepGoing: ["Keep going", "계속하기"],
59
63
  agentCmdNew: ["Start a new conversation", "새 대화 시작"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.38",
3
+ "version": "3.0.0-alpha.39",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -1,5 +1,10 @@
1
1
  import { Err } from "akanjs/dictionary";
2
- import type { LlmTurnRequest } from "./predefinedAdaptor/llm.adaptor";
2
+ import type {
3
+ AgentWireAttachment,
4
+ AgentWireMessage,
5
+ LlmAccepts,
6
+ LlmTurnRequest,
7
+ } from "./predefinedAdaptor/llm.adaptor";
3
8
  import { LlmAdaptorRole } from "./predefinedAdaptor/role.adaptor";
4
9
  import { serve } from "./serve";
5
10
 
@@ -7,8 +12,49 @@ export class AgentService extends serve("agent" as const, ({ plug }) => ({
7
12
  llm: plug(LlmAdaptorRole),
8
13
  })) {
9
14
  async runTurn(request: LlmTurnRequest, onDelta?: (delta: string) => void) {
10
- const answer = await this.llm.chat(request, onDelta);
15
+ const answer = await this.llm.chat(AgentService.readable(request, this.llm.accepts), onDelta);
11
16
  if (!answer) throw new Err("agent.error.llmUnavailable");
12
17
  return { text: answer.text ?? "", toolCalls: answer.toolCalls ?? [], stop: answer.stop };
13
18
  }
19
+
20
+ /**
21
+ * Replaces every attachment the provider cannot read with a note naming it, so no adaptor has to think about
22
+ * attachments it does not support and none can lose one quietly. The model has to be *told*, not merely spared:
23
+ * a file that vanishes on the way in is one it answers about from the filename, confidently and wrongly.
24
+ *
25
+ * The note rides in the message text because that is the one field every provider mapping already reads.
26
+ */
27
+ static readable(request: LlmTurnRequest, accepts: LlmAccepts | undefined): LlmTurnRequest {
28
+ if (!request.messages.some((message) => message.attachments?.length)) return request;
29
+ const messages = request.messages.map((message) => AgentService.readableMessage(message, accepts ?? {}));
30
+ return { ...request, messages };
31
+ }
32
+
33
+ private static readableMessage(message: AgentWireMessage, accepts: LlmAccepts): AgentWireMessage {
34
+ const { attachments = [], ...rest } = message;
35
+ if (!attachments.length) return message;
36
+ const kept = attachments.filter((attachment) => AgentService.isReadable(attachment, accepts));
37
+ if (kept.length === attachments.length) return message;
38
+ const notes = attachments.filter((attachment) => !kept.includes(attachment)).map(AgentService.note);
39
+ return {
40
+ ...rest,
41
+ ...(kept.length ? { attachments: kept } : {}),
42
+ text: [message.text, ...notes].filter(Boolean).join("\n\n"),
43
+ };
44
+ }
45
+
46
+ /** Extracted text is readable by every model there is; bytes and links need the provider to say so. */
47
+ private static isReadable(attachment: AgentWireAttachment, accepts: LlmAccepts): boolean {
48
+ if (attachment.text) return true;
49
+ if (!attachment.data && !attachment.url) return false;
50
+ return attachment.mimeType.startsWith("image/") ? !!accepts.image : !!accepts.document;
51
+ }
52
+
53
+ private static note(attachment: AgentWireAttachment): string {
54
+ const why =
55
+ attachment.data || attachment.url
56
+ ? "this model cannot read that type"
57
+ : "its content is no longer available, as a reloaded conversation keeps the name and not the bytes";
58
+ return `[Attachment not read: ${attachment.name} (${attachment.mimeType}) — ${why}. Tell the user it was not read instead of guessing what it holds, and ask for the text if the answer needs it.]`;
59
+ }
14
60
  }
@@ -201,7 +201,19 @@ export class DeepseekLlm
201
201
  : {}),
202
202
  },
203
203
  ];
204
- return [{ role: "user" as const, content: message.text ?? "" }];
204
+ return [{ role: "user" as const, content: DeepseekLlm.userContent(message) }];
205
+ }
206
+
207
+ /**
208
+ * `accepts` is left undeclared, so by the time an attachment reaches here `AgentService.readable` has reduced it
209
+ * to its text and turned everything else into a note. Each block is labelled because a model handed two
210
+ * unlabelled documents can no longer cite either one.
211
+ */
212
+ static userContent(message: AgentWireMessage): string {
213
+ const blocks = (message.attachments ?? []).flatMap((attachment) =>
214
+ attachment.text ? [`--- attachment: ${attachment.name} (${attachment.mimeType}) ---\n${attachment.text}`] : [],
215
+ );
216
+ return [message.text, ...blocks].filter(Boolean).join("\n\n");
205
217
  }
206
218
 
207
219
  static turnAnswer(answer: DeepseekAnswer): LlmTurnAnswer {
@@ -12,6 +12,20 @@ export interface AgentWireToolResult {
12
12
  error?: string;
13
13
  }
14
14
 
15
+ /**
16
+ * A file the caller attached to one message. Exactly one carrier reaches the model — `data` as inlined bytes, `url`
17
+ * as something the provider fetches, `text` as content already extracted — and which of them a given provider can
18
+ * read is what `LlmAccepts` answers.
19
+ */
20
+ export interface AgentWireAttachment {
21
+ name: string;
22
+ mimeType: string;
23
+ /** Base64, with no `data:` prefix. */
24
+ data?: string;
25
+ url?: string;
26
+ text?: string;
27
+ }
28
+
15
29
  /**
16
30
  * One transcript message of the in-page agent wire (`use-agentic`'s WIRE.md), typed at both ends independently —
17
31
  * the wire is the contract, so the server never imports the client package.
@@ -19,6 +33,7 @@ export interface AgentWireToolResult {
19
33
  export interface AgentWireMessage {
20
34
  role: "user" | "assistant" | "tool";
21
35
  text?: string;
36
+ attachments?: AgentWireAttachment[];
22
37
  toolCalls?: AgentWireToolCall[];
23
38
  toolResults?: AgentWireToolResult[];
24
39
  error?: string;
@@ -62,6 +77,22 @@ export interface LlmAdaptor {
62
77
  * answer. An adapter may ignore it — the caller treats zero reported deltas as "answered whole".
63
78
  */
64
79
  chat(request: LlmTurnRequest, onDelta?: (delta: string) => void): Promise<LlmTurnAnswer | null>;
80
+ /** Which attachment carriers this provider's model can read. Omitted means text only. */
81
+ readonly accepts?: LlmAccepts;
82
+ }
83
+
84
+ /**
85
+ * What an adaptor's model reads beyond text. Declared rather than defaulted to true, because the failure of
86
+ * guessing wrong is the worst one available: a provider handed bytes it cannot decode either rejects the whole
87
+ * turn or accepts it having seen nothing, and the model then answers confidently about a file it never read.
88
+ * `AgentService` degrades what is not accepted into a note the model can repeat back, so a text-only provider
89
+ * needs no attachment code at all — which is every provider until somebody swaps one in for vision.
90
+ */
91
+ export interface LlmAccepts {
92
+ /** Inlined or linked image bytes. */
93
+ image?: boolean;
94
+ /** Non-image bytes handed over whole — a PDF the model parses itself. */
95
+ document?: boolean;
65
96
  }
66
97
 
67
98
  /**
@@ -1,7 +1,7 @@
1
1
  import { Translator } from "akanjs/client";
2
2
  import { parseAkanI18nEnv } from "akanjs/common";
3
3
  import { FetchClient } from "akanjs/fetch";
4
- import type { PromptContent, PromptResult, SerializedSignal } from "akanjs/signal";
4
+ import type { PromptContent, PromptMessage, PromptResult, SerializedSignal } from "akanjs/signal";
5
5
  import type { ChatMessage } from "../../vendor/use-agentic";
6
6
 
7
7
  export interface AgentPrompt {
@@ -59,7 +59,25 @@ export class AgentPrompts {
59
59
  /** The messages a prompt returns become the user's turn, the way an MCP client sends a `prompts/get` result. */
60
60
  static messagesOf(result: PromptResult): ChatMessage[] {
61
61
  if (typeof result === "string") return [{ role: "user", text: result }];
62
- return result.map((message) => ({ role: message.role, text: AgentPrompts.textOf(message.content) }));
62
+ return result.map((message) => AgentPrompts.#messageOf(message));
63
+ }
64
+
65
+ /**
66
+ * A binary block becomes an attachment. It used to become the string `[image]`, which a model reads as having
67
+ * been shown a picture — so a prompt built with `Msg.imageOf` produced confident answers about bytes that never
68
+ * left the server. The other block types are text already and stay text.
69
+ */
70
+ static #messageOf(message: PromptMessage): ChatMessage {
71
+ const { role, content } = message;
72
+ if (content.type !== "image" && content.type !== "audio") return { role, text: AgentPrompts.textOf(content) };
73
+ const name = AgentPrompts.#binaryName(content.mimeType);
74
+ return { role, attachments: [{ name, mimeType: content.mimeType, data: content.data }] };
75
+ }
76
+
77
+ /** `Msg.image` carries no filename — the protocol has nowhere to put one — so the type is the label. */
78
+ static #binaryName(mimeType: string) {
79
+ const [kind, subtype] = mimeType.split("/");
80
+ return subtype ? `${kind}.${subtype.split("+")[0]}` : mimeType;
63
81
  }
64
82
 
65
83
  static textOf(content: PromptContent): string {
@@ -1 +1 @@
1
- export declare const baseDictionary: import("./dictInfo.d.ts").ServiceDictInfo<[string, string], "ping" | "pingBody" | "pingParam" | "pingQuery" | "wsPing" | "pubsubPing", never, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view">;
1
+ export declare const baseDictionary: import("./dictInfo.d.ts").ServiceDictInfo<[string, string], "ping" | "pingBody" | "pingParam" | "pingQuery" | "wsPing" | "pubsubPing", never, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentAttach" | "agentAttachRemove" | "agentAttachTooLarge" | "agentAttachUnsupported" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view">;
@@ -1,13 +1,13 @@
1
1
  import type { AgentEndpoint, AgentTurn, BaseEndpoint } from "akanjs/signal";
2
2
  export declare const dictionary: {
3
- base: import("./locale.d.ts").DictModule<import("./locale.d.ts").ServiceTranslatorKey<"base", BaseEndpoint, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view">, never>;
3
+ base: import("./locale.d.ts").DictModule<import("./locale.d.ts").ServiceTranslatorKey<"base", BaseEndpoint, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentAttach" | "agentAttachRemove" | "agentAttachTooLarge" | "agentAttachUnsupported" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view">, never>;
4
4
  agentTurn: import("./locale.d.ts").DictModule<import("./locale.d.ts").ScalarTranslatorKey<"agentTurn", AgentTurn, never> | `agentStop.${string}` | `agentStop.${string}.desc`, never>;
5
5
  agent: import("./locale.d.ts").DictModule<import("./locale.d.ts").ServiceTranslatorKey<"agent", AgentEndpoint, never>, "agent.error.llmUnavailable" | "agent.error.deepseekRequestFailed">;
6
6
  };
7
- export declare const Err: import("./trans.d.ts").ErrConstructor<"agent.error.llmUnavailable" | "agent.error.deepseekRequestFailed">, translate: (lang: "en" | "ko" | "zhChs" | "zhCht" | "ja", key: import("./locale.d.ts").ServiceTranslatorKey<"base", BaseEndpoint, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view"> | import("./locale.d.ts").ScalarTranslatorKey<"agentTurn", AgentTurn, never> | `agentStop.${string}` | `agentStop.${string}.desc` | import("./locale.d.ts").ServiceTranslatorKey<"agent", AgentEndpoint, never>, data?: import("./trans.d.ts").TranslationData) => string, msg: {
8
- info: (key: import("./locale.d.ts").ServiceTranslatorKey<"base", BaseEndpoint, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view"> | import("./locale.d.ts").ScalarTranslatorKey<"agentTurn", AgentTurn, never> | `agentStop.${string}` | `agentStop.${string}.desc` | import("./locale.d.ts").ServiceTranslatorKey<"agent", AgentEndpoint, never>, option?: import("./trans.d.ts").TransMessageOption) => void;
9
- success: (key: import("./locale.d.ts").ServiceTranslatorKey<"base", BaseEndpoint, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view"> | import("./locale.d.ts").ScalarTranslatorKey<"agentTurn", AgentTurn, never> | `agentStop.${string}` | `agentStop.${string}.desc` | import("./locale.d.ts").ServiceTranslatorKey<"agent", AgentEndpoint, never>, option?: import("./trans.d.ts").TransMessageOption) => void;
10
- error: (key: import("./locale.d.ts").ServiceTranslatorKey<"base", BaseEndpoint, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view"> | import("./locale.d.ts").ScalarTranslatorKey<"agentTurn", AgentTurn, never> | `agentStop.${string}` | `agentStop.${string}.desc` | import("./locale.d.ts").ServiceTranslatorKey<"agent", AgentEndpoint, never>, option?: import("./trans.d.ts").TransMessageOption) => void;
11
- warning: (key: import("./locale.d.ts").ServiceTranslatorKey<"base", BaseEndpoint, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view"> | import("./locale.d.ts").ScalarTranslatorKey<"agentTurn", AgentTurn, never> | `agentStop.${string}` | `agentStop.${string}.desc` | import("./locale.d.ts").ServiceTranslatorKey<"agent", AgentEndpoint, never>, option?: import("./trans.d.ts").TransMessageOption) => void;
12
- loading: (key: import("./locale.d.ts").ServiceTranslatorKey<"base", BaseEndpoint, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view"> | import("./locale.d.ts").ScalarTranslatorKey<"agentTurn", AgentTurn, never> | `agentStop.${string}` | `agentStop.${string}.desc` | import("./locale.d.ts").ServiceTranslatorKey<"agent", AgentEndpoint, never>, option?: import("./trans.d.ts").TransMessageOption) => void;
13
- }, getDictionary: (lang: "en" | "ko" | "zhChs" | "zhCht" | "ja") => object, getAllDictionary: () => import("./trans.d.ts").RootDictionary, __Dict_Key__: import("./locale.d.ts").ServiceTranslatorKey<"base", BaseEndpoint, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view"> | import("./locale.d.ts").ScalarTranslatorKey<"agentTurn", AgentTurn, never> | `agentStop.${string}` | `agentStop.${string}.desc` | import("./locale.d.ts").ServiceTranslatorKey<"agent", AgentEndpoint, never>, __Error_Key__: "agent.error.llmUnavailable" | "agent.error.deepseekRequestFailed";
7
+ export declare const Err: import("./trans.d.ts").ErrConstructor<"agent.error.llmUnavailable" | "agent.error.deepseekRequestFailed">, translate: (lang: "en" | "ko" | "zhChs" | "zhCht" | "ja", key: import("./locale.d.ts").ServiceTranslatorKey<"base", BaseEndpoint, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentAttach" | "agentAttachRemove" | "agentAttachTooLarge" | "agentAttachUnsupported" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view"> | import("./locale.d.ts").ScalarTranslatorKey<"agentTurn", AgentTurn, never> | `agentStop.${string}` | `agentStop.${string}.desc` | import("./locale.d.ts").ServiceTranslatorKey<"agent", AgentEndpoint, never>, data?: import("./trans.d.ts").TranslationData) => string, msg: {
8
+ info: (key: import("./locale.d.ts").ServiceTranslatorKey<"base", BaseEndpoint, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentAttach" | "agentAttachRemove" | "agentAttachTooLarge" | "agentAttachUnsupported" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view"> | import("./locale.d.ts").ScalarTranslatorKey<"agentTurn", AgentTurn, never> | `agentStop.${string}` | `agentStop.${string}.desc` | import("./locale.d.ts").ServiceTranslatorKey<"agent", AgentEndpoint, never>, option?: import("./trans.d.ts").TransMessageOption) => void;
9
+ success: (key: import("./locale.d.ts").ServiceTranslatorKey<"base", BaseEndpoint, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentAttach" | "agentAttachRemove" | "agentAttachTooLarge" | "agentAttachUnsupported" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view"> | import("./locale.d.ts").ScalarTranslatorKey<"agentTurn", AgentTurn, never> | `agentStop.${string}` | `agentStop.${string}.desc` | import("./locale.d.ts").ServiceTranslatorKey<"agent", AgentEndpoint, never>, option?: import("./trans.d.ts").TransMessageOption) => void;
10
+ error: (key: import("./locale.d.ts").ServiceTranslatorKey<"base", BaseEndpoint, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentAttach" | "agentAttachRemove" | "agentAttachTooLarge" | "agentAttachUnsupported" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view"> | import("./locale.d.ts").ScalarTranslatorKey<"agentTurn", AgentTurn, never> | `agentStop.${string}` | `agentStop.${string}.desc` | import("./locale.d.ts").ServiceTranslatorKey<"agent", AgentEndpoint, never>, option?: import("./trans.d.ts").TransMessageOption) => void;
11
+ warning: (key: import("./locale.d.ts").ServiceTranslatorKey<"base", BaseEndpoint, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentAttach" | "agentAttachRemove" | "agentAttachTooLarge" | "agentAttachUnsupported" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view"> | import("./locale.d.ts").ScalarTranslatorKey<"agentTurn", AgentTurn, never> | `agentStop.${string}` | `agentStop.${string}.desc` | import("./locale.d.ts").ServiceTranslatorKey<"agent", AgentEndpoint, never>, option?: import("./trans.d.ts").TransMessageOption) => void;
12
+ loading: (key: import("./locale.d.ts").ServiceTranslatorKey<"base", BaseEndpoint, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentAttach" | "agentAttachRemove" | "agentAttachTooLarge" | "agentAttachUnsupported" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view"> | import("./locale.d.ts").ScalarTranslatorKey<"agentTurn", AgentTurn, never> | `agentStop.${string}` | `agentStop.${string}.desc` | import("./locale.d.ts").ServiceTranslatorKey<"agent", AgentEndpoint, never>, option?: import("./trans.d.ts").TransMessageOption) => void;
13
+ }, getDictionary: (lang: "en" | "ko" | "zhChs" | "zhCht" | "ja") => object, getAllDictionary: () => import("./trans.d.ts").RootDictionary, __Dict_Key__: import("./locale.d.ts").ServiceTranslatorKey<"base", BaseEndpoint, "error" | "remove" | "save" | "refresh" | "password" | "stop" | "send" | "skip" | "agent" | "ok" | "connecting" | "new" | "somethingWrong" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "success" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "unauthorized" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentAnswer" | "agentAttach" | "agentAttachRemove" | "agentAttachTooLarge" | "agentAttachUnsupported" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "approve" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "edit" | "view"> | import("./locale.d.ts").ScalarTranslatorKey<"agentTurn", AgentTurn, never> | `agentStop.${string}` | `agentStop.${string}.desc` | import("./locale.d.ts").ServiceTranslatorKey<"agent", AgentEndpoint, never>, __Error_Key__: "agent.error.llmUnavailable" | "agent.error.deepseekRequestFailed";
@@ -1,4 +1,4 @@
1
- import type { LlmTurnRequest } from "./predefinedAdaptor/llm.adaptor";
1
+ import type { LlmAccepts, LlmTurnRequest } from "./predefinedAdaptor/llm.adaptor";
2
2
  declare const AgentService_base: import("./serve.d.ts").ServiceCls<"agent", {}, {
3
3
  llm: import("./injectInfo.d.ts").InjectInfo<"plug", import("./predefinedAdaptor.d.ts").LlmAdaptor, never, never>;
4
4
  }>;
@@ -8,5 +8,17 @@ export declare class AgentService extends AgentService_base {
8
8
  toolCalls: import("./predefinedAdaptor.d.ts").AgentWireToolCall[];
9
9
  stop: "end" | "toolUse";
10
10
  }>;
11
+ /**
12
+ * Replaces every attachment the provider cannot read with a note naming it, so no adaptor has to think about
13
+ * attachments it does not support and none can lose one quietly. The model has to be *told*, not merely spared:
14
+ * a file that vanishes on the way in is one it answers about from the filename, confidently and wrongly.
15
+ *
16
+ * The note rides in the message text because that is the one field every provider mapping already reads.
17
+ */
18
+ static readable(request: LlmTurnRequest, accepts: LlmAccepts | undefined): LlmTurnRequest;
19
+ private static readableMessage;
20
+ /** Extracted text is readable by every model there is; bytes and links need the provider to say so. */
21
+ private static isReadable;
22
+ private static note;
11
23
  }
12
24
  export {};
@@ -57,6 +57,12 @@ export declare class DeepseekLlm extends DeepseekLlm_base implements LlmAdaptor
57
57
  /** Context rides below the instructions framed as data — screen state must never read as directives. */
58
58
  static systemPrompt({ instructions, context }: LlmTurnRequest): string;
59
59
  static providerMessages(message: AgentWireMessage): DeepseekMessage[];
60
+ /**
61
+ * `accepts` is left undeclared, so by the time an attachment reaches here `AgentService.readable` has reduced it
62
+ * to its text and turned everything else into a note. Each block is labelled because a model handed two
63
+ * unlabelled documents can no longer cite either one.
64
+ */
65
+ static userContent(message: AgentWireMessage): string;
60
66
  static turnAnswer(answer: DeepseekAnswer): LlmTurnAnswer;
61
67
  /** The provider sends arguments as a JSON string; an unparsable one becomes an empty call rather than a crash. */
62
68
  static parsedArgs(raw: string | undefined): Record<string, unknown>;
@@ -10,6 +10,19 @@ export interface AgentWireToolResult {
10
10
  changes?: unknown[];
11
11
  error?: string;
12
12
  }
13
+ /**
14
+ * A file the caller attached to one message. Exactly one carrier reaches the model — `data` as inlined bytes, `url`
15
+ * as something the provider fetches, `text` as content already extracted — and which of them a given provider can
16
+ * read is what `LlmAccepts` answers.
17
+ */
18
+ export interface AgentWireAttachment {
19
+ name: string;
20
+ mimeType: string;
21
+ /** Base64, with no `data:` prefix. */
22
+ data?: string;
23
+ url?: string;
24
+ text?: string;
25
+ }
13
26
  /**
14
27
  * One transcript message of the in-page agent wire (`use-agentic`'s WIRE.md), typed at both ends independently —
15
28
  * the wire is the contract, so the server never imports the client package.
@@ -17,6 +30,7 @@ export interface AgentWireToolResult {
17
30
  export interface AgentWireMessage {
18
31
  role: "user" | "assistant" | "tool";
19
32
  text?: string;
33
+ attachments?: AgentWireAttachment[];
20
34
  toolCalls?: AgentWireToolCall[];
21
35
  toolResults?: AgentWireToolResult[];
22
36
  error?: string;
@@ -55,6 +69,21 @@ export interface LlmAdaptor {
55
69
  * answer. An adapter may ignore it — the caller treats zero reported deltas as "answered whole".
56
70
  */
57
71
  chat(request: LlmTurnRequest, onDelta?: (delta: string) => void): Promise<LlmTurnAnswer | null>;
72
+ /** Which attachment carriers this provider's model can read. Omitted means text only. */
73
+ readonly accepts?: LlmAccepts;
74
+ }
75
+ /**
76
+ * What an adaptor's model reads beyond text. Declared rather than defaulted to true, because the failure of
77
+ * guessing wrong is the worst one available: a provider handed bytes it cannot decode either rejects the whole
78
+ * turn or accepts it having seen nothing, and the model then answers confidently about a file it never read.
79
+ * `AgentService` degrades what is not accepted into a note the model can repeat back, so a text-only provider
80
+ * needs no attachment code at all — which is every provider until somebody swaps one in for vision.
81
+ */
82
+ export interface LlmAccepts {
83
+ /** Inlined or linked image bytes. */
84
+ image?: boolean;
85
+ /** Non-image bytes handed over whole — a PDF the model parses itself. */
86
+ document?: boolean;
58
87
  }
59
88
  /**
60
89
  * Settings for whichever adaptor fills `LlmAdaptorRole`, registered with `option.setLlm(...)` and injected as the
@@ -0,0 +1,16 @@
1
+ import type { MessageAttachment } from "../../vendor/use-agentic.d.ts";
2
+ interface AttachProps {
3
+ className?: string;
4
+ label: string;
5
+ onPick: (files: File[]) => void;
6
+ }
7
+ export declare const Attach: ({ className, label, onPick }: AttachProps) => import("react/jsx-runtime").JSX.Element;
8
+ interface ChipsProps {
9
+ className?: string;
10
+ attachments: readonly MessageAttachment[];
11
+ /** Omitted for a sent message: what is already on the wire cannot be taken back. */
12
+ onRemove?: (index: number) => void;
13
+ removeLabel?: string;
14
+ }
15
+ export declare const Chips: ({ className, attachments, onRemove, removeLabel }: ChipsProps) => import("react/jsx-runtime").JSX.Element;
16
+ export {};
@@ -1,5 +1,6 @@
1
1
  import { type ReactNode } from "react";
2
2
  import { type AgentRunner } from "../../vendor/use-agentic.d.ts";
3
+ import { type AttachReader } from "./attachment.d.ts";
3
4
  import { type PersistOption } from "./sessionHistory.d.ts";
4
5
  export interface ChatProps {
5
6
  className?: string;
@@ -14,6 +15,13 @@ export interface ChatProps {
14
15
  persist?: PersistOption;
15
16
  /** Renders in the page flow instead of floating above it — a zone chat that lives inside its own section. */
16
17
  inline?: boolean;
18
+ /**
19
+ * Reads a file the user attached into an attachment, or answers `null` to leave it to the built-in reader
20
+ * (images as bytes, text as text). This is where an app puts what needs a parser — a PDF's text, a spreadsheet's
21
+ * cells — since the framework carries attachments but depends on nothing that can extract one. It runs before
22
+ * the built-in, so it can also replace how an image is prepared.
23
+ */
24
+ attach?: AttachReader;
17
25
  }
18
26
  /**
19
27
  * The user-facing half of the in-page agent: one floating chat wired to the same surface the dock inspects.
@@ -22,6 +30,6 @@ export interface ChatProps {
22
30
  * no history). An enclosing AgentProvider's session wins, which is how an app isolates a surface or swaps the
23
31
  * loop while keeping this UI.
24
32
  */
25
- export declare const DefaultChat: ({ className, title, instructions, runner, maxTurns, defaultOpen, persist, inline, }: ChatProps) => ReactNode;
33
+ export declare const DefaultChat: ({ className, title, instructions, runner, maxTurns, defaultOpen, persist, inline, attach, }: ChatProps) => ReactNode;
26
34
  declare const _default: import("react").ComponentType<ChatProps>;
27
35
  export default _default;
@@ -0,0 +1,23 @@
1
+ import type { MessageAttachment } from "../../vendor/use-agentic.d.ts";
2
+ /** Turns one picked file into an attachment, or `null` to say this reader does not handle that file. */
3
+ export type AttachReader = (file: File) => Promise<MessageAttachment | null>;
4
+ /**
5
+ * Per-file ceiling for the composer. The bytes ride inside one turn's JSON request, so a file past this is not a
6
+ * slow attachment — it is a request the relay and the provider both refuse, and refusing it here is the only place
7
+ * the user learns which file it was.
8
+ */
9
+ export declare const maxAttachmentBytes: number;
10
+ export type AttachFailure = "tooLarge" | "unsupported";
11
+ export declare class Attachment {
12
+ #private;
13
+ /**
14
+ * Reads what a browser can read on its own — an image as bytes, a text-ish file as text — and hands everything
15
+ * else to the app's own reader. That split is the layer boundary: a PDF needs a parser, so extracting one is the
16
+ * app's business (`attach`), while carrying the result is the framework's.
17
+ *
18
+ * The app's reader runs first so it can also replace the built-in handling, which is what downscaling an image
19
+ * before it costs a megabyte of prompt looks like.
20
+ */
21
+ static read(file: File, attach?: AttachReader): Promise<MessageAttachment | AttachFailure>;
22
+ static failure(value: MessageAttachment | AttachFailure): value is AttachFailure;
23
+ }
@@ -14,8 +14,10 @@ export interface DropdownProps {
14
14
  dropdownClassName?: string;
15
15
  /** Trigger edge the menu lines up with. Position is computed, so a `left-0` class cannot do this. */
16
16
  align?: "start" | "end";
17
+ /** Names this dropdown for the in-page agent. Without it the menu publishes nothing — two on one screen would share a name. */
18
+ namespace?: string;
17
19
  }
18
- export declare const DefaultDropdown: ({ value, content, className, buttonClassName, dropdownClassName, align, }: DropdownProps) => import("react/jsx-runtime").JSX.Element;
20
+ export declare const DefaultDropdown: ({ value, content, className, buttonClassName, dropdownClassName, align, namespace, }: DropdownProps) => import("react/jsx-runtime").JSX.Element;
19
21
  /**
20
22
  * Dropdown. Resolves to a route-scoped override when a `page/**\/_overrides.tsx`
21
23
  * in the route's ancestry declares one, otherwise renders {@link DefaultDropdown}.
@@ -1,4 +1,5 @@
1
1
  export { Agent } from "./Agent.d.ts";
2
+ export { type AttachReader, maxAttachmentBytes } from "./Agent/attachment.d.ts";
2
3
  export { agentAttrs } from "./agentAttrs.d.ts";
3
4
  export { animated } from "./animated.d.ts";
4
5
  export { Badge } from "./Badge.d.ts";
@@ -101,9 +101,32 @@ export interface ToolCallResult {
101
101
  changes?: ResourceDiff[];
102
102
  error?: string;
103
103
  }
104
+ /**
105
+ * A file the user handed the conversation rather than the screen — which is why it rides a message instead of a
106
+ * tool, the same reason `askUser` belongs to the session and not to the surface.
107
+ *
108
+ * Three carriers, one of which every attachment must have: `data` inlines the bytes, `url` points at something the
109
+ * provider can fetch, and `text` is content somebody already extracted — the only form a text-only model can read.
110
+ * They mirror what the server's own `Msg.image` / `Msg.link` / `Msg.resource` builders produce, so a prompt's
111
+ * attachment and a user's are the same thing on the wire.
112
+ *
113
+ * Whether a given carrier reaches the model is the provider's answer, not this type's: a backend drops what its
114
+ * model cannot read and says so in the transcript, because a silently dropped file is one the model then
115
+ * hallucinates about.
116
+ */
117
+ export interface MessageAttachment {
118
+ name: string;
119
+ mimeType: string;
120
+ /** Base64, with no `data:` prefix. */
121
+ data?: string;
122
+ url?: string;
123
+ text?: string;
124
+ }
104
125
  export interface ChatMessage {
105
126
  role: ChatRole;
106
127
  text?: string;
128
+ /** Files the message carries. Content, not instructions — a backend frames them the way it frames context. */
129
+ attachments?: MessageAttachment[];
107
130
  toolCalls?: ToolCallRequest[];
108
131
  toolResults?: ToolCallResult[];
109
132
  /** A failed or capped turn, recorded in the transcript rather than thrown past it. */
@@ -0,0 +1,77 @@
1
+ "use client";
2
+ import { cn } from "akanjs/client";
3
+ import { useRef } from "react";
4
+ import { AiOutlineClose, AiOutlinePaperClip } from "react-icons/ai";
5
+ import type { MessageAttachment } from "../../vendor/use-agentic";
6
+
7
+ interface AttachProps {
8
+ className?: string;
9
+ label: string;
10
+ onPick: (files: File[]) => void;
11
+ }
12
+
13
+ export const Attach = ({ className, label, onPick }: AttachProps) => {
14
+ const ref = useRef<HTMLInputElement>(null);
15
+ return (
16
+ <>
17
+ <button
18
+ aria-label={label}
19
+ className={cn("shrink-0 text-foreground/50 hover:text-foreground", className)}
20
+ onClick={() => ref.current?.click()}
21
+ title={label}
22
+ type="button"
23
+ >
24
+ <AiOutlinePaperClip />
25
+ </button>
26
+ <input
27
+ className="hidden"
28
+ multiple
29
+ onChange={(event) => {
30
+ onPick([...(event.target.files ?? [])]);
31
+
32
+ event.target.value = "";
33
+ }}
34
+ ref={ref}
35
+ type="file"
36
+ />
37
+ </>
38
+ );
39
+ };
40
+
41
+ interface ChipsProps {
42
+ className?: string;
43
+ attachments: readonly MessageAttachment[];
44
+ /** Omitted for a sent message: what is already on the wire cannot be taken back. */
45
+ onRemove?: (index: number) => void;
46
+ removeLabel?: string;
47
+ }
48
+
49
+ export const Chips = ({ className, attachments, onRemove, removeLabel }: ChipsProps) => (
50
+ <div className={cn("flex flex-wrap gap-1", className)}>
51
+ {attachments.map((attachment, idx) => (
52
+ <span
53
+ className="flex items-center gap-1 rounded-field bg-muted px-2 py-0.5 text-xs"
54
+ key={`${attachment.name}-${idx}`}
55
+ >
56
+ {attachment.data && attachment.mimeType.startsWith("image/") ? (
57
+ <img
58
+ alt={attachment.name}
59
+ className="size-6 rounded-field object-cover"
60
+ src={`data:${attachment.mimeType};base64,${attachment.data}`}
61
+ />
62
+ ) : null}
63
+ <span className="max-w-32 truncate">{attachment.name}</span>
64
+ {onRemove ? (
65
+ <button
66
+ aria-label={removeLabel}
67
+ className="text-foreground/40 hover:text-foreground"
68
+ onClick={() => onRemove(idx)}
69
+ type="button"
70
+ >
71
+ <AiOutlineClose />
72
+ </button>
73
+ ) : null}
74
+ </span>
75
+ ))}
76
+ </div>
77
+ );
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { cn } from "akanjs/client";
3
3
  import { type AgentProgressReport, AgentSession, type ChatMessage, type ToolCallResult } from "../../vendor/use-agentic";
4
+ import { Chips } from "./Attach";
4
5
  import Markdown from "./Markdown";
5
6
 
6
7
  interface BubbleProps {
@@ -90,13 +91,11 @@ export default function Bubble({ className, message, progress, results }: Bubble
90
91
  );
91
92
  if (message.role === "user")
92
93
  return (
93
- <div
94
- className={cn(
95
- "max-w-[85%] self-end whitespace-pre-wrap rounded-box bg-primary/10 px-3 py-2 text-sm",
96
- className,
97
- )}
98
- >
99
- {message.text}
94
+ <div className={cn("flex max-w-[85%] flex-col items-end gap-1 self-end", className)}>
95
+ {message.attachments?.length ? <Chips attachments={message.attachments} className="justify-end" /> : null}
96
+ {message.text ? (
97
+ <p className="whitespace-pre-wrap rounded-box bg-primary/10 px-3 py-2 text-sm">{message.text}</p>
98
+ ) : null}
100
99
  </div>
101
100
  );
102
101
  const isDrafting = !message.text && !message.toolCalls?.length && !message.error;
package/ui/Agent/Chat.tsx CHANGED
@@ -5,11 +5,13 @@ import { AgentContext, type AgentPrompt, AgentPrompts, ensureStoreSurface, Scree
5
5
  import { type ReactNode, useContext, useEffect, useRef, useState, useSyncExternalStore } from "react";
6
6
  import { createPortal } from "react-dom";
7
7
  import { AiOutlineClear, AiOutlineClose } from "react-icons/ai";
8
- import { type AgentRunner, AgentSession, SessionContext } from "../../vendor/use-agentic";
8
+ import { type AgentRunner, AgentSession, type MessageAttachment, SessionContext } from "../../vendor/use-agentic";
9
9
  import { Button } from "../Button";
10
10
  import { inputRecipe } from "../recipe";
11
11
  import { createOverridable } from "../UiOverride";
12
12
  import Approval from "./Approval";
13
+ import { Attach, Chips } from "./Attach";
14
+ import { Attachment, type AttachReader } from "./attachment";
13
15
  import Bubble from "./Bubble";
14
16
  import { type ChatCommand, ChatCommands } from "./ChatCommands";
15
17
  import { fetchRunner } from "./fetchRunner";
@@ -30,6 +32,13 @@ export interface ChatProps {
30
32
  persist?: PersistOption;
31
33
  /** Renders in the page flow instead of floating above it — a zone chat that lives inside its own section. */
32
34
  inline?: boolean;
35
+ /**
36
+ * Reads a file the user attached into an attachment, or answers `null` to leave it to the built-in reader
37
+ * (images as bytes, text as text). This is where an app puts what needs a parser — a PDF's text, a spreadsheet's
38
+ * cells — since the framework carries attachments but depends on nothing that can extract one. It runs before
39
+ * the built-in, so it can also replace how an image is prepared.
40
+ */
41
+ attach?: AttachReader;
33
42
  }
34
43
 
35
44
  const isApplePlatform = () => /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
@@ -52,6 +61,7 @@ export const DefaultChat = ({
52
61
  defaultOpen = false,
53
62
  persist,
54
63
  inline = false,
64
+ attach,
55
65
  }: ChatProps) => {
56
66
  const { l } = usePage();
57
67
  const provided = useContext(SessionContext);
@@ -76,6 +86,7 @@ export const DefaultChat = ({
76
86
  );
77
87
  const [open, setOpen] = useState(defaultOpen);
78
88
  const [draft, setDraft] = useState("");
89
+ const [attached, setAttached] = useState<MessageAttachment[]>([]);
79
90
  const sent = useRef<string[]>([]);
80
91
  const stashed = useRef("");
81
92
  const [recall, setRecall] = useState(0);
@@ -128,6 +139,21 @@ export const DefaultChat = ({
128
139
  session.report(`/${prompt.name} failed: ${error instanceof Error ? error.message : String(error)}`);
129
140
  }
130
141
  };
142
+ /** Staged one at a time so one unreadable file names itself instead of failing the whole drop silently. */
143
+ const attachFiles = async (files: File[]) => {
144
+ for (const file of files) {
145
+ try {
146
+ const read = await Attachment.read(file, attach);
147
+ if (!Attachment.failure(read)) setAttached((current) => [...current, read]);
148
+ else
149
+ session.note(
150
+ l(read === "tooLarge" ? "base.agentAttachTooLarge" : "base.agentAttachUnsupported", { name: file.name }),
151
+ );
152
+ } catch (error) {
153
+ session.report(`${file.name}: ${error instanceof Error ? error.message : String(error)}`);
154
+ }
155
+ }
156
+ };
131
157
  const remember = (text: string) => {
132
158
  if (sent.current[sent.current.length - 1] !== text) sent.current = [...sent.current, text].slice(-30);
133
159
  setRecall(0);
@@ -157,10 +183,10 @@ export const DefaultChat = ({
157
183
  };
158
184
  const send = () => {
159
185
  const text = draft.trim();
160
- if (!text) return;
186
+ if (!text && !attached.length) return;
161
187
 
162
188
  const question = session.pendingQuestion;
163
- if (question) {
189
+ if (question && text) {
164
190
  setDraft("");
165
191
  question.answer(question.multiple ? [text] : text);
166
192
  return;
@@ -175,12 +201,17 @@ export const DefaultChat = ({
175
201
  if (session.isRunning) return;
176
202
  const prompt = command ? prompts.current?.find(command.name) : null;
177
203
  setDraft("");
178
- remember(text);
204
+ if (text) remember(text);
179
205
  if (command && prompt) {
180
206
  void runPrompt(prompt, command.args);
181
207
  return;
182
208
  }
183
- void session.send(text);
209
+ if (!attached.length) {
210
+ void session.send(text);
211
+ return;
212
+ }
213
+ setAttached([]);
214
+ void session.send([{ role: "user", ...(text ? { text } : {}), attachments: attached }]);
184
215
  };
185
216
  const query = /^\/[A-Za-z0-9_-]*$/.test(draft) ? draft : "";
186
217
  const commandMenu = query ? ChatCommands.list(l).filter((command) => `/${command.name}`.startsWith(query)) : [];
@@ -241,6 +272,11 @@ export const DefaultChat = ({
241
272
  !inline && floatingLayer,
242
273
  className,
243
274
  )}
275
+ onDragOver={(event) => event.preventDefault()}
276
+ onDrop={(event) => {
277
+ event.preventDefault();
278
+ void attachFiles([...event.dataTransfer.files]);
279
+ }}
244
280
  >
245
281
  <header className="flex items-center gap-2 border-foreground/5 border-b px-4 py-3">
246
282
  <span className="font-semibold text-sm">{title ?? l("base.agent")}</span>
@@ -278,34 +314,50 @@ export const DefaultChat = ({
278
314
  <Question key={session.pendingQuestion.callId} question={session.pendingQuestion} />
279
315
  ) : null}
280
316
  <Menu commands={commandMenu} onCommand={runCommand} onPrompt={pick} prompts={promptMenu} />
281
- <div className="flex items-center gap-2 border-foreground/5 border-t p-3">
282
- <input
283
- className={inputRecipe({ size: "sm" }, "flex-1")}
284
- onChange={(event) => setDraft(event.target.value)}
285
- ref={inputRef}
286
- onKeyDown={(event) => {
287
-
288
- if ((event.key === "ArrowUp" || event.key === "ArrowDown") && sent.current.length) {
317
+ <div className="flex flex-col gap-2 border-foreground/5 border-t p-3">
318
+ {attached.length ? (
319
+ <Chips
320
+ attachments={attached}
321
+ onRemove={(idx) => setAttached((current) => current.filter((_, at) => at !== idx))}
322
+ removeLabel={l("base.agentAttachRemove")}
323
+ />
324
+ ) : null}
325
+ <div className="flex items-center gap-2">
326
+ <Attach label={l("base.agentAttach")} onPick={(files) => void attachFiles(files)} />
327
+ <input
328
+ className={inputRecipe({ size: "sm" }, "flex-1")}
329
+ onChange={(event) => setDraft(event.target.value)}
330
+ ref={inputRef}
331
+ onKeyDown={(event) => {
332
+
333
+ if ((event.key === "ArrowUp" || event.key === "ArrowDown") && sent.current.length) {
334
+ event.preventDefault();
335
+ step(event.key === "ArrowUp" ? 1 : -1);
336
+ return;
337
+ }
338
+ if (event.key !== "Enter" || event.nativeEvent.isComposing) return;
289
339
  event.preventDefault();
290
- step(event.key === "ArrowUp" ? 1 : -1);
291
- return;
292
- }
293
- if (event.key !== "Enter" || event.nativeEvent.isComposing) return;
294
- event.preventDefault();
295
- send();
296
- }}
297
- placeholder={session.pendingQuestion ? l("base.agentAnswer") : l("base.agentPlaceholder")}
298
- value={draft}
299
- />
300
- {session.isRunning && !session.pendingQuestion ? (
301
- <Button onClick={session.abort} size="sm" variant="outline">
302
- {l("base.stop")}
303
- </Button>
304
- ) : (
305
- <Button disabled={!draft.trim()} onClick={send} size="sm">
306
- {l("base.send")}
307
- </Button>
308
- )}
340
+ send();
341
+ }}
342
+ onPaste={(event) => {
343
+ const files = [...event.clipboardData.files];
344
+ if (!files.length) return;
345
+ event.preventDefault();
346
+ void attachFiles(files);
347
+ }}
348
+ placeholder={session.pendingQuestion ? l("base.agentAnswer") : l("base.agentPlaceholder")}
349
+ value={draft}
350
+ />
351
+ {session.isRunning && !session.pendingQuestion ? (
352
+ <Button onClick={session.abort} size="sm" variant="outline">
353
+ {l("base.stop")}
354
+ </Button>
355
+ ) : (
356
+ <Button disabled={!draft.trim() && !attached.length} onClick={send} size="sm">
357
+ {l("base.send")}
358
+ </Button>
359
+ )}
360
+ </div>
309
361
  </div>
310
362
  </aside>,
311
363
  );
@@ -72,6 +72,8 @@ export class ChatCommands {
72
72
  if (message.local) continue;
73
73
  lines.push(`**${message.role}**`);
74
74
  if (message.text) lines.push(message.text);
75
+ for (const attachment of message.attachments ?? [])
76
+ lines.push(`- attached \`${attachment.name}\` (${attachment.mimeType})`);
75
77
  for (const call of message.toolCalls ?? []) lines.push(`- call \`${call.name}\` ${JSON.stringify(call.args)}`);
76
78
  for (const result of message.toolResults ?? [])
77
79
  lines.push(
@@ -0,0 +1,53 @@
1
+ import type { MessageAttachment } from "../../vendor/use-agentic";
2
+
3
+ /** Turns one picked file into an attachment, or `null` to say this reader does not handle that file. */
4
+ export type AttachReader = (file: File) => Promise<MessageAttachment | null>;
5
+
6
+ /**
7
+ * Per-file ceiling for the composer. The bytes ride inside one turn's JSON request, so a file past this is not a
8
+ * slow attachment — it is a request the relay and the provider both refuse, and refusing it here is the only place
9
+ * the user learns which file it was.
10
+ */
11
+ export const maxAttachmentBytes = 4 * 1024 * 1024;
12
+
13
+ export type AttachFailure = "tooLarge" | "unsupported";
14
+
15
+ const textMimes = new Set(["application/json", "application/xml", "application/x-yaml", "application/yaml"]);
16
+
17
+ export class Attachment {
18
+ /**
19
+ * Reads what a browser can read on its own — an image as bytes, a text-ish file as text — and hands everything
20
+ * else to the app's own reader. That split is the layer boundary: a PDF needs a parser, so extracting one is the
21
+ * app's business (`attach`), while carrying the result is the framework's.
22
+ *
23
+ * The app's reader runs first so it can also replace the built-in handling, which is what downscaling an image
24
+ * before it costs a megabyte of prompt looks like.
25
+ */
26
+ static async read(file: File, attach?: AttachReader): Promise<MessageAttachment | AttachFailure> {
27
+ if (file.size > maxAttachmentBytes) return "tooLarge";
28
+ const injected = await attach?.(file);
29
+ if (injected) return injected;
30
+
31
+ const mimeType = file.type.split(";")[0].trim().toLowerCase();
32
+ if (mimeType.startsWith("image/")) return { name: file.name, mimeType, data: await Attachment.#base64(file) };
33
+ if (mimeType.startsWith("text/") || textMimes.has(mimeType))
34
+ return { name: file.name, mimeType: mimeType || "text/plain", text: await file.text() };
35
+ return "unsupported";
36
+ }
37
+
38
+ static failure(value: MessageAttachment | AttachFailure): value is AttachFailure {
39
+ return typeof value === "string";
40
+ }
41
+
42
+ /**
43
+ * Chunked because `String.fromCharCode(...bytes)` spreads one argument per byte, and a megabyte of them is a
44
+ * RangeError rather than a slow call.
45
+ */
46
+ static async #base64(file: File): Promise<string> {
47
+ const bytes = new Uint8Array(await file.arrayBuffer());
48
+ const chunk = 0x8000;
49
+ let binary = "";
50
+ for (let at = 0; at < bytes.length; at += chunk) binary += String.fromCharCode(...bytes.subarray(at, at + chunk));
51
+ return btoa(binary);
52
+ }
53
+ }
@@ -3,6 +3,25 @@ import type { ChatMessage, SessionHistory } from "../../vendor/use-agentic";
3
3
 
4
4
  export type PersistOption = boolean | { storage?: "session" | "local"; key?: string };
5
5
 
6
+ /**
7
+ * Attachment content never reaches storage. Web storage is a few megabytes per origin, one screenshot fills a
8
+ * chunk of it, and `AgentSession` swallows a failed save — so persisting the bytes would quietly stop persisting
9
+ * the transcript itself. The name and type stay so a restored conversation still reads as what happened, and a
10
+ * `url` stays because a pointer is not content; the server then tells the model the content is gone rather than
11
+ * letting it answer from the filename.
12
+ */
13
+ const withoutContent = (message: ChatMessage): ChatMessage =>
14
+ message.attachments?.length
15
+ ? {
16
+ ...message,
17
+ attachments: message.attachments.map(({ name, mimeType, url }) => ({
18
+ name,
19
+ mimeType,
20
+ ...(url ? { url } : {}),
21
+ })),
22
+ }
23
+ : message;
24
+
6
25
  /**
7
26
  * Maps the `persist` prop onto a `SessionHistory` over web storage. Session storage is the default on purpose:
8
27
  * surviving a refresh is the whole ask, and a transcript that dies with the tab never lingers on a shared machine
@@ -24,7 +43,7 @@ export const sessionHistoryOf = (persist: PersistOption | undefined, pathKey = "
24
43
  return parsed.v === version && Array.isArray(parsed.messages) ? parsed.messages : null;
25
44
  },
26
45
  save: (messages) => {
27
- storage.setItem(key, JSON.stringify({ v: version, messages: messages.slice(-cap) }));
46
+ storage.setItem(key, JSON.stringify({ v: version, messages: messages.slice(-cap).map(withoutContent) }));
28
47
  },
29
48
  clear: () => {
30
49
  storage.removeItem(key);
package/ui/Dropdown.tsx CHANGED
@@ -1,8 +1,11 @@
1
1
  "use client";
2
2
  import { cn } from "akanjs/client";
3
+ import { capitalize } from "akanjs/common";
4
+ import { st } from "akanjs/store";
3
5
  import { type ReactNode, useEffect, useId, useRef, useState } from "react";
4
6
  import { createPortal } from "react-dom";
5
7
 
8
+ import { agentAttrs } from "./agentAttrs";
6
9
  import { buttonRecipe } from "./Button";
7
10
  import {
8
11
  isOwnOverlayClick,
@@ -32,6 +35,8 @@ export interface DropdownProps {
32
35
  dropdownClassName?: string;
33
36
  /** Trigger edge the menu lines up with. Position is computed, so a `left-0` class cannot do this. */
34
37
  align?: "start" | "end";
38
+ /** Names this dropdown for the in-page agent. Without it the menu publishes nothing — two on one screen would share a name. */
39
+ namespace?: string;
35
40
  }
36
41
 
37
42
  export const DefaultDropdown = ({
@@ -41,9 +46,11 @@ export const DefaultDropdown = ({
41
46
  buttonClassName,
42
47
  dropdownClassName,
43
48
  align = "end",
49
+ namespace,
44
50
  }: DropdownProps) => {
45
51
  const [opened, setOpened] = useState(false);
46
- const [mounted, setMounted] = useState(false);
52
+
53
+ const [portal, setPortal] = useState<HTMLElement | null>(null);
47
54
  const ref = useRef<HTMLDivElement>(null);
48
55
  const menuRef = useRef<HTMLUListElement>(null);
49
56
  const scope = useOverlayScope(useId());
@@ -52,6 +59,29 @@ export const DefaultDropdown = ({
52
59
 
53
60
  const recipe = useUiRecipe("button") ?? buttonRecipe;
54
61
  const position = useOverlayPosition({ opened, triggerRef: ref, panelRef: menuRef, align });
62
+ const suffix = namespace ? capitalize(namespace) : "";
63
+ st.expose(namespace ? `dropdownIn${suffix}` : null, opened, { desc: "Whether this dropdown menu is showing." });
64
+ const openDropdown = st
65
+ .tool(namespace ? `openDropdownIn${suffix}` : null, {
66
+ desc: `Open the ${namespace ?? ""} dropdown menu.`,
67
+ effect: "state",
68
+ })
69
+ .exec(() => {
70
+ setOpened(true);
71
+ });
72
+ const closeDropdown = st
73
+ .tool(namespace ? `closeDropdownIn${suffix}` : null, {
74
+ desc: `Close the ${namespace ?? ""} dropdown menu.`,
75
+ effect: "state",
76
+ })
77
+ .exec(() => {
78
+ setOpened(false);
79
+ });
80
+
81
+ const toggle = opened ? closeDropdown : openDropdown;
82
+ useEffect(() => {
83
+ setPortal(document.body);
84
+ }, []);
55
85
  useEffect(() => {
56
86
  if (!opened) return;
57
87
  const onMouseDown = (e: MouseEvent) => {
@@ -102,15 +132,15 @@ export const DefaultDropdown = ({
102
132
  aria-haspopup="menu"
103
133
  aria-expanded={opened}
104
134
  className={recipe({ variant: "ghost" }, ["flex", buttonClassName])}
105
- onClick={() => {
106
- setMounted(true);
107
- setOpened((o) => !o);
108
- }}
135
+ onClick={toggle}
136
+ {...agentAttrs(toggle)}
109
137
  >
110
138
  {value}
111
139
  </button>
112
- {/* Hidden rather than unmounted once opened: unmounting takes any overlay a menu item opened down with it. */}
113
- {mounted && typeof document !== "undefined" ? createPortal(menu, document.body) : null}
140
+ {/* Mounted from the first render and hidden while closed: a menu item declares its tool on mount, so an
141
+ unmounted menu publishes nothing an agent could find and unmounting an open one takes any overlay
142
+ a menu item opened down with it. */}
143
+ {portal ? createPortal(menu, portal) : null}
114
144
  </div>
115
145
  );
116
146
  };
package/ui/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { Agent } from "./Agent";
2
+ export { type AttachReader, maxAttachmentBytes } from "./Agent/attachment";
2
3
  export { agentAttrs } from "./agentAttrs";
3
4
  export { animated } from "./animated";
4
5
  export { Badge } from "./Badge";
@@ -237,7 +237,9 @@ export class AgentSession {
237
237
  */
238
238
  retry = async (): Promise<boolean> => {
239
239
  if (this.#active) return false;
240
- const at = this.#messages.findLastIndex((message) => message.role === "user" && !!message.text);
240
+ const at = this.#messages.findLastIndex(
241
+ (message) => message.role === "user" && (!!message.text || !!message.attachments?.length),
242
+ );
241
243
  if (at < 0) return false;
242
244
  const again = this.#messages[at];
243
245
  this.#messages = this.#messages.slice(0, at);
@@ -33,6 +33,24 @@ tools, and the loop stay in the client; the server serves exactly one stateless
33
33
  - `context` blocks are host vocabulary. The server forwards them to the model as data, framed as data.
34
34
  - Everything is JSON-serializable by contract; a tool whose `result` is not is the tool's bug.
35
35
 
36
+ ## Attachments
37
+
38
+ A message may carry files in `attachments` (`MessageAttachment`, `types.ts`) — one of `data` (base64 bytes), `url`,
39
+ or `text` (already-extracted content), plus `name` and `mimeType`:
40
+
41
+ ```jsonc
42
+ { "role": "user", "text": "What does this chart say?",
43
+ "attachments": [{ "name": "q3.png", "mimeType": "image/png", "data": "iVBORw0KG…" }] }
44
+ ```
45
+
46
+ They are content, not instructions, and the server frames them the way it frames `context`. **A backend must not
47
+ silently drop one its model cannot read** — a file the model never saw is a file it invents an answer about. Replace
48
+ it with a note in the message text saying which file was not read and why, so the model can say so and ask for
49
+ another form. `text` is readable by every provider by definition, which is what makes an extracted PDF work against
50
+ a text-only model.
51
+
52
+ Nothing here is stored: the wire carries the bytes for exactly one turn's request.
53
+
36
54
  ## Response
37
55
 
38
56
  `200` with a single JSON object:
@@ -119,9 +119,33 @@ export interface ToolCallResult {
119
119
  error?: string;
120
120
  }
121
121
 
122
+ /**
123
+ * A file the user handed the conversation rather than the screen — which is why it rides a message instead of a
124
+ * tool, the same reason `askUser` belongs to the session and not to the surface.
125
+ *
126
+ * Three carriers, one of which every attachment must have: `data` inlines the bytes, `url` points at something the
127
+ * provider can fetch, and `text` is content somebody already extracted — the only form a text-only model can read.
128
+ * They mirror what the server's own `Msg.image` / `Msg.link` / `Msg.resource` builders produce, so a prompt's
129
+ * attachment and a user's are the same thing on the wire.
130
+ *
131
+ * Whether a given carrier reaches the model is the provider's answer, not this type's: a backend drops what its
132
+ * model cannot read and says so in the transcript, because a silently dropped file is one the model then
133
+ * hallucinates about.
134
+ */
135
+ export interface MessageAttachment {
136
+ name: string;
137
+ mimeType: string;
138
+ /** Base64, with no `data:` prefix. */
139
+ data?: string;
140
+ url?: string;
141
+ text?: string;
142
+ }
143
+
122
144
  export interface ChatMessage {
123
145
  role: ChatRole;
124
146
  text?: string;
147
+ /** Files the message carries. Content, not instructions — a backend frames them the way it frames context. */
148
+ attachments?: MessageAttachment[];
125
149
  toolCalls?: ToolCallRequest[];
126
150
  toolResults?: ToolCallResult[];
127
151
  /** A failed or capped turn, recorded in the transcript rather than thrown past it. */