@neeter/server
Advanced tools
+2
-2
@@ -1,2 +0,2 @@ | ||
| export type { CustomEvent, SSEEvent } from "@neeter/types"; | ||
| export type { Base64ImageSource, ContentBlock, CustomEvent, ImageBlock, SSEEvent, TextBlock, UserMessageContent, } from "@neeter/types"; | ||
| export { createSandboxHook } from "./hooks.js"; | ||
@@ -7,3 +7,3 @@ export { createJsonSessionStore } from "./json-store.js"; | ||
| export { createAgentRouter } from "./router.js"; | ||
| export { type ResumeOptions, type Session, type SessionInit, SessionManager, type SessionManagerOptions, sessionMeta, } from "./session.js"; | ||
| export { extractText, type ResumeOptions, type Session, type SessionInit, SessionManager, type SessionManagerOptions, sessionMeta, } from "./session.js"; | ||
| export { MessageTranslator, sseEncode, streamSession, type TranslatorConfig, } from "./translator.js"; |
+1
-1
@@ -6,3 +6,3 @@ export { createSandboxHook } from "./hooks.js"; | ||
| export { createAgentRouter } from "./router.js"; | ||
| export { SessionManager, sessionMeta, } from "./session.js"; | ||
| export { extractText, SessionManager, sessionMeta, } from "./session.js"; | ||
| export { MessageTranslator, sseEncode, streamSession, } from "./translator.js"; |
+70
-11
| import { Hono } from "hono"; | ||
| import { cors } from "hono/cors"; | ||
| import { sessionMeta } from "./session.js"; | ||
| import { extractText, sessionMeta } from "./session.js"; | ||
| import { sseEncode, streamSession } from "./translator.js"; | ||
| const VALID_IMAGE_MEDIA_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]); | ||
| function validateContentBlocks(blocks) { | ||
| if (blocks.length === 0) | ||
| return { ok: false, error: "Content array must not be empty" }; | ||
| const validated = []; | ||
| for (const block of blocks) { | ||
| const b = block; | ||
| switch (b.type) { | ||
| case "text": { | ||
| if (typeof b.text !== "string" || !b.text.trim()) { | ||
| return { ok: false, error: "Text blocks must have non-empty text" }; | ||
| } | ||
| validated.push({ type: "text", text: b.text }); | ||
| break; | ||
| } | ||
| case "image": { | ||
| const src = b.source; | ||
| if (!src || src.type !== "base64") { | ||
| return { ok: false, error: "Image blocks must have a base64 source" }; | ||
| } | ||
| if (!VALID_IMAGE_MEDIA_TYPES.has(src.media_type)) { | ||
| return { ok: false, error: `Unsupported media type: ${src.media_type}` }; | ||
| } | ||
| if (typeof src.data !== "string" || !src.data) { | ||
| return { ok: false, error: "Image blocks must have non-empty base64 data" }; | ||
| } | ||
| validated.push({ | ||
| type: "image", | ||
| source: { | ||
| type: "base64", | ||
| media_type: src.media_type, | ||
| data: src.data, | ||
| }, | ||
| }); | ||
| break; | ||
| } | ||
| default: | ||
| return { ok: false, error: `Unknown content block type: ${b.type}` }; | ||
| } | ||
| } | ||
| return { ok: true, content: validated }; | ||
| } | ||
| const PERSISTED_EVENTS = new Set([ | ||
@@ -101,6 +143,20 @@ "text_delta", | ||
| const body = await c.req.json(); | ||
| if (!body.text?.trim()) | ||
| return c.json({ error: "Message text required" }, 400); | ||
| const text = body.text.trim(); | ||
| session.pushMessage(text); | ||
| let messageContent; | ||
| if (Array.isArray(body.content)) { | ||
| const result = validateContentBlocks(body.content); | ||
| if (!result.ok) | ||
| return c.json({ error: result.error }, 400); | ||
| messageContent = result.content; | ||
| } | ||
| else if (body.text?.trim()) { | ||
| messageContent = body.text.trim(); | ||
| } | ||
| else { | ||
| return c.json({ error: "Message text or content required" }, 400); | ||
| } | ||
| session.pushMessage(messageContent); | ||
| const text = extractText(messageContent); | ||
| const persistData = { text }; | ||
| if (typeof messageContent !== "string") | ||
| persistData.content = messageContent; | ||
| const store = sessions.getStore(); | ||
@@ -110,3 +166,3 @@ if (store && session.sdkSessionId) { | ||
| meta: sessionMeta(session), | ||
| events: [{ event: "user_message", data: JSON.stringify({ text }) }], | ||
| events: [{ event: "user_message", data: JSON.stringify(persistData) }], | ||
| }); | ||
@@ -116,3 +172,3 @@ } | ||
| const pending = pendingUserMessages.get(session) ?? []; | ||
| pending.push(text); | ||
| pending.push(messageContent); | ||
| pendingUserMessages.set(session, pending); | ||
@@ -134,6 +190,9 @@ } | ||
| if (pending) { | ||
| const userEvents = pending.map((text) => ({ | ||
| event: "user_message", | ||
| data: JSON.stringify({ text }), | ||
| })); | ||
| const userEvents = pending.map((content) => { | ||
| const text = extractText(content); | ||
| const data = { text }; | ||
| if (typeof content !== "string") | ||
| data.content = content; | ||
| return { event: "user_message", data: JSON.stringify(data) }; | ||
| }); | ||
| void store.save(session.sdkSessionId, { | ||
@@ -140,0 +199,0 @@ meta: sessionMeta(session), |
| import { type HookCallbackMatcher, type HookEvent, type McpServerConfig, type Query } from "@anthropic-ai/claude-agent-sdk"; | ||
| import type { SessionHistoryEntry, SessionStore, SSEEvent } from "@neeter/types"; | ||
| import type { SessionHistoryEntry, SessionStore, SSEEvent, UserMessageContent } from "@neeter/types"; | ||
| import { PermissionGate } from "./permission-gate.js"; | ||
| /** Extract the text portion from a `UserMessageContent` value. */ | ||
| export declare function extractText(content: UserMessageContent): string; | ||
| /** Configuration returned by the `SessionManager` factory for each new session. */ | ||
@@ -60,3 +62,3 @@ export interface SessionInit<TCtx> { | ||
| context: TCtx; | ||
| pushMessage(text: string): void; | ||
| pushMessage(content: UserMessageContent): void; | ||
| messageIterator: Query; | ||
@@ -63,0 +65,0 @@ permissionGate: PermissionGate; |
+12
-3
@@ -13,2 +13,11 @@ import { randomUUID } from "node:crypto"; | ||
| } | ||
| /** Extract the text portion from a `UserMessageContent` value. */ | ||
| export function extractText(content) { | ||
| if (typeof content === "string") | ||
| return content; | ||
| return content | ||
| .filter((b) => b.type === "text") | ||
| .map((b) => b.text) | ||
| .join(" "); | ||
| } | ||
| export function sessionMeta(session) { | ||
@@ -184,8 +193,8 @@ return { | ||
| replayGate: !!extraQueryOptions?.resume, | ||
| pushMessage: (text) => { | ||
| pushMessage: (content) => { | ||
| session.replayGate = false; | ||
| session.lastActivityAt = Date.now(); | ||
| if (!session.firstPrompt) | ||
| session.firstPrompt = text; | ||
| channel.push(userMessage(text)); | ||
| session.firstPrompt = extractText(content); | ||
| channel.push(userMessage(content)); | ||
| }, | ||
@@ -192,0 +201,0 @@ messageIterator, |
@@ -157,5 +157,7 @@ import { PushChannel } from "./push-channel.js"; | ||
| const msg = message.message; | ||
| // Only emit checkpoints for real user messages (string content), | ||
| // not tool-result round-trips (array content with tool_result blocks). | ||
| const isToolResult = Array.isArray(msg?.content); | ||
| // Emit checkpoints for real user messages (string or content-array with | ||
| // text/image blocks), not tool-result round-trips (content-array with | ||
| // tool_result blocks). | ||
| const isToolResult = Array.isArray(msg?.content) && | ||
| msg.content.some((b) => b.type === "tool_result"); | ||
| const uuid = message.uuid; | ||
@@ -162,0 +164,0 @@ if (uuid && !isToolResult) { |
+2
-2
| { | ||
| "name": "@neeter/server", | ||
| "version": "0.16.1", | ||
| "version": "0.17.0", | ||
| "description": "Hono server toolkit for building chat UIs on top of the Claude Agent SDK", | ||
@@ -24,3 +24,3 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@neeter/types": "0.16.1" | ||
| "@neeter/types": "0.17.0" | ||
| }, | ||
@@ -27,0 +27,0 @@ "peerDependencies": { |
+23
-0
@@ -48,2 +48,25 @@ # @neeter/server | ||
| ## Multimodal messages | ||
| Send images alongside text via the messages endpoint: | ||
| ```typescript | ||
| // POST /api/sessions/:id/messages | ||
| { | ||
| "content": [ | ||
| { "type": "text", "text": "Describe this image" }, | ||
| { | ||
| "type": "image", | ||
| "source": { | ||
| "type": "base64", | ||
| "media_type": "image/png", | ||
| "data": "<base64>" | ||
| } | ||
| } | ||
| ] | ||
| } | ||
| ``` | ||
| Plain `{ "text": "..." }` payloads continue to work as before. See the [Server Guide](https://github.com/quantumleeps/neeter/blob/main/docs/server.md#multimodal-messages) for details. | ||
| ## Examples | ||
@@ -50,0 +73,0 @@ |
58248
6.85%1269
6.02%86
36.51%+ Added
- Removed
Updated