diff --git a/AGENTS.md b/AGENTS.md index e2863194..045bd361 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,11 +74,12 @@ Memoh/ ├── db/ # Database │ ├── migrations/ # SQL migration files │ └── queries/ # SQL query files (sqlc input) -├── docker/ # Docker configuration +├── conf/ # Configuration templates (app.example.toml, app.dev.toml, app.docker.toml) +├── devenv/ # Development environment (docker-compose for local infra) +├── docker/ # Docker build & runtime (Dockerfiles, entrypoints, nginx.conf) ├── docs/ # Documentation site ├── scripts/ # Utility scripts -├── config.toml.example # Configuration template -├── docker-compose.yml # Docker Compose orchestration +├── docker-compose.yml # Docker Compose orchestration (production) ├── mise.toml # mise tasks and tool version definitions └── sqlc.yaml # sqlc code generation config ``` @@ -90,8 +91,7 @@ Memoh/ 1. Install [mise](https://mise.jdx.dev/) 2. Install toolchains and dependencies: `mise install` 3. Initialize the project: `mise run setup` -4. Copy the config file: `cp config.toml.example config.toml` and edit as needed -5. Start the dev environment: `mise run dev` +4. Start the dev environment: `mise run dev` ### Common Commands @@ -157,8 +157,7 @@ Migrations live in `db/migrations/` and follow a dual-update convention: ### Container Management -- macOS development requires running containerd via Lima (see `.github/CONTRIBUTING.md`). -- In Docker deployment, containerd runs as a standalone service. +- In Docker deployment, containerd runs inside the server container. - Each bot has its own isolated container instance. ## Database Tables @@ -183,7 +182,7 @@ Migrations live in `db/migrations/` and follow a dual-update convention: ## Configuration -The main configuration file is `config.toml` (copied from `config.toml.example`), containing: +The main configuration file is `config.toml` (copied from `conf/app.example.toml` or `conf/app.dev.toml` for development), containing: - `[server]` — HTTP listen address - `[admin]` — Admin account credentials diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d9e20426..fbbe8383 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,111 +1,48 @@ # Contributing Guide -## Mise +## Prerequisites -You need to install mise first. +- [Docker](https://docs.docker.com/get-docker/) (for dev infrastructure) +- [mise](https://mise.jdx.dev/) (task runner & toolchain manager) -### Linux/macOS +### Install mise ```bash +# macOS / Linux curl https://mise.run | sh -``` - -Or use homebrew: - -```bash +# or brew install mise -``` -### Windows - -```bash +# Windows winget install jdx.mise ``` -## Initialize - -Install toolchains and dependencies: +## Quick Start ```bash -mise install +mise install # Install toolchains (Go, Node, Bun, pnpm, sqlc) +mise run setup # Start infra + copy config + migrate DB + install deps +mise run dev # Start server + agent + web (all hot-reload) ``` -Setup project: +That's it. `setup` handles everything automatically: +1. Copies `conf/app.dev.toml` → `config.toml` (if not exists) +2. Starts PostgreSQL + Qdrant via `devenv/docker-compose.yml` +3. Runs database migrations +4. Installs Go/Node/pnpm dependencies + +## Daily Development ```bash -mise run setup +mise run dev # Start all services with hot-reload ``` -## Configure - -Copy config.toml.example to config.toml and configure: +## Infrastructure ```bash -cp config.toml.example config.toml -``` - -## Containerd (macOS) - -本项目依赖 containerd 运行容器。macOS 上通过 [Lima](https://lima-vm.io/) 在虚拟机中运行 containerd。 - -### 安装与启动 Lima VM - -```bash -# 安装 Lima(如果尚未安装) -brew install lima - -# 启动默认 VM(脚本也会自动执行) -mise run containerd-install -``` - -### 启动 containerd 服务 - -Lima VM 启动后,containerd 不一定会自动运行,需要手动启用: - -```bash -limactl shell default -- sudo systemctl enable --now containerd -``` - -### Socket 转发 - -Go 应用运行在 macOS 宿主机上,但 containerd socket (`/run/containerd/containerd.sock`) 位于 Lima VM 内部,宿主机无法直接访问。 - -由于 containerd socket 权限为 `root:root rw-rw----`,SSH 转发以普通用户身份无法直接访问。需要先在 VM 内安装 `socat` 并以 root 权限创建代理 socket,再通过 SSH 转发到宿主机: - -```bash -# 1. 安装 socat(仅首次需要) -limactl shell default -- sudo apt-get install -y socat - -# 2. 在 VM 内创建代理 socket(以 root 权限运行,普通用户可访问) -limactl shell default -- sudo bash -c \ - 'rm -f /tmp/containerd-proxy.sock; nohup socat UNIX-LISTEN:/tmp/containerd-proxy.sock,fork,mode=0666 UNIX-CONNECT:/run/containerd/containerd.sock > /dev/null 2>&1 &' - -# 3. SSH 转发代理 socket 到宿主机 -rm -f /tmp/containerd-lima.sock -ssh -nNT -L /tmp/containerd-lima.sock:/tmp/containerd-proxy.sock \ - -F ~/.lima/default/ssh.config lima-default & -``` - -然后在 `config.toml` 中配置转发后的 socket 路径: - -```toml -[containerd] -socket_path = "/tmp/containerd-lima.sock" -``` - -### 常见问题 - -- **Lima VM 状态为 Broken**:运行 `limactl stop default && limactl start default` 重启 VM。 -- **连接超时 (`dial unix:///run/containerd/containerd.sock: timeout`)**:检查 VM 是否运行、containerd 是否启动、socket 转发是否建立。 -- **gRPC EOF 错误 (`error reading server preface: EOF`)**:通常是 socket 权限问题,确认使用了 socat 代理(步骤 2),而非直接转发 `/run/containerd/containerd.sock`。 -- **转发断开**:socat 代理和 SSH 转发均为后台进程,重启电脑或 VM 后需要重新执行步骤 2 和 3。 - -## Development - -Start development environment: - -```bash -mise run dev +mise run infra # Start dev postgres + qdrant +mise run infra-down # Stop dev infrastructure +mise run infra-logs # View infrastructure logs ``` ## More Commands @@ -113,15 +50,28 @@ mise run dev | Command | Description | | ------- | ----------- | | `mise run dev` | Start development environment | -| `mise run setup` | Setup development environment | -| `mise run db-up` | Initialize and Migrate Database | -| `mise run db-down` | Drop Database | +| `mise run setup` | Full setup (infra + config + migrate + deps) | +| `mise run infra` | Start dev infrastructure only | +| `mise run infra-down` | Stop dev infrastructure | +| `mise run db-up` | Run database migrations | +| `mise run db-down` | Roll back database migrations | | `mise run swagger-generate` | Generate Swagger documentation | +| `mise run sdk-generate` | Generate TypeScript SDK | | `mise run sqlc-generate` | Generate SQL code | -| `mise run pnpm-install` | Install dependencies | -| `mise run go-install` | Install Go dependencies | -| `mise run //agent:dev` | Start agent gateway development server | -| `mise run //cmd/agent:start` | Start main server | -| `mise run //packages/web:dev` | Start web development server | -| `mise run //packages/web:build` | Build web | -| `mise run //packages/web:start` | Start web preview | \ No newline at end of file +| `mise run //agent:dev` | Start agent gateway only | +| `mise run //cmd/agent:start` | Start main server only | +| `mise run //packages/web:dev` | Start web dev server only | + +## Project Layout + +``` +conf/ — Configuration templates (app.example.toml, app.dev.toml, app.docker.toml) +devenv/ — Development infrastructure (docker-compose for postgres + qdrant) +docker/ — Production Docker build & runtime (Dockerfiles, entrypoints) +cmd/ — Go application entry points +internal/ — Go backend core code +agent/ — Agent Gateway (Bun/Elysia) +packages/ — Frontend monorepo (web, ui, sdk, cli, config) +db/ — Database migrations and queries +scripts/ — Utility scripts +``` diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 72412736..16eb630f 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -3,7 +3,7 @@ ## One-Click Install ```bash -curl -fsSL https://raw.githubusercontent.com/memohai/Memoh/main/scripts/install.sh | sh +curl -fsSL https://raw.githubusercontent.com/memohai/Memoh/main/scripts/install.sh | sudo sh ``` Or manually: @@ -11,9 +11,11 @@ Or manually: ```bash git clone https://github.com/memohai/Memoh.git cd Memoh -docker compose up -d +sudo docker compose up -d ``` +> On macOS or if your user is in the `docker` group, `sudo` is not required. + Access: - Web UI: http://localhost:8082 - API: http://localhost:8080 @@ -30,14 +32,14 @@ That's it. No containerd, nerdctl, or buildkit required on the host. ## Custom Configuration -By default, Docker Compose uses `docker/config/config.docker.toml` (no file in project root is mounted; only this config file is mounted into the containers). +By default, Docker Compose uses `conf/app.docker.toml` (no file in project root is mounted; only this config file is mounted into the containers). To use your own config, create and edit it in the project root, then point `MEMOH_CONFIG` at it (path is on the host; run `docker compose` from the project root): ```bash -cp docker/config/config.docker.toml config.toml +cp conf/app.docker.toml config.toml nano config.toml -MEMOH_CONFIG=./config.toml docker compose up -d +sudo MEMOH_CONFIG=./config.toml docker compose up -d ``` Recommended changes for production: @@ -47,6 +49,8 @@ Recommended changes for production: ## Common Commands +> Prefix with `sudo` on Linux if your user is not in the `docker` group. + ```bash docker compose up -d # Start docker compose down # Stop diff --git a/README.md b/README.md index 12fdb554..db54ad16 100644 --- a/README.md +++ b/README.md @@ -33,19 +33,21 @@ Memoh is a AI agent system platform. Users can create their own AI bots and chat One-click install (**requires [Docker](https://www.docker.com/get-started/)**): ```bash -curl -fsSL https://raw.githubusercontent.com/memohai/Memoh/main/scripts/install.sh | sh +curl -fsSL https://raw.githubusercontent.com/memohai/Memoh/main/scripts/install.sh | sudo sh ``` -*Silent install with all defaults: `curl -fsSL ... | sh -s -- -y`* +*Silent install with all defaults: `curl -fsSL ... | sudo sh -s -- -y`* Or manually: ```bash git clone --depth 1 https://github.com/memohai/Memoh.git cd Memoh -docker compose up -d +sudo docker compose up -d ``` +> On macOS or if your user is in the `docker` group, `sudo` is not required. + Visit after startup. Default login: `admin` / `admin123` See [DEPLOYMENT.md](DEPLOYMENT.md) for custom configuration and production setup. diff --git a/README_CN.md b/README_CN.md index ab117fd6..6f3035f0 100644 --- a/README_CN.md +++ b/README_CN.md @@ -31,19 +31,21 @@ Memoh 是一个 AI Agent 系统平台。用户可通过 Telegram、Discord、飞 一键安装(**需先安装 [Docker](https://www.docker.com/get-started/)**): ```bash -curl -fsSL https://raw.githubusercontent.com/memohai/Memoh/main/scripts/install.sh | sh +curl -fsSL https://raw.githubusercontent.com/memohai/Memoh/main/scripts/install.sh | sudo sh ``` -*静默安装(全部默认):`curl -fsSL ... | sh -s -- -y`* +*静默安装(全部默认):`curl -fsSL ... | sudo sh -s -- -y`* 或手动部署: ```bash git clone --depth 1 https://github.com/memohai/Memoh.git cd Memoh -docker compose up -d +sudo docker compose up -d ``` +> macOS 或用户已在 `docker` 用户组中时,无需 `sudo`。 + 启动后访问 。默认登录:`admin` / `admin123` 自定义配置与生产部署请参阅 [DEPLOYMENT.md](DEPLOYMENT.md)。 diff --git a/agent/src/agent.ts b/agent/src/agent.ts index e462db48..c2c57fb5 100644 --- a/agent/src/agent.ts +++ b/agent/src/agent.ts @@ -27,14 +27,43 @@ import { dedupeAttachments, AttachmentsStreamExtractor, } from './utils/attachments' -import type { - ContainerFileAttachment, - ImageAttachment, -} from './types/attachment' +import type { ContainerFileAttachment, GatewayInputAttachment } from './types/attachment' import { getMCPTools } from './tools/mcp' import { getTools } from './tools' import { buildIdentityHeaders } from './utils/headers' +export const buildNativeImageParts = (attachments: GatewayInputAttachment[]): ImagePart[] => { + return attachments + .filter((attachment) => + attachment.type === 'image' && + (attachment.transport === 'inline_data_url' || attachment.transport === 'public_url') && + Boolean(attachment.payload), + ) + .map((attachment) => ({ type: 'image', image: attachment.payload } as ImagePart)) +} + +const buildFileRefs = ( + attachments: GatewayInputAttachment[], + supportsImage: boolean, +): ContainerFileAttachment[] => { + return attachments + .filter((attachment) => { + if (attachment.transport !== 'tool_file_ref' || !attachment.payload) { + return false + } + if (attachment.type === 'file') { + return true + } + // When image native modality is unavailable, keep image refs as tool files. + return !supportsImage && attachment.type === 'image' + }) + .map((attachment) => ({ + type: 'file' as const, + path: attachment.payload, + metadata: attachment.metadata, + })) +} + export const createAgent = ( { model: modelConfig, @@ -258,29 +287,8 @@ export const createAgent = ( const generateUserPrompt = (input: AgentInput) => { const supportsImage = hasInputModality(modelConfig, ModelInput.Image) - // Separate attachments by model capability: native images vs fallback file paths. - const nativeImages = supportsImage - ? input.attachments.filter((a) => a.type === 'image') - : [] - const fallbackFiles = input.attachments.filter( - (a): a is ContainerFileAttachment => a.type === 'file', - ) - // Images the model cannot handle natively are mentioned as path references. - const unsupportedImages: ContainerFileAttachment[] = supportsImage - ? [] - : input.attachments - .filter((a) => a.type === 'image') - .map((a) => ({ - type: 'file' as const, - path: String( - (a as ImageAttachment).path || a.metadata?.path || '[image]', - ), - metadata: a.metadata, - })) - const allFiles: ContainerFileAttachment[] = [ - ...fallbackFiles, - ...unsupportedImages, - ] + const allFiles = buildFileRefs(input.attachments, supportsImage) + const imageParts = supportsImage ? buildNativeImageParts(input.attachments) : [] const text = user(input.query, { channelIdentityId: identity.channelIdentityId || '', @@ -290,18 +298,6 @@ export const createAgent = ( date: new Date(), attachments: allFiles, }) - const imageParts: ImagePart[] = nativeImages - .map((image) => { - const img = image as ImageAttachment - if (img.base64) { - return { type: 'image', image: img.base64 } as ImagePart - } - if (img.url) { - return { type: 'image', image: img.url } as ImagePart - } - return { type: 'image', image: '' } as ImagePart - }) - .filter((p) => p.image !== '') const userMessage: UserModelMessage = { role: 'user', content: [{ type: 'text', text }, ...imageParts], @@ -310,10 +306,9 @@ export const createAgent = ( } const ask = async (input: AgentInput) => { - const preparedInput = await prepareInputWithMCPImageBase64(input) - const userPrompt = generateUserPrompt(preparedInput) - const messages = [...preparedInput.messages, userPrompt] - preparedInput.skills.forEach((skill) => enableSkill(skill)) + const userPrompt = generateUserPrompt(input) + const messages = [...input.messages, userPrompt] + input.skills.forEach((skill) => enableSkill(skill)) const systemPrompt = await generateSystemPrompt() const { tools, close } = await getAgentTools() const { response, reasoning, text, usage } = await generateText({ @@ -451,10 +446,9 @@ export const createAgent = ( } async function* stream(input: AgentInput): AsyncGenerator { - const preparedInput = await prepareInputWithMCPImageBase64(input) - const userPrompt = generateUserPrompt(preparedInput) - const messages = [...preparedInput.messages, userPrompt] - preparedInput.skills.forEach((skill) => enableSkill(skill)) + const userPrompt = generateUserPrompt(input) + const messages = [...input.messages, userPrompt] + input.skills.forEach((skill) => enableSkill(skill)) const systemPrompt = await generateSystemPrompt() const attachmentsExtractor = new AttachmentsStreamExtractor() const result: { diff --git a/agent/src/models.ts b/agent/src/models.ts index 18a7968f..1407c0e8 100644 --- a/agent/src/models.ts +++ b/agent/src/models.ts @@ -41,23 +41,17 @@ export const ScheduleModel = z.object({ command: z.string().min(1, 'Schedule command is required'), }) -export const ImageAttachmentModel = z.object({ - type: z.literal('image'), - base64: z.string().optional(), - path: z.string().optional(), +export const AttachmentModel = z.object({ + contentHash: z.string().optional(), + type: z.string().min(1, 'Attachment type is required'), mime: z.string().optional(), + size: z.number().int().nonnegative().optional(), name: z.string().optional(), + transport: z.enum(['inline_data_url', 'public_url', 'tool_file_ref']), + payload: z.string().min(1, 'Attachment payload is required'), metadata: z.record(z.string(), z.any()).optional(), }) -export const FileAttachmentModel = z.object({ - type: z.literal('file'), - path: z.string().min(1, 'File path is required'), - metadata: z.record(z.string(), z.any()).optional(), -}) - -export const AttachmentModel = z.union([ImageAttachmentModel, FileAttachmentModel]) - export const HTTPMCPConnectionModel = z.object({ name: z.string().min(1, 'Name is required'), type: z.literal('http'), diff --git a/agent/src/modules/chat.ts b/agent/src/modules/chat.ts index c9d04099..c31892ff 100644 --- a/agent/src/modules/chat.ts +++ b/agent/src/modules/chat.ts @@ -48,7 +48,7 @@ export const chatModule = new Elysia({ prefix: '/chat' }) }) }, { body: AgentModel.extend({ - query: z.string(), + query: z.string().optional().default(''), }), }) .post('/stream', async function* ({ body, bearer }) { @@ -89,7 +89,7 @@ export const chatModule = new Elysia({ prefix: '/chat' }) } }, { body: AgentModel.extend({ - query: z.string(), + query: z.string().optional().default(''), }), }) .post('/trigger-schedule', async ({ body, bearer }) => { diff --git a/agent/src/test/attachments.test.ts b/agent/src/test/attachments.test.ts index 317e3c0a..84998edc 100644 --- a/agent/src/test/attachments.test.ts +++ b/agent/src/test/attachments.test.ts @@ -7,7 +7,8 @@ import { dedupeAttachments, AttachmentsStreamExtractor, } from '../utils/attachments' -import type { ContainerFileAttachment } from '../types/attachment' +import { buildNativeImageParts } from '../agent' +import type { ContainerFileAttachment, GatewayInputAttachment } from '../types/attachment' // --------------------------------------------------------------------------- // parseAttachmentPaths @@ -196,6 +197,27 @@ describe('dedupeAttachments', () => { }) }) +describe('buildNativeImageParts', () => { + test('keeps inline data url and public url images', () => { + const attachments: GatewayInputAttachment[] = [ + { type: 'image', transport: 'inline_data_url', payload: 'data:image/png;base64,AAAA' }, + { type: 'image', transport: 'public_url', payload: 'https://example.com/demo.png' }, + ] + const parts = buildNativeImageParts(attachments) + expect(parts).toHaveLength(2) + expect(parts[0].image).toBe('data:image/png;base64,AAAA') + expect(parts[1].image).toBe('https://example.com/demo.png') + }) + + test('drops tool_file_ref images', () => { + const attachments: GatewayInputAttachment[] = [ + { type: 'image', transport: 'tool_file_ref', payload: '/data/media/image/demo.png' }, + ] + const parts = buildNativeImageParts(attachments) + expect(parts).toEqual([]) + }) +}) + // --------------------------------------------------------------------------- // AttachmentsStreamExtractor // --------------------------------------------------------------------------- diff --git a/agent/src/test/unified_mcp_tools.test.ts b/agent/src/test/unified_mcp_tools.test.ts index a3c7b319..c580a365 100644 --- a/agent/src/test/unified_mcp_tools.test.ts +++ b/agent/src/test/unified_mcp_tools.test.ts @@ -72,9 +72,14 @@ describe('getMCPTools (unified endpoint)', () => { try { const endpoint = `http://127.0.0.1:${server.port}/bots/bot-1/tools` - const { tools, close } = await getMCPTools(endpoint, { - Authorization: 'Bearer test-token', - }) + const { tools, close } = await getMCPTools([{ + type: 'http', + name: 'builtin', + url: endpoint, + headers: { + Authorization: 'Bearer test-token', + }, + }]) expect(Object.keys(tools)).toContain('search_memory') expect(seenMethods).toContain('initialize') diff --git a/agent/src/types/agent.ts b/agent/src/types/agent.ts index 1c5df13f..40c657f1 100644 --- a/agent/src/types/agent.ts +++ b/agent/src/types/agent.ts @@ -1,6 +1,6 @@ import { ModelMessage } from 'ai' import { ModelConfig } from './model' -import { AgentAttachment } from './attachment' +import { GatewayInputAttachment } from './attachment' import { MCPConnection } from './mcp' export interface IdentityContext { @@ -45,7 +45,7 @@ export interface AgentParams { export interface AgentInput { messages: ModelMessage[] - attachments: AgentAttachment[] + attachments: GatewayInputAttachment[] skills: string[] query: string } diff --git a/agent/src/types/attachment.ts b/agent/src/types/attachment.ts index 961bdd0b..9bce0297 100644 --- a/agent/src/types/attachment.ts +++ b/agent/src/types/attachment.ts @@ -1,9 +1,25 @@ +export type GatewayAttachmentTransport = + | 'inline_data_url' + | 'public_url' + | 'tool_file_ref' + +export interface GatewayInputAttachment { + contentHash?: string + type: string + mime?: string + size?: number + name?: string + transport: GatewayAttachmentTransport + payload: string + metadata?: Record +} + export interface BaseAgentAttachment { type: string url?: string name?: string mime?: string - asset_id?: string + content_hash?: string metadata?: Record } diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 7fce0013..4f2d23f7 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "io" + "io/fs" "log/slog" "net/http" "os" @@ -17,6 +19,7 @@ import ( "go.uber.org/fx/fxevent" "golang.org/x/crypto/bcrypt" + dbembed "github.com/memohai/memoh/db" "github.com/memohai/memoh/internal/accounts" "github.com/memohai/memoh/internal/bind" "github.com/memohai/memoh/internal/boot" @@ -49,7 +52,6 @@ import ( mcpweb "github.com/memohai/memoh/internal/mcp/providers/web" mcpfederation "github.com/memohai/memoh/internal/mcp/sources/federation" "github.com/memohai/memoh/internal/media" - "github.com/memohai/memoh/internal/media/providers/containerfs" "github.com/memohai/memoh/internal/memory" "github.com/memohai/memoh/internal/message" "github.com/memohai/memoh/internal/message/event" @@ -61,11 +63,66 @@ import ( "github.com/memohai/memoh/internal/searchproviders" "github.com/memohai/memoh/internal/server" "github.com/memohai/memoh/internal/settings" + "github.com/memohai/memoh/internal/storage/providers/containerfs" "github.com/memohai/memoh/internal/subagent" "github.com/memohai/memoh/internal/version" ) +func migrationsFS() fs.FS { + sub, err := fs.Sub(dbembed.MigrationsFS, "migrations") + if err != nil { + panic(fmt.Sprintf("embedded migrations: %v", err)) + } + return sub +} + func main() { + cmd := "serve" + if len(os.Args) > 1 { + cmd = os.Args[1] + } + + switch cmd { + case "serve": + runServe() + case "migrate": + runMigrate(os.Args[2:]) + case "version": + fmt.Printf("memoh-server %s\n", version.GetInfo()) + default: + fmt.Fprintf(os.Stderr, "Usage: memoh-server \n\nCommands:\n serve Start the server (default)\n migrate Run database migrations (up|down|version|force)\n version Print version information\n") + os.Exit(1) + } +} + +func runMigrate(args []string) { + if len(args) == 0 { + fmt.Fprintf(os.Stderr, "Usage: memoh-server migrate \n") + os.Exit(1) + } + + cfg, err := provideConfig() + if err != nil { + fmt.Fprintf(os.Stderr, "config: %v\n", err) + os.Exit(1) + } + + logger.Init(cfg.Log.Level, cfg.Log.Format) + log := logger.L + + migrateCmd := args[0] + var migrateArgs []string + if len(args) > 1 { + migrateArgs = args[1:] + } + + if err := db.RunMigrate(log, cfg.Postgres, migrationsFS(), migrateCmd, migrateArgs); err != nil { + log.Error("migration failed", slog.Any("error", err)) + os.Exit(1) + } +} + +func runServe() { fx.New( fx.Provide( provideConfig, @@ -316,9 +373,10 @@ func provideScheduleTriggerer(resolver *flow.Resolver) schedule.Triggerer { // conversation flow // --------------------------------------------------------------------------- -func provideChatResolver(log *slog.Logger, cfg config.Config, modelsService *models.Service, queries *dbsqlc.Queries, memoryService *memory.Service, chatService *conversation.Service, msgService *message.DBService, settingsService *settings.Service, containerdHandler *handlers.ContainerdHandler) *flow.Resolver { +func provideChatResolver(log *slog.Logger, cfg config.Config, modelsService *models.Service, queries *dbsqlc.Queries, memoryService *memory.Service, chatService *conversation.Service, msgService *message.DBService, settingsService *settings.Service, mediaService *media.Service, containerdHandler *handlers.ContainerdHandler) *flow.Resolver { resolver := flow.NewResolver(log, modelsService, queries, memoryService, chatService, msgService, settingsService, cfg.AgentGateway.BaseURL(), 120*time.Second) resolver.SetSkillLoader(&skillLoaderAdapter{handler: containerdHandler}) + resolver.SetGatewayAssetLoader(&gatewayAssetLoaderAdapter{media: mediaService}) return resolver } @@ -326,9 +384,11 @@ func provideChatResolver(log *slog.Logger, cfg config.Config, modelsService *mod // channel providers // --------------------------------------------------------------------------- -func provideChannelRegistry(log *slog.Logger, hub *local.RouteHub) *channel.Registry { +func provideChannelRegistry(log *slog.Logger, hub *local.RouteHub, mediaService *media.Service) *channel.Registry { registry := channel.NewRegistry() - registry.MustRegister(telegram.NewTelegramAdapter(log)) + tgAdapter := telegram.NewTelegramAdapter(log) + tgAdapter.SetAssetOpener(mediaService) + registry.MustRegister(tgAdapter) registry.MustRegister(feishu.NewFeishuAdapter(log)) registry.MustRegister(local.NewCLIAdapter(hub)) registry.MustRegister(local.NewWebAdapter(hub)) @@ -338,6 +398,7 @@ func provideChannelRegistry(log *slog.Logger, hub *local.RouteHub) *channel.Regi func provideChannelRouter( log *slog.Logger, registry *channel.Registry, + hub *local.RouteHub, routeService *route.DBService, msgService *message.DBService, resolver *flow.Resolver, @@ -351,6 +412,7 @@ func provideChannelRouter( ) *inbound.ChannelInboundProcessor { processor := inbound.NewChannelInboundProcessor(log, registry, routeService, msgService, resolver, identityService, botService, policyService, preauthService, bindService, rc.JwtSecret, 5*time.Minute) processor.SetMediaService(mediaService) + processor.SetStreamObserver(local.NewRouteHubBroadcaster(hub)) return processor } @@ -370,8 +432,8 @@ func provideChannelLifecycleService(channelStore *channel.Store, channelManager // containerd handler & tool gateway // --------------------------------------------------------------------------- -func provideContainerdHandler(log *slog.Logger, service ctr.Service, cfg config.Config, botService *bots.Service, accountService *accounts.Service, policyService *policy.Service, queries *dbsqlc.Queries) *handlers.ContainerdHandler { - return handlers.NewContainerdHandler(log, service, cfg.MCP, cfg.Containerd.Namespace, botService, accountService, policyService, queries) +func provideContainerdHandler(log *slog.Logger, service ctr.Service, manager *mcp.Manager, cfg config.Config, botService *bots.Service, accountService *accounts.Service, policyService *policy.Service, queries *dbsqlc.Queries) *handlers.ContainerdHandler { + return handlers.NewContainerdHandler(log, service, manager, cfg.MCP, cfg.Containerd.Namespace, botService, accountService, policyService, queries) } func provideToolGatewayService(log *slog.Logger, cfg config.Config, channelManager *channel.Manager, registry *channel.Registry, channelStore *channel.Store, scheduleService *schedule.Service, memoryService *memory.Service, chatService *conversation.Service, accountService *accounts.Service, settingsService *settings.Service, searchProviderService *searchproviders.Service, manager *mcp.Manager, containerdHandler *handlers.ContainerdHandler, mcpConnService *mcp.ConnectionService) *mcp.ToolGatewayService { @@ -418,13 +480,13 @@ func provideAuthHandler(log *slog.Logger, accountService *accounts.Service, rc * return handlers.NewAuthHandler(log, accountService, rc.JwtSecret, rc.JwtExpiresIn) } -func provideMessageHandler(log *slog.Logger, resolver *flow.Resolver, chatService *conversation.Service, msgService *message.DBService, mediaService *media.Service, botService *bots.Service, accountService *accounts.Service, identityService *identities.Service, hub *event.Hub) *handlers.MessageHandler { - h := handlers.NewMessageHandler(log, resolver, chatService, msgService, botService, accountService, identityService, hub) +func provideMessageHandler(log *slog.Logger, chatService *conversation.Service, msgService *message.DBService, mediaService *media.Service, botService *bots.Service, accountService *accounts.Service, hub *event.Hub) *handlers.MessageHandler { + h := handlers.NewMessageHandler(log, chatService, msgService, botService, accountService, hub) h.SetMediaService(mediaService) return h } -func provideMediaService(log *slog.Logger, queries *dbsqlc.Queries, cfg config.Config) (*media.Service, error) { +func provideMediaService(log *slog.Logger, cfg config.Config) (*media.Service, error) { dataRoot := strings.TrimSpace(cfg.MCP.DataRoot) if dataRoot == "" { dataRoot = config.DefaultDataRoot @@ -433,7 +495,7 @@ func provideMediaService(log *slog.Logger, queries *dbsqlc.Queries, cfg config.C if err != nil { return nil, fmt.Errorf("init media provider: %w", err) } - return media.NewService(log, queries, provider), nil + return media.NewService(log, provider), nil } func provideUsersHandler(log *slog.Logger, accountService *accounts.Service, identityService *identities.Service, botService *bots.Service, routeService *route.DBService, channelStore *channel.Store, channelLifecycle *channel.Lifecycle, channelManager *channel.Manager, registry *channel.Registry) *handlers.UsersHandler { @@ -711,3 +773,19 @@ func (a *skillLoaderAdapter) LoadSkills(ctx context.Context, botID string) ([]fl } return entries, nil } + +// gatewayAssetLoaderAdapter bridges media service to flow gateway asset loader. +type gatewayAssetLoaderAdapter struct { + media *media.Service +} + +func (a *gatewayAssetLoaderAdapter) OpenForGateway(ctx context.Context, botID, contentHash string) (io.ReadCloser, string, error) { + if a == nil || a.media == nil { + return nil, "", fmt.Errorf("media service not configured") + } + reader, asset, err := a.media.Open(ctx, botID, contentHash) + if err != nil { + return nil, "", err + } + return reader, strings.TrimSpace(asset.Mime), nil +} diff --git a/cmd/cli/main.go b/cmd/cli/main.go deleted file mode 100644 index 1836dfaf..00000000 --- a/cmd/cli/main.go +++ /dev/null @@ -1,257 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "flag" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "runtime" - "strconv" - "strings" - "sync" - "time" - - gocni "github.com/containerd/go-cni" -) - -func main() { - flag.CommandLine.SetOutput(io.Discard) - containerID := flag.String("container-id", "", "") - flag.Parse() - - if len(flag.Args()) > 0 { - switch flag.Arg(0) { - case "cni-setup": - os.Exit(runCNISetup(flag.Args()[1:])) - case "cni-remove": - os.Exit(runCNIRemove(flag.Args()[1:])) - case "cni-check": - os.Exit(runCNICheck(flag.Args()[1:])) - case "cni-status": - os.Exit(runCNIStatus(flag.Args()[1:])) - } - } - - if *containerID == "" { - os.Exit(2) - } - - cmd := buildMCPCommand(*containerID) - if err := runWithStdio(cmd); err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - os.Exit(exitErr.ExitCode()) - } - os.Exit(1) - } -} - -func buildMCPCommand(containerID string) *exec.Cmd { - execID := "mcp-" + strconv.FormatInt(time.Now().UnixNano(), 10) - if runtime.GOOS == "darwin" { - return exec.Command( - "limactl", - "shell", - "--tty=false", - "default", - "--", - "sudo", - "-n", - "ctr", - "-n", - "default", - "tasks", - "exec", - "--exec-id", - execID, - containerID, - "/mcp", - ) - } - return exec.Command( - "ctr", - "-n", - "default", - "tasks", - "exec", - "--exec-id", - execID, - containerID, - "/mcp", - ) -} - -func runWithStdio(cmd *exec.Cmd) error { - stdin, err := cmd.StdinPipe() - if err != nil { - return err - } - stdout, err := cmd.StdoutPipe() - if err != nil { - _ = stdin.Close() - return err - } - stderr, err := cmd.StderrPipe() - if err != nil { - _ = stdin.Close() - _ = stdout.Close() - return err - } - - if err := cmd.Start(); err != nil { - _ = stdin.Close() - _ = stdout.Close() - _ = stderr.Close() - return err - } - - var wg sync.WaitGroup - wg.Add(3) - go func() { - defer wg.Done() - _, _ = io.Copy(stdin, os.Stdin) - _ = stdin.Close() - }() - go func() { - defer wg.Done() - _, _ = io.Copy(os.Stdout, stdout) - }() - go func() { - defer wg.Done() - _, _ = io.Copy(os.Stderr, stderr) - }() - - err = cmd.Wait() - wg.Wait() - return err -} - -func runCNISetup(args []string) int { - id, netns, err := parseCNIArgs(args) - if err != nil { - return exitWithError(err) - } - cni, err := newCNIFromArgs(args) - if err != nil { - return exitWithError(err) - } - if err := cni.Load(gocni.WithLoNetwork, gocni.WithDefaultConf); err != nil { - return exitWithError(err) - } - result, err := cni.Setup(context.Background(), id, netns) - if err != nil { - return exitWithError(err) - } - if result != nil { - _ = json.NewEncoder(os.Stdout).Encode(result) - } - return 0 -} - -func runCNIRemove(args []string) int { - id, netns, err := parseCNIArgs(args) - if err != nil { - return exitWithError(err) - } - cni, err := newCNIFromArgs(args) - if err != nil { - return exitWithError(err) - } - if err := cni.Load(gocni.WithLoNetwork, gocni.WithDefaultConf); err != nil { - return exitWithError(err) - } - if err := cni.Remove(context.Background(), id, netns); err != nil { - return exitWithError(err) - } - return 0 -} - -func runCNICheck(args []string) int { - id, netns, err := parseCNIArgs(args) - if err != nil { - return exitWithError(err) - } - cni, err := newCNIFromArgs(args) - if err != nil { - return exitWithError(err) - } - if err := cni.Load(gocni.WithLoNetwork, gocni.WithDefaultConf); err != nil { - return exitWithError(err) - } - if err := cni.Check(context.Background(), id, netns); err != nil { - return exitWithError(err) - } - return 0 -} - -func runCNIStatus(args []string) int { - cni, err := newCNIFromArgs(args) - if err != nil { - return exitWithError(err) - } - if err := cni.Load(gocni.WithLoNetwork, gocni.WithDefaultConf); err != nil { - return exitWithError(err) - } - if err := cni.Status(); err != nil { - return exitWithError(err) - } - return 0 -} - -func parseCNIArgs(args []string) (string, string, error) { - fs := flag.NewFlagSet("cni", flag.ContinueOnError) - fs.SetOutput(io.Discard) - id := fs.String("id", "", "") - netns := fs.String("netns", "", "") - pid := fs.Int("pid", 0, "") - _ = fs.String("conf-dir", "", "") - _ = fs.String("bin-dir", "", "") - _ = fs.String("if-prefix", "", "") - if err := fs.Parse(args); err != nil { - return "", "", err - } - if *id == "" { - return "", "", fmt.Errorf("missing --id") - } - if *netns == "" && *pid == 0 { - return "", "", fmt.Errorf("missing --netns or --pid") - } - if *netns == "" { - *netns = filepath.Join("/proc", strconv.Itoa(*pid), "ns", "net") - } - return *id, *netns, nil -} - -func newCNIFromArgs(args []string) (gocni.CNI, error) { - fs := flag.NewFlagSet("cni", flag.ContinueOnError) - fs.SetOutput(io.Discard) - confDir := fs.String("conf-dir", "", "") - binDir := fs.String("bin-dir", "", "") - ifPrefix := fs.String("if-prefix", "", "") - _ = fs.String("id", "", "") - _ = fs.String("netns", "", "") - _ = fs.Int("pid", 0, "") - if err := fs.Parse(args); err != nil { - return nil, err - } - - opts := []gocni.Opt{} - if strings.TrimSpace(*binDir) != "" { - opts = append(opts, gocni.WithPluginDir([]string{*binDir})) - } - if strings.TrimSpace(*confDir) != "" { - opts = append(opts, gocni.WithPluginConfDir(*confDir)) - } - if strings.TrimSpace(*ifPrefix) != "" { - opts = append(opts, gocni.WithInterfacePrefix(*ifPrefix)) - } - return gocni.New(opts...) -} - -func exitWithError(err error) int { - _, _ = fmt.Fprintln(os.Stderr, err.Error()) - return 1 -} diff --git a/cmd/feishu-echo/main.go b/cmd/feishu-echo/main.go deleted file mode 100644 index d092ca6a..00000000 --- a/cmd/feishu-echo/main.go +++ /dev/null @@ -1,120 +0,0 @@ -// feishu-echo is a minimal Feishu bot that connects via WebSocket and counts received events. -// Used to verify whether message loss is due to our app logic or network/Feishu delivery. -// -// Usage: -// -// FEISHU_APP_ID=xxx FEISHU_APP_SECRET=xxx FEISHU_ENCRYPT=xxx FEISHU_VERIFY=xxx go run ./cmd/feishu-echo -package main - -import ( - "context" - "log" - "os" - "os/signal" - "strings" - "sync/atomic" - "time" - - larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" - larkws "github.com/larksuite/oapi-sdk-go/v3/ws" - - "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher" -) - -type eventCounts struct { - messageReceive atomic.Int64 - messageRead atomic.Int64 - reactionCreated atomic.Int64 - reactionDeleted atomic.Int64 -} - -func (c *eventCounts) log() { - log.Printf("[feishu-echo] counts: receive=%d read=%d reaction_created=%d reaction_deleted=%d", - c.messageReceive.Load(), c.messageRead.Load(), c.reactionCreated.Load(), c.reactionDeleted.Load()) -} - -func main() { - appID := strings.TrimSpace(os.Getenv("FEISHU_APP_ID")) - appSecret := strings.TrimSpace(os.Getenv("FEISHU_APP_SECRET")) - encryptKey := strings.TrimSpace(os.Getenv("FEISHU_ENCRYPT")) - verifyToken := strings.TrimSpace(os.Getenv("FEISHU_VERIFY")) - - if appID == "" || appSecret == "" { - log.Fatal("FEISHU_APP_ID and FEISHU_APP_SECRET are required") - } - - log.Printf("[feishu-echo] starting with app_id=%s (encrypt=%v, verify=%v)", appID, encryptKey != "", verifyToken != "") - - counts := new(eventCounts) - eventDispatcher := dispatcher.NewEventDispatcher(verifyToken, encryptKey) - - eventDispatcher.OnP2MessageReceiveV1(func(_ context.Context, _ *larkim.P2MessageReceiveV1) error { - counts.messageReceive.Add(1) - counts.log() - return nil - }) - - eventDispatcher.OnP2MessageReadV1(func(_ context.Context, _ *larkim.P2MessageReadV1) error { - counts.messageRead.Add(1) - counts.log() - return nil - }) - - eventDispatcher.OnP2MessageReactionCreatedV1(func(_ context.Context, _ *larkim.P2MessageReactionCreatedV1) error { - counts.reactionCreated.Add(1) - counts.log() - return nil - }) - - eventDispatcher.OnP2MessageReactionDeletedV1(func(_ context.Context, _ *larkim.P2MessageReactionDeletedV1) error { - counts.reactionDeleted.Add(1) - counts.log() - return nil - }) - - client := larkws.NewClient( - appID, - appSecret, - larkws.WithEventHandler(eventDispatcher), - ) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - go func() { - sig := make(chan os.Signal, 1) - signal.Notify(sig, os.Interrupt) - <-sig - log.Println("[feishu-echo] interrupt, shutting down") - cancel() - counts.log() - os.Exit(0) - }() - - const reconnectDelay = 3 * time.Second -run: - for { - if ctx.Err() != nil { - break run - } - log.Println("[feishu-echo] connecting to Feishu WebSocket...") - err := client.Start(ctx) - if ctx.Err() != nil { - break run - } - if err != nil { - log.Printf("[feishu-echo] client error: %v; reconnecting in %v", err, reconnectDelay) - } else { - log.Printf("[feishu-echo] connection closed; reconnecting in %v", reconnectDelay) - } - timer := time.NewTimer(reconnectDelay) - select { - case <-ctx.Done(): - timer.Stop() - break run - case <-timer.C: - } - } - counts.log() - log.Println("[feishu-echo] stopped") -} diff --git a/cmd/feishu-echo/main_test.go b/cmd/feishu-echo/main_test.go deleted file mode 100644 index 9ed03377..00000000 --- a/cmd/feishu-echo/main_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package main - -import ( - "testing" -) - -func TestEventCounts(t *testing.T) { - c := new(eventCounts) - c.log() - if c.messageReceive.Load() != 0 || c.messageRead.Load() != 0 { - t.Fatalf("initial counts should be 0") - } - c.messageReceive.Add(2) - c.messageRead.Add(1) - c.reactionCreated.Add(1) - if c.messageReceive.Load() != 2 || c.messageRead.Load() != 1 || c.reactionCreated.Load() != 1 { - t.Fatalf("counts after add: receive=2 read=1 reaction_created=1") - } - c.log() -} diff --git a/conf/app.dev.toml b/conf/app.dev.toml new file mode 100644 index 00000000..9e889a27 --- /dev/null +++ b/conf/app.dev.toml @@ -0,0 +1,53 @@ +# Memoh development configuration +# Connects to devenv/docker-compose.yml infrastructure + +[log] +level = "debug" +format = "text" + +[server] +addr = ":8080" + +[admin] +username = "admin" +password = "admin123" +email = "dev@memoh.local" + +[auth] +jwt_secret = "memoh-dev-secret-do-not-use-in-production" +jwt_expires_in = "168h" + +# Containerd is typically not available in local development. +# Uncomment and configure if you have containerd running locally. +# [containerd] +# socket_path = "/run/containerd/containerd.sock" +# namespace = "default" + +# [mcp] +# image = "docker.io/library/memoh-mcp:dev" +# snapshotter = "overlayfs" +# data_root = "data" +# data_mount = "/data" + +[postgres] +host = "127.0.0.1" +port = 5432 +user = "memoh" +password = "memoh123" +database = "memoh" +sslmode = "disable" + +[qdrant] +base_url = "http://127.0.0.1:6334" +api_key = "" +collection = "memory" +timeout_seconds = 10 + +[agent_gateway] +host = "127.0.0.1" +port = 8081 +server_addr = ":8080" + +[web] +host = "127.0.0.1" +port = 8082 diff --git a/docker/config/config.docker.toml b/conf/app.docker.toml similarity index 100% rename from docker/config/config.docker.toml rename to conf/app.docker.toml diff --git a/config.toml.example b/conf/app.example.toml similarity index 64% rename from config.toml.example rename to conf/app.example.toml index 4952ac28..178c2ffc 100644 --- a/config.toml.example +++ b/conf/app.example.toml @@ -1,24 +1,23 @@ -## Service configuration +# Memoh configuration template +# Copy to config.toml and adjust values for your environment. +# For local development, use: cp conf/app.dev.toml config.toml + [log] level = "info" format = "text" [server] -# HTTP listen address addr = ":8080" -## Admin [admin] username = "admin" -password = "123456" -email = "demo@demo.com" +password = "admin123" +email = "admin@memoh.local" -## Auth configuration [auth] -jwt_secret = "YZq8kXrW5dFpNt9mLxQvHbRjKsMnOePw" +jwt_secret = "CHANGE-ME-TO-A-RANDOM-SECRET" jwt_expires_in = "168h" -## Containerd configuration [containerd] socket_path = "/run/containerd/containerd.sock" namespace = "default" @@ -31,30 +30,25 @@ data_mount = "/data" cni_bin_dir = "/opt/cni/bin" cni_conf_dir = "/etc/cni/net.d" -## Postgres configuration [postgres] -host = "localhost" +host = "127.0.0.1" port = 5432 -user = "postgres" -password = "1234" -database = "demo" +user = "memoh" +password = "memoh123" +database = "memoh" sslmode = "disable" - -## Qdrant configuration [qdrant] base_url = "http://127.0.0.1:6334" api_key = "" collection = "memory" timeout_seconds = 10 -## Agent Gateway [agent_gateway] host = "127.0.0.1" port = 8081 server_addr = ":8080" -## Web [web] host = "127.0.0.1" port = 8082 diff --git a/db/embed.go b/db/embed.go new file mode 100644 index 00000000..ca2ab5d7 --- /dev/null +++ b/db/embed.go @@ -0,0 +1,8 @@ +package db + +import "embed" + +// MigrationsFS contains all SQL migration files embedded at compile time. +// +//go:embed migrations/*.sql +var MigrationsFS embed.FS diff --git a/db/migrations/0001_init.up.sql b/db/migrations/0001_init.up.sql index 15ea464f..421f812b 100644 --- a/db/migrations/0001_init.up.sql +++ b/db/migrations/0001_init.up.sql @@ -277,27 +277,33 @@ CREATE TABLE IF NOT EXISTS containers ( CREATE INDEX IF NOT EXISTS idx_containers_bot_id ON containers(bot_id); CREATE TABLE IF NOT EXISTS snapshots ( - id TEXT PRIMARY KEY, + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), container_id TEXT NOT NULL REFERENCES containers(container_id) ON DELETE CASCADE, - parent_snapshot_id TEXT REFERENCES snapshots(id) ON DELETE SET NULL, + runtime_snapshot_name TEXT NOT NULL, + parent_runtime_snapshot_name TEXT, snapshotter TEXT NOT NULL, - digest TEXT, + source TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE INDEX IF NOT EXISTS idx_snapshots_container_id ON snapshots(container_id); -CREATE INDEX IF NOT EXISTS idx_snapshots_parent_id ON snapshots(parent_snapshot_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_snapshots_container_runtime_name + ON snapshots(container_id, runtime_snapshot_name); +CREATE INDEX IF NOT EXISTS idx_snapshots_container_created_at + ON snapshots(container_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_snapshots_runtime_name + ON snapshots(runtime_snapshot_name); CREATE TABLE IF NOT EXISTS container_versions ( - id TEXT PRIMARY KEY, + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), container_id TEXT NOT NULL REFERENCES containers(container_id) ON DELETE CASCADE, - snapshot_id TEXT NOT NULL REFERENCES snapshots(id) ON DELETE RESTRICT, + snapshot_id UUID NOT NULL REFERENCES snapshots(id) ON DELETE RESTRICT, version INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (container_id, version) ); CREATE INDEX IF NOT EXISTS idx_container_versions_container_id ON container_versions(container_id); +CREATE INDEX IF NOT EXISTS idx_container_versions_snapshot_id ON container_versions(snapshot_id); CREATE TABLE IF NOT EXISTS lifecycle_events ( id TEXT PRIMARY KEY, @@ -370,39 +376,17 @@ CREATE TABLE IF NOT EXISTS bot_storage_bindings ( CREATE INDEX IF NOT EXISTS idx_bot_storage_bindings_bot_id ON bot_storage_bindings(bot_id); --- media_assets: immutable media objects with dedup by content hash -CREATE TABLE IF NOT EXISTS media_assets ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE, - storage_provider_id UUID REFERENCES storage_providers(id) ON DELETE SET NULL, - content_hash TEXT NOT NULL, - media_type TEXT NOT NULL, - mime TEXT NOT NULL DEFAULT 'application/octet-stream', - size_bytes BIGINT NOT NULL DEFAULT 0, - storage_key TEXT NOT NULL, - original_name TEXT, - width INTEGER, - height INTEGER, - duration_ms BIGINT, - metadata JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - CONSTRAINT media_assets_bot_hash_unique UNIQUE (bot_id, content_hash) -); - -CREATE INDEX IF NOT EXISTS idx_media_assets_bot_id ON media_assets(bot_id); -CREATE INDEX IF NOT EXISTS idx_media_assets_content_hash ON media_assets(content_hash); - --- bot_history_message_assets: join table linking messages to media assets +-- bot_history_message_assets: soft link (message -> content_hash only). +-- MIME, size, storage_key are derived from storage at read time. CREATE TABLE IF NOT EXISTS bot_history_message_assets ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), message_id UUID NOT NULL REFERENCES bot_history_messages(id) ON DELETE CASCADE, - asset_id UUID NOT NULL REFERENCES media_assets(id) ON DELETE CASCADE, role TEXT NOT NULL DEFAULT 'attachment', ordinal INTEGER NOT NULL DEFAULT 0, + content_hash TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - CONSTRAINT message_asset_unique UNIQUE (message_id, asset_id) + CONSTRAINT message_asset_content_unique UNIQUE (message_id, content_hash) ); CREATE INDEX IF NOT EXISTS idx_message_assets_message_id ON bot_history_message_assets(message_id); -CREATE INDEX IF NOT EXISTS idx_message_assets_asset_id ON bot_history_message_assets(asset_id); diff --git a/db/queries/media.sql b/db/queries/media.sql index 08fee2ae..8f0dd74c 100644 --- a/db/queries/media.sql +++ b/db/queries/media.sql @@ -24,93 +24,30 @@ RETURNING *; -- name: GetBotStorageBinding :one SELECT * FROM bot_storage_bindings WHERE bot_id = sqlc.arg(bot_id); --- name: CreateMediaAsset :one -INSERT INTO media_assets ( - bot_id, storage_provider_id, content_hash, media_type, mime, - size_bytes, storage_key, original_name, width, height, duration_ms, metadata -) -VALUES ( - sqlc.arg(bot_id), - sqlc.narg(storage_provider_id)::uuid, - sqlc.arg(content_hash), - sqlc.arg(media_type), - sqlc.arg(mime), - sqlc.arg(size_bytes), - sqlc.arg(storage_key), - sqlc.narg(original_name)::text, - sqlc.narg(width)::integer, - sqlc.narg(height)::integer, - sqlc.narg(duration_ms)::bigint, - sqlc.arg(metadata) -) -ON CONFLICT (bot_id, content_hash) DO UPDATE SET - bot_id = media_assets.bot_id -RETURNING *; - --- name: GetMediaAssetByID :one -SELECT * FROM media_assets WHERE id = sqlc.arg(id); - --- name: GetMediaAssetByHash :one -SELECT * FROM media_assets -WHERE bot_id = sqlc.arg(bot_id) AND content_hash = sqlc.arg(content_hash); - --- name: ListMediaAssetsByBotID :many -SELECT * FROM media_assets -WHERE bot_id = sqlc.arg(bot_id) -ORDER BY created_at DESC; - --- name: DeleteMediaAsset :exec -DELETE FROM media_assets WHERE id = sqlc.arg(id); - -- name: CreateMessageAsset :one -INSERT INTO bot_history_message_assets (message_id, asset_id, role, ordinal) -VALUES (sqlc.arg(message_id), sqlc.arg(asset_id), sqlc.arg(role), sqlc.arg(ordinal)) -ON CONFLICT (message_id, asset_id) DO UPDATE SET +INSERT INTO bot_history_message_assets (message_id, role, ordinal, content_hash) +VALUES ( + sqlc.arg(message_id), + sqlc.arg(role), + sqlc.arg(ordinal), + sqlc.arg(content_hash) +) +ON CONFLICT (message_id, content_hash) DO UPDATE SET role = EXCLUDED.role, ordinal = EXCLUDED.ordinal RETURNING *; -- name: ListMessageAssets :many -SELECT - ma.id AS rel_id, - ma.message_id, - ma.asset_id, - ma.role, - ma.ordinal, - a.media_type, - a.mime, - a.size_bytes, - a.storage_key, - a.original_name, - a.width, - a.height, - a.duration_ms, - a.metadata AS asset_metadata -FROM bot_history_message_assets ma -JOIN media_assets a ON a.id = ma.asset_id -WHERE ma.message_id = sqlc.arg(message_id) -ORDER BY ma.ordinal ASC; +SELECT id AS rel_id, message_id, role, ordinal, content_hash +FROM bot_history_message_assets +WHERE message_id = sqlc.arg(message_id) +ORDER BY ordinal ASC; -- name: ListMessageAssetsBatch :many -SELECT - ma.id AS rel_id, - ma.message_id, - ma.asset_id, - ma.role, - ma.ordinal, - a.media_type, - a.mime, - a.size_bytes, - a.storage_key, - a.original_name, - a.width, - a.height, - a.duration_ms, - a.metadata AS asset_metadata -FROM bot_history_message_assets ma -JOIN media_assets a ON a.id = ma.asset_id -WHERE ma.message_id = ANY(sqlc.arg(message_ids)::uuid[]) -ORDER BY ma.message_id, ma.ordinal ASC; +SELECT id AS rel_id, message_id, role, ordinal, content_hash +FROM bot_history_message_assets +WHERE message_id = ANY(sqlc.arg(message_ids)::uuid[]) +ORDER BY message_id, ordinal ASC; -- name: DeleteMessageAssets :exec DELETE FROM bot_history_message_assets WHERE message_id = sqlc.arg(message_id); diff --git a/db/queries/snapshots.sql b/db/queries/snapshots.sql index deb969d7..cc9c333a 100644 --- a/db/queries/snapshots.sql +++ b/db/queries/snapshots.sql @@ -1,10 +1,63 @@ --- name: InsertSnapshot :exec -INSERT INTO snapshots (id, container_id, parent_snapshot_id, snapshotter, digest) -VALUES ( - sqlc.arg(id), - sqlc.arg(container_id), - sqlc.arg(parent_snapshot_id), - sqlc.arg(snapshotter), - sqlc.arg(digest) +-- name: UpsertSnapshot :one +INSERT INTO snapshots ( + container_id, + runtime_snapshot_name, + parent_runtime_snapshot_name, + snapshotter, + source ) -ON CONFLICT (id) DO NOTHING; +VALUES ( + sqlc.arg(container_id), + sqlc.arg(runtime_snapshot_name), + sqlc.arg(parent_runtime_snapshot_name), + sqlc.arg(snapshotter), + sqlc.arg(source) +) +ON CONFLICT (container_id, runtime_snapshot_name) DO UPDATE +SET + parent_runtime_snapshot_name = EXCLUDED.parent_runtime_snapshot_name, + snapshotter = EXCLUDED.snapshotter, + source = EXCLUDED.source +RETURNING id, container_id, runtime_snapshot_name, parent_runtime_snapshot_name, snapshotter, source, created_at; + +-- name: ListSnapshotsByContainerID :many +SELECT + id, + container_id, + runtime_snapshot_name, + parent_runtime_snapshot_name, + snapshotter, + source, + created_at +FROM snapshots +WHERE container_id = sqlc.arg(container_id) +ORDER BY created_at DESC; + +-- name: ListSnapshotsWithVersionByContainerID :many +SELECT + s.id, + s.container_id, + s.runtime_snapshot_name, + s.parent_runtime_snapshot_name, + s.snapshotter, + s.source, + s.created_at, + cv.version +FROM snapshots s +LEFT JOIN container_versions cv ON cv.snapshot_id = s.id +WHERE s.container_id = sqlc.arg(container_id) +ORDER BY s.created_at DESC; + +-- name: GetSnapshotByContainerAndRuntimeName :one +SELECT + id, + container_id, + runtime_snapshot_name, + parent_runtime_snapshot_name, + snapshotter, + source, + created_at +FROM snapshots +WHERE container_id = sqlc.arg(container_id) + AND runtime_snapshot_name = sqlc.arg(runtime_snapshot_name) +LIMIT 1; diff --git a/db/queries/versions.sql b/db/queries/versions.sql index 49028b18..89f2ced3 100644 --- a/db/queries/versions.sql +++ b/db/queries/versions.sql @@ -1,18 +1,31 @@ -- name: ListVersionsByContainerID :many -SELECT * FROM container_versions WHERE container_id = sqlc.arg(container_id) ORDER BY version ASC; +SELECT + cv.id, + cv.container_id, + cv.snapshot_id, + cv.version, + cv.created_at, + s.runtime_snapshot_name +FROM container_versions cv +JOIN snapshots s ON s.id = cv.snapshot_id +WHERE cv.container_id = sqlc.arg(container_id) +ORDER BY cv.version ASC; -- name: NextVersion :one SELECT COALESCE(MAX(version), 0) + 1 FROM container_versions WHERE container_id = sqlc.arg(container_id); -- name: InsertVersion :one -INSERT INTO container_versions (id, container_id, snapshot_id, version) +INSERT INTO container_versions (container_id, snapshot_id, version) VALUES ( - sqlc.arg(id), sqlc.arg(container_id), sqlc.arg(snapshot_id), sqlc.arg(version) ) -RETURNING *; +RETURNING id, container_id, snapshot_id, version, created_at; --- name: GetVersionSnapshotID :one -SELECT snapshot_id FROM container_versions WHERE container_id = sqlc.arg(container_id) AND version = sqlc.arg(version); +-- name: GetVersionSnapshotRuntimeName :one +SELECT s.runtime_snapshot_name +FROM container_versions cv +JOIN snapshots s ON s.id = cv.snapshot_id +WHERE cv.container_id = sqlc.arg(container_id) + AND cv.version = sqlc.arg(version); diff --git a/devenv/docker-compose.yml b/devenv/docker-compose.yml new file mode 100644 index 00000000..db202367 --- /dev/null +++ b/devenv/docker-compose.yml @@ -0,0 +1,41 @@ +name: "memoh-dev" +services: + postgres: + image: postgres:18-alpine + container_name: memoh-dev-postgres + environment: + POSTGRES_DB: memoh + POSTGRES_USER: memoh + POSTGRES_PASSWORD: memoh123 + TZ: ${TZ:-UTC} + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U memoh"] + interval: 5s + timeout: 3s + retries: 5 + restart: unless-stopped + + qdrant: + image: qdrant/qdrant:latest + container_name: memoh-dev-qdrant + volumes: + - qdrant_data:/qdrant/storage + ports: + - "6333:6333" + - "6334:6334" + healthcheck: + test: ["CMD-SHELL", "timeout 5s bash -c ':> /dev/tcp/127.0.0.1/6333' || exit 1"] + interval: 5s + timeout: 3s + retries: 5 + restart: unless-stopped + +volumes: + postgres_data: + driver: local + qdrant_data: + driver: local diff --git a/docker-compose.yml b/docker-compose.yml index 121162d5..2303ab54 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,9 +7,9 @@ services: POSTGRES_DB: memoh POSTGRES_USER: memoh POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-memoh123} + TZ: ${TZ:-UTC} volumes: - - postgres_data:/var/lib/postgresql - - ./db/migrations:/docker-entrypoint-initdb.d:ro + - postgres_data:/var/lib/postgresql/data expose: - "5432" healthcheck: @@ -38,18 +38,24 @@ services: networks: - memoh-network - containerd: + migrate: build: context: . - dockerfile: docker/Dockerfile.containerd - container_name: memoh-containerd - privileged: true - pid: host + dockerfile: docker/Dockerfile.server + args: + - VERSION=${MEMOH_VERSION:-dev} + - COMMIT_HASH=${MEMOH_COMMIT:-unknown} + - BUILD_TIME=${MEMOH_BUILD_TIME:-unknown} + container_name: memoh-migrate + environment: + TZ: ${TZ:-UTC} + entrypoint: ["/app/memoh-server", "migrate", "up"] volumes: - - containerd_sock:/run/containerd - - containerd_data:/var/lib/containerd - - memoh_data:/opt/memoh/data - restart: unless-stopped + - ${MEMOH_CONFIG:-./conf/app.docker.toml}:/app/config.toml:ro + depends_on: + postgres: + condition: service_healthy + restart: "no" networks: - memoh-network @@ -62,28 +68,22 @@ services: - COMMIT_HASH=${MEMOH_COMMIT:-unknown} - BUILD_TIME=${MEMOH_BUILD_TIME:-unknown} container_name: memoh-server + privileged: true pid: host + environment: + TZ: ${TZ:-UTC} volumes: - - ${MEMOH_CONFIG:-./docker/config/config.docker.toml}:/app/config.toml:ro - - containerd_sock:/run/containerd + - ${MEMOH_CONFIG:-./conf/app.docker.toml}:/app/config.toml:ro - containerd_data:/var/lib/containerd - server_cni_state:/var/lib/cni - memoh_data:/opt/memoh/data - cap_add: - - SYS_ADMIN - - NET_ADMIN - security_opt: - - seccomp:unconfined - - apparmor:unconfined ports: - "8080:8080" depends_on: - postgres: - condition: service_healthy + migrate: + condition: service_completed_successfully qdrant: condition: service_healthy - containerd: - condition: service_healthy restart: unless-stopped networks: - memoh-network @@ -93,8 +93,10 @@ services: context: . dockerfile: docker/Dockerfile.agent container_name: memoh-agent + environment: + TZ: ${TZ:-UTC} volumes: - - ${MEMOH_CONFIG:-./docker/config/config.docker.toml}:/config.toml:ro + - ${MEMOH_CONFIG:-./conf/app.docker.toml}:/config.toml:ro ports: - "8081:8081" depends_on: @@ -125,8 +127,6 @@ volumes: driver: local qdrant_data: driver: local - containerd_sock: - driver: local containerd_data: driver: local memoh_data: diff --git a/docker/Dockerfile.server b/docker/Dockerfile.server index d95fb516..8d16ba06 100644 --- a/docker/Dockerfile.server +++ b/docker/Dockerfile.server @@ -1,8 +1,9 @@ # syntax=docker/dockerfile:1 -FROM golang:1.25-alpine AS builder + +# ---- Stage 1: Build server binary ---- +FROM golang:1.25-alpine AS server-builder WORKDIR /build - RUN apk add --no-cache git make COPY go.mod go.sum ./ @@ -14,7 +15,6 @@ COPY . . ARG VERSION=dev ARG COMMIT_HASH=unknown ARG BUILD_TIME=unknown - ARG TARGETOS ARG TARGETARCH @@ -39,11 +39,79 @@ RUN --mount=type=cache,target=/go/pkg/mod \ -X github.com/memohai/memoh/internal/version.BuildTime=${BUILD_TIME}" \ -o memoh-server ./cmd/agent/main.go +# ---- Stage 2: Build MCP binary ---- +FROM golang:1.25-alpine AS mcp-builder + +WORKDIR /src +RUN apk add --no-cache ca-certificates git + +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download + +COPY . . + +ARG TARGETARCH=amd64 +ARG COMMIT_HASH=unknown +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} \ + go build -trimpath \ + -ldflags "-s -w -X github.com/memohai/memoh/internal/version.CommitHash=${COMMIT_HASH}" \ + -o /out/mcp ./cmd/mcp + +# ---- Stage 3: Assemble MCP image rootfs ---- +FROM alpine:latest AS mcp-rootfs + +RUN apk add --no-cache grep curl bash +RUN apk add --no-cache nodejs npm +RUN apk add --no-cache python3 && \ + curl -LsSf https://astral.sh/uv/install.sh | sh && \ + ln -sf /root/.local/bin/uv /usr/local/bin/uv && \ + ln -sf /root/.local/bin/uvx /usr/local/bin/uvx + +COPY --from=mcp-builder /out/mcp /opt/mcp +COPY cmd/mcp/template /opt/mcp-template + +RUN printf '#!/bin/sh\n\ +[ -e /app/mcp ] || { mkdir -p /app; [ -f /opt/mcp ] && cp -a /opt/mcp /app/mcp 2>/dev/null || true; }\n\ +if [ -x /app/mcp ]; then exec /app/mcp "$@"; fi\n\ +exec /opt/mcp "$@"\n' > /opt/entrypoint.sh && chmod +x /opt/entrypoint.sh + +RUN tar -cf /tmp/rootfs.tar \ + --exclude='./proc' --exclude='./sys' --exclude='./dev' \ + --exclude='./tmp' --exclude='./run' \ + -C / . + +# ---- Stage 4: Package rootfs as OCI image tar ---- +FROM alpine:latest AS oci-exporter + +COPY --from=mcp-rootfs /tmp/rootfs.tar /tmp/layer.tar +ARG MCP_IMAGE_TAG=docker.io/library/memoh-mcp:latest + +RUN set -e \ + && LAYER_SHA=$(sha256sum /tmp/layer.tar | awk '{print $1}') \ + && LAYER_SIZE=$(wc -c < /tmp/layer.tar) \ + && mkdir -p "/tmp/image/${LAYER_SHA}" /out \ + && mv /tmp/layer.tar "/tmp/image/${LAYER_SHA}/layer.tar" \ + && printf '{"architecture":"amd64","os":"linux","created":"1970-01-01T00:00:00Z","config":{"Entrypoint":["/opt/entrypoint.sh"],"WorkingDir":"/app","Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]},"rootfs":{"type":"layers","diff_ids":["sha256:%s"]},"history":[{"created":"1970-01-01T00:00:00Z","comment":"memoh-mcp image"}]}' \ + "${LAYER_SHA}" > /tmp/config.json \ + && CONFIG_SHA=$(sha256sum /tmp/config.json | awk '{print $1}') \ + && mv /tmp/config.json "/tmp/image/${CONFIG_SHA}.json" \ + && printf '[{"Config":"%s.json","RepoTags":["%s"],"Layers":["%s/layer.tar"]}]' \ + "${CONFIG_SHA}" "${MCP_IMAGE_TAG}" "${LAYER_SHA}" > /tmp/image/manifest.json \ + && cd /tmp/image && tar -cf /out/memoh-mcp.tar manifest.json "${CONFIG_SHA}.json" "${LAYER_SHA}/" + +# ---- Stage 5: Final runtime (containerd + server + CNI) ---- FROM alpine:latest WORKDIR /app -RUN apk add --no-cache ca-certificates tzdata wget nerdctl cni-plugins iptables \ +# containerd runtime +RUN apk add --no-cache containerd containerd-ctr + +# CNI plugins + iptables (for MCP container networking) +RUN apk add --no-cache ca-certificates tzdata wget cni-plugins iptables \ && mkdir -p /opt/cni/bin \ && (cp -a /usr/lib/cni/. /opt/cni/bin/ 2>/dev/null || true) \ && (cp -a /usr/libexec/cni/. /opt/cni/bin/ 2>/dev/null || true) \ @@ -58,7 +126,7 @@ RUN apk add --no-cache ca-certificates tzdata wget nerdctl cni-plugins iptables ' "bridge": "cni0",' \ ' "isGateway": true,' \ ' "ipMasq": true,' \ - ' "promiscMode": true,' \ + ' "hairpinMode": true,' \ ' "ipam": {' \ ' "type": "host-local",' \ ' "ranges": [[' \ @@ -76,16 +144,24 @@ RUN apk add --no-cache ca-certificates tzdata wget nerdctl cni-plugins iptables ' ]' \ '}' > /etc/cni/net.d/10-memoh.conflist -COPY --from=builder /build/memoh-server /app/memoh-server -COPY --from=builder /build/spec /app/spec +# MCP image for containerd import +COPY --from=oci-exporter /out/memoh-mcp.tar /opt/images/memoh-mcp.tar -RUN mkdir -p /opt/memoh/data +# Server binary and spec +COPY --from=server-builder /build/memoh-server /app/memoh-server +COPY --from=server-builder /build/spec /app/spec + +# Entrypoint: start containerd, then server +COPY docker/server-entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +RUN mkdir -p /opt/memoh/data /run/containerd /var/lib/containerd + +VOLUME ["/var/lib/containerd", "/opt/memoh/data"] EXPOSE 8080 -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8080/health \ - || wget --no-verbose --tries=1 --spider http://server:8080/health \ - || exit 1 +HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8080/health || exit 1 -CMD ["/app/memoh-server"] +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/Dockerfile.web b/docker/Dockerfile.web index ed70a354..cec58def 100644 --- a/docker/Dockerfile.web +++ b/docker/Dockerfile.web @@ -25,7 +25,7 @@ FROM nginx:alpine COPY --from=builder /build/packages/web/dist /usr/share/nginx/html -COPY docker/config/nginx.conf /etc/nginx/conf.d/default.conf +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 8082 diff --git a/docker/config/nginx.conf b/docker/nginx.conf similarity index 100% rename from docker/config/nginx.conf rename to docker/nginx.conf diff --git a/docker/server-entrypoint.sh b/docker/server-entrypoint.sh new file mode 100644 index 00000000..0d053096 --- /dev/null +++ b/docker/server-entrypoint.sh @@ -0,0 +1,69 @@ +#!/bin/sh +set -e + +MCP_IMAGE="${MCP_IMAGE:-docker.io/library/memoh-mcp:latest}" + +# ---- Setup cgroup v2 delegation for nested containerd ---- +if [ -f /sys/fs/cgroup/cgroup.controllers ]; then + echo "Setting up cgroup v2 delegation..." + mkdir -p /sys/fs/cgroup/init + # Move existing processes out of root cgroup to allow subtree control + while read -r pid; do + echo "$pid" > /sys/fs/cgroup/init/cgroup.procs 2>/dev/null || true + done < /sys/fs/cgroup/cgroup.procs + # Enable all available controllers for subtree delegation + sed -e 's/ / +/g' -e 's/^/+/' < /sys/fs/cgroup/cgroup.controllers \ + > /sys/fs/cgroup/cgroup.subtree_control 2>/dev/null || true +fi + +# ---- Start containerd in background ---- +mkdir -p /run/containerd +containerd & +CONTAINERD_PID=$! + +echo "Waiting for containerd..." +for i in $(seq 1 30); do + if ctr version >/dev/null 2>&1; then + break + fi + sleep 1 +done + +if ! ctr version >/dev/null 2>&1; then + echo "ERROR: containerd not responsive after 30s" + exit 1 +fi +echo "containerd is running (pid $CONTAINERD_PID)" + +# ---- Import MCP image if not already present ---- +if ! ctr -n default images check "name==${MCP_IMAGE}" 2>/dev/null | grep -q "${MCP_IMAGE}"; then + echo "Importing MCP image into containerd..." + for tar in /opt/images/*.tar; do + if [ -f "$tar" ]; then + ctr -n default images import --all-platforms "$tar" 2>&1 || true + fi + done + if ctr -n default images check "name==${MCP_IMAGE}" 2>/dev/null | grep -q "${MCP_IMAGE}"; then + echo "MCP image ready: ${MCP_IMAGE}" + else + echo "WARNING: MCP image not available after import, will try pull at runtime" + fi +else + echo "MCP image already present: ${MCP_IMAGE}" +fi + +echo "containerd is ready, starting memoh-server..." + +# ---- Start server (foreground, trap signals for graceful shutdown) ---- +trap 'echo "Shutting down..."; kill $SERVER_PID 2>/dev/null; kill $CONTAINERD_PID 2>/dev/null; wait' TERM INT + +/app/memoh-server serve & +SERVER_PID=$! + +wait $SERVER_PID +EXIT_CODE=$? + +kill $CONTAINERD_PID 2>/dev/null || true +wait $CONTAINERD_PID 2>/dev/null || true + +exit $EXIT_CODE diff --git a/docs/docs/installation/docker.md b/docs/docs/installation/docker.md index db6e9fb9..a414308e 100644 --- a/docs/docs/installation/docker.md +++ b/docs/docs/installation/docker.md @@ -13,7 +13,7 @@ Docker is the recommended way to run Memoh. The stack includes PostgreSQL, Qdran Run the official install script (requires Docker and Docker Compose): ```bash -curl -fsSL https://raw.githubusercontent.com/memohai/Memoh/main/scripts/install.sh | sh +curl -fsSL https://raw.githubusercontent.com/memohai/Memoh/main/scripts/install.sh | sudo sh ``` The script will: @@ -27,7 +27,7 @@ The script will: **Silent install** (use all defaults, no prompts): ```bash -curl -fsSL https://raw.githubusercontent.com/memohai/Memoh/main/scripts/install.sh | sh -s -- -y +curl -fsSL https://raw.githubusercontent.com/memohai/Memoh/main/scripts/install.sh | sudo sh -s -- -y ``` Defaults when running silently: @@ -45,10 +45,12 @@ Clone the repository and start with Docker Compose: ```bash git clone https://github.com/memohai/Memoh.git cd Memoh -docker compose up -d +sudo docker compose up -d ``` -By default, Docker Compose uses `docker/config/config.docker.toml`. No config file in the project root is mounted — only this built-in config is used. See [config.toml reference](./config-toml) for all configuration fields. +> On macOS or if your user is in the `docker` group, `sudo` is not required. + +By default, Docker Compose uses `conf/app.docker.toml`. No config file in the project root is mounted — only this built-in config is used. See [config.toml reference](./config-toml) for all configuration fields. ## Access Points @@ -71,14 +73,14 @@ To use your own config file: 1. Copy the Docker config template and edit it. See [config.toml reference](./config-toml) for field descriptions: ```bash -cp docker/config/config.docker.toml config.toml +cp conf/app.docker.toml config.toml nano config.toml ``` 2. Point `MEMOH_CONFIG` at your config when starting (path is on the host; run `docker compose` from the project root): ```bash -MEMOH_CONFIG=./config.toml docker compose up -d +sudo MEMOH_CONFIG=./config.toml docker compose up -d ``` **Recommended changes for production** (see [config.toml reference](./config-toml) for details): @@ -89,6 +91,8 @@ MEMOH_CONFIG=./config.toml docker compose up -d ## Common Commands +> Prefix with `sudo` on Linux if your user is not in the `docker` group. + ```bash docker compose up -d # Start docker compose down # Stop diff --git a/docs/docs/troubleshooting.md b/docs/docs/troubleshooting.md index 8d5e6a0b..b82bb121 100644 --- a/docs/docs/troubleshooting.md +++ b/docs/docs/troubleshooting.md @@ -47,7 +47,7 @@ docker exec memoh-containerd ctr -n default containers rm mcp- docker compose restart server ``` -> **Note**: If you also run the server locally (outside Docker), keep the Docker config (`docker/config/config.docker.toml`) separate from your local `config.toml`, and update `docker-compose.yml` to mount the Docker-specific config instead. +> **Note**: If you also run the server locally (outside Docker), keep the Docker config (`conf/app.docker.toml`) separate from your local `config.toml`, and update `docker-compose.yml` to mount the Docker-specific config instead. ## MCP Container: Image update not taking effect after rebuild diff --git a/go.mod b/go.mod index 4ec4a115..bf7ed440 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/containerd/platforms v1.0.0-rc.2 github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.8.0 github.com/labstack/echo-jwt/v4 v4.4.0 @@ -81,6 +82,7 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/labstack/gommon v0.4.2 // indirect + github.com/lib/pq v1.10.9 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/moby/locker v1.0.1 // indirect diff --git a/go.sum b/go.sum index 3f987f7b..cae2737f 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ cyphar.com/go-pathrs v0.2.3 h1:0pH8gep37wB0BgaXrEaN1OtZhUMeS7VvaejSr6i822o= cyphar.com/go-pathrs v0.2.3/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= @@ -69,8 +71,16 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -117,6 +127,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= @@ -178,10 +190,14 @@ github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0 github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk= github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= @@ -194,6 +210,8 @@ github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modelcontextprotocol/go-sdk v1.3.0 h1:gMfZkv3DzQF5q/DcQePo5rahEY+sguyPfXDfNBcT0Zs= github.com/modelcontextprotocol/go-sdk v1.3.0/go.mod h1:AnQ//Qc6+4nIyyrB4cxBU7UW9VibK4iOZBeyP/rF1IE= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -202,6 +220,9 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= github.com/onsi/ginkgo/v2 v2.20.1 h1:YlVIbqct+ZmnEph770q9Q7NVAz4wwIiVNahee6JyUzo= github.com/onsi/ginkgo/v2 v2.20.1/go.mod h1:lG9ey2Z29hR41WMVthyJBGUBcBhGOtoPF2VFMvBXFCI= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= diff --git a/internal/attachment/normalize.go b/internal/attachment/normalize.go new file mode 100644 index 00000000..ee462f33 --- /dev/null +++ b/internal/attachment/normalize.go @@ -0,0 +1,140 @@ +package attachment + +import ( + "bytes" + "encoding/base64" + "fmt" + "io" + "net/http" + "strings" + + "github.com/memohai/memoh/internal/media" +) + +// MapMediaType maps attachment type strings to media types. +func MapMediaType(rawType string) media.MediaType { + switch strings.ToLower(strings.TrimSpace(rawType)) { + case "image", "gif": + return media.MediaTypeImage + case "audio", "voice": + return media.MediaTypeAudio + case "video": + return media.MediaTypeVideo + default: + return media.MediaTypeFile + } +} + +// NormalizeMime normalizes MIME to lowercase token form. +func NormalizeMime(raw string) string { + mime := strings.ToLower(strings.TrimSpace(raw)) + if mime == "" { + return "" + } + if idx := strings.Index(mime, ";"); idx >= 0 { + mime = strings.TrimSpace(mime[:idx]) + } + return mime +} + +// MimeFromDataURL extracts MIME from a data URL. +func MimeFromDataURL(raw string) string { + value := strings.TrimSpace(raw) + lower := strings.ToLower(value) + if !strings.HasPrefix(lower, "data:") { + return "" + } + rest := value[len("data:"):] + if idx := strings.Index(rest, ";"); idx >= 0 { + return NormalizeMime(rest[:idx]) + } + if idx := strings.Index(rest, ","); idx >= 0 { + return NormalizeMime(rest[:idx]) + } + return "" +} + +// ResolveMime resolves source MIME and sniffed MIME into final MIME. +func ResolveMime(mediaType media.MediaType, sourceMime, sniffedMime string) string { + source := NormalizeMime(sourceMime) + sniffed := NormalizeMime(sniffedMime) + sourceGeneric := source == "" || source == "application/octet-stream" + + if mediaType == media.MediaTypeImage { + if strings.HasPrefix(source, "image/") { + return source + } + if strings.HasPrefix(sniffed, "image/") { + return sniffed + } + if !sourceGeneric { + return source + } + if sniffed != "" { + return sniffed + } + return "application/octet-stream" + } + + if !sourceGeneric { + return source + } + if sniffed != "" { + return sniffed + } + if source != "" { + return source + } + return "application/octet-stream" +} + +// PrepareReaderAndMime reads a small prefix for MIME sniffing and replays it. +func PrepareReaderAndMime(reader io.Reader, mediaType media.MediaType, sourceMime string) (io.Reader, string, error) { + if reader == nil { + return nil, "", fmt.Errorf("reader is required") + } + header := make([]byte, 512) + n, err := reader.Read(header) + if err != nil && err != io.EOF { + return nil, "", fmt.Errorf("read mime sniff bytes: %w", err) + } + header = header[:n] + sniffed := "" + if len(header) > 0 { + sniffed = NormalizeMime(http.DetectContentType(header)) + } + finalMime := ResolveMime(mediaType, sourceMime, sniffed) + return io.MultiReader(bytes.NewReader(header), reader), finalMime, nil +} + +// NormalizeBase64DataURL normalizes raw base64 into a data URL. +func NormalizeBase64DataURL(input, mime string) string { + value := strings.TrimSpace(input) + if value == "" { + return "" + } + if strings.HasPrefix(strings.ToLower(value), "data:") { + return value + } + mime = NormalizeMime(mime) + if mime == "" { + mime = "application/octet-stream" + } + return "data:" + mime + ";base64," + value +} + +// DecodeBase64 decodes both raw base64 and data URL base64 content. +// The returned reader is bounded to maxBytes+1 for caller-side size validation. +func DecodeBase64(input string, maxBytes int64) (io.Reader, error) { + value := strings.TrimSpace(input) + if value == "" { + return nil, fmt.Errorf("base64 payload is empty") + } + if strings.HasPrefix(strings.ToLower(value), "data:") { + if idx := strings.Index(value, ","); idx >= 0 { + value = value[idx+1:] + } + } + decoder := base64.NewDecoder(base64.StdEncoding, strings.NewReader(value)) + return io.LimitReader(decoder, maxBytes+1), nil +} diff --git a/internal/attachment/normalize_test.go b/internal/attachment/normalize_test.go new file mode 100644 index 00000000..084c856d --- /dev/null +++ b/internal/attachment/normalize_test.go @@ -0,0 +1,121 @@ +package attachment + +import ( + "io" + "strings" + "testing" + + "github.com/memohai/memoh/internal/media" +) + +func TestMapMediaType(t *testing.T) { + cases := []struct { + name string + in string + want media.MediaType + }{ + {name: "image", in: "image", want: media.MediaTypeImage}, + {name: "gif", in: "gif", want: media.MediaTypeImage}, + {name: "audio", in: "audio", want: media.MediaTypeAudio}, + {name: "voice", in: "voice", want: media.MediaTypeAudio}, + {name: "video", in: "video", want: media.MediaTypeVideo}, + {name: "default", in: "file", want: media.MediaTypeFile}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := MapMediaType(tc.in) + if got != tc.want { + t.Fatalf("MapMediaType(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestNormalizeBase64DataURL(t *testing.T) { + got := NormalizeBase64DataURL("AAAA", "image/png") + if got != "data:image/png;base64,AAAA" { + t.Fatalf("unexpected normalized value: %q", got) + } + + already := "data:image/jpeg;base64,BBBB" + if NormalizeBase64DataURL(already, "image/png") != already { + t.Fatalf("expected data url to pass through") + } +} + +func TestNormalizeMime(t *testing.T) { + got := NormalizeMime("IMAGE/JPEG; charset=utf-8") + if got != "image/jpeg" { + t.Fatalf("NormalizeMime unexpected result: %q", got) + } +} + +func TestMimeFromDataURL(t *testing.T) { + got := MimeFromDataURL("data:image/png;base64,AAAA") + if got != "image/png" { + t.Fatalf("MimeFromDataURL unexpected result: %q", got) + } + if MimeFromDataURL("https://example.com/demo.png") != "" { + t.Fatalf("MimeFromDataURL should return empty for non-data-url") + } +} + +func TestResolveMime(t *testing.T) { + if got := ResolveMime(media.MediaTypeImage, "application/octet-stream", "image/jpeg"); got != "image/jpeg" { + t.Fatalf("ResolveMime image unexpected result: %q", got) + } + if got := ResolveMime(media.MediaTypeFile, "application/octet-stream", "application/pdf"); got != "application/pdf" { + t.Fatalf("ResolveMime file unexpected result: %q", got) + } + if got := ResolveMime(media.MediaTypeImage, "", ""); got != "application/octet-stream" { + t.Fatalf("ResolveMime empty unexpected result: %q", got) + } +} + +func TestPrepareReaderAndMime(t *testing.T) { + reader, mime, err := PrepareReaderAndMime(strings.NewReader("\x89PNG\r\n\x1a\npayload"), media.MediaTypeImage, "") + if err != nil { + t.Fatalf("PrepareReaderAndMime returned error: %v", err) + } + if mime != "image/png" { + t.Fatalf("PrepareReaderAndMime mime = %q, want image/png", mime) + } + raw, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("read prepared reader failed: %v", err) + } + if !strings.HasPrefix(string(raw), "\x89PNG\r\n\x1a\n") { + t.Fatalf("prepared reader lost prefix bytes") + } +} + +func TestDecodeBase64(t *testing.T) { + reader, err := DecodeBase64("aGVsbG8=", 1024) + if err != nil { + t.Fatalf("DecodeBase64 returned error: %v", err) + } + raw, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("read decoded bytes failed: %v", err) + } + if string(raw) != "hello" { + t.Fatalf("decoded content = %q, want hello", string(raw)) + } + + reader, err = DecodeBase64("data:text/plain;base64,aGVsbG8=", 1024) + if err != nil { + t.Fatalf("DecodeBase64 with data URL returned error: %v", err) + } + raw, err = io.ReadAll(reader) + if err != nil { + t.Fatalf("read decoded data URL bytes failed: %v", err) + } + if string(raw) != "hello" { + t.Fatalf("decoded data URL content = %q, want hello", string(raw)) + } + + _, err = DecodeBase64("", 1024) + if err == nil { + t.Fatalf("expected empty base64 to return error") + } +} diff --git a/internal/channel/adapters/feishu/feishu_test.go b/internal/channel/adapters/feishu/feishu_test.go index a8726bf5..1d476108 100644 --- a/internal/channel/adapters/feishu/feishu_test.go +++ b/internal/channel/adapters/feishu/feishu_test.go @@ -207,6 +207,32 @@ func TestExtractFeishuInboundImageAttachmentReference(t *testing.T) { } } +func TestExtractFeishuInboundFileAttachmentInfersVideoType(t *testing.T) { + t.Parallel() + + content := `{"file_key":"file_1","file_name":"clip.mp4","mime_type":"video/mp4"}` + msgType := larkim.MsgTypeFile + event := &larkim.P2MessageReceiveV1{ + Event: &larkim.P2MessageReceiveV1Data{ + Message: &larkim.EventMessage{ + MessageType: &msgType, + Content: &content, + }, + }, + } + got := extractFeishuInbound(event, "") + if len(got.Message.Attachments) != 1 { + t.Fatalf("expected one attachment, got %d", len(got.Message.Attachments)) + } + att := got.Message.Attachments[0] + if att.Type != channel.AttachmentVideo { + t.Fatalf("expected inferred video type, got %s", att.Type) + } + if att.Mime != "video/mp4" { + t.Fatalf("expected normalized mime video/mp4, got %s", att.Mime) + } +} + func TestFeishuDescriptorIncludesStreamingAndMedia(t *testing.T) { t.Parallel() diff --git a/internal/channel/adapters/feishu/inbound.go b/internal/channel/adapters/feishu/inbound.go index 7a4a4846..ee1ed02b 100644 --- a/internal/channel/adapters/feishu/inbound.go +++ b/internal/channel/adapters/feishu/inbound.go @@ -57,16 +57,17 @@ func extractFeishuInbound(event *larkim.P2MessageReceiveV1, botOpenID string) ch } case larkim.MsgTypeImage: if key, ok := contentMap["image_key"].(string); ok { - msg.Attachments = append(msg.Attachments, channel.Attachment{ + msg.Attachments = append(msg.Attachments, channel.NormalizeInboundChannelAttachment(channel.Attachment{ Type: channel.AttachmentImage, PlatformKey: key, SourcePlatform: Type.String(), Metadata: map[string]any{"message_id": msg.ID}, - }) + })) } case larkim.MsgTypeFile, larkim.MsgTypeAudio, larkim.MsgTypeMedia: if key, ok := contentMap["file_key"].(string); ok { name, _ := contentMap["file_name"].(string) + mime, _ := contentMap["mime_type"].(string) attType := channel.AttachmentFile switch *message.MessageType { case larkim.MsgTypeAudio: @@ -74,13 +75,14 @@ func extractFeishuInbound(event *larkim.P2MessageReceiveV1, botOpenID string) ch case larkim.MsgTypeMedia: attType = channel.AttachmentVideo } - msg.Attachments = append(msg.Attachments, channel.Attachment{ + msg.Attachments = append(msg.Attachments, channel.NormalizeInboundChannelAttachment(channel.Attachment{ Type: attType, PlatformKey: key, SourcePlatform: Type.String(), Name: name, + Mime: mime, Metadata: map[string]any{"message_id": msg.ID}, - }) + })) } } } @@ -275,24 +277,28 @@ func extractFeishuPostAttachments(contentMap map[string]any, messageID string) [ tag := strings.ToLower(strings.TrimSpace(stringValue(part["tag"]))) if tag == "img" { if key, ok := part["image_key"].(string); ok && strings.TrimSpace(key) != "" { - result = append(result, channel.Attachment{ + mime := strings.TrimSpace(stringValue(part["mime_type"])) + result = append(result, channel.NormalizeInboundChannelAttachment(channel.Attachment{ Type: channel.AttachmentImage, PlatformKey: strings.TrimSpace(key), SourcePlatform: Type.String(), + Mime: mime, Metadata: map[string]any{"message_id": messageID}, - }) + })) } } if tag == "file" { if key, ok := part["file_key"].(string); ok && strings.TrimSpace(key) != "" { name := strings.TrimSpace(stringValue(part["file_name"])) - result = append(result, channel.Attachment{ + mime := strings.TrimSpace(stringValue(part["mime_type"])) + result = append(result, channel.NormalizeInboundChannelAttachment(channel.Attachment{ Type: channel.AttachmentFile, PlatformKey: strings.TrimSpace(key), SourcePlatform: Type.String(), Name: name, + Mime: mime, Metadata: map[string]any{"message_id": messageID}, - }) + })) } } } diff --git a/internal/channel/adapters/local/broadcaster.go b/internal/channel/adapters/local/broadcaster.go new file mode 100644 index 00000000..ea1cc31a --- /dev/null +++ b/internal/channel/adapters/local/broadcaster.go @@ -0,0 +1,36 @@ +package local + +import ( + "context" + + "github.com/memohai/memoh/internal/channel" +) + +// RouteHubBroadcaster implements channel.StreamObserver by forwarding events +// to the RouteHub. This enables cross-channel visibility: events from external +// channels (Telegram, Feishu, …) are mirrored to WebUI/CLI subscribers. +type RouteHubBroadcaster struct { + hub *RouteHub +} + +// NewRouteHubBroadcaster creates a broadcaster that publishes to the given hub. +func NewRouteHubBroadcaster(hub *RouteHub) *RouteHubBroadcaster { + return &RouteHubBroadcaster{hub: hub} +} + +// OnStreamEvent publishes the event to all RouteHub subscribers keyed by botID. +func (b *RouteHubBroadcaster) OnStreamEvent(_ context.Context, botID string, source channel.ChannelType, event channel.StreamEvent) { + if b.hub == nil || botID == "" { + return + } + // Stamp source channel into metadata so the WebUI can distinguish origin. + // Clone metadata to avoid mutating the caller's event. + enriched := event + meta := make(map[string]any, len(event.Metadata)+1) + for k, v := range event.Metadata { + meta[k] = v + } + meta["source_channel"] = string(source) + enriched.Metadata = meta + b.hub.PublishEvent(botID, enriched) +} diff --git a/internal/channel/adapters/local/broadcaster_test.go b/internal/channel/adapters/local/broadcaster_test.go new file mode 100644 index 00000000..4a909ad6 --- /dev/null +++ b/internal/channel/adapters/local/broadcaster_test.go @@ -0,0 +1,95 @@ +package local + +import ( + "context" + "testing" + + "github.com/memohai/memoh/internal/channel" +) + +func TestRouteHubBroadcaster_OnStreamEvent(t *testing.T) { + hub := NewRouteHub() + _, ch, cancel := hub.Subscribe("bot1") + defer cancel() + + broadcaster := NewRouteHubBroadcaster(hub) + event := channel.StreamEvent{ + Type: channel.StreamEventDelta, + Delta: "hello from telegram", + } + + broadcaster.OnStreamEvent(context.Background(), "bot1", "telegram", event) + + select { + case received := <-ch: + if received.Event.Delta != "hello from telegram" { + t.Fatalf("unexpected delta: %s", received.Event.Delta) + } + if received.Event.Metadata == nil { + t.Fatal("expected metadata to be set") + } + if received.Event.Metadata["source_channel"] != "telegram" { + t.Fatalf("unexpected source_channel: %v", received.Event.Metadata["source_channel"]) + } + default: + t.Fatal("expected event to be published to hub") + } +} + +func TestRouteHubBroadcaster_EmptyBotID(t *testing.T) { + hub := NewRouteHub() + _, ch, cancel := hub.Subscribe("") + defer cancel() + + broadcaster := NewRouteHubBroadcaster(hub) + broadcaster.OnStreamEvent(context.Background(), "", "telegram", channel.StreamEvent{ + Type: channel.StreamEventDelta, + Delta: "should be dropped", + }) + + select { + case <-ch: + t.Fatal("expected no event for empty botID") + default: + // OK: no event published. + } +} + +func TestRouteHubBroadcaster_NilHub(t *testing.T) { + broadcaster := NewRouteHubBroadcaster(nil) + // Must not panic. + broadcaster.OnStreamEvent(context.Background(), "bot1", "telegram", channel.StreamEvent{ + Type: channel.StreamEventDelta, + Delta: "no-op", + }) +} + +func TestRouteHubBroadcaster_PreservesOriginalMetadata(t *testing.T) { + hub := NewRouteHub() + _, ch, cancel := hub.Subscribe("bot1") + defer cancel() + + broadcaster := NewRouteHubBroadcaster(hub) + event := channel.StreamEvent{ + Type: channel.StreamEventDelta, + Delta: "data", + Metadata: map[string]any{ + "existing_key": "existing_value", + }, + } + + broadcaster.OnStreamEvent(context.Background(), "bot1", "feishu", event) + + select { + case received := <-ch: + if received.Event.Metadata["source_channel"] != "feishu" { + t.Fatalf("unexpected source_channel: %v", received.Event.Metadata["source_channel"]) + } + // The original event should NOT be mutated (enriched is a copy). + if event.Metadata["source_channel"] != nil { + t.Fatal("original event metadata should not be mutated") + } + default: + t.Fatal("expected event") + } +} diff --git a/internal/channel/adapters/telegram/stream.go b/internal/channel/adapters/telegram/stream.go index 1e3a471f..e941c411 100644 --- a/internal/channel/adapters/telegram/stream.go +++ b/internal/channel/adapters/telegram/stream.go @@ -16,6 +16,7 @@ import ( const telegramStreamEditThrottle = 5000 * time.Millisecond const telegramStreamToolHintText = "Calling tools..." +const telegramStreamPendingSuffix = "\n……" var testEditFunc func(bot *tgbotapi.BotAPI, chatID int64, msgID int, text string, parseMode string) error @@ -82,7 +83,7 @@ func (s *telegramOutboundStream) ensureStreamMessage(ctx context.Context, text s if strings.TrimSpace(text) == "" { text = "..." } else { - text = text + "\n……" + text = strings.TrimSpace(text) + telegramStreamPendingSuffix } chatID, msgID, err := sendTelegramTextReturnMessage(bot, s.target, text, replyTo, s.parseMode) if err != nil { @@ -97,6 +98,12 @@ func (s *telegramOutboundStream) ensureStreamMessage(ctx context.Context, text s return nil } +func normalizeStreamComparableText(value string) string { + normalized := strings.TrimSpace(value) + normalized = strings.TrimSuffix(normalized, telegramStreamPendingSuffix) + return strings.TrimSpace(normalized) +} + func (s *telegramOutboundStream) editStreamMessage(ctx context.Context, text string) error { s.mu.Lock() chatID := s.streamChatID @@ -107,10 +114,10 @@ func (s *telegramOutboundStream) editStreamMessage(ctx context.Context, text str if msgID == 0 { return nil } - text = text + "\n……" - if strings.TrimSpace(text) == lastEdited { + if normalizeStreamComparableText(text) == normalizeStreamComparableText(lastEdited) { return nil } + text = strings.TrimSpace(text) + telegramStreamPendingSuffix if time.Since(lastEditedAt) < telegramStreamEditThrottle { return nil } @@ -216,7 +223,25 @@ func (s *telegramOutboundStream) Push(ctx context.Context, event channel.StreamE return s.editStreamMessageFinal(ctx, telegramStreamToolHintText) case channel.StreamEventToolCallEnd: return nil - case channel.StreamEventAttachment, channel.StreamEventProcessingFailed, channel.StreamEventAgentStart, channel.StreamEventAgentEnd, channel.StreamEventPhaseStart, channel.StreamEventPhaseEnd, channel.StreamEventProcessingStarted, channel.StreamEventProcessingCompleted: + case channel.StreamEventAttachment: + if len(event.Attachments) == 0 { + return nil + } + bot, replyTo, err := s.getBotAndReply(ctx) + if err != nil { + return err + } + for _, att := range event.Attachments { + if sendErr := sendTelegramAttachmentWithAssets(ctx, bot, s.target, att, "", replyTo, "", s.adapter.assets); sendErr != nil { + slog.Warn("telegram: stream attachment send failed", + slog.String("config_id", s.cfg.ID), + slog.String("type", string(att.Type)), + slog.Any("error", sendErr), + ) + } + } + return nil + case channel.StreamEventProcessingFailed, channel.StreamEventAgentStart, channel.StreamEventAgentEnd, channel.StreamEventPhaseStart, channel.StreamEventPhaseEnd, channel.StreamEventProcessingStarted, channel.StreamEventProcessingCompleted: return nil case channel.StreamEventDelta: if event.Delta == "" { @@ -282,7 +307,7 @@ func (s *telegramOutboundStream) Push(ctx context.Context, event channel.StreamE if i > 0 { to = 0 } - if err := sendTelegramAttachment(bot, s.target, att, "", to, parseMode); err != nil && s.adapter.logger != nil { + if err := sendTelegramAttachmentWithAssets(ctx, bot, s.target, att, "", to, parseMode, s.adapter.assets); err != nil && s.adapter.logger != nil { s.adapter.logger.Error("stream final attachment failed", slog.String("config_id", s.cfg.ID), slog.Any("error", err)) } } diff --git a/internal/channel/adapters/telegram/telegram.go b/internal/channel/adapters/telegram/telegram.go index 6568adb2..66c55229 100644 --- a/internal/channel/adapters/telegram/telegram.go +++ b/internal/channel/adapters/telegram/telegram.go @@ -2,6 +2,7 @@ package telegram import ( "context" + "encoding/base64" "errors" "fmt" "io" @@ -22,11 +23,17 @@ import ( const telegramMaxMessageLength = 4096 +// assetOpener reads stored asset bytes by content hash. +type assetOpener interface { + Open(ctx context.Context, botID, contentHash string) (io.ReadCloser, media.Asset, error) +} + // TelegramAdapter implements the channel.Adapter, channel.Sender, and channel.Receiver interfaces for Telegram. type TelegramAdapter struct { logger *slog.Logger mu sync.RWMutex bots map[string]*tgbotapi.BotAPI // keyed by bot token + assets assetOpener } // NewTelegramAdapter creates a TelegramAdapter with the given logger. @@ -42,6 +49,11 @@ func NewTelegramAdapter(log *slog.Logger) *TelegramAdapter { return adapter } +// SetAssetOpener injects the media asset reader for storage-first file delivery. +func (a *TelegramAdapter) SetAssetOpener(opener assetOpener) { + a.assets = opener +} + var getOrCreateBotForTest func(a *TelegramAdapter, token, configID string) (*tgbotapi.BotAPI, error) func (a *TelegramAdapter) getOrCreateBot(token, configID string) (*tgbotapi.BotAPI, error) { @@ -310,7 +322,7 @@ func (a *TelegramAdapter) Send(ctx context.Context, cfg channel.ChannelConfig, m if i > 0 { applyReply = 0 } - if err := sendTelegramAttachment(bot, to, att, caption, applyReply, parseMode); err != nil { + if err := sendTelegramAttachmentWithAssets(ctx, bot, to, att, caption, applyReply, parseMode, a.assets); err != nil { if a.logger != nil { a.logger.Error("send attachment failed", slog.String("config_id", cfg.ID), slog.Any("error", err)) } @@ -509,19 +521,35 @@ func getTelegramRetryAfter(err error) time.Duration { return 0 } +func sendTelegramAttachmentWithAssets(ctx context.Context, bot *tgbotapi.BotAPI, target string, att channel.Attachment, caption string, replyTo int, parseMode string, opener assetOpener) error { + return sendTelegramAttachmentImpl(ctx, bot, target, att, caption, replyTo, parseMode, opener) +} + func sendTelegramAttachment(bot *tgbotapi.BotAPI, target string, att channel.Attachment, caption string, replyTo int, parseMode string) error { + return sendTelegramAttachmentImpl(context.Background(), bot, target, att, caption, replyTo, parseMode, nil) +} + +func sendTelegramAttachmentImpl(_ context.Context, bot *tgbotapi.BotAPI, target string, att channel.Attachment, caption string, replyTo int, parseMode string, opener assetOpener) error { urlRef := strings.TrimSpace(att.URL) keyRef := strings.TrimSpace(att.PlatformKey) sourcePlatform := strings.TrimSpace(att.SourcePlatform) - if urlRef == "" && keyRef == "" { + base64Ref := strings.TrimSpace(att.Base64) + assetID := strings.TrimSpace(att.ContentHash) + if urlRef == "" && keyRef == "" && base64Ref == "" && assetID == "" { return fmt.Errorf("attachment reference is required") } if strings.TrimSpace(caption) == "" && strings.TrimSpace(att.Caption) != "" { caption = strings.TrimSpace(att.Caption) } - file := tgbotapi.RequestFileData(tgbotapi.FileURL(urlRef)) - if keyRef != "" && (sourcePlatform == "" || strings.EqualFold(sourcePlatform, Type.String())) { - file = tgbotapi.FileID(keyRef) + var botID string + if att.Metadata != nil { + if bid, ok := att.Metadata["bot_id"].(string); ok { + botID = bid + } + } + file, err := resolveTelegramFile(urlRef, keyRef, base64Ref, sourcePlatform, att, assetID, botID, opener) + if err != nil { + return err } isChannel := strings.HasPrefix(target, "@") switch att.Type { @@ -619,6 +647,88 @@ func sendTelegramAttachment(bot *tgbotapi.BotAPI, target string, att channel.Att } } +// resolveTelegramFile determines the best tgbotapi.RequestFileData for an attachment. +// Priority: PlatformKey > ContentHash (storage) > public URL > base64 data URL. +func resolveTelegramFile(urlRef, keyRef, base64Ref, sourcePlatform string, att channel.Attachment, assetID, botID string, opener assetOpener) (tgbotapi.RequestFileData, error) { + if keyRef != "" && (sourcePlatform == "" || strings.EqualFold(sourcePlatform, Type.String())) { + return tgbotapi.FileID(keyRef), nil + } + if assetID != "" && botID != "" && opener != nil { + reader, asset, err := opener.Open(context.Background(), botID, assetID) + if err == nil { + data, readErr := io.ReadAll(io.LimitReader(reader, media.MaxAssetBytes+1)) + _ = reader.Close() + if readErr == nil && len(data) > 0 { + name := strings.TrimSpace(att.Name) + if name == "" { + name = fileNameFromMime(asset.Mime, string(att.Type)) + } + return tgbotapi.FileBytes{Name: name, Bytes: data}, nil + } + } + } + if urlRef != "" && !strings.HasPrefix(strings.ToLower(urlRef), "data:") && !strings.HasPrefix(urlRef, "/") { + return tgbotapi.FileURL(urlRef), nil + } + raw := base64Ref + if raw == "" { + raw = urlRef + } + if raw != "" && strings.HasPrefix(strings.ToLower(raw), "data:") { + decoded, err := decodeDataURLBytes(raw) + if err != nil { + return nil, fmt.Errorf("decode data url for telegram upload: %w", err) + } + name := strings.TrimSpace(att.Name) + if name == "" { + name = fileNameFromMime(att.Mime, string(att.Type)) + } + return tgbotapi.FileBytes{Name: name, Bytes: decoded}, nil + } + if urlRef != "" { + return tgbotapi.FileURL(urlRef), nil + } + return nil, fmt.Errorf("no usable attachment reference for telegram") +} + +func decodeDataURLBytes(dataURL string) ([]byte, error) { + value := dataURL + if idx := strings.Index(value, ","); idx >= 0 { + value = value[idx+1:] + } + return io.ReadAll(io.LimitReader( + base64StdDecoder(strings.NewReader(value)), + media.MaxAssetBytes+1, + )) +} + +func base64StdDecoder(r io.Reader) io.Reader { + return base64.NewDecoder(base64.StdEncoding, r) +} + +func fileNameFromMime(mime, fallbackType string) string { + mime = strings.ToLower(strings.TrimSpace(mime)) + switch { + case strings.HasPrefix(mime, "image/png"): + return "image.png" + case strings.HasPrefix(mime, "image/jpeg"), strings.HasPrefix(mime, "image/jpg"): + return "image.jpg" + case strings.HasPrefix(mime, "image/gif"): + return "image.gif" + case strings.HasPrefix(mime, "image/webp"): + return "image.webp" + case strings.HasPrefix(mime, "audio/"): + return "audio.mp3" + case strings.HasPrefix(mime, "video/"): + return "video.mp4" + default: + if strings.TrimSpace(fallbackType) == "image" { + return "image.png" + } + return "file.bin" + } +} + func buildTelegramReplyRef(msg *tgbotapi.Message, chatID string) *channel.ReplyRef { if msg == nil || msg.ReplyToMessage == nil { return nil @@ -797,7 +907,7 @@ func (a *TelegramAdapter) buildTelegramAttachment(bot *tgbotapi.BotAPI, attType if fileID != "" { att.Metadata["file_id"] = fileID } - return att + return channel.NormalizeInboundChannelAttachment(att) } // ResolveAttachment resolves a Telegram attachment reference to a byte stream. diff --git a/internal/channel/adapters/telegram/telegram_test.go b/internal/channel/adapters/telegram/telegram_test.go index 87620bff..c845613b 100644 --- a/internal/channel/adapters/telegram/telegram_test.go +++ b/internal/channel/adapters/telegram/telegram_test.go @@ -3,6 +3,7 @@ package telegram import ( "context" "fmt" + "io" "strings" "testing" "time" @@ -10,6 +11,7 @@ import ( tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" "github.com/memohai/memoh/internal/channel" + "github.com/memohai/memoh/internal/media" ) func TestResolveTelegramSender(t *testing.T) { @@ -96,6 +98,19 @@ func TestBuildTelegramAttachmentIncludesPlatformReference(t *testing.T) { } } +func TestBuildTelegramAttachmentInfersTypeFromMime(t *testing.T) { + t.Parallel() + + adapter := NewTelegramAdapter(nil) + att := adapter.buildTelegramAttachment(nil, channel.AttachmentFile, "file_2", "photo.jpg", "IMAGE/JPEG; charset=utf-8", 10) + if att.Type != channel.AttachmentImage { + t.Fatalf("expected image type, got: %s", att.Type) + } + if att.Mime != "image/jpeg" { + t.Fatalf("expected normalized mime image/jpeg, got: %s", att.Mime) + } +} + func TestTelegramResolveAttachmentRequiresReference(t *testing.T) { t.Parallel() @@ -532,3 +547,144 @@ func TestProcessingFailed_DelegatesToCompleted(t *testing.T) { t.Fatalf("empty handle should be no-op: %v", err) } } + +func TestResolveTelegramFile_PlatformKey(t *testing.T) { + t.Parallel() + + att := channel.Attachment{Type: channel.AttachmentImage, PlatformKey: "file_id_123"} + file, err := resolveTelegramFile("", "file_id_123", "", "", att, "", "", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := file.(tgbotapi.FileID); !ok { + t.Fatalf("expected FileID, got %T", file) + } +} + +func TestResolveTelegramFile_PublicURL(t *testing.T) { + t.Parallel() + + att := channel.Attachment{Type: channel.AttachmentImage} + file, err := resolveTelegramFile("https://example.com/img.png", "", "", "", att, "", "", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := file.(tgbotapi.FileURL); !ok { + t.Fatalf("expected FileURL, got %T", file) + } +} + +func TestResolveTelegramFile_DataURL(t *testing.T) { + t.Parallel() + + dataURL := "data:image/png;base64,iVBORw0KGgo=" + att := channel.Attachment{Type: channel.AttachmentImage, Mime: "image/png", Name: "test.png"} + file, err := resolveTelegramFile("", "", dataURL, "", att, "", "", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + fb, ok := file.(tgbotapi.FileBytes) + if !ok { + t.Fatalf("expected FileBytes, got %T", file) + } + if fb.Name != "test.png" { + t.Fatalf("expected name test.png, got %q", fb.Name) + } + if len(fb.Bytes) == 0 { + t.Fatal("expected non-empty bytes") + } +} + +func TestResolveTelegramFile_NoReference(t *testing.T) { + t.Parallel() + + att := channel.Attachment{Type: channel.AttachmentImage} + _, err := resolveTelegramFile("", "", "", "", att, "", "", nil) + if err == nil { + t.Fatal("expected error when no reference available") + } +} + +func TestResolveTelegramFile_ContainerPathFallsToBase64(t *testing.T) { + t.Parallel() + + dataURL := "data:image/jpeg;base64,/9j/4AAQ" + att := channel.Attachment{Type: channel.AttachmentImage, Mime: "image/jpeg"} + file, err := resolveTelegramFile("/data/media/image/a.jpg", "", dataURL, "", att, "", "", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := file.(tgbotapi.FileBytes); !ok { + t.Fatalf("expected FileBytes for container path + base64, got %T", file) + } +} + +type mockAssetOpener struct { + data []byte + mime string +} + +func (m *mockAssetOpener) Open(_ context.Context, _, _ string) (io.ReadCloser, media.Asset, error) { + return io.NopCloser(strings.NewReader(string(m.data))), media.Asset{Mime: m.mime}, nil +} + +func TestResolveTelegramFile_ContentHash(t *testing.T) { + t.Parallel() + + opener := &mockAssetOpener{data: []byte("fake-png-bytes"), mime: "image/png"} + att := channel.Attachment{Type: channel.AttachmentImage, ContentHash: "asset-123", Name: "output.png"} + file, err := resolveTelegramFile("", "", "", "", att, "asset-123", "bot-1", opener) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + fb, ok := file.(tgbotapi.FileBytes) + if !ok { + t.Fatalf("expected FileBytes from asset reader, got %T", file) + } + if fb.Name != "output.png" { + t.Fatalf("expected name output.png, got %q", fb.Name) + } + if string(fb.Bytes) != "fake-png-bytes" { + t.Fatalf("expected fake-png-bytes, got %q", string(fb.Bytes)) + } +} + +func TestResolveTelegramFile_ContentHashPriorityOverURL(t *testing.T) { + t.Parallel() + + opener := &mockAssetOpener{data: []byte("from-storage"), mime: "image/jpeg"} + att := channel.Attachment{Type: channel.AttachmentImage, ContentHash: "a1"} + file, err := resolveTelegramFile("https://example.com/fallback.jpg", "", "", "", att, "a1", "bot-1", opener) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + fb, ok := file.(tgbotapi.FileBytes) + if !ok { + t.Fatalf("expected FileBytes (asset priority over URL), got %T", file) + } + if string(fb.Bytes) != "from-storage" { + t.Fatalf("expected from-storage, got %q", string(fb.Bytes)) + } +} + +func TestFileNameFromMime(t *testing.T) { + t.Parallel() + + tests := []struct { + mime string + fallbackType string + want string + }{ + {"image/png", "image", "image.png"}, + {"image/jpeg", "image", "image.jpg"}, + {"image/gif", "image", "image.gif"}, + {"video/mp4", "video", "video.mp4"}, + {"", "image", "image.png"}, + {"application/octet-stream", "", "file.bin"}, + } + for _, tt := range tests { + if got := fileNameFromMime(tt.mime, tt.fallbackType); got != tt.want { + t.Errorf("fileNameFromMime(%q, %q) = %q, want %q", tt.mime, tt.fallbackType, got, tt.want) + } + } +} diff --git a/internal/channel/inbound/channel.go b/internal/channel/inbound/channel.go index 39bff54d..a94cc35a 100644 --- a/internal/channel/inbound/channel.go +++ b/internal/channel/inbound/channel.go @@ -9,9 +9,11 @@ import ( "net/http" "regexp" "strings" + "sync" "time" "unicode" + "github.com/memohai/memoh/internal/attachment" "github.com/memohai/memoh/internal/auth" "github.com/memohai/memoh/internal/channel" "github.com/memohai/memoh/internal/channel/route" @@ -38,8 +40,9 @@ type RouteResolver interface { type mediaIngestor interface { Ingest(ctx context.Context, input media.IngestInput) (media.Asset, error) + // GetByStorageKey resolves an asset by reading its sidecar JSON. + GetByStorageKey(ctx context.Context, botID, storageKey string) (media.Asset, error) // AccessPath returns a consumer-accessible reference for a persisted asset. - // The format depends on the storage backend (e.g. container path, URL). AccessPath(asset media.Asset) string } @@ -54,6 +57,7 @@ type ChannelInboundProcessor struct { jwtSecret string tokenTTL time.Duration identity *IdentityResolver + observer channel.StreamObserver } // NewChannelInboundProcessor creates a processor with channel identity-based resolution. @@ -106,6 +110,16 @@ func (p *ChannelInboundProcessor) SetMediaService(mediaService mediaIngestor) { p.mediaService = mediaService } +// SetStreamObserver configures an observer that receives copies of all stream +// events produced for non-local channels (e.g. Telegram, Feishu). This enables +// cross-channel visibility in the WebUI without coupling adapters to the hub. +func (p *ChannelInboundProcessor) SetStreamObserver(observer channel.StreamObserver) { + if p == nil { + return + } + p.observer = observer +} + // HandleInbound processes an inbound channel message through identity resolution and chat gateway. func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel.ChannelConfig, msg channel.InboundMessage, sender channel.StreamReplySender) error { if p.runner == nil { @@ -156,7 +170,7 @@ func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel identity := state.Identity resolvedAttachments := p.ingestInboundAttachments(ctx, cfg, msg, strings.TrimSpace(identity.BotID), msg.Message.Attachments) - attachments := mapChannelAttachments(resolvedAttachments) + attachments := mapChannelToChatAttachments(resolvedAttachments) // Resolve or create the route via channel_routes. if p.routeResolver == nil { @@ -293,6 +307,14 @@ func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel _ = stream.Close(context.WithoutCancel(ctx)) }() + // For non-local channels, wrap the stream so events are mirrored to the + // RouteHub (and thus to WebUI/CLI subscribers). + if p.observer != nil && !isLocalChannelType(msg.Channel) { + stream = channel.NewTeeStream(stream, p.observer, strings.TrimSpace(identity.BotID), msg.Channel) + // Broadcast the inbound user message so WebUI can display it. + p.broadcastInboundMessage(ctx, strings.TrimSpace(identity.BotID), msg, text, identity, resolvedAttachments) + } + if err := stream.Push(ctx, channel.StreamEvent{ Type: channel.StreamEventStatus, Status: channel.StreamStatusStarted, @@ -305,6 +327,20 @@ func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel return err } + // Mutex-protected collector for outbound asset refs. The resolver's + // streaming goroutine calls OutboundAssetCollector at persist time. + var ( + assetMu sync.Mutex + outboundAssetRefs []conversation.OutboundAssetRef + ) + assetCollector := func() []conversation.OutboundAssetRef { + assetMu.Lock() + defer assetMu.Unlock() + result := make([]conversation.OutboundAssetRef, len(outboundAssetRefs)) + copy(result, outboundAssetRefs) + return result + } + chunkCh, streamErrCh := p.runner.StreamChat(ctx, conversation.ChatRequest{ BotID: identity.BotID, ChatID: activeChatID, @@ -321,11 +357,13 @@ func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel Channels: []string{msg.Channel.String()}, UserMessagePersisted: userMessagePersisted, Attachments: attachments, + OutboundAssetCollector: assetCollector, }) var ( - finalMessages []conversation.ModelMessage - streamErr error + finalMessages []conversation.ModelMessage + outboundAttachments []channel.Attachment + streamErr error ) for chunkCh != nil || streamErrCh != nil { select { @@ -347,8 +385,34 @@ func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel } continue } - for _, event := range events { - if pushErr := stream.Push(ctx, event); pushErr != nil { + for i, event := range events { + if event.Type == channel.StreamEventAttachment && len(event.Attachments) > 0 { + ingested := p.ingestOutboundAttachments(ctx, strings.TrimSpace(identity.BotID), event.Attachments) + events[i].Attachments = ingested + outboundAttachments = append(outboundAttachments, ingested...) + assetMu.Lock() + for _, att := range ingested { + contentHash := strings.TrimSpace(att.ContentHash) + if contentHash == "" { + continue + } + ref := conversation.OutboundAssetRef{ + ContentHash: contentHash, + Role: "attachment", + Ordinal: len(outboundAssetRefs), + Mime: strings.TrimSpace(att.Mime), + SizeBytes: att.Size, + } + if att.Metadata != nil { + if sk, ok := att.Metadata["storage_key"].(string); ok { + ref.StorageKey = sk + } + } + outboundAssetRefs = append(outboundAssetRefs, ref) + } + assetMu.Unlock() + } + if pushErr := stream.Push(ctx, events[i]); pushErr != nil { streamErr = pushErr break } @@ -409,9 +473,10 @@ func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel } outputs := flow.ExtractAssistantOutputs(finalMessages) + attachmentsApplied := false for _, output := range outputs { outMessage := buildChannelMessage(output, desc.Capabilities) - if outMessage.IsEmpty() { + if outMessage.IsEmpty() && !(len(outboundAttachments) > 0 && !attachmentsApplied) { continue } plainText := strings.TrimSpace(outMessage.PlainText()) @@ -421,6 +486,10 @@ func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel if isMessagingToolDuplicate(plainText, sentTexts) { continue } + if !attachmentsApplied && len(outboundAttachments) > 0 { + outMessage.Attachments = append(outMessage.Attachments, outboundAttachments...) + attachmentsApplied = true + } if outMessage.Reply == nil && sourceMessageID != "" { outMessage.Reply = &channel.ReplyRef{ Target: target, @@ -436,6 +505,18 @@ func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel return err } } + if !attachmentsApplied && len(outboundAttachments) > 0 { + attachMsg := channel.Message{Attachments: outboundAttachments} + if sourceMessageID != "" { + attachMsg.Reply = &channel.ReplyRef{Target: target, MessageID: sourceMessageID} + } + if err := stream.Push(ctx, channel.StreamEvent{ + Type: channel.StreamEventFinal, + Final: &channel.StreamFinalizePayload{Message: attachMsg}, + }); err != nil { + return err + } + } if err := stream.Push(ctx, channel.StreamEvent{ Type: channel.StreamEventStatus, Status: channel.StreamStatusCompleted, @@ -1176,35 +1257,6 @@ func parseRawJSON(raw json.RawMessage) any { return v } -// mapChannelAttachments converts channel.Attachment slice to conversation.ChatAttachment slice. -// When an attachment has been ingested (AssetID is set), the URL field contains -// the container-internal path; it is mapped to Path for downstream consumers. -func mapChannelAttachments(attachments []channel.Attachment) []conversation.ChatAttachment { - if len(attachments) == 0 { - return nil - } - result := make([]conversation.ChatAttachment, 0, len(attachments)) - for _, att := range attachments { - ca := conversation.ChatAttachment{ - Type: string(att.Type), - PlatformKey: att.PlatformKey, - AssetID: att.AssetID, - Name: att.Name, - Mime: att.Mime, - Size: att.Size, - Metadata: att.Metadata, - } - if strings.TrimSpace(att.AssetID) != "" { - ca.Path = att.URL - ca.Base64 = att.Base64 - } else { - ca.URL = att.URL - } - result = append(result, ca) - } - return result -} - func (p *ChannelInboundProcessor) ingestInboundAttachments( ctx context.Context, cfg channel.ChannelConfig, @@ -1218,7 +1270,7 @@ func (p *ChannelInboundProcessor) ingestInboundAttachments( result := make([]channel.Attachment, 0, len(attachments)) for _, att := range attachments { item := att - if strings.TrimSpace(item.AssetID) != "" { + if strings.TrimSpace(item.ContentHash) != "" { result = append(result, item) continue } @@ -1236,8 +1288,9 @@ func (p *ChannelInboundProcessor) ingestInboundAttachments( result = append(result, item) continue } - if strings.TrimSpace(item.Mime) == "" { - item.Mime = strings.TrimSpace(payload.mime) + sourceMime := attachment.NormalizeMime(item.Mime) + if sourceMime == "" { + sourceMime = attachment.NormalizeMime(payload.mime) } if strings.TrimSpace(item.Name) == "" { item.Name = strings.TrimSpace(payload.name) @@ -1245,15 +1298,31 @@ func (p *ChannelInboundProcessor) ingestInboundAttachments( if item.Size == 0 && payload.size > 0 { item.Size = payload.size } + mediaType := attachment.MapMediaType(string(item.Type)) + preparedReader, finalMime, err := attachment.PrepareReaderAndMime(payload.reader, mediaType, sourceMime) + if err != nil { + if payload.reader != nil { + _ = payload.reader.Close() + } + if p.logger != nil { + p.logger.Warn( + "inbound attachment mime prepare failed", + slog.Any("error", err), + slog.String("attachment_type", strings.TrimSpace(string(item.Type))), + slog.String("attachment_url", strings.TrimSpace(item.URL)), + slog.String("platform_key", strings.TrimSpace(item.PlatformKey)), + ) + } + result = append(result, item) + continue + } + item.Mime = finalMime maxBytes := media.MaxAssetBytes asset, err := p.mediaService.Ingest(ctx, media.IngestInput{ - BotID: botID, - MediaType: mapInboundAttachmentMediaType(string(item.Type)), - Mime: strings.TrimSpace(item.Mime), - OriginalName: strings.TrimSpace(item.Name), - Metadata: item.Metadata, - Reader: payload.reader, - MaxBytes: maxBytes, + BotID: botID, + Mime: strings.TrimSpace(item.Mime), + Reader: preparedReader, + MaxBytes: maxBytes, }) if payload.reader != nil { _ = payload.reader.Close() @@ -1271,11 +1340,17 @@ func (p *ChannelInboundProcessor) ingestInboundAttachments( result = append(result, item) continue } - item.AssetID = asset.ID + item.ContentHash = asset.ContentHash item.URL = p.mediaService.AccessPath(asset) item.PlatformKey = "" + item.Base64 = "" + if item.Metadata == nil { + item.Metadata = make(map[string]any) + } + item.Metadata["bot_id"] = botID + item.Metadata["storage_key"] = asset.StorageKey if strings.TrimSpace(item.Mime) == "" { - item.Mime = strings.TrimSpace(asset.Mime) + item.Mime = attachment.NormalizeMime(asset.Mime) } if item.Size == 0 && asset.SizeBytes > 0 { item.Size = asset.SizeBytes @@ -1310,11 +1385,27 @@ func (p *ChannelInboundProcessor) loadInboundAttachmentPayload( } return payload, nil } - // When URL download fails and platform_key exists, attempt resolver fallback. - if strings.TrimSpace(att.PlatformKey) == "" { + // When URL download fails and no other source exists, return URL error. + if strings.TrimSpace(att.PlatformKey) == "" && strings.TrimSpace(att.Base64) == "" { return inboundAttachmentPayload{}, err } } + rawBase64 := strings.TrimSpace(att.Base64) + if rawBase64 != "" { + decoded, err := attachment.DecodeBase64(rawBase64, media.MaxAssetBytes) + if err != nil { + return inboundAttachmentPayload{}, fmt.Errorf("decode attachment base64: %w", err) + } + mimeType := strings.TrimSpace(att.Mime) + if mimeType == "" { + mimeType = strings.TrimSpace(attachment.MimeFromDataURL(rawBase64)) + } + return inboundAttachmentPayload{ + reader: io.NopCloser(decoded), + mime: mimeType, + name: strings.TrimSpace(att.Name), + }, nil + } platformKey := strings.TrimSpace(att.PlatformKey) if platformKey == "" { return inboundAttachmentPayload{}, fmt.Errorf("attachment has no ingestible payload") @@ -1387,17 +1478,197 @@ func (p *ChannelInboundProcessor) resolveAttachmentResolver(channelType channel. return resolver } -func mapInboundAttachmentMediaType(t string) media.MediaType { - switch strings.ToLower(strings.TrimSpace(t)) { - case "image", "gif": - return media.MediaTypeImage - case "audio", "voice": - return media.MediaTypeAudio - case "video": - return media.MediaTypeVideo - default: - return media.MediaTypeFile +// ingestOutboundAttachments persists LLM-generated attachment data URLs via the +// media service, replacing ephemeral data URLs with stable asset references. +// For container-internal paths (non-HTTP), it attempts to resolve the existing +// asset by matching the storage key extracted from the path. +func (p *ChannelInboundProcessor) ingestOutboundAttachments(ctx context.Context, botID string, attachments []channel.Attachment) []channel.Attachment { + if len(attachments) == 0 || p.mediaService == nil || strings.TrimSpace(botID) == "" { + return attachments } + result := make([]channel.Attachment, 0, len(attachments)) + for _, att := range attachments { + item := att + rawURL := strings.TrimSpace(item.URL) + if strings.TrimSpace(item.ContentHash) != "" { + result = append(result, item) + continue + } + // Non-data-URL, non-HTTP path: try to resolve as an existing asset via storage key. + if rawURL != "" && !isDataURL(rawURL) && !isHTTPURL(rawURL) { + if resolved := p.resolveContainerPathAsset(ctx, botID, rawURL, &item); resolved { + result = append(result, item) + continue + } + result = append(result, item) + continue + } + if !isDataURL(rawURL) { + result = append(result, item) + continue + } + decoded, err := attachment.DecodeBase64(rawURL, media.MaxAssetBytes) + if err != nil { + if p.logger != nil { + p.logger.Warn("decode outbound attachment data url failed", slog.Any("error", err)) + } + result = append(result, item) + continue + } + mimeType := attachment.NormalizeMime(item.Mime) + if mimeType == "" { + mimeType = attachment.MimeFromDataURL(rawURL) + } + asset, err := p.mediaService.Ingest(ctx, media.IngestInput{ + BotID: botID, + Mime: mimeType, + Reader: decoded, + MaxBytes: media.MaxAssetBytes, + }) + if err != nil { + if p.logger != nil { + p.logger.Warn("ingest outbound attachment failed", slog.Any("error", err)) + } + result = append(result, item) + continue + } + item.ContentHash = asset.ContentHash + item.URL = "" + item.Base64 = "" + if item.Metadata == nil { + item.Metadata = make(map[string]any) + } + item.Metadata["bot_id"] = botID + item.Metadata["storage_key"] = asset.StorageKey + if strings.TrimSpace(item.Mime) == "" { + item.Mime = attachment.NormalizeMime(asset.Mime) + } + if item.Size == 0 && asset.SizeBytes > 0 { + item.Size = asset.SizeBytes + } + result = append(result, item) + } + return result +} + +func isDataURL(raw string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(raw)), "data:") +} + +func isHTTPURL(raw string) bool { + lower := strings.ToLower(strings.TrimSpace(raw)) + return strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") +} + +// resolveContainerPathAsset attempts to match a container-internal file path +// to an existing media asset by extracting the storage key from the path. +// Returns true if the asset was resolved and item was updated. +func (p *ChannelInboundProcessor) resolveContainerPathAsset(ctx context.Context, botID, accessPath string, item *channel.Attachment) bool { + storageKey := extractStorageKey(accessPath, botID) + if storageKey == "" { + return false + } + asset, err := p.mediaService.GetByStorageKey(ctx, botID, storageKey) + if err != nil { + return false + } + item.ContentHash = asset.ContentHash + if item.Metadata == nil { + item.Metadata = make(map[string]any) + } + item.Metadata["bot_id"] = botID + item.Metadata["storage_key"] = asset.StorageKey + if strings.TrimSpace(item.Mime) == "" { + item.Mime = attachment.NormalizeMime(asset.Mime) + } + if item.Size == 0 && asset.SizeBytes > 0 { + item.Size = asset.SizeBytes + } + return true +} + +// extractStorageKey derives the media storage key from a container-internal +// access path. The expected path format is /data/media/. +func extractStorageKey(accessPath, _ string) string { + const marker = "/data/media/" + idx := strings.Index(accessPath, marker) + if idx < 0 { + return "" + } + return accessPath[idx+len(marker):] +} + +// isLocalChannelType returns true for channels that already publish to RouteHub +// natively (Web, CLI). Wrapping these with a tee would cause duplicate events. +func isLocalChannelType(ct channel.ChannelType) bool { + s := strings.ToLower(strings.TrimSpace(string(ct))) + return s == "web" || s == "cli" +} + +// broadcastInboundMessage notifies the observer about the user's inbound +// message so WebUI subscribers see the full conversation, not just the bot reply. +func (p *ChannelInboundProcessor) broadcastInboundMessage( + ctx context.Context, + botID string, + msg channel.InboundMessage, + text string, + identity InboundIdentity, + resolvedAttachments []channel.Attachment, +) { + if p.observer == nil || strings.TrimSpace(botID) == "" { + return + } + inboundMsg := channel.Message{ + Text: text, + Attachments: resolvedAttachments, + Metadata: map[string]any{ + "external_message_id": strings.TrimSpace(msg.Message.ID), + "sender_display_name": strings.TrimSpace(identity.DisplayName), + }, + } + p.observer.OnStreamEvent(ctx, botID, msg.Channel, channel.StreamEvent{ + Type: channel.StreamEventFinal, + Final: &channel.StreamFinalizePayload{ + Message: inboundMsg, + }, + Metadata: map[string]any{ + "source_channel": string(msg.Channel), + "role": "user", + "sender_user_id": strings.TrimSpace(identity.UserID), + }, + }) +} + +// channelAttachmentsToAssetRefs converts channel Attachments to message AssetRefs +// with full metadata for denormalized persistence. +func channelAttachmentsToAssetRefs(attachments []channel.Attachment, role string) []messagepkg.AssetRef { + if len(attachments) == 0 { + return nil + } + refs := make([]messagepkg.AssetRef, 0, len(attachments)) + for idx, att := range attachments { + contentHash := strings.TrimSpace(att.ContentHash) + if contentHash == "" { + continue + } + ref := messagepkg.AssetRef{ + ContentHash: contentHash, + Role: role, + Ordinal: idx, + Mime: strings.TrimSpace(att.Mime), + SizeBytes: att.Size, + } + if att.Metadata != nil { + if sk, ok := att.Metadata["storage_key"].(string); ok { + ref.StorageKey = sk + } + } + refs = append(refs, ref) + } + if len(refs) == 0 { + return nil + } + return refs } func chatAttachmentsToAssetRefs(attachments []conversation.ChatAttachment) []messagepkg.AssetRef { @@ -1406,15 +1677,23 @@ func chatAttachmentsToAssetRefs(attachments []conversation.ChatAttachment) []mes } refs := make([]messagepkg.AssetRef, 0, len(attachments)) for idx, att := range attachments { - assetID := strings.TrimSpace(att.AssetID) - if assetID == "" { + contentHash := strings.TrimSpace(att.ContentHash) + if contentHash == "" { continue } - refs = append(refs, messagepkg.AssetRef{ - AssetID: assetID, - Role: "attachment", - Ordinal: idx, - }) + ref := messagepkg.AssetRef{ + ContentHash: contentHash, + Role: "attachment", + Ordinal: idx, + Mime: strings.TrimSpace(att.Mime), + SizeBytes: att.Size, + } + if att.Metadata != nil { + if sk, ok := att.Metadata["storage_key"].(string); ok { + ref.StorageKey = sk + } + } + refs = append(refs, ref) } if len(refs) == 0 { return nil @@ -1422,6 +1701,32 @@ func chatAttachmentsToAssetRefs(attachments []conversation.ChatAttachment) []mes return refs } +func mapChannelToChatAttachments(attachments []channel.Attachment) []conversation.ChatAttachment { + if len(attachments) == 0 { + return nil + } + result := make([]conversation.ChatAttachment, 0, len(attachments)) + for _, att := range attachments { + ca := conversation.ChatAttachment{ + Type: string(att.Type), + PlatformKey: att.PlatformKey, + ContentHash: att.ContentHash, + Name: att.Name, + Mime: attachment.NormalizeMime(att.Mime), + Size: att.Size, + Metadata: att.Metadata, + Base64: attachment.NormalizeBase64DataURL(att.Base64, attachment.NormalizeMime(att.Mime)), + } + if strings.TrimSpace(att.ContentHash) != "" { + ca.Path = att.URL + } else { + ca.URL = att.URL + } + result = append(result, ca) + } + return result +} + // parseAttachmentDelta converts raw JSON attachment data to channel Attachments. func parseAttachmentDelta(raw json.RawMessage) []channel.Attachment { if len(raw) == 0 { @@ -1432,7 +1737,7 @@ func parseAttachmentDelta(raw json.RawMessage) []channel.Attachment { URL string `json:"url"` Path string `json:"path"` PlatformKey string `json:"platform_key"` - AssetID string `json:"asset_id"` + ContentHash string `json:"content_hash"` Name string `json:"name"` Mime string `json:"mime"` Size int64 `json:"size"` @@ -1450,7 +1755,7 @@ func parseAttachmentDelta(raw json.RawMessage) []channel.Attachment { Type: channel.AttachmentType(strings.TrimSpace(item.Type)), URL: url, PlatformKey: strings.TrimSpace(item.PlatformKey), - AssetID: strings.TrimSpace(item.AssetID), + ContentHash: strings.TrimSpace(item.ContentHash), Name: strings.TrimSpace(item.Name), Mime: strings.TrimSpace(item.Mime), Size: item.Size, diff --git a/internal/channel/inbound/channel_test.go b/internal/channel/inbound/channel_test.go index 87bc43a6..ca980962 100644 --- a/internal/channel/inbound/channel_test.go +++ b/internal/channel/inbound/channel_test.go @@ -2,6 +2,7 @@ package inbound import ( "context" + "encoding/base64" "encoding/json" "errors" "io" @@ -176,18 +177,22 @@ type fakeChatService struct { } type fakeMediaIngestor struct { - nextID string - nextMime string - ingestErr error - calls int - inputs []media.IngestInput + nextID string + nextMime string + ingestErr error + calls int + inputs []media.IngestInput + payloads [][]byte + storageKeyAsset media.Asset + storageKeyErr error } func (f *fakeMediaIngestor) Ingest(ctx context.Context, input media.IngestInput) (media.Asset, error) { f.calls++ f.inputs = append(f.inputs, input) if input.Reader != nil { - _, _ = io.ReadAll(input.Reader) + payload, _ := io.ReadAll(input.Reader) + f.payloads = append(f.payloads, payload) } if f.ingestErr != nil { return media.Asset{}, f.ingestErr @@ -201,18 +206,18 @@ func (f *fakeMediaIngestor) Ingest(ctx context.Context, input media.IngestInput) mime = strings.TrimSpace(input.Mime) } return media.Asset{ - ID: id, - Mime: mime, - StorageKey: input.BotID + "/" + string(input.MediaType) + "/test/" + id, + ContentHash: id, + Mime: mime, + StorageKey: "test/" + id, }, nil } +func (f *fakeMediaIngestor) GetByStorageKey(_ context.Context, _, _ string) (media.Asset, error) { + return f.storageKeyAsset, f.storageKeyErr +} + func (f *fakeMediaIngestor) AccessPath(asset media.Asset) string { - sub := asset.StorageKey - if idx := strings.IndexByte(sub, '/'); idx >= 0 { - sub = sub[idx+1:] - } - return "/data/media/" + sub + return "/data/media/" + asset.StorageKey } type fakeAttachmentResolverAdapter struct{} @@ -529,7 +534,7 @@ func TestChannelInboundProcessorPersistsAttachmentAssetRefs(t *testing.T) { { Type: channel.AttachmentImage, URL: "https://example.com/img.png", - AssetID: "asset-1", + ContentHash: "asset-1", Name: "img.png", Mime: "image/png", }, @@ -552,14 +557,14 @@ func TestChannelInboundProcessorPersistsAttachmentAssetRefs(t *testing.T) { if len(chatSvc.persistedIn[0].Assets) != 1 { t.Fatalf("expected one persisted asset ref, got %d", len(chatSvc.persistedIn[0].Assets)) } - if got := chatSvc.persistedIn[0].Assets[0].AssetID; got != "asset-1" { - t.Fatalf("expected persisted asset id asset-1, got %q", got) + if got := chatSvc.persistedIn[0].Assets[0].ContentHash; got != "asset-1" { + t.Fatalf("expected persisted content_hash asset-1, got %q", got) } if len(gateway.gotReq.Attachments) != 1 { t.Fatalf("expected one gateway attachment, got %d", len(gateway.gotReq.Attachments)) } - if got := gateway.gotReq.Attachments[0].AssetID; got != "asset-1" { - t.Fatalf("expected gateway attachment asset_id asset-1, got %q", got) + if got := gateway.gotReq.Attachments[0].ContentHash; got != "asset-1" { + t.Fatalf("expected gateway attachment content_hash asset-1, got %q", got) } } @@ -612,14 +617,89 @@ func TestChannelInboundProcessorIngestsPlatformKeyWithResolver(t *testing.T) { if len(gateway.gotReq.Attachments) != 1 { t.Fatalf("expected one gateway attachment, got %d", len(gateway.gotReq.Attachments)) } - if got := gateway.gotReq.Attachments[0].AssetID; got != "asset-resolved-1" { + if got := gateway.gotReq.Attachments[0].ContentHash; got != "asset-resolved-1" { t.Fatalf("expected resolved asset id, got %q", got) } if len(chatSvc.persistedIn) != 1 || len(chatSvc.persistedIn[0].Assets) != 1 { t.Fatalf("expected one persisted asset ref, got %+v", chatSvc.persistedIn) } - if got := chatSvc.persistedIn[0].Assets[0].AssetID; got != "asset-resolved-1" { - t.Fatalf("expected persisted asset id asset-resolved-1, got %q", got) + if got := chatSvc.persistedIn[0].Assets[0].ContentHash; got != "asset-resolved-1" { + t.Fatalf("expected persisted content_hash asset-resolved-1, got %q", got) + } +} + +func TestChannelInboundProcessorIngestsBase64Attachment(t *testing.T) { + channelIdentitySvc := &fakeChannelIdentityService{channelIdentity: identities.ChannelIdentity{ID: "channelIdentity-base64"}} + memberSvc := &fakeMemberService{isMember: true} + chatSvc := &fakeChatService{resolveResult: route.ResolveConversationResult{ChatID: "chat-base64", RouteID: "route-base64"}} + gateway := &fakeChatGateway{ + resp: conversation.ChatResponse{ + Messages: []conversation.ModelMessage{ + {Role: "assistant", Content: conversation.NewTextContent("ok")}, + }, + }, + } + processor := NewChannelInboundProcessor(slog.Default(), nil, chatSvc, chatSvc, gateway, channelIdentitySvc, memberSvc, nil, nil, nil, "", 0) + mediaSvc := &fakeMediaIngestor{nextID: "asset-base64-1", nextMime: "image/png"} + processor.SetMediaService(mediaSvc) + sender := &fakeReplySender{} + + encoded := base64.StdEncoding.EncodeToString([]byte("fake-image-bytes")) + cfg := channel.ChannelConfig{ID: "cfg-base64", BotID: "bot-1", ChannelType: channel.ChannelType("web")} + msg := channel.InboundMessage{ + BotID: "bot-1", + Channel: channel.ChannelType("web"), + Message: channel.Message{ + ID: "msg-base64-1", + Text: "attachment base64 test", + Attachments: []channel.Attachment{ + { + Type: channel.AttachmentImage, + Base64: "data:image/png;base64," + encoded, + Name: "cat.png", + }, + }, + }, + ReplyTarget: "web-target", + Sender: channel.Identity{ + SubjectID: "web-subject", + Attributes: map[string]string{ + "user_id": "web-user-id", + }, + }, + Conversation: channel.Conversation{ + ID: "web-conv", + Type: "p2p", + }, + } + + if err := processor.HandleInbound(context.Background(), cfg, msg, sender); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mediaSvc.calls != 1 { + t.Fatalf("expected media ingest to be called once, got %d", mediaSvc.calls) + } + if len(mediaSvc.payloads) != 1 || string(mediaSvc.payloads[0]) != "fake-image-bytes" { + t.Fatalf("unexpected ingested payload: %+v", mediaSvc.payloads) + } + if len(gateway.gotReq.Attachments) != 1 { + t.Fatalf("expected one gateway attachment, got %d", len(gateway.gotReq.Attachments)) + } + gotAttachment := gateway.gotReq.Attachments[0] + if gotAttachment.ContentHash != "asset-base64-1" { + t.Fatalf("expected resolved asset id, got %q", gotAttachment.ContentHash) + } + if gotAttachment.Base64 != "" { + t.Fatalf("expected base64 to be cleared after ingest, got %q", gotAttachment.Base64) + } + if !strings.HasPrefix(gotAttachment.Path, "/data/media/") { + t.Fatalf("expected attachment path under /data/media/, got %q", gotAttachment.Path) + } + if len(chatSvc.persistedIn) != 1 || len(chatSvc.persistedIn[0].Assets) != 1 { + t.Fatalf("expected one persisted asset ref, got %+v", chatSvc.persistedIn) + } + if got := chatSvc.persistedIn[0].Assets[0].ContentHash; got != "asset-base64-1" { + t.Fatalf("expected persisted content_hash asset-base64-1, got %q", got) } } @@ -1134,3 +1214,194 @@ func TestMapStreamChunkToChannelEvents_FinalMessages(t *testing.T) { t.Fatalf("expected role assistant, got %q", messages[0].Role) } } + +func TestIngestOutboundAttachments_DataURL(t *testing.T) { + t.Parallel() + + p := &ChannelInboundProcessor{} + attachments := []channel.Attachment{ + {Type: channel.AttachmentImage, URL: "data:image/png;base64,iVBORw0KGgo=", Mime: "image/png"}, + } + // Without media service, attachments pass through unchanged. + result := p.ingestOutboundAttachments(context.Background(), "bot-1", attachments) + if len(result) != 1 { + t.Fatalf("expected 1 attachment, got %d", len(result)) + } + if result[0].ContentHash != "" { + t.Fatalf("expected empty content_hash without media service, got %q", result[0].ContentHash) + } +} + +func TestIngestOutboundAttachments_NonDataURL(t *testing.T) { + t.Parallel() + + p := &ChannelInboundProcessor{} + attachments := []channel.Attachment{ + {Type: channel.AttachmentImage, URL: "https://example.com/img.png"}, + {Type: channel.AttachmentImage, ContentHash: "existing-asset", URL: "/data/media/img.png"}, + } + result := p.ingestOutboundAttachments(context.Background(), "bot-1", attachments) + if len(result) != 2 { + t.Fatalf("expected 2 attachments, got %d", len(result)) + } + if result[0].URL != "https://example.com/img.png" { + t.Fatalf("expected public URL preserved, got %q", result[0].URL) + } + if result[1].ContentHash != "existing-asset" { + t.Fatalf("expected existing content_hash preserved, got %q", result[1].ContentHash) + } +} + +func TestChannelAttachmentsToAssetRefs(t *testing.T) { + t.Parallel() + + attachments := []channel.Attachment{ + {ContentHash: "a1", Type: channel.AttachmentImage}, + {Type: channel.AttachmentFile}, + {ContentHash: "a2", Type: channel.AttachmentAudio}, + } + refs := channelAttachmentsToAssetRefs(attachments, "output") + if len(refs) != 2 { + t.Fatalf("expected 2 refs, got %d", len(refs)) + } + if refs[0].ContentHash != "a1" || refs[0].Role != "output" { + t.Fatalf("unexpected ref[0]: %+v", refs[0]) + } + if refs[1].ContentHash != "a2" { + t.Fatalf("unexpected ref[1]: %+v", refs[1]) + } +} + +func TestIsDataURL(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + want bool + }{ + {"data:image/png;base64,abc", true}, + {"DATA:text/plain;base64,abc", true}, + {"https://example.com", false}, + {"/data/media/img.png", false}, + {"", false}, + } + for _, tt := range tests { + if got := isDataURL(tt.input); got != tt.want { + t.Errorf("isDataURL(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestExtractStorageKey(t *testing.T) { + t.Parallel() + tests := []struct { + accessPath string + botID string + want string + }{ + {"/data/media/26da/26da0cc7.jpg", "bot-1", "26da/26da0cc7.jpg"}, + {"/data/media/abcd/abcd1234.pdf", "bot-2", "abcd/abcd1234.pdf"}, + {"https://example.com/img.png", "bot-1", ""}, + {"", "bot-1", ""}, + } + for _, tt := range tests { + got := extractStorageKey(tt.accessPath, tt.botID) + if got != tt.want { + t.Errorf("extractStorageKey(%q, %q) = %q, want %q", tt.accessPath, tt.botID, got, tt.want) + } + } +} + +func TestIsHTTPURL(t *testing.T) { + t.Parallel() + tests := []struct { + input string + want bool + }{ + {"https://example.com/img.png", true}, + {"http://localhost:8080/test", true}, + {"HTTP://EXAMPLE.COM", true}, + {"/data/media/img.png", false}, + {"data:image/png;base64,abc", false}, + {"", false}, + } + for _, tt := range tests { + if got := isHTTPURL(tt.input); got != tt.want { + t.Errorf("isHTTPURL(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestIngestOutboundAttachments_ContainerPath(t *testing.T) { + t.Parallel() + + ms := &fakeMediaIngestor{ + storageKeyAsset: media.Asset{ContentHash: "resolved-asset-1", Mime: "image/jpeg", SizeBytes: 1024}, + } + p := &ChannelInboundProcessor{mediaService: ms} + attachments := []channel.Attachment{ + {Type: channel.AttachmentImage, URL: "/data/media/26da/26da0cc7.jpg"}, + } + result := p.ingestOutboundAttachments(context.Background(), "bot-1", attachments) + if len(result) != 1 { + t.Fatalf("expected 1 attachment, got %d", len(result)) + } + if result[0].ContentHash != "resolved-asset-1" { + t.Fatalf("expected content_hash resolved-asset-1, got %q", result[0].ContentHash) + } + if result[0].Metadata["bot_id"] != "bot-1" { + t.Fatalf("expected bot_id in metadata, got %v", result[0].Metadata) + } +} + +func TestIngestOutboundAttachments_ContainerPathNotFound(t *testing.T) { + t.Parallel() + + ms := &fakeMediaIngestor{ + storageKeyErr: errors.New("not found"), + } + p := &ChannelInboundProcessor{mediaService: ms} + attachments := []channel.Attachment{ + {Type: channel.AttachmentImage, URL: "/data/media/26da/missing.jpg"}, + } + result := p.ingestOutboundAttachments(context.Background(), "bot-1", attachments) + if len(result) != 1 { + t.Fatalf("expected 1 attachment, got %d", len(result)) + } + if result[0].ContentHash != "" { + t.Fatalf("expected empty content_hash for unresolved path, got %q", result[0].ContentHash) + } +} + +func TestMapChannelToChatAttachments(t *testing.T) { + t.Parallel() + + attachments := []channel.Attachment{ + { + Type: channel.AttachmentImage, + ContentHash: "asset-1", + URL: "/data/media/ab/c.png", + Base64: "AAAA", + Mime: "image/png", + }, + { + Type: channel.AttachmentFile, + URL: "https://example.com/doc.pdf", + Name: "doc.pdf", + }, + } + + mapped := mapChannelToChatAttachments(attachments) + if len(mapped) != 2 { + t.Fatalf("expected 2 mapped attachments, got %d", len(mapped)) + } + if mapped[0].Path != "/data/media/ab/c.png" { + t.Fatalf("expected asset attachment path, got %q", mapped[0].Path) + } + if !strings.HasPrefix(mapped[0].Base64, "data:image/png;base64,") { + t.Fatalf("expected normalized base64 data url, got %q", mapped[0].Base64) + } + if mapped[1].URL != "https://example.com/doc.pdf" { + t.Fatalf("expected non-asset attachment URL, got %q", mapped[1].URL) + } +} diff --git a/internal/channel/normalize.go b/internal/channel/normalize.go new file mode 100644 index 00000000..51cf0edc --- /dev/null +++ b/internal/channel/normalize.go @@ -0,0 +1,66 @@ +package channel + +import ( + "path/filepath" + "strings" + + "github.com/memohai/memoh/internal/attachment" +) + +// InferAttachmentType infers a canonical attachment type from type/mime/name. +func InferAttachmentType(currentType AttachmentType, mime, name string) AttachmentType { + switch strings.ToLower(strings.TrimSpace(string(currentType))) { + case string(AttachmentImage): + return AttachmentImage + case string(AttachmentGIF): + return AttachmentGIF + case string(AttachmentAudio): + return AttachmentAudio + case string(AttachmentVoice): + return AttachmentVoice + case string(AttachmentVideo): + return AttachmentVideo + case string(AttachmentFile): + // keep inferring below for better classification + default: + // unknown type: infer from mime/name + } + + normalizedMime := attachment.NormalizeMime(mime) + switch { + case strings.HasPrefix(normalizedMime, "image/gif"): + return AttachmentGIF + case strings.HasPrefix(normalizedMime, "image/"): + return AttachmentImage + case strings.HasPrefix(normalizedMime, "audio/"): + return AttachmentAudio + case strings.HasPrefix(normalizedMime, "video/"): + return AttachmentVideo + } + + ext := strings.ToLower(strings.TrimSpace(filepath.Ext(strings.TrimSpace(name)))) + switch ext { + case ".gif": + return AttachmentGIF + case ".jpg", ".jpeg", ".png", ".webp", ".bmp", ".heic", ".heif": + return AttachmentImage + case ".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac": + return AttachmentAudio + case ".mp4", ".mov", ".mkv", ".webm": + return AttachmentVideo + default: + return AttachmentFile + } +} + +// NormalizeInboundChannelAttachment normalizes a channel attachment at adapter boundary. +func NormalizeInboundChannelAttachment(att Attachment) Attachment { + att.Type = InferAttachmentType(att.Type, att.Mime, att.Name) + att.Mime = attachment.NormalizeMime(att.Mime) + att.URL = strings.TrimSpace(att.URL) + att.PlatformKey = strings.TrimSpace(att.PlatformKey) + att.SourcePlatform = strings.TrimSpace(att.SourcePlatform) + att.Name = strings.TrimSpace(att.Name) + att.Caption = strings.TrimSpace(att.Caption) + return att +} diff --git a/internal/channel/normalize_test.go b/internal/channel/normalize_test.go new file mode 100644 index 00000000..3eafff27 --- /dev/null +++ b/internal/channel/normalize_test.go @@ -0,0 +1,97 @@ +package channel + +import "testing" + +func TestInferAttachmentType(t *testing.T) { + cases := []struct { + name string + inType AttachmentType + mime string + file string + want AttachmentType + }{ + { + name: "keep explicit image", + inType: AttachmentImage, + mime: "", + file: "", + want: AttachmentImage, + }, + { + name: "infer from image mime", + inType: AttachmentFile, + mime: "image/jpeg", + file: "a.bin", + want: AttachmentImage, + }, + { + name: "infer gif from mime", + inType: AttachmentFile, + mime: "image/gif", + file: "a.bin", + want: AttachmentGIF, + }, + { + name: "infer from audio extension", + inType: AttachmentFile, + mime: "", + file: "a.mp3", + want: AttachmentAudio, + }, + { + name: "infer from video extension", + inType: AttachmentFile, + mime: "", + file: "a.mp4", + want: AttachmentVideo, + }, + { + name: "fallback file", + inType: AttachmentFile, + mime: "", + file: "a.unknown", + want: AttachmentFile, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := InferAttachmentType(tc.inType, tc.mime, tc.file) + if got != tc.want { + t.Fatalf("InferAttachmentType got %q want %q", got, tc.want) + } + }) + } +} + +func TestNormalizeInboundChannelAttachment(t *testing.T) { + normalized := NormalizeInboundChannelAttachment(Attachment{ + Type: AttachmentFile, + Mime: "IMAGE/JPEG; charset=utf-8", + Name: " photo.jpg ", + URL: " https://example.com/x ", + PlatformKey: " file_key_1 ", + SourcePlatform: " feishu ", + Caption: " hello ", + }) + if normalized.Type != AttachmentImage { + t.Fatalf("expected inferred image type, got %q", normalized.Type) + } + if normalized.Mime != "image/jpeg" { + t.Fatalf("expected normalized mime image/jpeg, got %q", normalized.Mime) + } + if normalized.Name != "photo.jpg" { + t.Fatalf("expected trimmed name, got %q", normalized.Name) + } + if normalized.URL != "https://example.com/x" { + t.Fatalf("expected trimmed url, got %q", normalized.URL) + } + if normalized.PlatformKey != "file_key_1" { + t.Fatalf("expected trimmed platform key, got %q", normalized.PlatformKey) + } + if normalized.SourcePlatform != "feishu" { + t.Fatalf("expected trimmed source platform, got %q", normalized.SourcePlatform) + } + if normalized.Caption != "hello" { + t.Fatalf("expected trimmed caption, got %q", normalized.Caption) + } +} diff --git a/internal/channel/observer.go b/internal/channel/observer.go new file mode 100644 index 00000000..ac24910f --- /dev/null +++ b/internal/channel/observer.go @@ -0,0 +1,47 @@ +package channel + +import "context" + +// StreamObserver receives copies of stream events for cross-channel broadcasting +// or external notification (e.g. webhooks). Implementations must be safe for +// concurrent use and should not block. +type StreamObserver interface { + // OnStreamEvent is called for every event pushed to an outbound stream. + // botID identifies the bot that owns the conversation. + // source is the channel type that originated the event (e.g. "telegram"). + OnStreamEvent(ctx context.Context, botID string, source ChannelType, event StreamEvent) +} + +// teeStream wraps an OutboundStream and mirrors every Push call to an observer. +type teeStream struct { + primary OutboundStream + observer StreamObserver + botID string + source ChannelType +} + +// NewTeeStream wraps primary so that every Push also notifies observer. +func NewTeeStream(primary OutboundStream, observer StreamObserver, botID string, source ChannelType) OutboundStream { + if observer == nil { + return primary + } + return &teeStream{ + primary: primary, + observer: observer, + botID: botID, + source: source, + } +} + +func (t *teeStream) Push(ctx context.Context, event StreamEvent) error { + err := t.primary.Push(ctx, event) + // Notify observer regardless of push error — the event was produced and + // should still be visible in monitoring/WebUI even if the primary channel + // delivery failed (e.g. Telegram rate-limit). + t.observer.OnStreamEvent(ctx, t.botID, t.source, event) + return err +} + +func (t *teeStream) Close(ctx context.Context) error { + return t.primary.Close(ctx) +} diff --git a/internal/channel/observer_test.go b/internal/channel/observer_test.go new file mode 100644 index 00000000..bc7e0df0 --- /dev/null +++ b/internal/channel/observer_test.go @@ -0,0 +1,150 @@ +package channel + +import ( + "context" + "sync" + "testing" +) + +type recordingObserver struct { + mu sync.Mutex + events []observedEvent +} + +type observedEvent struct { + BotID string + Source ChannelType + Event StreamEvent +} + +func (r *recordingObserver) OnStreamEvent(_ context.Context, botID string, source ChannelType, event StreamEvent) { + r.mu.Lock() + defer r.mu.Unlock() + r.events = append(r.events, observedEvent{BotID: botID, Source: source, Event: event}) +} + +func (r *recordingObserver) recorded() []observedEvent { + r.mu.Lock() + defer r.mu.Unlock() + cp := make([]observedEvent, len(r.events)) + copy(cp, r.events) + return cp +} + +// stubStream records pushed events. +type stubStream struct { + mu sync.Mutex + events []StreamEvent + closed bool +} + +func (s *stubStream) Push(_ context.Context, event StreamEvent) error { + s.mu.Lock() + defer s.mu.Unlock() + s.events = append(s.events, event) + return nil +} + +func (s *stubStream) Close(_ context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + s.closed = true + return nil +} + +func TestNewTeeStream_NilObserver(t *testing.T) { + primary := &stubStream{} + result := NewTeeStream(primary, nil, "bot1", "telegram") + if result != primary { + t.Fatal("expected primary stream returned when observer is nil") + } +} + +func TestTeeStream_Push(t *testing.T) { + primary := &stubStream{} + obs := &recordingObserver{} + stream := NewTeeStream(primary, obs, "bot1", "telegram") + + event := StreamEvent{Type: StreamEventDelta, Delta: "hello"} + if err := stream.Push(context.Background(), event); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + primary.mu.Lock() + if len(primary.events) != 1 { + t.Fatalf("expected 1 primary event, got %d", len(primary.events)) + } + if primary.events[0].Delta != "hello" { + t.Fatalf("unexpected primary delta: %s", primary.events[0].Delta) + } + primary.mu.Unlock() + + recorded := obs.recorded() + if len(recorded) != 1 { + t.Fatalf("expected 1 observed event, got %d", len(recorded)) + } + if recorded[0].BotID != "bot1" { + t.Fatalf("unexpected botID: %s", recorded[0].BotID) + } + if recorded[0].Source != "telegram" { + t.Fatalf("unexpected source: %s", recorded[0].Source) + } + if recorded[0].Event.Delta != "hello" { + t.Fatalf("unexpected observed delta: %s", recorded[0].Event.Delta) + } +} + +func TestTeeStream_Close(t *testing.T) { + primary := &stubStream{} + obs := &recordingObserver{} + stream := NewTeeStream(primary, obs, "bot1", "telegram") + + if err := stream.Close(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + primary.mu.Lock() + if !primary.closed { + t.Fatal("expected primary stream to be closed") + } + primary.mu.Unlock() + + if len(obs.recorded()) != 0 { + t.Fatal("close should not produce observer events") + } +} + +func TestTeeStream_MultipleEvents(t *testing.T) { + primary := &stubStream{} + obs := &recordingObserver{} + stream := NewTeeStream(primary, obs, "bot1", "feishu") + + events := []StreamEvent{ + {Type: StreamEventStatus, Status: StreamStatusStarted}, + {Type: StreamEventDelta, Delta: "chunk1"}, + {Type: StreamEventDelta, Delta: "chunk2"}, + {Type: StreamEventFinal, Final: &StreamFinalizePayload{Message: Message{Text: "done"}}}, + {Type: StreamEventStatus, Status: StreamStatusCompleted}, + } + for _, event := range events { + if err := stream.Push(context.Background(), event); err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + + primary.mu.Lock() + if len(primary.events) != len(events) { + t.Fatalf("expected %d primary events, got %d", len(events), len(primary.events)) + } + primary.mu.Unlock() + + recorded := obs.recorded() + if len(recorded) != len(events) { + t.Fatalf("expected %d observed events, got %d", len(events), len(recorded)) + } + for i, r := range recorded { + if r.Source != "feishu" { + t.Fatalf("event %d: unexpected source %s", i, r.Source) + } + } +} diff --git a/internal/channel/outbound.go b/internal/channel/outbound.go index 357fe538..9d1ac759 100644 --- a/internal/channel/outbound.go +++ b/internal/channel/outbound.go @@ -377,18 +377,18 @@ func normalizeAttachmentRefs(attachments []Attachment, defaultPlatform ChannelTy item := att item.URL = strings.TrimSpace(item.URL) item.PlatformKey = strings.TrimSpace(item.PlatformKey) - item.AssetID = strings.TrimSpace(item.AssetID) + item.ContentHash = strings.TrimSpace(item.ContentHash) item.SourcePlatform = strings.TrimSpace(item.SourcePlatform) if item.SourcePlatform == "" && item.PlatformKey != "" { item.SourcePlatform = defaultPlatform.String() } - if item.URL == "" && item.PlatformKey == "" && item.AssetID == "" { + if item.URL == "" && item.PlatformKey == "" && item.ContentHash == "" { return nil, fmt.Errorf("attachment reference is required") } - // asset_id-only attachments require media resolution before dispatch. + // content_hash-only attachments require media resolution before dispatch. // Adapters expect url or platform_key; fail loudly if neither is available. - if item.URL == "" && item.PlatformKey == "" && item.AssetID != "" { - return nil, fmt.Errorf("attachment %s has asset_id but no sendable url or platform_key; media resolution required before dispatch", item.AssetID) + if item.URL == "" && item.PlatformKey == "" && item.ContentHash != "" { + return nil, fmt.Errorf("attachment %s has content_hash but no sendable url or platform_key; media resolution required before dispatch", item.ContentHash) } normalized = append(normalized, item) } diff --git a/internal/channel/types.go b/internal/channel/types.go index 32f9ba81..268616f4 100644 --- a/internal/channel/types.go +++ b/internal/channel/types.go @@ -216,7 +216,7 @@ type Attachment struct { URL string `json:"url,omitempty"` PlatformKey string `json:"platform_key,omitempty"` SourcePlatform string `json:"source_platform,omitempty"` - AssetID string `json:"asset_id,omitempty"` + ContentHash string `json:"content_hash,omitempty"` Base64 string `json:"base64,omitempty"` // data URL for agent delivery Name string `json:"name,omitempty"` Size int64 `json:"size,omitempty"` diff --git a/internal/containerd/network.go b/internal/containerd/network.go index a65a1937..1cd15259 100644 --- a/internal/containerd/network.go +++ b/internal/containerd/network.go @@ -1,13 +1,10 @@ package containerd import ( - "bytes" "context" "fmt" "os" - "os/exec" "path/filepath" - "runtime" "strings" "github.com/containerd/containerd/v2/client" @@ -30,9 +27,6 @@ func SetupNetwork(ctx context.Context, task client.Task, containerID string, CNI if pid == 0 { return fmt.Errorf("task pid not available for %s", containerID) } - if runtime.GOOS == "darwin" { - return setupNetworkWithCLI(ctx, containerID, pid, CNIBinDir, CNIConfDir) - } if _, err := os.Stat(CNIConfDir); err != nil { return fmt.Errorf("cni config dir missing: %s: %w", CNIConfDir, err) @@ -56,56 +50,15 @@ func SetupNetwork(ctx context.Context, task client.Task, containerID string, CNI return err } _, err = cni.Setup(ctx, containerID, netnsPath) - if err == nil { - return nil - } - if !isDuplicateAllocationError(err) { - return err - } - if rmErr := cni.Remove(ctx, containerID, netnsPath); rmErr != nil { - return rmErr - } - _, err = cni.Setup(ctx, containerID, netnsPath) - return err -} - -func setupNetworkWithCLI(ctx context.Context, containerID string, pid uint32, CNIBinDir string, CNIConfDir string) error { - args := []string{ - "shell", - "--tty=false", - "default", - "--", - "sudo", - "-n", - "memoh-cli", - "cni-setup", - "--id", containerID, - "--pid", fmt.Sprint(pid), - "--conf-dir", CNIConfDir, - "--bin-dir", CNIBinDir, - } - cmd := exec.CommandContext(ctx, "limactl", args...) - var stderr bytes.Buffer - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - if stderr.Len() > 0 { - cniErr := fmt.Errorf("cni cli failed: %s", strings.TrimSpace(stderr.String())) - if !isDuplicateAllocationError(cniErr) { - return cniErr - } - } else if !isDuplicateAllocationError(err) { + if err != nil { + if !isDuplicateAllocationError(err) { return err } - if rmErr := removeNetworkWithCLI(ctx, containerID, pid, CNIBinDir, CNIConfDir); rmErr != nil { + if rmErr := cni.Remove(ctx, containerID, netnsPath); rmErr != nil { return rmErr } - cmd = exec.CommandContext(ctx, "limactl", args...) - stderr.Reset() - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - if stderr.Len() > 0 { - return fmt.Errorf("cni cli failed: %s", strings.TrimSpace(stderr.String())) - } + _, err = cni.Setup(ctx, containerID, netnsPath) + if err != nil { return err } } @@ -128,9 +81,6 @@ func RemoveNetwork(ctx context.Context, task client.Task, containerID string, CN if pid == 0 { return fmt.Errorf("task pid not available for %s", containerID) } - if runtime.GOOS == "darwin" { - return removeNetworkWithCLI(ctx, containerID, pid, CNIBinDir, CNIConfDir) - } if _, err := os.Stat(CNIConfDir); err != nil { return fmt.Errorf("cni config dir missing: %s: %w", CNIConfDir, err) @@ -157,33 +107,6 @@ func RemoveNetwork(ctx context.Context, task client.Task, containerID string, CN return cni.Remove(ctx, containerID, netnsPath) } -func removeNetworkWithCLI(ctx context.Context, containerID string, pid uint32, CNIBinDir string, CNIConfDir string) error { - args := []string{ - "shell", - "--tty=false", - "default", - "--", - "sudo", - "-n", - "memoh-cli", - "cni-remove", - "--id", containerID, - "--pid", fmt.Sprint(pid), - "--conf-dir", CNIConfDir, - "--bin-dir", CNIBinDir, - } - cmd := exec.CommandContext(ctx, "limactl", args...) - var stderr bytes.Buffer - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - if stderr.Len() > 0 { - return fmt.Errorf("cni cli failed: %s", strings.TrimSpace(stderr.String())) - } - return err - } - return nil -} - func isDuplicateAllocationError(err error) bool { if err == nil { return false diff --git a/internal/containerd/resolv.go b/internal/containerd/resolv.go index 8455c0c1..776b763c 100644 --- a/internal/containerd/resolv.go +++ b/internal/containerd/resolv.go @@ -1,11 +1,8 @@ package containerd import ( - "fmt" "os" - "os/exec" "path/filepath" - "runtime" "strings" ) @@ -21,13 +18,7 @@ func ResolveConfSource(dataDir string) (string, error) { if strings.TrimSpace(dataDir) == "" { return "", ErrInvalidArgument } - if runtime.GOOS == "darwin" { - if ok, err := limaFileExists(systemdResolvConf); err != nil { - return "", err - } else if ok { - return systemdResolvConf, nil - } - } else if _, err := os.Stat(systemdResolvConf); err == nil { + if _, err := os.Stat(systemdResolvConf); err == nil { return systemdResolvConf, nil } @@ -45,29 +36,3 @@ func ResolveConfSource(dataDir string) (string, error) { } return fallbackPath, nil } - -func limaFileExists(path string) (bool, error) { - if strings.TrimSpace(path) == "" { - return false, ErrInvalidArgument - } - cmd := exec.Command( - "limactl", - "shell", - "--tty=false", - "default", - "--", - "test", - "-f", - path, - ) - if err := cmd.Run(); err == nil { - return true, nil - } else if exitErr, ok := err.(*exec.ExitError); ok { - if exitErr.ExitCode() == 1 { - return false, nil - } - return false, fmt.Errorf("lima test failed for %s: %w", path, err) - } else { - return false, fmt.Errorf("lima test failed for %s: %w", path, err) - } -} diff --git a/internal/containerd/service.go b/internal/containerd/service.go index c825905a..df245616 100644 --- a/internal/containerd/service.go +++ b/internal/containerd/service.go @@ -100,8 +100,8 @@ type ExecTaskResult struct { } type SnapshotCommitResult struct { - VersionSnapshotID string - ActiveSnapshotID string + VersionSnapshotName string + ActiveSnapshotName string } type ListTasksOptions struct { diff --git a/internal/conversation/flow/capability_policy.go b/internal/conversation/flow/capability_policy.go index b70bebda..57924146 100644 --- a/internal/conversation/flow/capability_policy.go +++ b/internal/conversation/flow/capability_policy.go @@ -1,6 +1,16 @@ package flow -import "github.com/memohai/memoh/internal/models" +import ( + "strings" + + "github.com/memohai/memoh/internal/models" +) + +const ( + gatewayTransportInlineDataURL = "inline_data_url" + gatewayTransportPublicURL = "public_url" + gatewayTransportToolFileRef = "tool_file_ref" +) // attachmentModality maps an attachment type string to the input modality it requires. var attachmentModality = map[string]string{ @@ -10,16 +20,20 @@ var attachmentModality = map[string]string{ "file": models.ModelInputFile, } -// gatewayAttachment is the structured attachment payload sent to the agent gateway. -// Only fields consumable by the agent/LLM are serialized; internal references -// (asset_id, platform_key, url) are stripped before dispatch. +// gatewayAttachment is the strict server-to-gateway attachment contract. +// ContentHash is the content reference (replaces legacy assetId). type gatewayAttachment struct { - Type string `json:"type"` - Base64 string `json:"base64,omitempty"` - Path string `json:"path,omitempty"` - Mime string `json:"mime,omitempty"` - Name string `json:"name,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` + ContentHash string `json:"contentHash,omitempty"` + Type string `json:"type"` + Mime string `json:"mime,omitempty"` + Size int64 `json:"size,omitempty"` + Name string `json:"name,omitempty"` + Transport string `json:"transport"` + Payload string `json:"payload"` + Metadata map[string]any `json:"metadata,omitempty"` + + // FallbackPath is an internal helper only used by server-side routing. + FallbackPath string `json:"-"` } // capabilityRouteResult holds the outcome of splitting attachments by model capability. @@ -31,13 +45,12 @@ type capabilityRouteResult struct { Fallback []gatewayAttachment } -// routeAttachmentsByCapability splits attachments based on the model's supported -// input modalities. Supported modalities produce native multimodal input; unsupported -// modalities produce container path references for tool-based access. +// routeAttachmentsByCapability splits attachments based on both model capability +// and gateway native support. Unsupported items are routed through fallback. func routeAttachmentsByCapability(modalities []string, attachments []gatewayAttachment) capabilityRouteResult { supported := make(map[string]struct{}, len(modalities)) for _, m := range modalities { - supported[m] = struct{}{} + supported[strings.ToLower(strings.TrimSpace(m))] = struct{}{} } result := capabilityRouteResult{ @@ -45,12 +58,18 @@ func routeAttachmentsByCapability(modalities []string, attachments []gatewayAtta Fallback: make([]gatewayAttachment, 0), } for _, att := range attachments { + att.Type = strings.ToLower(strings.TrimSpace(att.Type)) + att.Transport = strings.ToLower(strings.TrimSpace(att.Transport)) requiredModality, known := attachmentModality[att.Type] if !known { // Unknown attachment types always go through fallback path. result.Fallback = append(result.Fallback, att) continue } + if !isGatewayNativeAttachment(att) { + result.Fallback = append(result.Fallback, att) + continue + } if _, ok := supported[requiredModality]; ok { result.Native = append(result.Native, att) } else { @@ -60,6 +79,19 @@ func routeAttachmentsByCapability(modalities []string, attachments []gatewayAtta return result } +func isGatewayNativeAttachment(att gatewayAttachment) bool { + switch att.Type { + case "image": + transport := strings.ToLower(strings.TrimSpace(att.Transport)) + if transport != gatewayTransportInlineDataURL && transport != gatewayTransportPublicURL { + return false + } + return strings.TrimSpace(att.Payload) != "" + default: + return false + } +} + // attachmentsToAny converts typed gateway attachments to []any for JSON serialization. func attachmentsToAny(atts []gatewayAttachment) []any { out := make([]any, 0, len(atts)) diff --git a/internal/conversation/flow/capability_policy_test.go b/internal/conversation/flow/capability_policy_test.go index d0bfa37a..139651bc 100644 --- a/internal/conversation/flow/capability_policy_test.go +++ b/internal/conversation/flow/capability_policy_test.go @@ -9,19 +9,21 @@ import ( func TestRouteAttachmentsByCapability_AllSupported(t *testing.T) { modalities := []string{"text", "image", "audio"} attachments := []gatewayAttachment{ - {Type: "image", Base64: "abc"}, - {Type: "audio", Path: "/data/voice.wav"}, + {Type: "image", Transport: gatewayTransportInlineDataURL, Payload: "data:image/png;base64,abc"}, + {Type: "audio", Transport: gatewayTransportToolFileRef, Payload: "/data/voice.wav"}, } result := routeAttachmentsByCapability(modalities, attachments) - assert.Len(t, result.Native, 2) - assert.Len(t, result.Fallback, 0) + assert.Len(t, result.Native, 1) + assert.Len(t, result.Fallback, 1) + assert.Equal(t, "image", result.Native[0].Type) + assert.Equal(t, "audio", result.Fallback[0].Type) } func TestRouteAttachmentsByCapability_TextOnly(t *testing.T) { modalities := []string{"text"} attachments := []gatewayAttachment{ - {Type: "image", Base64: "abc"}, - {Type: "video", Path: "/data/video.mp4"}, + {Type: "image", Transport: gatewayTransportInlineDataURL, Payload: "data:image/png;base64,abc"}, + {Type: "video", Transport: gatewayTransportToolFileRef, Payload: "/data/video.mp4"}, } result := routeAttachmentsByCapability(modalities, attachments) assert.Len(t, result.Native, 0) @@ -31,9 +33,9 @@ func TestRouteAttachmentsByCapability_TextOnly(t *testing.T) { func TestRouteAttachmentsByCapability_Mixed(t *testing.T) { modalities := []string{"text", "image"} attachments := []gatewayAttachment{ - {Type: "image", Base64: "abc"}, - {Type: "video", Path: "/data/video.mp4"}, - {Type: "audio", Path: "/data/audio.mp3"}, + {Type: "image", Transport: gatewayTransportInlineDataURL, Payload: "data:image/png;base64,abc"}, + {Type: "video", Transport: gatewayTransportToolFileRef, Payload: "/data/video.mp4"}, + {Type: "audio", Transport: gatewayTransportToolFileRef, Payload: "/data/audio.mp3"}, } result := routeAttachmentsByCapability(modalities, attachments) assert.Len(t, result.Native, 1) @@ -41,10 +43,32 @@ func TestRouteAttachmentsByCapability_Mixed(t *testing.T) { assert.Len(t, result.Fallback, 2) } +func TestRouteAttachmentsByCapability_ImagePathOnlyFallsBack(t *testing.T) { + modalities := []string{"text", "image"} + attachments := []gatewayAttachment{ + {Type: "image", Transport: gatewayTransportToolFileRef, Payload: "/data/image.png"}, + } + result := routeAttachmentsByCapability(modalities, attachments) + assert.Len(t, result.Native, 0) + assert.Len(t, result.Fallback, 1) + assert.Equal(t, "image", result.Fallback[0].Type) +} + +func TestRouteAttachmentsByCapability_ImageURLIsNative(t *testing.T) { + modalities := []string{"text", "image"} + attachments := []gatewayAttachment{ + {Type: "image", Transport: gatewayTransportPublicURL, Payload: "https://example.com/image.png"}, + } + result := routeAttachmentsByCapability(modalities, attachments) + assert.Len(t, result.Native, 1) + assert.Len(t, result.Fallback, 0) + assert.Equal(t, "image", result.Native[0].Type) +} + func TestRouteAttachmentsByCapability_UnknownType(t *testing.T) { modalities := []string{"text", "image"} attachments := []gatewayAttachment{ - {Type: "hologram", Path: "/data/holo.dat"}, + {Type: "hologram", Transport: gatewayTransportToolFileRef, Payload: "/data/holo.dat"}, } result := routeAttachmentsByCapability(modalities, attachments) assert.Len(t, result.Native, 0) @@ -59,8 +83,8 @@ func TestRouteAttachmentsByCapability_Empty(t *testing.T) { func TestAttachmentsToAny(t *testing.T) { atts := []gatewayAttachment{ - {Type: "image", Base64: "abc"}, - {Type: "file", Path: "/data/doc.pdf"}, + {Type: "image", Transport: gatewayTransportInlineDataURL, Payload: "data:image/png;base64,abc"}, + {Type: "file", Transport: gatewayTransportToolFileRef, Payload: "/data/doc.pdf"}, } result := attachmentsToAny(atts) assert.Len(t, result, 2) diff --git a/internal/conversation/flow/resolver.go b/internal/conversation/flow/resolver.go index 49a897ba..e315ac6a 100644 --- a/internal/conversation/flow/resolver.go +++ b/internal/conversation/flow/resolver.go @@ -2,8 +2,8 @@ package flow import ( "bufio" - "bytes" "context" + "encoding/base64" "encoding/json" "fmt" "io" @@ -15,6 +15,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" + attachmentpkg "github.com/memohai/memoh/internal/attachment" "github.com/memohai/memoh/internal/conversation" "github.com/memohai/memoh/internal/db" "github.com/memohai/memoh/internal/db/sqlc" @@ -31,6 +32,8 @@ const ( memoryContextMaxItems = 8 memoryContextItemMaxChars = 220 sharedMemoryNamespace = "bot" + // Keep gateway payload bounded when inlining binary attachments as data URLs. + gatewayInlineAttachmentMaxBytes int64 = 20 * 1024 * 1024 ) // SkillEntry represents a skill loaded from the container. @@ -51,6 +54,11 @@ type ConversationSettingsReader interface { GetSettings(ctx context.Context, conversationID string) (conversation.Settings, error) } +// gatewayAssetLoader resolves content_hash references to binary payloads for gateway dispatch. +type gatewayAssetLoader interface { + OpenForGateway(ctx context.Context, botID, contentHash string) (reader io.ReadCloser, mime string, err error) +} + // Resolver orchestrates chat with the agent gateway. type Resolver struct { modelsService *models.Service @@ -60,6 +68,7 @@ type Resolver struct { messageService messagepkg.Service settingsService *settings.Service skillLoader SkillLoader + assetLoader gatewayAssetLoader gatewayBaseURL string timeout time.Duration logger *slog.Logger @@ -106,6 +115,12 @@ func (r *Resolver) SetSkillLoader(sl SkillLoader) { r.skillLoader = sl } +// SetGatewayAssetLoader configures optional asset loading used to inline +// attachments before calling the agent gateway. +func (r *Resolver) SetGatewayAssetLoader(loader gatewayAssetLoader) { + r.assetLoader = loader +} + // --- gateway payload --- type gatewayModelConfig struct { @@ -142,7 +157,7 @@ type gatewayRequest struct { Messages []conversation.ModelMessage `json:"messages"` Skills []string `json:"skills"` UsableSkills []gatewaySkill `json:"usableSkills"` - Query string `json:"query,omitempty"` + Query string `json:"query"` Identity gatewayIdentity `json:"identity"` Attachments []any `json:"attachments"` } @@ -169,11 +184,30 @@ type gatewaySchedule struct { } // triggerScheduleRequest is the payload for POST /chat/trigger-schedule. +// It omits "query" from JSON so the trigger-schedule endpoint does not receive it. type triggerScheduleRequest struct { gatewayRequest Schedule gatewaySchedule `json:"schedule"` } +// MarshalJSON marshals the request without the "query" field for trigger-schedule. +func (t triggerScheduleRequest) MarshalJSON() ([]byte, error) { + type alias struct { + gatewayRequest + Schedule gatewaySchedule `json:"schedule"` + } + raw, err := json.Marshal(alias(t)) + if err != nil { + return nil, err + } + var m map[string]json.RawMessage + if err := json.Unmarshal(raw, &m); err != nil { + return nil, err + } + delete(m, "query") + return json.Marshal(m) +} + // --- resolved context (shared by Chat / StreamChat / TriggerSchedule) --- type resolvedContext struct { @@ -279,7 +313,7 @@ func (r *Resolver) resolve(ctx context.Context, req conversation.ChatRequest) (r ConversationType: strings.TrimSpace(req.ConversationType), SessionToken: req.ChatToken, }, - Attachments: r.routeAndMergeAttachments(chatModel, req), + Attachments: r.routeAndMergeAttachments(ctx, chatModel, req), } return resolvedContext{payload: payload, model: chatModel, provider: provider}, nil @@ -407,18 +441,18 @@ func (r *Resolver) StreamChat(ctx context.Context, req conversation.ChatRequest) // --- HTTP helpers --- func (r *Resolver) postChat(ctx context.Context, payload gatewayRequest, token string) (gatewayResponse, error) { - body, err := json.Marshal(payload) - if err != nil { - return gatewayResponse{}, err - } url := r.gatewayBaseURL + "/chat/" - r.logger.Info("gateway request", slog.String("url", url), slog.String("body_prefix", truncate(string(body), 200))) + r.logger.Info( + "gateway request", + slog.String("url", url), + slog.Int("messages", len(payload.Messages)), + slog.Int("attachments", len(payload.Attachments)), + ) - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + httpReq, err := newJSONRequestWithContext(ctx, http.MethodPost, url, payload) if err != nil { return gatewayResponse{}, err } - httpReq.Header.Set("Content-Type", "application/json") if strings.TrimSpace(token) != "" { httpReq.Header.Set("Authorization", token) } @@ -448,18 +482,13 @@ func (r *Resolver) postChat(ctx context.Context, payload gatewayRequest, token s // postTriggerSchedule sends a trigger-schedule request to the agent gateway. func (r *Resolver) postTriggerSchedule(ctx context.Context, payload triggerScheduleRequest, token string) (gatewayResponse, error) { - body, err := json.Marshal(payload) - if err != nil { - return gatewayResponse{}, err - } url := r.gatewayBaseURL + "/chat/trigger-schedule" r.logger.Info("gateway trigger-schedule request", slog.String("url", url), slog.String("schedule_id", payload.Schedule.ID)) - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + httpReq, err := newJSONRequestWithContext(ctx, http.MethodPost, url, payload) if err != nil { return gatewayResponse{}, err } - httpReq.Header.Set("Content-Type", "application/json") if strings.TrimSpace(token) != "" { httpReq.Header.Set("Authorization", token) } @@ -488,17 +517,17 @@ func (r *Resolver) postTriggerSchedule(ctx context.Context, payload triggerSched } func (r *Resolver) streamChat(ctx context.Context, payload gatewayRequest, req conversation.ChatRequest, chunkCh chan<- conversation.StreamChunk) error { - body, err := json.Marshal(payload) - if err != nil { - return err - } url := r.gatewayBaseURL + "/chat/stream" - r.logger.Info("gateway stream request", slog.String("url", url), slog.String("body_prefix", truncate(string(body), 200))) - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + r.logger.Info( + "gateway stream request", + slog.String("url", url), + slog.Int("messages", len(payload.Messages)), + slog.Int("attachments", len(payload.Attachments)), + ) + httpReq, err := newJSONRequestWithContext(ctx, http.MethodPost, url, payload) if err != nil { return err } - httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Accept", "text/event-stream") if strings.TrimSpace(req.Token) != "" { httpReq.Header.Set("Authorization", req.Token) @@ -552,6 +581,21 @@ func (r *Resolver) streamChat(ctx context.Context, payload gatewayRequest, req c return scanner.Err() } +func newJSONRequestWithContext(ctx context.Context, method, url string, payload any) (*http.Request, error) { + pr, pw := io.Pipe() + go func() { + enc := json.NewEncoder(pw) + _ = pw.CloseWithError(enc.Encode(payload)) + }() + req, err := http.NewRequestWithContext(ctx, method, url, pr) + if err != nil { + _ = pr.Close() + return nil, err + } + req.Header.Set("Content-Type", "application/json") + return req, nil +} + // tryStoreStream attempts to extract final messages from a stream event and persist them. func (r *Resolver) tryStoreStream(ctx context.Context, req conversation.ChatRequest, eventType, data string) (bool, error) { // event: done + data: {messages: [...]} @@ -593,37 +637,38 @@ func (r *Resolver) tryStoreStream(ctx context.Context, req conversation.ChatRequ // routeAndMergeAttachments applies CapabilityFallbackPolicy to split // request attachments by model input modalities, then merges the results // into a single []any for the gateway request. -func (r *Resolver) routeAndMergeAttachments(model models.GetResponse, req conversation.ChatRequest) []any { +func (r *Resolver) routeAndMergeAttachments(ctx context.Context, model models.GetResponse, req conversation.ChatRequest) []any { if len(req.Attachments) == 0 { return []any{} } - typed := make([]gatewayAttachment, 0, len(req.Attachments)) - for _, raw := range req.Attachments { - typed = append(typed, gatewayAttachment{ - Type: raw.Type, - Base64: raw.Base64, - Path: raw.Path, - Mime: raw.Mime, - Name: raw.Name, - Metadata: raw.Metadata, - }) - } + typed := r.prepareGatewayAttachments(ctx, req) routed := routeAttachmentsByCapability(model.InputModalities, typed) - // Convert unsupported attachments to file-path references. + // Convert unsupported attachments to tool file references. for i := range routed.Fallback { - if routed.Fallback[i].Path == "" && routed.Fallback[i].Base64 != "" { - // Cannot downgrade base64-only to path; keep as native so the agent can - // attempt best-effort processing or skip. - routed.Native = append(routed.Native, routed.Fallback[i]) + fallbackPath := strings.TrimSpace(routed.Fallback[i].FallbackPath) + if fallbackPath == "" { + // Cannot downgrade non-file payloads to tool file references. + // Drop them explicitly to keep gateway contract deterministic. + if r != nil && r.logger != nil { + r.logger.Warn( + "drop attachment without fallback path", + slog.String("type", strings.TrimSpace(routed.Fallback[i].Type)), + slog.String("transport", strings.TrimSpace(routed.Fallback[i].Transport)), + slog.String("content_hash", strings.TrimSpace(routed.Fallback[i].ContentHash)), + slog.Bool("has_payload", strings.TrimSpace(routed.Fallback[i].Payload) != ""), + ) + } routed.Fallback[i] = gatewayAttachment{} continue } routed.Fallback[i].Type = "file" + routed.Fallback[i].Transport = gatewayTransportToolFileRef + routed.Fallback[i].Payload = fallbackPath } merged := make([]any, 0, len(routed.Native)+len(routed.Fallback)) merged = append(merged, attachmentsToAny(routed.Native)...) for _, fb := range routed.Fallback { - if fb.Type == "" { + if fb.Type == "" || strings.TrimSpace(fb.Transport) == "" || strings.TrimSpace(fb.Payload) == "" { continue } merged = append(merged, fb) @@ -634,6 +679,203 @@ func (r *Resolver) routeAndMergeAttachments(model models.GetResponse, req conver return merged } +func (r *Resolver) prepareGatewayAttachments(ctx context.Context, req conversation.ChatRequest) []gatewayAttachment { + if len(req.Attachments) == 0 { + return nil + } + prepared := make([]gatewayAttachment, 0, len(req.Attachments)) + for _, raw := range req.Attachments { + attachmentType := strings.ToLower(strings.TrimSpace(raw.Type)) + payload := strings.TrimSpace(raw.Base64) + transport := "" + fallbackPath := strings.TrimSpace(raw.Path) + if payload != "" { + transport = gatewayTransportInlineDataURL + } else { + rawURL := strings.TrimSpace(raw.URL) + if isDataURL(rawURL) { + payload = rawURL + transport = gatewayTransportInlineDataURL + } else if isLikelyPublicURL(rawURL) { + payload = rawURL + transport = gatewayTransportPublicURL + } else if rawURL != "" && fallbackPath == "" { + fallbackPath = rawURL + } + } + item := gatewayAttachment{ + ContentHash: strings.TrimSpace(raw.ContentHash), + Type: attachmentType, + Mime: strings.TrimSpace(raw.Mime), + Size: raw.Size, + Name: strings.TrimSpace(raw.Name), + Transport: transport, + Payload: payload, + Metadata: raw.Metadata, + FallbackPath: fallbackPath, + } + item = normalizeGatewayAttachmentPayload(item) + item = r.inlineImageAttachmentAssetIfNeeded(ctx, strings.TrimSpace(req.BotID), item) + prepared = append(prepared, item) + } + return prepared +} + +func normalizeGatewayAttachmentPayload(item gatewayAttachment) gatewayAttachment { + if item.Transport != gatewayTransportInlineDataURL { + return item + } + payload := strings.TrimSpace(item.Payload) + if payload == "" { + return item + } + lower := strings.ToLower(payload) + if strings.HasPrefix(lower, "data:") { + if strings.TrimSpace(item.Mime) == "" || strings.EqualFold(strings.TrimSpace(item.Mime), "application/octet-stream") { + if start := strings.Index(payload, ":"); start >= 0 { + rest := payload[start+1:] + if end := strings.Index(rest, ";"); end > 0 { + mime := strings.TrimSpace(rest[:end]) + if mime != "" { + item.Mime = mime + } + } + } + } + return item + } + mime := strings.TrimSpace(item.Mime) + if mime == "" { + mime = "application/octet-stream" + } + item.Payload = attachmentpkg.NormalizeBase64DataURL(payload, mime) + return item +} + +func isLikelyPublicURL(raw string) bool { + trimmed := strings.ToLower(strings.TrimSpace(raw)) + return strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") +} + +func isDataURL(raw string) bool { + trimmed := strings.ToLower(strings.TrimSpace(raw)) + return strings.HasPrefix(trimmed, "data:") +} + +func (r *Resolver) inlineImageAttachmentAssetIfNeeded(ctx context.Context, botID string, item gatewayAttachment) gatewayAttachment { + if item.Type != "image" { + return item + } + if strings.TrimSpace(item.Payload) != "" && + (item.Transport == gatewayTransportInlineDataURL || item.Transport == gatewayTransportPublicURL) { + return item + } + contentHash := strings.TrimSpace(item.ContentHash) + if contentHash == "" { + return item + } + dataURL, mime, err := r.inlineAssetAsDataURL(ctx, botID, contentHash, item.Type, item.Mime) + if err != nil { + if r != nil && r.logger != nil { + r.logger.Warn( + "inline gateway image attachment failed", + slog.Any("error", err), + slog.String("bot_id", botID), + slog.String("content_hash", contentHash), + ) + } + return item + } + item.Transport = gatewayTransportInlineDataURL + item.Payload = dataURL + if strings.TrimSpace(item.Mime) == "" { + item.Mime = mime + } + return item +} + +func (r *Resolver) inlineAssetAsDataURL(ctx context.Context, botID, contentHash, attachmentType, fallbackMime string) (string, string, error) { + if r == nil || r.assetLoader == nil { + return "", "", fmt.Errorf("gateway asset loader not configured") + } + reader, assetMime, err := r.assetLoader.OpenForGateway(ctx, botID, contentHash) + if err != nil { + return "", "", fmt.Errorf("open asset: %w", err) + } + defer func() { + _ = reader.Close() + }() + mime := strings.TrimSpace(fallbackMime) + if mime == "" { + mime = strings.TrimSpace(assetMime) + } + dataURL, resolvedMime, err := encodeReaderAsDataURL(reader, gatewayInlineAttachmentMaxBytes, attachmentType, mime) + if err != nil { + return "", "", err + } + return dataURL, resolvedMime, nil +} + +func encodeReaderAsDataURL(reader io.Reader, maxBytes int64, attachmentType, fallbackMime string) (string, string, error) { + if reader == nil { + return "", "", fmt.Errorf("reader is required") + } + if maxBytes <= 0 { + return "", "", fmt.Errorf("max bytes must be greater than 0") + } + limited := &io.LimitedReader{R: reader, N: maxBytes + 1} + head := make([]byte, 512) + n, err := limited.Read(head) + if err != nil && err != io.EOF { + return "", "", fmt.Errorf("read asset: %w", err) + } + head = head[:n] + + mime := strings.TrimSpace(fallbackMime) + if strings.EqualFold(strings.TrimSpace(attachmentType), "image") && + (strings.TrimSpace(mime) == "" || strings.EqualFold(strings.TrimSpace(mime), "application/octet-stream")) { + detected := strings.TrimSpace(http.DetectContentType(head)) + if strings.HasPrefix(strings.ToLower(detected), "image/") { + mime = detected + } + } + if mime == "" { + mime = "application/octet-stream" + } + + var encoded strings.Builder + encoded.Grow(len("data:") + len(mime) + len(";base64,")) + encoded.WriteString("data:") + encoded.WriteString(mime) + encoded.WriteString(";base64,") + + encoder := base64.NewEncoder(base64.StdEncoding, &encoded) + if len(head) > 0 { + if _, err := encoder.Write(head); err != nil { + _ = encoder.Close() + return "", "", fmt.Errorf("encode asset head: %w", err) + } + } + copied, err := io.Copy(encoder, limited) + if err != nil { + _ = encoder.Close() + return "", "", fmt.Errorf("encode asset body: %w", err) + } + if err := encoder.Close(); err != nil { + return "", "", fmt.Errorf("finalize asset encoding: %w", err) + } + + total := int64(len(head)) + copied + if total > maxBytes { + return "", "", fmt.Errorf( + "asset too large to inline: %d > %d", + total, + maxBytes, + ) + } + return encoded.String(), mime, nil +} + // --- container resolution --- func (r *Resolver) resolveContainerID(ctx context.Context, botID, explicit string) string { @@ -925,6 +1167,22 @@ func (r *Resolver) storeMessages(ctx context.Context, req conversation.ChatReque } meta := buildRouteMetadata(req) senderChannelIdentityID, senderUserID := r.resolvePersistSenderIDs(ctx, req) + + // Determine the last assistant message index for outbound asset attachment. + lastAssistantIdx := -1 + if req.OutboundAssetCollector != nil { + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role == "assistant" { + lastAssistantIdx = i + break + } + } + } + var outboundAssets []messagepkg.AssetRef + if lastAssistantIdx >= 0 { + outboundAssets = outboundAssetRefsToMessageRefs(req.OutboundAssetCollector()) + } + for i, msg := range messages { content, err := json.Marshal(msg) if err != nil { @@ -946,6 +1204,9 @@ func (r *Resolver) storeMessages(ctx context.Context, req conversation.ChatReque } else if strings.TrimSpace(req.ExternalMessageID) != "" { sourceReplyToMessageID = req.ExternalMessageID } + if i == lastAssistantIdx && len(outboundAssets) > 0 { + assets = append(assets, outboundAssets...) + } var msgUsage json.RawMessage if i == len(messages)-1 && len(usage) > 0 { msgUsage = usage @@ -969,23 +1230,59 @@ func (r *Resolver) storeMessages(ctx context.Context, req conversation.ChatReque } } +// outboundAssetRefsToMessageRefs converts outbound asset refs from the streaming +// collector into message-level asset refs for persistence. +func outboundAssetRefsToMessageRefs(refs []conversation.OutboundAssetRef) []messagepkg.AssetRef { + if len(refs) == 0 { + return nil + } + result := make([]messagepkg.AssetRef, 0, len(refs)) + for _, ref := range refs { + contentHash := strings.TrimSpace(ref.ContentHash) + if contentHash == "" { + continue + } + role := ref.Role + if strings.TrimSpace(role) == "" { + role = "attachment" + } + result = append(result, messagepkg.AssetRef{ + ContentHash: contentHash, + Role: role, + Ordinal: ref.Ordinal, + Mime: ref.Mime, + SizeBytes: ref.SizeBytes, + StorageKey: ref.StorageKey, + }) + } + return result +} + // chatAttachmentsToAssetRefs converts ChatAttachment slice to message AssetRef slice. -// Only attachments that carry an asset_id are included; others have not been ingested yet. +// Only attachments that carry a content_hash are included. func chatAttachmentsToAssetRefs(attachments []conversation.ChatAttachment) []messagepkg.AssetRef { if len(attachments) == 0 { return nil } refs := make([]messagepkg.AssetRef, 0, len(attachments)) for i, att := range attachments { - id := strings.TrimSpace(att.AssetID) - if id == "" { + contentHash := strings.TrimSpace(att.ContentHash) + if contentHash == "" { continue } - refs = append(refs, messagepkg.AssetRef{ - AssetID: id, - Role: "attachment", - Ordinal: i, - }) + ref := messagepkg.AssetRef{ + ContentHash: contentHash, + Role: "attachment", + Ordinal: i, + Mime: strings.TrimSpace(att.Mime), + SizeBytes: att.Size, + } + if att.Metadata != nil { + if sk, ok := att.Metadata["storage_key"].(string); ok { + ref.StorageKey = sk + } + } + refs = append(refs, ref) } return refs } diff --git a/internal/conversation/flow/resolver_test.go b/internal/conversation/flow/resolver_test.go index 1f744576..85868064 100644 --- a/internal/conversation/flow/resolver_test.go +++ b/internal/conversation/flow/resolver_test.go @@ -1,16 +1,20 @@ package flow import ( + "bytes" "context" + "encoding/base64" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" + "strings" "testing" "time" "github.com/memohai/memoh/internal/conversation" + "github.com/memohai/memoh/internal/models" ) func TestPostTriggerSchedule_Endpoint(t *testing.T) { @@ -164,3 +168,259 @@ func TestPostTriggerSchedule_GatewayError(t *testing.T) { t.Fatal("expected error for 500 response") } } + +type fakeGatewayAssetLoader struct { + openFn func(ctx context.Context, botID, contentHash string) (io.ReadCloser, string, error) +} + +func (f *fakeGatewayAssetLoader) OpenForGateway(ctx context.Context, botID, contentHash string) (io.ReadCloser, string, error) { + if f == nil || f.openFn == nil { + return nil, "", io.EOF + } + return f.openFn(ctx, botID, contentHash) +} + +func TestPrepareGatewayAttachments_InlineAssetToBase64(t *testing.T) { + resolver := &Resolver{ + logger: slog.Default(), + assetLoader: &fakeGatewayAssetLoader{ + openFn: func(ctx context.Context, botID, contentHash string) (io.ReadCloser, string, error) { + if contentHash != "asset-1" { + t.Fatalf("unexpected content hash: %s", contentHash) + } + return io.NopCloser(strings.NewReader("image-binary")), "image/png", nil + }, + }, + } + req := conversation.ChatRequest{ + BotID: "bot-1", + Attachments: []conversation.ChatAttachment{ + { + Type: "image", + ContentHash: "asset-1", + }, + }, + } + + prepared := resolver.prepareGatewayAttachments(context.Background(), req) + if len(prepared) != 1 { + t.Fatalf("expected 1 attachment, got %d", len(prepared)) + } + if prepared[0].Transport != gatewayTransportInlineDataURL { + t.Fatalf("expected inline transport, got %q", prepared[0].Transport) + } + if !strings.HasPrefix(prepared[0].Payload, "data:image/png;base64,") { + t.Fatalf("expected data url image attachment, got %q", prepared[0].Payload) + } + if prepared[0].Mime != "image/png" { + t.Fatalf("expected mime image/png, got %q", prepared[0].Mime) + } +} + +func TestPrepareGatewayAttachments_DataURLFromURLFieldIsNativeInline(t *testing.T) { + resolver := &Resolver{logger: slog.Default()} + req := conversation.ChatRequest{ + Attachments: []conversation.ChatAttachment{ + { + Type: "image", + URL: "data:image/png;base64,AAAA", + }, + }, + } + + prepared := resolver.prepareGatewayAttachments(context.Background(), req) + if len(prepared) != 1 { + t.Fatalf("expected 1 attachment, got %d", len(prepared)) + } + if prepared[0].Transport != gatewayTransportInlineDataURL { + t.Fatalf("expected inline transport, got %q", prepared[0].Transport) + } + if prepared[0].Payload != "data:image/png;base64,AAAA" { + t.Fatalf("unexpected payload: %q", prepared[0].Payload) + } + if prepared[0].FallbackPath != "" { + t.Fatalf("expected empty fallback path, got %q", prepared[0].FallbackPath) + } +} + +func TestPrepareGatewayAttachments_PublicURLFromURLFieldIsNativePublic(t *testing.T) { + resolver := &Resolver{logger: slog.Default()} + req := conversation.ChatRequest{ + Attachments: []conversation.ChatAttachment{ + { + Type: "image", + URL: "https://example.com/demo.png", + }, + }, + } + + prepared := resolver.prepareGatewayAttachments(context.Background(), req) + if len(prepared) != 1 { + t.Fatalf("expected 1 attachment, got %d", len(prepared)) + } + if prepared[0].Transport != gatewayTransportPublicURL { + t.Fatalf("expected public transport, got %q", prepared[0].Transport) + } + if prepared[0].Payload != "https://example.com/demo.png" { + t.Fatalf("unexpected payload: %q", prepared[0].Payload) + } + if prepared[0].FallbackPath != "" { + t.Fatalf("expected empty fallback path, got %q", prepared[0].FallbackPath) + } +} + +func TestRouteAndMergeAttachments_ImagePathOnlyFallsBackToFile(t *testing.T) { + resolver := &Resolver{logger: slog.Default()} + model := models.GetResponse{ + Model: models.Model{ + InputModalities: []string{models.ModelInputText, models.ModelInputImage}, + }, + } + req := conversation.ChatRequest{ + Attachments: []conversation.ChatAttachment{ + { + Type: "image", + Path: "/data/media/image/demo.png", + }, + }, + } + + merged := resolver.routeAndMergeAttachments(context.Background(), model, req) + if len(merged) != 1 { + t.Fatalf("expected 1 attachment, got %d", len(merged)) + } + item, ok := merged[0].(gatewayAttachment) + if !ok { + t.Fatalf("expected gatewayAttachment type") + } + if item.Type != "file" { + t.Fatalf("expected fallback type file, got %q", item.Type) + } + if item.Transport != gatewayTransportToolFileRef { + t.Fatalf("expected tool_file_ref transport, got %q", item.Transport) + } + if item.Payload != "/data/media/image/demo.png" { + t.Fatalf("unexpected fallback payload: %q", item.Payload) + } +} + +func TestPrepareGatewayAttachments_DetectsImageMimeWhenOctetStream(t *testing.T) { + jpegBytes := []byte{ + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, + 0x49, 0x46, 0x00, 0x01, 0xFF, 0xD9, + } + resolver := &Resolver{ + logger: slog.Default(), + assetLoader: &fakeGatewayAssetLoader{ + openFn: func(ctx context.Context, botID, contentHash string) (io.ReadCloser, string, error) { + return io.NopCloser(bytes.NewReader(jpegBytes)), "application/octet-stream", nil + }, + }, + } + req := conversation.ChatRequest{ + BotID: "bot-1", + Attachments: []conversation.ChatAttachment{ + { + Type: "image", + ContentHash: "asset-2", + }, + }, + } + + prepared := resolver.prepareGatewayAttachments(context.Background(), req) + if len(prepared) != 1 { + t.Fatalf("expected 1 attachment, got %d", len(prepared)) + } + if prepared[0].Transport != gatewayTransportInlineDataURL { + t.Fatalf("expected inline transport, got %q", prepared[0].Transport) + } + if !strings.HasPrefix(prepared[0].Payload, "data:image/jpeg;base64,") { + t.Fatalf("expected detected image/jpeg data url, got %q", prepared[0].Payload) + } + if prepared[0].Mime != "image/jpeg" { + t.Fatalf("expected mime image/jpeg, got %q", prepared[0].Mime) + } +} + +func TestRouteAndMergeAttachments_DropsUnsupportedInlineWithoutFallbackPath(t *testing.T) { + resolver := &Resolver{logger: slog.Default()} + model := models.GetResponse{ + Model: models.Model{ + InputModalities: []string{models.ModelInputText, models.ModelInputVideo}, + }, + } + req := conversation.ChatRequest{ + Attachments: []conversation.ChatAttachment{ + { + Type: "video", + Base64: "AAAA", + }, + }, + } + + merged := resolver.routeAndMergeAttachments(context.Background(), model, req) + if len(merged) != 0 { + t.Fatalf("expected unsupported inline attachment to be dropped, got %d", len(merged)) + } +} + +func TestEncodeReaderAsDataURL_DetectsImageMime(t *testing.T) { + jpegBytes := []byte{ + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, + 0x49, 0x46, 0x00, 0x01, 0xFF, 0xD9, + } + + dataURL, mime, err := encodeReaderAsDataURL( + bytes.NewReader(jpegBytes), + int64(len(jpegBytes)), + "image", + "application/octet-stream", + ) + if err != nil { + t.Fatalf("encodeReaderAsDataURL returned error: %v", err) + } + if mime != "image/jpeg" { + t.Fatalf("expected image/jpeg mime, got %q", mime) + } + expected := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(jpegBytes) + if dataURL != expected { + t.Fatalf("unexpected data URL") + } +} + +func TestEncodeReaderAsDataURL_RejectsOversizedPayload(t *testing.T) { + _, _, err := encodeReaderAsDataURL(strings.NewReader("12345"), 4, "image", "image/png") + if err == nil { + t.Fatal("expected error for oversized payload") + } + if !strings.Contains(err.Error(), "asset too large to inline") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestOutboundAssetRefsToMessageRefs(t *testing.T) { + t.Parallel() + refs := []conversation.OutboundAssetRef{ + {ContentHash: "a1", Role: "attachment", Ordinal: 0}, + {ContentHash: "", Role: "attachment", Ordinal: 1}, + {ContentHash: "a2", Ordinal: 2}, + } + result := outboundAssetRefsToMessageRefs(refs) + if len(result) != 2 { + t.Fatalf("expected 2 refs, got %d", len(result)) + } + if result[0].ContentHash != "a1" || result[0].Role != "attachment" { + t.Fatalf("unexpected ref[0]: %+v", result[0]) + } + if result[1].ContentHash != "a2" || result[1].Role != "attachment" { + t.Fatalf("unexpected ref[1]: %+v", result[1]) + } +} + +func TestOutboundAssetRefsToMessageRefs_Empty(t *testing.T) { + t.Parallel() + result := outboundAssetRefsToMessageRefs(nil) + if result != nil { + t.Fatalf("expected nil, got %v", result) + } +} diff --git a/internal/conversation/types.go b/internal/conversation/types.go index 6d7c6c09..65d6cd07 100644 --- a/internal/conversation/types.go +++ b/internal/conversation/types.go @@ -198,13 +198,23 @@ type ChatAttachment struct { Path string `json:"path,omitempty"` URL string `json:"url,omitempty"` PlatformKey string `json:"platform_key,omitempty"` - AssetID string `json:"asset_id,omitempty"` + ContentHash string `json:"content_hash,omitempty"` Name string `json:"name,omitempty"` Mime string `json:"mime,omitempty"` Size int64 `json:"size,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` } +// OutboundAssetRef carries an asset reference accumulated during outbound streaming. +type OutboundAssetRef struct { + ContentHash string + Role string + Ordinal int + Mime string + SizeBytes int64 + StorageKey string +} + // ChatRequest is the input for Chat and StreamChat. type ChatRequest struct { BotID string `json:"-"` @@ -220,6 +230,10 @@ type ChatRequest struct { ConversationType string `json:"-"` UserMessagePersisted bool `json:"-"` + // OutboundAssetCollector returns asset refs accumulated during outbound streaming. + // Set by the inbound channel processor; called by the resolver at persist time. + OutboundAssetCollector func() []OutboundAssetRef `json:"-"` + Query string `json:"query"` Model string `json:"model,omitempty"` Provider string `json:"provider,omitempty"` diff --git a/internal/db/db.go b/internal/db/db.go index eea327d0..56c72b5c 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -2,7 +2,6 @@ package db import ( "context" - "fmt" "github.com/jackc/pgx/v5/pgxpool" @@ -10,14 +9,5 @@ import ( ) func Open(ctx context.Context, cfg config.PostgresConfig) (*pgxpool.Pool, error) { - dsn := fmt.Sprintf( - "postgres://%s:%s@%s:%d/%s?sslmode=%s", - cfg.User, - cfg.Password, - cfg.Host, - cfg.Port, - cfg.Database, - cfg.SSLMode, - ) - return pgxpool.New(ctx, dsn) + return pgxpool.New(ctx, DSN(cfg)) } diff --git a/internal/db/migrate.go b/internal/db/migrate.go new file mode 100644 index 00000000..4898017d --- /dev/null +++ b/internal/db/migrate.go @@ -0,0 +1,87 @@ +package db + +import ( + "fmt" + "io/fs" + "log/slog" + + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/postgres" + "github.com/golang-migrate/migrate/v4/source/iofs" + + "github.com/memohai/memoh/internal/config" +) + +// RunMigrate applies or rolls back database migrations. +// The migrationsFS should contain .sql files at its root (not in a subdirectory). +// Supported commands: "up", "down", "version", "force N". +func RunMigrate(logger *slog.Logger, cfg config.PostgresConfig, migrationsFS fs.FS, command string, args []string) error { + switch command { + case "up", "down", "version", "force": + default: + return fmt.Errorf("unknown migrate command: %s (use: up, down, version, force)", command) + } + if command == "force" && len(args) == 0 { + return fmt.Errorf("force requires a version number argument") + } + + dsn := DSN(cfg) + sourceDriver, err := iofs.New(migrationsFS, ".") + if err != nil { + return fmt.Errorf("migration source: %w", err) + } + + m, err := migrate.NewWithSourceInstance("iofs", sourceDriver, dsn) + if err != nil { + return fmt.Errorf("migrate init: %w", err) + } + defer m.Close() + + m.Log = &migrateLogger{logger: logger} + + switch command { + case "up": + if err := m.Up(); err != nil && err != migrate.ErrNoChange { + return fmt.Errorf("migrate up: %w", err) + } + ver, dirty, _ := m.Version() + logger.Info("migration complete", slog.Uint64("version", uint64(ver)), slog.Bool("dirty", dirty)) + + case "down": + if err := m.Down(); err != nil && err != migrate.ErrNoChange { + return fmt.Errorf("migrate down: %w", err) + } + logger.Info("all migrations rolled back") + + case "version": + ver, dirty, err := m.Version() + if err != nil { + return fmt.Errorf("migrate version: %w", err) + } + logger.Info("current version", slog.Uint64("version", uint64(ver)), slog.Bool("dirty", dirty)) + + case "force": + var version int + if _, err := fmt.Sscanf(args[0], "%d", &version); err != nil { + return fmt.Errorf("invalid version: %w", err) + } + if err := m.Force(version); err != nil { + return fmt.Errorf("migrate force: %w", err) + } + logger.Info("forced version", slog.Int("version", version)) + } + + return nil +} + +type migrateLogger struct { + logger *slog.Logger +} + +func (l *migrateLogger) Printf(format string, v ...interface{}) { + l.logger.Info(fmt.Sprintf(format, v...)) +} + +func (l *migrateLogger) Verbose() bool { + return false +} diff --git a/internal/db/migrate_test.go b/internal/db/migrate_test.go new file mode 100644 index 00000000..d61e63c8 --- /dev/null +++ b/internal/db/migrate_test.go @@ -0,0 +1,22 @@ +package db + +import ( + "testing" + + "github.com/memohai/memoh/internal/config" +) + +func TestRunMigrateUnknownCommand(t *testing.T) { + cfg := config.PostgresConfig{ + Host: "localhost", + Port: 5432, + User: "memoh", + Password: "secret", + Database: "memoh", + SSLMode: "disable", + } + err := RunMigrate(nil, cfg, nil, "invalid", nil) + if err == nil { + t.Fatal("expected error for unknown command") + } +} diff --git a/internal/db/sqlc/media.sql.go b/internal/db/sqlc/media.sql.go index a841240f..91b78409 100644 --- a/internal/db/sqlc/media.sql.go +++ b/internal/db/sqlc/media.sql.go @@ -11,110 +11,41 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -const createMediaAsset = `-- name: CreateMediaAsset :one -INSERT INTO media_assets ( - bot_id, storage_provider_id, content_hash, media_type, mime, - size_bytes, storage_key, original_name, width, height, duration_ms, metadata -) +const createMessageAsset = `-- name: CreateMessageAsset :one +INSERT INTO bot_history_message_assets (message_id, role, ordinal, content_hash) VALUES ( $1, - $2::uuid, + $2, $3, - $4, - $5, - $6, - $7, - $8::text, - $9::integer, - $10::integer, - $11::bigint, - $12 + $4 ) -ON CONFLICT (bot_id, content_hash) DO UPDATE SET - bot_id = media_assets.bot_id -RETURNING id, bot_id, storage_provider_id, content_hash, media_type, mime, size_bytes, storage_key, original_name, width, height, duration_ms, metadata, created_at -` - -type CreateMediaAssetParams struct { - BotID pgtype.UUID `json:"bot_id"` - StorageProviderID pgtype.UUID `json:"storage_provider_id"` - ContentHash string `json:"content_hash"` - MediaType string `json:"media_type"` - Mime string `json:"mime"` - SizeBytes int64 `json:"size_bytes"` - StorageKey string `json:"storage_key"` - OriginalName pgtype.Text `json:"original_name"` - Width pgtype.Int4 `json:"width"` - Height pgtype.Int4 `json:"height"` - DurationMs pgtype.Int8 `json:"duration_ms"` - Metadata []byte `json:"metadata"` -} - -func (q *Queries) CreateMediaAsset(ctx context.Context, arg CreateMediaAssetParams) (MediaAsset, error) { - row := q.db.QueryRow(ctx, createMediaAsset, - arg.BotID, - arg.StorageProviderID, - arg.ContentHash, - arg.MediaType, - arg.Mime, - arg.SizeBytes, - arg.StorageKey, - arg.OriginalName, - arg.Width, - arg.Height, - arg.DurationMs, - arg.Metadata, - ) - var i MediaAsset - err := row.Scan( - &i.ID, - &i.BotID, - &i.StorageProviderID, - &i.ContentHash, - &i.MediaType, - &i.Mime, - &i.SizeBytes, - &i.StorageKey, - &i.OriginalName, - &i.Width, - &i.Height, - &i.DurationMs, - &i.Metadata, - &i.CreatedAt, - ) - return i, err -} - -const createMessageAsset = `-- name: CreateMessageAsset :one -INSERT INTO bot_history_message_assets (message_id, asset_id, role, ordinal) -VALUES ($1, $2, $3, $4) -ON CONFLICT (message_id, asset_id) DO UPDATE SET +ON CONFLICT (message_id, content_hash) DO UPDATE SET role = EXCLUDED.role, ordinal = EXCLUDED.ordinal -RETURNING id, message_id, asset_id, role, ordinal, created_at +RETURNING id, message_id, role, ordinal, content_hash, created_at ` type CreateMessageAssetParams struct { - MessageID pgtype.UUID `json:"message_id"` - AssetID pgtype.UUID `json:"asset_id"` - Role string `json:"role"` - Ordinal int32 `json:"ordinal"` + MessageID pgtype.UUID `json:"message_id"` + Role string `json:"role"` + Ordinal int32 `json:"ordinal"` + ContentHash string `json:"content_hash"` } func (q *Queries) CreateMessageAsset(ctx context.Context, arg CreateMessageAssetParams) (BotHistoryMessageAsset, error) { row := q.db.QueryRow(ctx, createMessageAsset, arg.MessageID, - arg.AssetID, arg.Role, arg.Ordinal, + arg.ContentHash, ) var i BotHistoryMessageAsset err := row.Scan( &i.ID, &i.MessageID, - &i.AssetID, &i.Role, &i.Ordinal, + &i.ContentHash, &i.CreatedAt, ) return i, err @@ -146,15 +77,6 @@ func (q *Queries) CreateStorageProvider(ctx context.Context, arg CreateStoragePr return i, err } -const deleteMediaAsset = `-- name: DeleteMediaAsset :exec -DELETE FROM media_assets WHERE id = $1 -` - -func (q *Queries) DeleteMediaAsset(ctx context.Context, id pgtype.UUID) error { - _, err := q.db.Exec(ctx, deleteMediaAsset, id) - return err -} - const deleteMessageAssets = `-- name: DeleteMessageAssets :exec DELETE FROM bot_history_message_assets WHERE message_id = $1 ` @@ -182,64 +104,6 @@ func (q *Queries) GetBotStorageBinding(ctx context.Context, botID pgtype.UUID) ( return i, err } -const getMediaAssetByHash = `-- name: GetMediaAssetByHash :one -SELECT id, bot_id, storage_provider_id, content_hash, media_type, mime, size_bytes, storage_key, original_name, width, height, duration_ms, metadata, created_at FROM media_assets -WHERE bot_id = $1 AND content_hash = $2 -` - -type GetMediaAssetByHashParams struct { - BotID pgtype.UUID `json:"bot_id"` - ContentHash string `json:"content_hash"` -} - -func (q *Queries) GetMediaAssetByHash(ctx context.Context, arg GetMediaAssetByHashParams) (MediaAsset, error) { - row := q.db.QueryRow(ctx, getMediaAssetByHash, arg.BotID, arg.ContentHash) - var i MediaAsset - err := row.Scan( - &i.ID, - &i.BotID, - &i.StorageProviderID, - &i.ContentHash, - &i.MediaType, - &i.Mime, - &i.SizeBytes, - &i.StorageKey, - &i.OriginalName, - &i.Width, - &i.Height, - &i.DurationMs, - &i.Metadata, - &i.CreatedAt, - ) - return i, err -} - -const getMediaAssetByID = `-- name: GetMediaAssetByID :one -SELECT id, bot_id, storage_provider_id, content_hash, media_type, mime, size_bytes, storage_key, original_name, width, height, duration_ms, metadata, created_at FROM media_assets WHERE id = $1 -` - -func (q *Queries) GetMediaAssetByID(ctx context.Context, id pgtype.UUID) (MediaAsset, error) { - row := q.db.QueryRow(ctx, getMediaAssetByID, id) - var i MediaAsset - err := row.Scan( - &i.ID, - &i.BotID, - &i.StorageProviderID, - &i.ContentHash, - &i.MediaType, - &i.Mime, - &i.SizeBytes, - &i.StorageKey, - &i.OriginalName, - &i.Width, - &i.Height, - &i.DurationMs, - &i.Metadata, - &i.CreatedAt, - ) - return i, err -} - const getStorageProviderByID = `-- name: GetStorageProviderByID :one SELECT id, name, provider, config, created_at, updated_at FROM storage_providers WHERE id = $1 ` @@ -276,84 +140,19 @@ func (q *Queries) GetStorageProviderByName(ctx context.Context, name string) (St return i, err } -const listMediaAssetsByBotID = `-- name: ListMediaAssetsByBotID :many -SELECT id, bot_id, storage_provider_id, content_hash, media_type, mime, size_bytes, storage_key, original_name, width, height, duration_ms, metadata, created_at FROM media_assets -WHERE bot_id = $1 -ORDER BY created_at DESC -` - -func (q *Queries) ListMediaAssetsByBotID(ctx context.Context, botID pgtype.UUID) ([]MediaAsset, error) { - rows, err := q.db.Query(ctx, listMediaAssetsByBotID, botID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []MediaAsset - for rows.Next() { - var i MediaAsset - if err := rows.Scan( - &i.ID, - &i.BotID, - &i.StorageProviderID, - &i.ContentHash, - &i.MediaType, - &i.Mime, - &i.SizeBytes, - &i.StorageKey, - &i.OriginalName, - &i.Width, - &i.Height, - &i.DurationMs, - &i.Metadata, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const listMessageAssets = `-- name: ListMessageAssets :many -SELECT - ma.id AS rel_id, - ma.message_id, - ma.asset_id, - ma.role, - ma.ordinal, - a.media_type, - a.mime, - a.size_bytes, - a.storage_key, - a.original_name, - a.width, - a.height, - a.duration_ms, - a.metadata AS asset_metadata -FROM bot_history_message_assets ma -JOIN media_assets a ON a.id = ma.asset_id -WHERE ma.message_id = $1 -ORDER BY ma.ordinal ASC +SELECT id AS rel_id, message_id, role, ordinal, content_hash +FROM bot_history_message_assets +WHERE message_id = $1 +ORDER BY ordinal ASC ` type ListMessageAssetsRow struct { - RelID pgtype.UUID `json:"rel_id"` - MessageID pgtype.UUID `json:"message_id"` - AssetID pgtype.UUID `json:"asset_id"` - Role string `json:"role"` - Ordinal int32 `json:"ordinal"` - MediaType string `json:"media_type"` - Mime string `json:"mime"` - SizeBytes int64 `json:"size_bytes"` - StorageKey string `json:"storage_key"` - OriginalName pgtype.Text `json:"original_name"` - Width pgtype.Int4 `json:"width"` - Height pgtype.Int4 `json:"height"` - DurationMs pgtype.Int8 `json:"duration_ms"` - AssetMetadata []byte `json:"asset_metadata"` + RelID pgtype.UUID `json:"rel_id"` + MessageID pgtype.UUID `json:"message_id"` + Role string `json:"role"` + Ordinal int32 `json:"ordinal"` + ContentHash string `json:"content_hash"` } func (q *Queries) ListMessageAssets(ctx context.Context, messageID pgtype.UUID) ([]ListMessageAssetsRow, error) { @@ -368,18 +167,9 @@ func (q *Queries) ListMessageAssets(ctx context.Context, messageID pgtype.UUID) if err := rows.Scan( &i.RelID, &i.MessageID, - &i.AssetID, &i.Role, &i.Ordinal, - &i.MediaType, - &i.Mime, - &i.SizeBytes, - &i.StorageKey, - &i.OriginalName, - &i.Width, - &i.Height, - &i.DurationMs, - &i.AssetMetadata, + &i.ContentHash, ); err != nil { return nil, err } @@ -392,42 +182,18 @@ func (q *Queries) ListMessageAssets(ctx context.Context, messageID pgtype.UUID) } const listMessageAssetsBatch = `-- name: ListMessageAssetsBatch :many -SELECT - ma.id AS rel_id, - ma.message_id, - ma.asset_id, - ma.role, - ma.ordinal, - a.media_type, - a.mime, - a.size_bytes, - a.storage_key, - a.original_name, - a.width, - a.height, - a.duration_ms, - a.metadata AS asset_metadata -FROM bot_history_message_assets ma -JOIN media_assets a ON a.id = ma.asset_id -WHERE ma.message_id = ANY($1::uuid[]) -ORDER BY ma.message_id, ma.ordinal ASC +SELECT id AS rel_id, message_id, role, ordinal, content_hash +FROM bot_history_message_assets +WHERE message_id = ANY($1::uuid[]) +ORDER BY message_id, ordinal ASC ` type ListMessageAssetsBatchRow struct { - RelID pgtype.UUID `json:"rel_id"` - MessageID pgtype.UUID `json:"message_id"` - AssetID pgtype.UUID `json:"asset_id"` - Role string `json:"role"` - Ordinal int32 `json:"ordinal"` - MediaType string `json:"media_type"` - Mime string `json:"mime"` - SizeBytes int64 `json:"size_bytes"` - StorageKey string `json:"storage_key"` - OriginalName pgtype.Text `json:"original_name"` - Width pgtype.Int4 `json:"width"` - Height pgtype.Int4 `json:"height"` - DurationMs pgtype.Int8 `json:"duration_ms"` - AssetMetadata []byte `json:"asset_metadata"` + RelID pgtype.UUID `json:"rel_id"` + MessageID pgtype.UUID `json:"message_id"` + Role string `json:"role"` + Ordinal int32 `json:"ordinal"` + ContentHash string `json:"content_hash"` } func (q *Queries) ListMessageAssetsBatch(ctx context.Context, messageIds []pgtype.UUID) ([]ListMessageAssetsBatchRow, error) { @@ -442,18 +208,9 @@ func (q *Queries) ListMessageAssetsBatch(ctx context.Context, messageIds []pgtyp if err := rows.Scan( &i.RelID, &i.MessageID, - &i.AssetID, &i.Role, &i.Ordinal, - &i.MediaType, - &i.Mime, - &i.SizeBytes, - &i.StorageKey, - &i.OriginalName, - &i.Width, - &i.Height, - &i.DurationMs, - &i.AssetMetadata, + &i.ContentHash, ); err != nil { return nil, err } diff --git a/internal/db/sqlc/messages.sql.go b/internal/db/sqlc/messages.sql.go index b1299353..10344696 100644 --- a/internal/db/sqlc/messages.sql.go +++ b/internal/db/sqlc/messages.sql.go @@ -133,7 +133,7 @@ SELECT m.bot_id, m.route_id, m.sender_channel_identity_id, - m.sender_account_user_id AS sender_user_id, + COALESCE(ci.user_id, m.sender_account_user_id) AS sender_user_id, m.channel_type AS platform, m.source_message_id AS external_message_id, m.source_reply_to_message_id, @@ -211,7 +211,7 @@ SELECT m.bot_id, m.route_id, m.sender_channel_identity_id, - m.sender_account_user_id AS sender_user_id, + COALESCE(ci.user_id, m.sender_account_user_id) AS sender_user_id, m.channel_type AS platform, m.source_message_id AS external_message_id, m.source_reply_to_message_id, @@ -296,7 +296,7 @@ SELECT m.bot_id, m.route_id, m.sender_channel_identity_id, - m.sender_account_user_id AS sender_user_id, + COALESCE(ci.user_id, m.sender_account_user_id) AS sender_user_id, m.channel_type AS platform, m.source_message_id AS external_message_id, m.source_reply_to_message_id, @@ -379,7 +379,7 @@ SELECT m.bot_id, m.route_id, m.sender_channel_identity_id, - m.sender_account_user_id AS sender_user_id, + COALESCE(ci.user_id, m.sender_account_user_id) AS sender_user_id, m.channel_type AS platform, m.source_message_id AS external_message_id, m.source_reply_to_message_id, diff --git a/internal/db/sqlc/models.go b/internal/db/sqlc/models.go index e15399c0..bcdea5de 100644 --- a/internal/db/sqlc/models.go +++ b/internal/db/sqlc/models.go @@ -75,12 +75,12 @@ type BotHistoryMessage struct { } type BotHistoryMessageAsset struct { - ID pgtype.UUID `json:"id"` - MessageID pgtype.UUID `json:"message_id"` - AssetID pgtype.UUID `json:"asset_id"` - Role string `json:"role"` - Ordinal int32 `json:"ordinal"` - CreatedAt pgtype.Timestamptz `json:"created_at"` + ID pgtype.UUID `json:"id"` + MessageID pgtype.UUID `json:"message_id"` + Role string `json:"role"` + Ordinal int32 `json:"ordinal"` + ContentHash string `json:"content_hash"` + CreatedAt pgtype.Timestamptz `json:"created_at"` } type BotMember struct { @@ -150,9 +150,9 @@ type Container struct { } type ContainerVersion struct { - ID string `json:"id"` + ID pgtype.UUID `json:"id"` ContainerID string `json:"container_id"` - SnapshotID string `json:"snapshot_id"` + SnapshotID pgtype.UUID `json:"snapshot_id"` Version int32 `json:"version"` CreatedAt pgtype.Timestamptz `json:"created_at"` } @@ -186,23 +186,6 @@ type McpConnection struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } -type MediaAsset struct { - ID pgtype.UUID `json:"id"` - BotID pgtype.UUID `json:"bot_id"` - StorageProviderID pgtype.UUID `json:"storage_provider_id"` - ContentHash string `json:"content_hash"` - MediaType string `json:"media_type"` - Mime string `json:"mime"` - SizeBytes int64 `json:"size_bytes"` - StorageKey string `json:"storage_key"` - OriginalName pgtype.Text `json:"original_name"` - Width pgtype.Int4 `json:"width"` - Height pgtype.Int4 `json:"height"` - DurationMs pgtype.Int8 `json:"duration_ms"` - Metadata []byte `json:"metadata"` - CreatedAt pgtype.Timestamptz `json:"created_at"` -} - type Model struct { ID pgtype.UUID `json:"id"` ModelID string `json:"model_id"` @@ -250,12 +233,13 @@ type SearchProvider struct { } type Snapshot struct { - ID string `json:"id"` - ContainerID string `json:"container_id"` - ParentSnapshotID pgtype.Text `json:"parent_snapshot_id"` - Snapshotter string `json:"snapshotter"` - Digest pgtype.Text `json:"digest"` - CreatedAt pgtype.Timestamptz `json:"created_at"` + ID pgtype.UUID `json:"id"` + ContainerID string `json:"container_id"` + RuntimeSnapshotName string `json:"runtime_snapshot_name"` + ParentRuntimeSnapshotName pgtype.Text `json:"parent_runtime_snapshot_name"` + Snapshotter string `json:"snapshotter"` + Source string `json:"source"` + CreatedAt pgtype.Timestamptz `json:"created_at"` } type StorageProvider struct { diff --git a/internal/db/sqlc/snapshots.sql.go b/internal/db/sqlc/snapshots.sql.go index 22d551f4..8773667e 100644 --- a/internal/db/sqlc/snapshots.sql.go +++ b/internal/db/sqlc/snapshots.sql.go @@ -11,8 +11,147 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -const insertSnapshot = `-- name: InsertSnapshot :exec -INSERT INTO snapshots (id, container_id, parent_snapshot_id, snapshotter, digest) +const getSnapshotByContainerAndRuntimeName = `-- name: GetSnapshotByContainerAndRuntimeName :one +SELECT + id, + container_id, + runtime_snapshot_name, + parent_runtime_snapshot_name, + snapshotter, + source, + created_at +FROM snapshots +WHERE container_id = $1 + AND runtime_snapshot_name = $2 +LIMIT 1 +` + +type GetSnapshotByContainerAndRuntimeNameParams struct { + ContainerID string `json:"container_id"` + RuntimeSnapshotName string `json:"runtime_snapshot_name"` +} + +func (q *Queries) GetSnapshotByContainerAndRuntimeName(ctx context.Context, arg GetSnapshotByContainerAndRuntimeNameParams) (Snapshot, error) { + row := q.db.QueryRow(ctx, getSnapshotByContainerAndRuntimeName, arg.ContainerID, arg.RuntimeSnapshotName) + var i Snapshot + err := row.Scan( + &i.ID, + &i.ContainerID, + &i.RuntimeSnapshotName, + &i.ParentRuntimeSnapshotName, + &i.Snapshotter, + &i.Source, + &i.CreatedAt, + ) + return i, err +} + +const listSnapshotsByContainerID = `-- name: ListSnapshotsByContainerID :many +SELECT + id, + container_id, + runtime_snapshot_name, + parent_runtime_snapshot_name, + snapshotter, + source, + created_at +FROM snapshots +WHERE container_id = $1 +ORDER BY created_at DESC +` + +func (q *Queries) ListSnapshotsByContainerID(ctx context.Context, containerID string) ([]Snapshot, error) { + rows, err := q.db.Query(ctx, listSnapshotsByContainerID, containerID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Snapshot + for rows.Next() { + var i Snapshot + if err := rows.Scan( + &i.ID, + &i.ContainerID, + &i.RuntimeSnapshotName, + &i.ParentRuntimeSnapshotName, + &i.Snapshotter, + &i.Source, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listSnapshotsWithVersionByContainerID = `-- name: ListSnapshotsWithVersionByContainerID :many +SELECT + s.id, + s.container_id, + s.runtime_snapshot_name, + s.parent_runtime_snapshot_name, + s.snapshotter, + s.source, + s.created_at, + cv.version +FROM snapshots s +LEFT JOIN container_versions cv ON cv.snapshot_id = s.id +WHERE s.container_id = $1 +ORDER BY s.created_at DESC +` + +type ListSnapshotsWithVersionByContainerIDRow struct { + ID pgtype.UUID `json:"id"` + ContainerID string `json:"container_id"` + RuntimeSnapshotName string `json:"runtime_snapshot_name"` + ParentRuntimeSnapshotName pgtype.Text `json:"parent_runtime_snapshot_name"` + Snapshotter string `json:"snapshotter"` + Source string `json:"source"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + Version pgtype.Int4 `json:"version"` +} + +func (q *Queries) ListSnapshotsWithVersionByContainerID(ctx context.Context, containerID string) ([]ListSnapshotsWithVersionByContainerIDRow, error) { + rows, err := q.db.Query(ctx, listSnapshotsWithVersionByContainerID, containerID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListSnapshotsWithVersionByContainerIDRow + for rows.Next() { + var i ListSnapshotsWithVersionByContainerIDRow + if err := rows.Scan( + &i.ID, + &i.ContainerID, + &i.RuntimeSnapshotName, + &i.ParentRuntimeSnapshotName, + &i.Snapshotter, + &i.Source, + &i.CreatedAt, + &i.Version, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertSnapshot = `-- name: UpsertSnapshot :one +INSERT INTO snapshots ( + container_id, + runtime_snapshot_name, + parent_runtime_snapshot_name, + snapshotter, + source +) VALUES ( $1, $2, @@ -20,24 +159,39 @@ VALUES ( $4, $5 ) -ON CONFLICT (id) DO NOTHING +ON CONFLICT (container_id, runtime_snapshot_name) DO UPDATE +SET + parent_runtime_snapshot_name = EXCLUDED.parent_runtime_snapshot_name, + snapshotter = EXCLUDED.snapshotter, + source = EXCLUDED.source +RETURNING id, container_id, runtime_snapshot_name, parent_runtime_snapshot_name, snapshotter, source, created_at ` -type InsertSnapshotParams struct { - ID string `json:"id"` - ContainerID string `json:"container_id"` - ParentSnapshotID pgtype.Text `json:"parent_snapshot_id"` - Snapshotter string `json:"snapshotter"` - Digest pgtype.Text `json:"digest"` +type UpsertSnapshotParams struct { + ContainerID string `json:"container_id"` + RuntimeSnapshotName string `json:"runtime_snapshot_name"` + ParentRuntimeSnapshotName pgtype.Text `json:"parent_runtime_snapshot_name"` + Snapshotter string `json:"snapshotter"` + Source string `json:"source"` } -func (q *Queries) InsertSnapshot(ctx context.Context, arg InsertSnapshotParams) error { - _, err := q.db.Exec(ctx, insertSnapshot, - arg.ID, +func (q *Queries) UpsertSnapshot(ctx context.Context, arg UpsertSnapshotParams) (Snapshot, error) { + row := q.db.QueryRow(ctx, upsertSnapshot, arg.ContainerID, - arg.ParentSnapshotID, + arg.RuntimeSnapshotName, + arg.ParentRuntimeSnapshotName, arg.Snapshotter, - arg.Digest, + arg.Source, ) - return err + var i Snapshot + err := row.Scan( + &i.ID, + &i.ContainerID, + &i.RuntimeSnapshotName, + &i.ParentRuntimeSnapshotName, + &i.Snapshotter, + &i.Source, + &i.CreatedAt, + ) + return i, err } diff --git a/internal/db/sqlc/versions.sql.go b/internal/db/sqlc/versions.sql.go index c3fe5f48..56618ef2 100644 --- a/internal/db/sqlc/versions.sql.go +++ b/internal/db/sqlc/versions.sql.go @@ -7,49 +7,48 @@ package sqlc import ( "context" + + "github.com/jackc/pgx/v5/pgtype" ) -const getVersionSnapshotID = `-- name: GetVersionSnapshotID :one -SELECT snapshot_id FROM container_versions WHERE container_id = $1 AND version = $2 +const getVersionSnapshotRuntimeName = `-- name: GetVersionSnapshotRuntimeName :one +SELECT s.runtime_snapshot_name +FROM container_versions cv +JOIN snapshots s ON s.id = cv.snapshot_id +WHERE cv.container_id = $1 + AND cv.version = $2 ` -type GetVersionSnapshotIDParams struct { +type GetVersionSnapshotRuntimeNameParams struct { ContainerID string `json:"container_id"` Version int32 `json:"version"` } -func (q *Queries) GetVersionSnapshotID(ctx context.Context, arg GetVersionSnapshotIDParams) (string, error) { - row := q.db.QueryRow(ctx, getVersionSnapshotID, arg.ContainerID, arg.Version) - var snapshot_id string - err := row.Scan(&snapshot_id) - return snapshot_id, err +func (q *Queries) GetVersionSnapshotRuntimeName(ctx context.Context, arg GetVersionSnapshotRuntimeNameParams) (string, error) { + row := q.db.QueryRow(ctx, getVersionSnapshotRuntimeName, arg.ContainerID, arg.Version) + var runtime_snapshot_name string + err := row.Scan(&runtime_snapshot_name) + return runtime_snapshot_name, err } const insertVersion = `-- name: InsertVersion :one -INSERT INTO container_versions (id, container_id, snapshot_id, version) +INSERT INTO container_versions (container_id, snapshot_id, version) VALUES ( $1, $2, - $3, - $4 + $3 ) RETURNING id, container_id, snapshot_id, version, created_at ` type InsertVersionParams struct { - ID string `json:"id"` - ContainerID string `json:"container_id"` - SnapshotID string `json:"snapshot_id"` - Version int32 `json:"version"` + ContainerID string `json:"container_id"` + SnapshotID pgtype.UUID `json:"snapshot_id"` + Version int32 `json:"version"` } func (q *Queries) InsertVersion(ctx context.Context, arg InsertVersionParams) (ContainerVersion, error) { - row := q.db.QueryRow(ctx, insertVersion, - arg.ID, - arg.ContainerID, - arg.SnapshotID, - arg.Version, - ) + row := q.db.QueryRow(ctx, insertVersion, arg.ContainerID, arg.SnapshotID, arg.Version) var i ContainerVersion err := row.Scan( &i.ID, @@ -62,24 +61,44 @@ func (q *Queries) InsertVersion(ctx context.Context, arg InsertVersionParams) (C } const listVersionsByContainerID = `-- name: ListVersionsByContainerID :many -SELECT id, container_id, snapshot_id, version, created_at FROM container_versions WHERE container_id = $1 ORDER BY version ASC +SELECT + cv.id, + cv.container_id, + cv.snapshot_id, + cv.version, + cv.created_at, + s.runtime_snapshot_name +FROM container_versions cv +JOIN snapshots s ON s.id = cv.snapshot_id +WHERE cv.container_id = $1 +ORDER BY cv.version ASC ` -func (q *Queries) ListVersionsByContainerID(ctx context.Context, containerID string) ([]ContainerVersion, error) { +type ListVersionsByContainerIDRow struct { + ID pgtype.UUID `json:"id"` + ContainerID string `json:"container_id"` + SnapshotID pgtype.UUID `json:"snapshot_id"` + Version int32 `json:"version"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + RuntimeSnapshotName string `json:"runtime_snapshot_name"` +} + +func (q *Queries) ListVersionsByContainerID(ctx context.Context, containerID string) ([]ListVersionsByContainerIDRow, error) { rows, err := q.db.Query(ctx, listVersionsByContainerID, containerID) if err != nil { return nil, err } defer rows.Close() - var items []ContainerVersion + var items []ListVersionsByContainerIDRow for rows.Next() { - var i ContainerVersion + var i ListVersionsByContainerIDRow if err := rows.Scan( &i.ID, &i.ContainerID, &i.SnapshotID, &i.Version, &i.CreatedAt, + &i.RuntimeSnapshotName, ); err != nil { return nil, err } diff --git a/internal/db/utils.go b/internal/db/utils.go index 42bd5ba9..02ddfe12 100644 --- a/internal/db/utils.go +++ b/internal/db/utils.go @@ -9,8 +9,22 @@ import ( "github.com/google/uuid" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" + "github.com/memohai/memoh/internal/config" ) +// DSN builds a PostgreSQL connection string from config. +func DSN(cfg config.PostgresConfig) string { + return fmt.Sprintf( + "postgres://%s:%s@%s:%d/%s?sslmode=%s", + cfg.User, + cfg.Password, + cfg.Host, + cfg.Port, + cfg.Database, + cfg.SSLMode, + ) +} + // ParseUUID converts a string UUID to pgtype.UUID. func ParseUUID(id string) (pgtype.UUID, error) { parsed, err := uuid.Parse(strings.TrimSpace(id)) diff --git a/internal/db/utils_test.go b/internal/db/utils_test.go index 92b01386..37cd23b6 100644 --- a/internal/db/utils_test.go +++ b/internal/db/utils_test.go @@ -8,8 +8,24 @@ import ( "github.com/google/uuid" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" + "github.com/memohai/memoh/internal/config" ) +func TestDSN(t *testing.T) { + cfg := config.PostgresConfig{ + Host: "localhost", + Port: 5432, + User: "memoh", + Password: "secret", + Database: "memoh", + SSLMode: "disable", + } + want := "postgres://memoh:secret@localhost:5432/memoh?sslmode=disable" + if got := DSN(cfg); got != want { + t.Errorf("DSN() = %q, want %q", got, want) + } +} + func TestParseUUID(t *testing.T) { validUUID := uuid.MustParse("550e8400-e29b-41d4-a716-446655440000") tests := []struct { diff --git a/internal/handlers/containerd.go b/internal/handlers/containerd.go index d78b3ed7..282087ab 100644 --- a/internal/handlers/containerd.go +++ b/internal/handlers/containerd.go @@ -14,6 +14,7 @@ import ( "time" tasktypes "github.com/containerd/containerd/api/types/task" + "github.com/containerd/containerd/v2/core/snapshots" "github.com/containerd/containerd/v2/pkg/namespaces" "github.com/containerd/containerd/v2/pkg/oci" "github.com/containerd/errdefs" @@ -35,6 +36,7 @@ import ( type ContainerdHandler struct { service ctr.Service + manager *mcp.Manager cfg config.MCPConfig namespace string logger *slog.Logger @@ -80,6 +82,8 @@ type CreateSnapshotResponse struct { ContainerID string `json:"container_id"` SnapshotName string `json:"snapshot_name"` Snapshotter string `json:"snapshotter"` + Version int `json:"version"` + Source string `json:"source"` } type SnapshotInfo struct { @@ -90,6 +94,9 @@ type SnapshotInfo struct { CreatedAt time.Time `json:"created_at,omitempty"` UpdatedAt time.Time `json:"updated_at,omitempty"` Labels map[string]string `json:"labels,omitempty"` + Source string `json:"source"` + Managed bool `json:"managed"` + Version *int `json:"version,omitempty"` } type ListSnapshotsResponse struct { @@ -97,9 +104,10 @@ type ListSnapshotsResponse struct { Snapshots []SnapshotInfo `json:"snapshots"` } -func NewContainerdHandler(log *slog.Logger, service ctr.Service, cfg config.MCPConfig, namespace string, botService *bots.Service, accountService *accounts.Service, policyService *policy.Service, queries *dbsqlc.Queries) *ContainerdHandler { +func NewContainerdHandler(log *slog.Logger, service ctr.Service, manager *mcp.Manager, cfg config.MCPConfig, namespace string, botService *bots.Service, accountService *accounts.Service, policyService *policy.Service, queries *dbsqlc.Queries) *ContainerdHandler { return &ContainerdHandler{ service: service, + manager: manager, cfg: cfg, namespace: namespace, logger: log.With(slog.String("handler", "containerd")), @@ -552,10 +560,41 @@ func (h *ContainerdHandler) CreateSnapshot(c echo.Context) error { if err != nil { return err } + if h.manager == nil { + return echo.NewHTTPError(http.StatusInternalServerError, "snapshot manager not configured") + } var req CreateSnapshotRequest if err := c.Bind(&req); err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } + created, err := h.manager.CreateSnapshot(c.Request().Context(), botID, req.SnapshotName, mcp.SnapshotSourceManual) + if err != nil { + if errdefs.IsNotFound(err) { + return echo.NewHTTPError(http.StatusNotFound, "container not found") + } + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + return c.JSON(http.StatusOK, CreateSnapshotResponse{ + ContainerID: created.ContainerID, + SnapshotName: created.SnapshotName, + Snapshotter: created.Snapshotter, + Version: created.Version, + Source: mcp.SnapshotSourceManual, + }) +} + +// ListSnapshots godoc +// @Summary List snapshots +// @Tags containerd +// @Param bot_id path string true "Bot ID" +// @Param snapshotter query string false "Snapshotter name" +// @Success 200 {object} ListSnapshotsResponse +// @Router /bots/{bot_id}/container/snapshots [get] +func (h *ContainerdHandler) ListSnapshots(c echo.Context) error { + botID, err := h.requireBotAccess(c) + if err != nil { + return err + } ctx := c.Request().Context() containerID, err := h.botContainerID(ctx, botID) if err != nil { @@ -572,57 +611,122 @@ func (h *ContainerdHandler) CreateSnapshot(c echo.Context) error { if strings.TrimSpace(h.namespace) != "" { infoCtx = namespaces.WithNamespace(ctx, h.namespace) } - info, err := container.Info(infoCtx) + containerInfo, err := container.Info(infoCtx) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - snapshotName := strings.TrimSpace(req.SnapshotName) - if snapshotName == "" { - snapshotName = containerID + "-" + time.Now().Format("20060102150405") - } - if err := h.service.CommitSnapshot(ctx, info.Snapshotter, snapshotName, info.SnapshotKey); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - return c.JSON(http.StatusOK, CreateSnapshotResponse{ - ContainerID: containerID, - SnapshotName: snapshotName, - Snapshotter: info.Snapshotter, - }) -} -// ListSnapshots godoc -// @Summary List snapshots -// @Tags containerd -// @Param bot_id path string true "Bot ID" -// @Param snapshotter query string false "Snapshotter name" -// @Success 200 {object} ListSnapshotsResponse -// @Router /bots/{bot_id}/container/snapshots [get] -func (h *ContainerdHandler) ListSnapshots(c echo.Context) error { - if _, err := h.requireBotAccess(c); err != nil { - return err + requestedSnapshotter := strings.TrimSpace(c.QueryParam("snapshotter")) + snapshotter := strings.TrimSpace(containerInfo.Snapshotter) + if requestedSnapshotter != "" { + if snapshotter != "" && requestedSnapshotter != snapshotter { + return echo.NewHTTPError(http.StatusBadRequest, "snapshotter does not match container snapshotter") + } + snapshotter = requestedSnapshotter } - snapshotter := strings.TrimSpace(c.QueryParam("snapshotter")) if snapshotter == "" { snapshotter = strings.TrimSpace(h.cfg.Snapshotter) } if snapshotter == "" { snapshotter = "overlayfs" } - snapshots, err := h.service.ListSnapshots(c.Request().Context(), snapshotter) + snapshotKey := strings.TrimSpace(containerInfo.SnapshotKey) + if snapshotKey == "" { + return echo.NewHTTPError(http.StatusInternalServerError, "container snapshot key is empty") + } + + allSnapshots, err := h.service.ListSnapshots(ctx, snapshotter) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - items := make([]SnapshotInfo, 0, len(snapshots)) - for _, info := range snapshots { + runtimeByName := make(map[string]snapshots.Info, len(allSnapshots)) + for _, info := range allSnapshots { + name := strings.TrimSpace(info.Name) + if name == "" { + continue + } + runtimeByName[name] = info + } + lineage, ok := snapshotLineage(snapshotKey, allSnapshots) + if !ok { + h.logger.Warn("container snapshot chain root not found", + slog.String("container_id", containerID), + slog.String("snapshotter", snapshotter), + slog.String("snapshot_key", snapshotKey), + ) + return echo.NewHTTPError(http.StatusInternalServerError, "container snapshot chain not found") + } + + metadataByName := map[string]dbsqlc.ListSnapshotsWithVersionByContainerIDRow{} + if h.queries != nil { + managedRows, dbErr := h.queries.ListSnapshotsWithVersionByContainerID(ctx, containerID) + if dbErr != nil { + return echo.NewHTTPError(http.StatusInternalServerError, dbErr.Error()) + } + for _, row := range managedRows { + name := strings.TrimSpace(row.RuntimeSnapshotName) + if name == "" { + continue + } + metadataByName[name] = row + } + } + + items := make([]SnapshotInfo, 0, len(lineage)+len(metadataByName)) + seen := make(map[string]struct{}, len(lineage)+len(metadataByName)) + appendRuntime := func(runtimeInfo snapshots.Info, fallbackSource string, meta *dbsqlc.ListSnapshotsWithVersionByContainerIDRow) { + source := fallbackSource + managed := false + var version *int + if meta != nil { + if strings.TrimSpace(meta.Source) != "" { + source = strings.TrimSpace(meta.Source) + } + managed = true + if meta.Version.Valid { + v := int(meta.Version.Int32) + version = &v + } + } items = append(items, SnapshotInfo{ Snapshotter: snapshotter, - Name: info.Name, - Parent: info.Parent, - Kind: info.Kind.String(), - CreatedAt: info.Created, - UpdatedAt: info.Updated, - Labels: info.Labels, + Name: runtimeInfo.Name, + Parent: runtimeInfo.Parent, + Kind: runtimeInfo.Kind.String(), + CreatedAt: runtimeInfo.Created, + UpdatedAt: runtimeInfo.Updated, + Labels: runtimeInfo.Labels, + Source: source, + Managed: managed, + Version: version, }) + seen[strings.TrimSpace(runtimeInfo.Name)] = struct{}{} + } + + for _, runtimeInfo := range lineage { + name := strings.TrimSpace(runtimeInfo.Name) + row, hasMeta := metadataByName[name] + if hasMeta { + appendRuntime(runtimeInfo, "image_layer", &row) + continue + } + appendRuntime(runtimeInfo, "image_layer", nil) + } + + for name, row := range metadataByName { + if _, exists := seen[name]; exists { + continue + } + runtimeInfo, exists := runtimeByName[name] + if !exists { + h.logger.Warn("managed snapshot not found in runtime", + slog.String("container_id", containerID), + slog.String("snapshot_name", name), + slog.String("snapshotter", snapshotter), + ) + continue + } + appendRuntime(runtimeInfo, "managed", &row) } sort.Slice(items, func(i, j int) bool { if items[i].CreatedAt.Equal(items[j].CreatedAt) { @@ -636,6 +740,40 @@ func (h *ContainerdHandler) ListSnapshots(c echo.Context) error { }) } +func snapshotLineage(root string, all []snapshots.Info) ([]snapshots.Info, bool) { + root = strings.TrimSpace(root) + if root == "" { + return nil, false + } + index := make(map[string]snapshots.Info, len(all)) + for _, info := range all { + name := strings.TrimSpace(info.Name) + if name == "" { + continue + } + index[name] = info + } + if _, ok := index[root]; !ok { + return nil, false + } + lineage := make([]snapshots.Info, 0, len(index)) + visited := make(map[string]struct{}, len(index)) + current := root + for current != "" { + if _, seen := visited[current]; seen { + break + } + info, ok := index[current] + if !ok { + break + } + lineage = append(lineage, info) + visited[current] = struct{}{} + current = strings.TrimSpace(info.Parent) + } + return lineage, true +} + // ---------- auth helpers ---------- func (h *ContainerdHandler) mcpImageRef() string { diff --git a/internal/handlers/containerd_snapshot_lineage_test.go b/internal/handlers/containerd_snapshot_lineage_test.go new file mode 100644 index 00000000..a4423c67 --- /dev/null +++ b/internal/handlers/containerd_snapshot_lineage_test.go @@ -0,0 +1,85 @@ +package handlers + +import ( + "testing" + + "github.com/containerd/containerd/v2/core/snapshots" +) + +func TestSnapshotLineage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + root string + input []snapshots.Info + wantFound bool + wantNames []string + }{ + { + name: "walk full snapshot ancestry", + root: "active-3", + input: []snapshots.Info{ + {Name: "active-3", Parent: "version-2"}, + {Name: "version-2", Parent: "version-1"}, + {Name: "version-1", Parent: "sha256:base-layer"}, + {Name: "sha256:base-layer", Parent: ""}, + {Name: "unrelated", Parent: ""}, + }, + wantFound: true, + wantNames: []string{"active-3", "version-2", "version-1", "sha256:base-layer"}, + }, + { + name: "root snapshot not found", + root: "missing", + input: []snapshots.Info{ + {Name: "active-1", Parent: "sha256:base-layer"}, + {Name: "sha256:base-layer", Parent: ""}, + }, + wantFound: false, + wantNames: nil, + }, + { + name: "missing parent keeps known chain", + root: "active-1", + input: []snapshots.Info{ + {Name: "active-1", Parent: "version-1"}, + }, + wantFound: true, + wantNames: []string{"active-1"}, + }, + { + name: "cycle is bounded by visited set", + root: "a", + input: []snapshots.Info{ + {Name: "a", Parent: "b"}, + {Name: "b", Parent: "a"}, + }, + wantFound: true, + wantNames: []string{"a", "b"}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, found := snapshotLineage(tt.root, tt.input) + if found != tt.wantFound { + t.Fatalf("found = %v, want %v", found, tt.wantFound) + } + if !found { + return + } + if len(got) != len(tt.wantNames) { + t.Fatalf("len(got) = %d, want %d", len(got), len(tt.wantNames)) + } + for i := range tt.wantNames { + if got[i].Name != tt.wantNames[i] { + t.Fatalf("got[%d].Name = %q, want %q", i, got[i].Name, tt.wantNames[i]) + } + } + }) + } +} diff --git a/internal/handlers/fs.go b/internal/handlers/fs.go index 4c750db5..04faf852 100644 --- a/internal/handlers/fs.go +++ b/internal/handlers/fs.go @@ -11,10 +11,8 @@ import ( "net/http" "os/exec" "path/filepath" - "runtime" "strings" "sync" - "time" "github.com/containerd/containerd/v2/pkg/namespaces" "github.com/containerd/errdefs" @@ -87,16 +85,9 @@ func (h *ContainerdHandler) getMCPSession(ctx context.Context, containerID strin } h.mcpMu.Unlock() - var sess *mcpSession - var err error - if runtime.GOOS == "darwin" { - sess, err = h.startLimaMCPSession(containerID) - } - if err != nil || sess == nil { - sess, err = h.startContainerdMCPSession(ctx, containerID) - if err != nil { - return nil, err - } + sess, err := h.startContainerdMCPSession(ctx, containerID) + if err != nil { + return nil, err } h.mcpMu.Lock() @@ -160,86 +151,6 @@ func (h *ContainerdHandler) startContainerdMCPSession(ctx context.Context, conta return sess, nil } -func (h *ContainerdHandler) startLimaMCPSession(containerID string) (*mcpSession, error) { - execID := fmt.Sprintf("mcp-%d", time.Now().UnixNano()) - cmd := exec.Command( - "limactl", - "shell", - "--tty=false", - "default", - "--", - "sudo", - "-n", - "ctr", - "-n", - "default", - "tasks", - "exec", - "--exec-id", - execID, - containerID, - "/app/mcp", - ) - - stdin, err := cmd.StdinPipe() - if err != nil { - return nil, err - } - stdout, err := cmd.StdoutPipe() - if err != nil { - _ = stdin.Close() - return nil, err - } - stderr, err := cmd.StderrPipe() - if err != nil { - _ = stdin.Close() - _ = stdout.Close() - return nil, err - } - if err := cmd.Start(); err != nil { - _ = stdin.Close() - _ = stdout.Close() - _ = stderr.Close() - return nil, err - } - - sess := &mcpSession{ - stdin: stdin, - stdout: stdout, - stderr: stderr, - cmd: cmd, - pending: make(map[string]chan *sdkjsonrpc.Response), - closed: make(chan struct{}), - } - transport := &sdkmcp.IOTransport{ - Reader: sess.stdout, - Writer: sess.stdin, - } - conn, err := transport.Connect(context.Background()) - if err != nil { - sess.closeWithError(err) - return nil, err - } - sess.conn = conn - - h.startMCPStderrLogger(stderr, containerID) - go sess.readLoop() - go func() { - if err := cmd.Wait(); err != nil { - if isBenignMCPSessionExit(err) { - sess.closeWithError(io.EOF) - return - } - h.logger.Error("mcp session exited", slog.Any("error", err), slog.String("container_id", containerID)) - sess.closeWithError(err) - return - } - sess.closeWithError(io.EOF) - }() - - return sess, nil -} - func (s *mcpSession) closeWithError(err error) { s.closeOnce.Do(func() { s.closeErr = err diff --git a/internal/handlers/local_channel.go b/internal/handlers/local_channel.go index 1b87ea11..89eb35d0 100644 --- a/internal/handlers/local_channel.go +++ b/internal/handlers/local_channel.go @@ -50,7 +50,18 @@ func (h *LocalChannelHandler) Register(e *echo.Echo) { group.POST("/messages", h.PostMessage) } -// StreamMessages streams responses for the bot route. +// StreamMessages godoc +// @Summary Subscribe to local channel events via SSE +// @Description Open a persistent SSE connection to receive real-time stream events for the given bot. +// @Tags local-channel +// @Produce text/event-stream +// @Param bot_id path string true "Bot ID" +// @Success 200 {string} string "SSE stream" +// @Failure 400 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /bots/{bot_id}/web/stream [get] +// @Router /bots/{bot_id}/cli/stream [get] func (h *LocalChannelHandler) StreamMessages(c echo.Context) error { channelIdentityID, err := h.requireChannelIdentityID(c) if err != nil { @@ -92,11 +103,7 @@ func (h *LocalChannelHandler) StreamMessages(c echo.Context) error { if !ok { return nil } - payload := map[string]any{ - "target": msg.Target, - "event": msg.Event, - } - data, err := json.Marshal(payload) + data, err := formatLocalStreamEvent(msg.Event) if err != nil { continue } @@ -109,11 +116,29 @@ func (h *LocalChannelHandler) StreamMessages(c echo.Context) error { } } -type localMessageRequest struct { +func formatLocalStreamEvent(event channel.StreamEvent) ([]byte, error) { + return json.Marshal(event) +} + +// LocalChannelMessageRequest is the request body for posting a local channel message. +type LocalChannelMessageRequest struct { Message channel.Message `json:"message"` } -// PostMessage sends a message through the local channel. +// PostMessage godoc +// @Summary Send a message to a local channel +// @Description Post a user message (with optional attachments) through the local channel pipeline. +// @Tags local-channel +// @Accept json +// @Produce json +// @Param bot_id path string true "Bot ID" +// @Param payload body LocalChannelMessageRequest true "Message payload" +// @Success 200 {object} map[string]string +// @Failure 400 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /bots/{bot_id}/web/messages [post] +// @Router /bots/{bot_id}/cli/messages [post] func (h *LocalChannelHandler) PostMessage(c echo.Context) error { channelIdentityID, err := h.requireChannelIdentityID(c) if err != nil { @@ -132,7 +157,7 @@ func (h *LocalChannelHandler) PostMessage(c echo.Context) error { if h.channelManager == nil || h.channelStore == nil { return echo.NewHTTPError(http.StatusInternalServerError, "channel manager not configured") } - var req localMessageRequest + var req LocalChannelMessageRequest if err := c.Bind(&req); err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } diff --git a/internal/handlers/local_channel_test.go b/internal/handlers/local_channel_test.go new file mode 100644 index 00000000..ad03d3e2 --- /dev/null +++ b/internal/handlers/local_channel_test.go @@ -0,0 +1,75 @@ +package handlers + +import ( + "encoding/json" + "testing" + + "github.com/memohai/memoh/internal/channel" +) + +func TestFormatLocalStreamEvent_UsesChannelEventShape(t *testing.T) { + t.Parallel() + + data, err := formatLocalStreamEvent(channel.StreamEvent{ + Type: channel.StreamEventDelta, + Delta: "hello", + Phase: channel.StreamPhaseText, + }) + if err != nil { + t.Fatalf("format local stream event failed: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("unmarshal payload failed: %v", err) + } + if got := payload["type"]; got != "delta" { + t.Fatalf("expected type delta, got %#v", got) + } + if got := payload["delta"]; got != "hello" { + t.Fatalf("expected delta hello, got %#v", got) + } + if got := payload["phase"]; got != "text" { + t.Fatalf("expected phase text, got %#v", got) + } + if _, ok := payload["target"]; ok { + t.Fatalf("unexpected wrapper field target in payload") + } + if _, ok := payload["event"]; ok { + t.Fatalf("unexpected wrapper field event in payload") + } +} + +func TestFormatLocalStreamEvent_EncodesToolCallAsToolCallObject(t *testing.T) { + t.Parallel() + + data, err := formatLocalStreamEvent(channel.StreamEvent{ + Type: channel.StreamEventToolCallStart, + ToolCall: &channel.StreamToolCall{ + Name: "exec", + CallID: "call-1", + Input: map[string]any{ + "command": "pwd", + }, + }, + }) + if err != nil { + t.Fatalf("format local stream event failed: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("unmarshal payload failed: %v", err) + } + toolCall, ok := payload["tool_call"].(map[string]any) + if !ok { + t.Fatalf("expected tool_call object, got %#v", payload["tool_call"]) + } + if got := toolCall["name"]; got != "exec" { + t.Fatalf("expected tool_call.name exec, got %#v", got) + } + if got := toolCall["call_id"]; got != "call-1" { + t.Fatalf("expected tool_call.call_id call-1, got %#v", got) + } + if _, ok := payload["toolName"]; ok { + t.Fatalf("unexpected camelCase toolName in payload") + } +} diff --git a/internal/handlers/mcp_stdio.go b/internal/handlers/mcp_stdio.go index 51fff854..846bd06d 100644 --- a/internal/handlers/mcp_stdio.go +++ b/internal/handlers/mcp_stdio.go @@ -6,8 +6,6 @@ import ( "io" "log/slog" "net/http" - "os/exec" - "runtime" "sort" "strings" "time" @@ -171,9 +169,6 @@ func (h *ContainerdHandler) HandleMCPStdio(c echo.Context) error { } func (h *ContainerdHandler) startContainerdMCPCommandSession(ctx context.Context, containerID string, req MCPStdioRequest) (*mcpSession, error) { - if runtime.GOOS == "darwin" { - return h.startLimaMCPCommandSession(containerID, req) - } args := append([]string{strings.TrimSpace(req.Command)}, req.Args...) env := buildEnvPairs(req.Env) execSession, err := h.service.ExecTaskStreaming(ctx, containerID, ctr.ExecTaskRequest{ @@ -300,89 +295,6 @@ func extractToolNames(payload map[string]any) []string { return names } -func (h *ContainerdHandler) startLimaMCPCommandSession(containerID string, req MCPStdioRequest) (*mcpSession, error) { - execID := fmt.Sprintf("mcp-stdio-%d", time.Now().UnixNano()) - cmdline := buildShellCommand(req) - cmd := exec.Command( - "limactl", - "shell", - "--tty=false", - "default", - "--", - "sudo", - "-n", - "ctr", - "-n", - "default", - "tasks", - "exec", - "--exec-id", - execID, - containerID, - "/bin/sh", - "-lc", - cmdline, - ) - - stdin, err := cmd.StdinPipe() - if err != nil { - return nil, err - } - stdout, err := cmd.StdoutPipe() - if err != nil { - _ = stdin.Close() - return nil, err - } - stderr, err := cmd.StderrPipe() - if err != nil { - _ = stdin.Close() - _ = stdout.Close() - return nil, err - } - if err := cmd.Start(); err != nil { - _ = stdin.Close() - _ = stdout.Close() - _ = stderr.Close() - return nil, err - } - - sess := &mcpSession{ - stdin: stdin, - stdout: stdout, - stderr: stderr, - cmd: cmd, - pending: make(map[string]chan *sdkjsonrpc.Response), - closed: make(chan struct{}), - } - transport := &sdkmcp.IOTransport{ - Reader: sess.stdout, - Writer: sess.stdin, - } - conn, err := transport.Connect(context.Background()) - if err != nil { - sess.closeWithError(err) - return nil, err - } - sess.conn = conn - - h.startMCPStderrLogger(stderr, containerID) - go sess.readLoop() - go func() { - if err := cmd.Wait(); err != nil { - if isBenignMCPSessionExit(err) { - sess.closeWithError(io.EOF) - return - } - h.logger.Error("mcp stdio session exited", slog.Any("error", err), slog.String("container_id", containerID)) - sess.closeWithError(err) - return - } - sess.closeWithError(io.EOF) - }() - - return sess, nil -} - func buildShellCommand(req MCPStdioRequest) string { cmd := strings.TrimSpace(req.Command) if cmd == "" { diff --git a/internal/handlers/message.go b/internal/handlers/message.go index 175c960a..437116e8 100644 --- a/internal/handlers/message.go +++ b/internal/handlers/message.go @@ -3,7 +3,6 @@ package handlers import ( "bufio" "context" - "encoding/base64" "encoding/json" "errors" "fmt" @@ -18,9 +17,7 @@ import ( "github.com/memohai/memoh/internal/accounts" "github.com/memohai/memoh/internal/bots" - "github.com/memohai/memoh/internal/channel/identities" "github.com/memohai/memoh/internal/conversation" - "github.com/memohai/memoh/internal/conversation/flow" "github.com/memohai/memoh/internal/media" messagepkg "github.com/memohai/memoh/internal/message" messageevent "github.com/memohai/memoh/internal/message/event" @@ -28,31 +25,27 @@ import ( // MessageHandler handles bot-scoped messaging endpoints. type MessageHandler struct { - runner flow.Runner conversationService conversation.Accessor messageService messagepkg.Service messageEvents messageevent.Subscriber mediaService *media.Service botService *bots.Service accountService *accounts.Service - channelIdentitySvc *identities.Service logger *slog.Logger } // NewMessageHandler creates a MessageHandler. -func NewMessageHandler(log *slog.Logger, runner flow.Runner, conversationService conversation.Accessor, messageService messagepkg.Service, botService *bots.Service, accountService *accounts.Service, channelIdentitySvc *identities.Service, eventSubscribers ...messageevent.Subscriber) *MessageHandler { +func NewMessageHandler(log *slog.Logger, conversationService conversation.Accessor, messageService messagepkg.Service, botService *bots.Service, accountService *accounts.Service, eventSubscribers ...messageevent.Subscriber) *MessageHandler { var messageEvents messageevent.Subscriber if len(eventSubscribers) > 0 { messageEvents = eventSubscribers[0] } return &MessageHandler{ - runner: runner, conversationService: conversationService, messageService: messageService, messageEvents: messageEvents, botService: botService, accountService: accountService, - channelIdentitySvc: channelIdentitySvc, logger: log.With(slog.String("handler", "conversation")), } } @@ -66,181 +59,14 @@ func (h *MessageHandler) SetMediaService(svc *media.Service) { func (h *MessageHandler) Register(e *echo.Echo) { // Bot-scoped message container (single shared history per bot). botGroup := e.Group("/bots/:bot_id") - botGroup.POST("/messages", h.SendMessage) - botGroup.POST("/messages/stream", h.StreamMessage) botGroup.GET("/messages", h.ListMessages) botGroup.GET("/messages/events", h.StreamMessageEvents) botGroup.DELETE("/messages", h.DeleteMessages) - botGroup.GET("/media/:asset_id", h.ServeMedia) + botGroup.GET("/media/:content_hash", h.ServeMedia) } // --- Messages --- -// SendMessage sends a synchronous conversation message. -func (h *MessageHandler) SendMessage(c echo.Context) error { - channelIdentityID, err := h.requireChannelIdentityID(c) - if err != nil { - return err - } - botID := strings.TrimSpace(c.Param("bot_id")) - if botID == "" { - return echo.NewHTTPError(http.StatusBadRequest, "bot id is required") - } - if _, err := h.authorizeBotAccess(c.Request().Context(), channelIdentityID, botID); err != nil { - return err - } - if err := h.requireParticipant(c.Request().Context(), botID, channelIdentityID); err != nil { - return err - } - - var req conversation.ChatRequest - if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) - } - if strings.TrimSpace(req.Query) == "" && len(req.Attachments) == 0 { - return echo.NewHTTPError(http.StatusBadRequest, "query or attachments is required") - } - req.BotID = botID - req.ChatID = botID - req.Token = c.Request().Header.Get("Authorization") - req.UserID = channelIdentityID - req.SourceChannelIdentityID = channelIdentityID - if strings.TrimSpace(req.CurrentChannel) == "" { - req.CurrentChannel = "web" - } - if strings.TrimSpace(req.ConversationType) == "" { - req.ConversationType = "direct" - } - if len(req.Channels) == 0 { - req.Channels = []string{req.CurrentChannel} - } - channelIdentityID = h.resolveWebChannelIdentity(c.Request().Context(), channelIdentityID, &req) - if req.Attachments, err = h.ingestInlineAttachments(c.Request().Context(), botID, req.Attachments); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) - } - - if h.runner == nil { - return echo.NewHTTPError(http.StatusInternalServerError, "conversation runner not configured") - } - resp, err := h.runner.Chat(c.Request().Context(), req) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - return c.JSON(http.StatusOK, resp) -} - -// StreamMessage sends a streaming conversation message. -func (h *MessageHandler) StreamMessage(c echo.Context) error { - channelIdentityID, err := h.requireChannelIdentityID(c) - if err != nil { - return err - } - botID := strings.TrimSpace(c.Param("bot_id")) - if botID == "" { - return echo.NewHTTPError(http.StatusBadRequest, "bot id is required") - } - if _, err := h.authorizeBotAccess(c.Request().Context(), channelIdentityID, botID); err != nil { - return err - } - if err := h.requireParticipant(c.Request().Context(), botID, channelIdentityID); err != nil { - return err - } - - var req conversation.ChatRequest - if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) - } - if strings.TrimSpace(req.Query) == "" && len(req.Attachments) == 0 { - return echo.NewHTTPError(http.StatusBadRequest, "query or attachments is required") - } - req.BotID = botID - req.ChatID = botID - req.Token = c.Request().Header.Get("Authorization") - req.UserID = channelIdentityID - req.SourceChannelIdentityID = channelIdentityID - if strings.TrimSpace(req.CurrentChannel) == "" { - req.CurrentChannel = "web" - } - if strings.TrimSpace(req.ConversationType) == "" { - req.ConversationType = "direct" - } - if len(req.Channels) == 0 { - req.Channels = []string{req.CurrentChannel} - } - channelIdentityID = h.resolveWebChannelIdentity(c.Request().Context(), channelIdentityID, &req) - if req.Attachments, err = h.ingestInlineAttachments(c.Request().Context(), botID, req.Attachments); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) - } - - if h.runner == nil { - return echo.NewHTTPError(http.StatusInternalServerError, "conversation runner not configured") - } - c.Response().Header().Set(echo.HeaderContentType, "text/event-stream") - c.Response().Header().Set(echo.HeaderCacheControl, "no-cache") - c.Response().Header().Set(echo.HeaderConnection, "keep-alive") - c.Response().WriteHeader(http.StatusOK) - - chunkChan, errChan := h.runner.StreamChat(c.Request().Context(), req) - flusher, ok := c.Response().Writer.(http.Flusher) - if !ok { - return echo.NewHTTPError(http.StatusInternalServerError, "streaming not supported") - } - writer := bufio.NewWriter(c.Response().Writer) - processingState := "started" - if err := writeSSEJSON(writer, flusher, map[string]string{"type": "processing_started"}); err != nil { - return nil - } - - for { - select { - case chunk, ok := <-chunkChan: - if !ok { - if processingState == "started" { - processingState = "completed" - if err := writeSSEJSON(writer, flusher, map[string]string{"type": "processing_completed"}); err != nil { - return nil - } - } - if err := writeSSEData(writer, flusher, "[DONE]"); err != nil { - return nil - } - return nil - } - if processingState == "started" { - processingState = "completed" - if err := writeSSEJSON(writer, flusher, map[string]string{"type": "processing_completed"}); err != nil { - return nil - } - } - if err := writeSSEData(writer, flusher, string(chunk)); err != nil { - return nil - } - case err := <-errChan: - if err != nil { - h.logger.Error("conversation stream failed", slog.Any("error", err)) - if processingState == "started" { - processingState = "failed" - if writeErr := writeSSEJSON(writer, flusher, map[string]string{ - "type": "processing_failed", - "error": err.Error(), - }); writeErr != nil { - h.logger.Warn("write SSE processing_failed event failed", slog.Any("error", writeErr)) - } - } - errData := map[string]string{ - "type": "error", - "error": err.Error(), - "message": err.Error(), - } - if writeErr := writeSSEJSON(writer, flusher, errData); writeErr != nil { - return nil - } - return nil - } - } - } -} - func writeSSEData(writer *bufio.Writer, flusher http.Flusher, payload string) error { if _, err := writer.WriteString(fmt.Sprintf("data: %s\n\n", payload)); err != nil { return err @@ -260,92 +86,6 @@ func writeSSEJSON(writer *bufio.Writer, flusher http.Flusher, payload any) error return writeSSEData(writer, flusher, string(data)) } -func (h *MessageHandler) ingestInlineAttachments(ctx context.Context, botID string, attachments []conversation.ChatAttachment) ([]conversation.ChatAttachment, error) { - if len(attachments) == 0 || h.mediaService == nil { - return attachments, nil - } - result := make([]conversation.ChatAttachment, 0, len(attachments)) - for _, att := range attachments { - item := att - if strings.TrimSpace(item.AssetID) != "" || strings.TrimSpace(item.Base64) == "" { - result = append(result, item) - continue - } - mediaType := mapAttachmentMediaType(item.Type) - maxBytes := media.MaxAssetBytes - raw, err := decodeAttachmentBase64(item.Base64, maxBytes) - if err != nil { - return nil, fmt.Errorf("invalid attachment base64: %w", err) - } - asset, err := h.mediaService.Ingest(ctx, media.IngestInput{ - BotID: botID, - MediaType: mediaType, - Mime: strings.TrimSpace(item.Mime), - OriginalName: strings.TrimSpace(item.Name), - Metadata: item.Metadata, - Reader: raw, - MaxBytes: maxBytes, - }) - if err != nil { - return nil, fmt.Errorf("ingest attachment failed: %w", err) - } - item.AssetID = asset.ID - item.Path = h.mediaService.AccessPath(asset) - mime := strings.TrimSpace(item.Mime) - if mime == "" { - mime = strings.TrimSpace(asset.Mime) - } - item.Base64 = normalizeBase64DataURL(item.Base64, mime) - if strings.TrimSpace(item.Mime) == "" { - item.Mime = asset.Mime - } - result = append(result, item) - } - return result, nil -} - -func decodeAttachmentBase64(input string, maxBytes int64) (io.Reader, error) { - value := strings.TrimSpace(input) - if value == "" { - return nil, fmt.Errorf("base64 payload is empty") - } - if strings.HasPrefix(strings.ToLower(value), "data:") { - if idx := strings.Index(value, ","); idx >= 0 { - value = value[idx+1:] - } - } - decoder := base64.NewDecoder(base64.StdEncoding, strings.NewReader(value)) - return io.LimitReader(decoder, maxBytes+1), nil -} - -func normalizeBase64DataURL(input, mime string) string { - value := strings.TrimSpace(input) - if value == "" { - return "" - } - if strings.HasPrefix(strings.ToLower(value), "data:") { - return value - } - mime = strings.TrimSpace(mime) - if mime == "" { - mime = "application/octet-stream" - } - return "data:" + mime + ";base64," + value -} - -func mapAttachmentMediaType(t string) media.MediaType { - switch strings.ToLower(strings.TrimSpace(t)) { - case "image", "gif": - return media.MediaTypeImage - case "audio", "voice": - return media.MediaTypeAudio - case "video": - return media.MediaTypeVideo - default: - return media.MediaTypeFile - } -} - func parseSinceParam(raw string) (time.Time, bool, error) { trimmed := strings.TrimSpace(raw) if trimmed == "" { @@ -418,9 +158,32 @@ func (h *MessageHandler) ListMessages(c echo.Context) error { if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } + h.fillAssetMimeFromStorage(c.Request().Context(), botID, messages) return c.JSON(http.StatusOK, map[string]any{"items": messages}) } +// fillAssetMimeFromStorage fills mime, storage_key, size_bytes from storage (soft link: DB only has content_hash). +func (h *MessageHandler) fillAssetMimeFromStorage(ctx context.Context, botID string, messages []messagepkg.Message) { + if h.mediaService == nil { + return + } + for i := range messages { + for j := range messages[i].Assets { + a := &messages[i].Assets[j] + if strings.TrimSpace(a.ContentHash) == "" { + continue + } + asset, err := h.mediaService.Resolve(ctx, botID, a.ContentHash) + if err != nil { + continue + } + a.Mime = asset.Mime + a.StorageKey = asset.StorageKey + a.SizeBytes = asset.SizeBytes + } + } +} + func parseBeforeParam(s string) (time.Time, bool) { trimmed := strings.TrimSpace(s) if trimmed == "" { @@ -507,6 +270,7 @@ func (h *MessageHandler) StreamMessageEvents(c echo.Context) error { if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } + h.fillAssetMimeFromStorage(c.Request().Context(), botID, backlog) for _, message := range backlog { if err := writeCreatedEvent(message); err != nil { return nil @@ -543,6 +307,7 @@ func (h *MessageHandler) StreamMessageEvents(c echo.Context) error { h.logger.Warn("decode message event failed", slog.Any("error", err)) continue } + h.fillAssetMimeFromStorage(c.Request().Context(), botID, []messagepkg.Message{message}) if err := writeCreatedEvent(message); err != nil { return nil } @@ -574,33 +339,6 @@ func (h *MessageHandler) DeleteMessages(c echo.Context) error { // --- helpers --- -// resolveWebChannelIdentity resolves (web, user_id) to a channel identity and sets req.SourceChannelIdentityID. -// Web uses user_id as the channel subject id (like Feishu open_id); the resolved ci has display_name and is linked to the user. -// Returns the channel_identity_id to use for the rest of the flow, or the original userID if resolution is skipped/fails. -func (h *MessageHandler) resolveWebChannelIdentity(ctx context.Context, userID string, req *conversation.ChatRequest) string { - if strings.TrimSpace(req.CurrentChannel) != "web" || h.channelIdentitySvc == nil || strings.TrimSpace(userID) == "" { - return userID - } - displayName := "" - if h.accountService != nil { - if account, err := h.accountService.Get(ctx, userID); err == nil { - displayName = strings.TrimSpace(account.DisplayName) - if displayName == "" { - displayName = strings.TrimSpace(account.Username) - } - } - } - ci, err := h.channelIdentitySvc.ResolveByChannelIdentity(ctx, "web", userID, displayName, nil) - if err != nil { - return userID - } - if err := h.channelIdentitySvc.LinkChannelIdentityToUser(ctx, ci.ID, userID); err != nil { - h.logger.Warn("link channel identity to user failed", slog.Any("error", err)) - } - req.SourceChannelIdentityID = ci.ID - return ci.ID -} - func (h *MessageHandler) requireChannelIdentityID(c echo.Context) (string, error) { return RequireChannelIdentityID(c) } @@ -661,7 +399,7 @@ func (h *MessageHandler) requireReadable(ctx context.Context, conversationID, ch return nil } -// ServeMedia streams a media asset by bot_id + asset_id with read-access authorization. +// ServeMedia streams a media asset by bot_id + content_hash with read-access authorization. func (h *MessageHandler) ServeMedia(c echo.Context) error { channelIdentityID, err := h.requireChannelIdentityID(c) if err != nil { @@ -671,9 +409,9 @@ func (h *MessageHandler) ServeMedia(c echo.Context) error { if botID == "" { return echo.NewHTTPError(http.StatusBadRequest, "bot id is required") } - assetID := strings.TrimSpace(c.Param("asset_id")) - if assetID == "" { - return echo.NewHTTPError(http.StatusBadRequest, "asset id is required") + contentHash := strings.TrimSpace(c.Param("content_hash")) + if contentHash == "" { + return echo.NewHTTPError(http.StatusBadRequest, "content hash is required") } if _, err := h.authorizeBotAccess(c.Request().Context(), channelIdentityID, botID); err != nil { return err @@ -684,7 +422,7 @@ func (h *MessageHandler) ServeMedia(c echo.Context) error { if h.mediaService == nil { return echo.NewHTTPError(http.StatusInternalServerError, "media service not configured") } - reader, asset, err := h.mediaService.Open(c.Request().Context(), assetID) + reader, asset, err := h.mediaService.Open(c.Request().Context(), botID, contentHash) if err != nil { if errors.Is(err, media.ErrAssetNotFound) { return echo.NewHTTPError(http.StatusNotFound, "asset not found") @@ -692,19 +430,12 @@ func (h *MessageHandler) ServeMedia(c echo.Context) error { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } defer reader.Close() - // Verify asset belongs to the authorized bot. - if strings.TrimSpace(asset.BotID) != botID { - return echo.NewHTTPError(http.StatusForbidden, "asset does not belong to bot") - } contentType := asset.Mime if contentType == "" { contentType = "application/octet-stream" } c.Response().Header().Set("Content-Type", contentType) c.Response().Header().Set("Cache-Control", "private, max-age=86400") - if asset.OriginalName != "" { - c.Response().Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", asset.OriginalName)) - } c.Response().WriteHeader(http.StatusOK) if _, err := io.Copy(c.Response().Writer, reader); err != nil { h.logger.Warn("serve media stream failed", slog.Any("error", err)) diff --git a/internal/handlers/message_test.go b/internal/handlers/message_test.go index da40d056..592d8df6 100644 --- a/internal/handlers/message_test.go +++ b/internal/handlers/message_test.go @@ -1,57 +1,87 @@ package handlers import ( - "encoding/base64" - "io" + "bufio" + "bytes" + "encoding/json" "strings" "testing" + "time" ) -func TestDecodeAttachmentBase64(t *testing.T) { +type testFlusher struct{} + +func (f *testFlusher) Flush() {} + +func TestParseSinceParam(t *testing.T) { t.Parallel() - data := []byte("hello") - encoded := base64.StdEncoding.EncodeToString(data) - decoded, err := decodeAttachmentBase64(encoded, 16) + now := time.Now().UTC().Truncate(time.Second) + parsed, ok, err := parseSinceParam(now.Format(time.RFC3339)) if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatalf("parse RFC3339 failed: %v", err) } - got, err := io.ReadAll(decoded) + if !ok { + t.Fatalf("expected parseSinceParam ok=true") + } + if !parsed.Equal(now) { + t.Fatalf("expected parsed time %s, got %s", now, parsed) + } + + parsedEpoch, ok, err := parseSinceParam("1735689600000") if err != nil { - t.Fatalf("read decoded failed: %v", err) + t.Fatalf("parse epoch millis failed: %v", err) } - if string(got) != "hello" { - t.Fatalf("unexpected decoded value: %q", string(got)) + if !ok { + t.Fatalf("expected epoch parse ok=true") + } + if parsedEpoch.UnixMilli() != 1735689600000 { + t.Fatalf("expected parsed epoch millis 1735689600000, got %d", parsedEpoch.UnixMilli()) + } + + if _, _, err := parseSinceParam("invalid-time"); err == nil { + t.Fatalf("expected invalid since parameter error") } } -func TestDecodeAttachmentBase64DataURL(t *testing.T) { +func TestParseBeforeParam(t *testing.T) { t.Parallel() - encoded := "data:text/plain;base64," + base64.StdEncoding.EncodeToString([]byte("payload")) - decoded, err := decodeAttachmentBase64(encoded, 32) - if err != nil { - t.Fatalf("unexpected error: %v", err) + if _, ok := parseBeforeParam(""); ok { + t.Fatalf("expected empty before value to be ignored") } - got, err := io.ReadAll(decoded) - if err != nil { - t.Fatalf("read decoded failed: %v", err) + parsed, ok := parseBeforeParam("1735689600000") + if !ok { + t.Fatalf("expected epoch millis before value to parse") } - if string(got) != "payload" { - t.Fatalf("unexpected decoded value: %q", string(got)) + if parsed.UnixMilli() != 1735689600000 { + t.Fatalf("expected parsed epoch millis 1735689600000, got %d", parsed.UnixMilli()) } } -func TestNormalizeBase64DataURL(t *testing.T) { +func TestWriteSSEJSON(t *testing.T) { t.Parallel() - raw := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("a", 4))) - got := normalizeBase64DataURL(raw, "image/png") - if !strings.HasPrefix(got, "data:image/png;base64,") { - t.Fatalf("expected data url prefix, got %q", got) + var output bytes.Buffer + writer := bufio.NewWriter(&output) + flusher := &testFlusher{} + + if err := writeSSEJSON(writer, flusher, map[string]any{"type": "ping"}); err != nil { + t.Fatalf("writeSSEJSON failed: %v", err) } - existing := "data:text/plain;base64,AAA=" - if normalizeBase64DataURL(existing, "image/png") != existing { - t.Fatalf("expected existing data url unchanged") + raw := output.String() + if !strings.HasPrefix(raw, "data: ") { + t.Fatalf("expected SSE data prefix, got %q", raw) + } + if !strings.HasSuffix(raw, "\n\n") { + t.Fatalf("expected SSE payload suffix, got %q", raw) + } + payloadText := strings.TrimSuffix(strings.TrimPrefix(raw, "data: "), "\n\n") + var payload map[string]any + if err := json.Unmarshal([]byte(payloadText), &payload); err != nil { + t.Fatalf("decode SSE payload failed: %v", err) + } + if payload["type"] != "ping" { + t.Fatalf("expected payload type ping, got %#v", payload["type"]) } } diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go index efb7bd00..8b610f15 100644 --- a/internal/mcp/manager.go +++ b/internal/mcp/manager.go @@ -3,14 +3,12 @@ package mcp import ( "bytes" "context" - "errors" "fmt" "log/slog" "os" - "os/exec" "path/filepath" - "runtime" "strings" + "sync" "time" "github.com/containerd/containerd/v2/pkg/oci" @@ -50,13 +48,15 @@ type ExecWithCaptureResult struct { } type Manager struct { - service ctr.Service - cfg config.MCPConfig - namespace string - containerID func(string) string - db *pgxpool.Pool - queries *dbsqlc.Queries - logger *slog.Logger + service ctr.Service + cfg config.MCPConfig + namespace string + containerID func(string) string + db *pgxpool.Pool + queries *dbsqlc.Queries + logger *slog.Logger + containerLockMu sync.Mutex + containerLocks map[string]*sync.Mutex } func NewManager(log *slog.Logger, service ctr.Service, cfg config.MCPConfig, namespace string, conn *pgxpool.Pool) *Manager { @@ -64,18 +64,32 @@ func NewManager(log *slog.Logger, service ctr.Service, cfg config.MCPConfig, nam namespace = config.DefaultNamespace } return &Manager{ - service: service, - cfg: cfg, - namespace: namespace, - db: conn, - queries: dbsqlc.New(conn), - logger: log.With(slog.String("component", "mcp")), + service: service, + cfg: cfg, + namespace: namespace, + db: conn, + queries: dbsqlc.New(conn), + logger: log.With(slog.String("component", "mcp")), + containerLocks: make(map[string]*sync.Mutex), containerID: func(botID string) string { return ContainerPrefix + botID }, } } +func (m *Manager) lockContainer(containerID string) func() { + m.containerLockMu.Lock() + lock, ok := m.containerLocks[containerID] + if !ok { + lock = &sync.Mutex{} + m.containerLocks[containerID] = lock + } + m.containerLockMu.Unlock() + + lock.Lock() + return lock.Unlock +} + func (m *Manager) Init(ctx context.Context) error { image := m.imageRef() @@ -254,7 +268,6 @@ func (m *Manager) Exec(ctx context.Context, req ExecRequest) (*ExecResult, error // ExecWithCapture runs a command in the bot container and returns stdout, stderr and exit code. // Use this when the caller needs command output (e.g. MCP exec tool). // The container must already be running; use Start(botID) or the container/start API to start it. -// On darwin, it uses Lima SSH to avoid virtiofs FIFO synchronization issues. func (m *Manager) ExecWithCapture(ctx context.Context, req ExecRequest) (*ExecWithCaptureResult, error) { if err := validateBotID(req.BotID); err != nil { return nil, err @@ -265,68 +278,9 @@ func (m *Manager) ExecWithCapture(ctx context.Context, req ExecRequest) (*ExecWi if m.queries == nil { return nil, fmt.Errorf("db is not configured") } - - if runtime.GOOS == "darwin" { - return m.execWithCaptureLima(ctx, req) - } return m.execWithCaptureContainerd(ctx, req) } -// execWithCaptureLima runs exec through Lima SSH so that all FIFO I/O stays -// inside the VM, avoiding virtiofs FIFO synchronization issues on macOS. -func (m *Manager) execWithCaptureLima(ctx context.Context, req ExecRequest) (*ExecWithCaptureResult, error) { - containerID := m.containerID(req.BotID) - execID := fmt.Sprintf("exec-%d", time.Now().UnixNano()) - - // Each element becomes a separate OS arg to limactl. Lima/SSH joins - // them with spaces and passes the result to the remote shell, so only - // values that may contain shell-special characters need quoting. - args := []string{"shell", "default", "--", - "sudo", "ctr", "-n", m.namespace, - "tasks", "exec", "--exec-id", execID, - } - if req.WorkDir != "" { - args = append(args, "--cwd", req.WorkDir) - } - for _, e := range req.Env { - args = append(args, "--env", e) - } - args = append(args, containerID) - // Pass command args as-is; Lima shell-quotes each OS arg for the - // remote SSH shell, preserving argument boundaries correctly. - args = append(args, req.Command...) - - cmd := exec.CommandContext(ctx, "limactl", args...) - var stdoutBuf, stderrBuf bytes.Buffer - cmd.Stdout = &stdoutBuf - cmd.Stderr = &stderrBuf - - exitCode := uint32(0) - if err := cmd.Run(); err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - exitCode = uint32(exitErr.ExitCode()) - } else { - return nil, fmt.Errorf("lima exec: %w", err) - } - } - - // ctr tasks exec may write its own errors to stderr; separate them from - // the container command's stderr output by checking for the ctr prefix. - stderr := stderrBuf.String() - if exitCode != 0 && strings.HasPrefix(stderr, "ctr:") { - return nil, fmt.Errorf("container exec failed: %s", strings.TrimSpace(stderr)) - } - - return &ExecWithCaptureResult{ - Stdout: stdoutBuf.String(), - Stderr: stderr, - ExitCode: exitCode, - }, nil -} - -// execWithCaptureContainerd uses the containerd ExecTask API with FIFO pipes. -// This works reliably on Linux where FIFO I/O stays on the same filesystem. func (m *Manager) execWithCaptureContainerd(ctx context.Context, req ExecRequest) (*ExecWithCaptureResult, error) { fifoDir, err := os.MkdirTemp(m.dataRoot(), "exec-fifo-") if err != nil { diff --git a/internal/mcp/versioning.go b/internal/mcp/versioning.go index f9de5043..c73e8041 100644 --- a/internal/mcp/versioning.go +++ b/internal/mcp/versioning.go @@ -4,10 +4,12 @@ import ( "context" "encoding/json" "fmt" + "strings" "time" "github.com/containerd/containerd/v2/pkg/oci" "github.com/containerd/errdefs" + "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/opencontainers/runtime-spec/specs-go" @@ -18,22 +20,102 @@ import ( dbsqlc "github.com/memohai/memoh/internal/db/sqlc" ) +const ( + SnapshotSourceManual = "manual" + SnapshotSourcePreExec = "pre_exec" + SnapshotSourceRollback = "rollback" +) + type VersionInfo struct { - ID string - Version int - SnapshotID string - CreatedAt time.Time + ID string + Version int + SnapshotName string + CreatedAt time.Time } -func (m *Manager) CreateVersion(ctx context.Context, userID string) (*VersionInfo, error) { +type SnapshotCreateInfo struct { + ContainerID string + SnapshotName string + Snapshotter string + Version int + CreatedAt time.Time +} + +func (m *Manager) CreateSnapshot(ctx context.Context, botID, snapshotName, source string) (*SnapshotCreateInfo, error) { if m.db == nil || m.queries == nil { return nil, fmt.Errorf("db is not configured") } - if err := validateBotID(userID); err != nil { + if err := validateBotID(botID); err != nil { return nil, err } - containerID := m.containerID(userID) + containerID := m.containerID(botID) + unlock := m.lockContainer(containerID) + defer unlock() + + container, err := m.service.GetContainer(ctx, containerID) + if err != nil { + return nil, err + } + info, err := container.Info(ctx) + if err != nil { + return nil, err + } + if _, err := m.ensureDBRecords(ctx, botID, info.ID, info.Runtime.Name, info.Image); err != nil { + return nil, err + } + + normalizedSnapshotName := strings.TrimSpace(snapshotName) + if normalizedSnapshotName == "" { + normalizedSnapshotName = fmt.Sprintf("%s-%s", containerID, time.Now().Format("20060102150405")) + } + normalizedSource := normalizeSnapshotSource(source) + + if err := m.service.CommitSnapshot(ctx, info.Snapshotter, normalizedSnapshotName, info.SnapshotKey); err != nil { + return nil, err + } + + _, versionNumber, createdAt, err := m.recordSnapshotVersion( + ctx, + containerID, + normalizedSnapshotName, + info.SnapshotKey, + info.Snapshotter, + normalizedSource, + ) + if err != nil { + return nil, err + } + if err := m.insertEvent(ctx, containerID, "snapshot_create", map[string]any{ + "snapshot_name": normalizedSnapshotName, + "snapshotter": info.Snapshotter, + "source": normalizedSource, + "version": versionNumber, + }); err != nil { + return nil, err + } + + return &SnapshotCreateInfo{ + ContainerID: containerID, + SnapshotName: normalizedSnapshotName, + Snapshotter: info.Snapshotter, + Version: versionNumber, + CreatedAt: createdAt, + }, nil +} + +func (m *Manager) CreateVersion(ctx context.Context, botID string) (*VersionInfo, error) { + if m.db == nil || m.queries == nil { + return nil, fmt.Errorf("db is not configured") + } + if err := validateBotID(botID); err != nil { + return nil, err + } + + containerID := m.containerID(botID) + unlock := m.lockContainer(containerID) + defer unlock() + container, err := m.service.GetContainer(ctx, containerID) if err != nil { return nil, err @@ -44,7 +126,7 @@ func (m *Manager) CreateVersion(ctx context.Context, userID string) (*VersionInf return nil, err } - if _, err := m.ensureDBRecords(ctx, userID, info.ID, info.Runtime.Name, info.Image); err != nil { + if _, err := m.ensureDBRecords(ctx, botID, info.ID, info.Runtime.Name, info.Image); err != nil { return nil, err } @@ -52,13 +134,13 @@ func (m *Manager) CreateVersion(ctx context.Context, userID string) (*VersionInf return nil, err } - versionSnapshotID := fmt.Sprintf("%s-v%d", containerID, time.Now().UnixNano()) - if err := m.service.CommitSnapshot(ctx, info.Snapshotter, versionSnapshotID, info.SnapshotKey); err != nil { + versionSnapshotName := fmt.Sprintf("%s-v%d", containerID, time.Now().UnixNano()) + if err := m.service.CommitSnapshot(ctx, info.Snapshotter, versionSnapshotName, info.SnapshotKey); err != nil { return nil, err } - activeSnapshotID := fmt.Sprintf("%s-active-%d", containerID, time.Now().UnixNano()) - if err := m.service.PrepareSnapshot(ctx, info.Snapshotter, activeSnapshotID, versionSnapshotID); err != nil { + activeSnapshotName := fmt.Sprintf("%s-active-%d", containerID, time.Now().UnixNano()) + if err := m.service.PrepareSnapshot(ctx, info.Snapshotter, activeSnapshotName, versionSnapshotName); err != nil { return nil, err } @@ -66,7 +148,7 @@ func (m *Manager) CreateVersion(ctx context.Context, userID string) (*VersionInf return nil, err } - dataDir, err := m.ensureBotDir(userID) + dataDir, err := m.ensureBotDir(botID) if err != nil { return nil, err } @@ -99,7 +181,7 @@ func (m *Manager) CreateVersion(ctx context.Context, userID string) (*VersionInf _, err = m.service.CreateContainerFromSnapshot(ctx, ctr.CreateContainerRequest{ ID: containerID, ImageRef: info.Image, - SnapshotID: activeSnapshotID, + SnapshotID: activeSnapshotName, Snapshotter: info.Snapshotter, Labels: info.Labels, SpecOpts: specOpts, @@ -108,35 +190,43 @@ func (m *Manager) CreateVersion(ctx context.Context, userID string) (*VersionInf return nil, err } - versionID, versionNumber, createdAt, err := m.insertVersion(ctx, containerID, versionSnapshotID, info.Snapshotter) + versionID, versionNumber, createdAt, err := m.recordSnapshotVersion( + ctx, + containerID, + versionSnapshotName, + info.SnapshotKey, + info.Snapshotter, + SnapshotSourcePreExec, + ) if err != nil { return nil, err } if err := m.insertEvent(ctx, containerID, "version_create", map[string]any{ - "snapshot_id": versionSnapshotID, - "version": versionNumber, + "snapshot_name": versionSnapshotName, + "version": versionNumber, + "version_id": versionID, }); err != nil { return nil, err } return &VersionInfo{ - ID: versionID, - Version: versionNumber, - SnapshotID: versionSnapshotID, - CreatedAt: createdAt, + ID: versionID, + Version: versionNumber, + SnapshotName: versionSnapshotName, + CreatedAt: createdAt, }, nil } -func (m *Manager) ListVersions(ctx context.Context, userID string) ([]VersionInfo, error) { +func (m *Manager) ListVersions(ctx context.Context, botID string) ([]VersionInfo, error) { if m.db == nil || m.queries == nil { return nil, fmt.Errorf("db is not configured") } - if err := validateBotID(userID); err != nil { + if err := validateBotID(botID); err != nil { return nil, err } - containerID := m.containerID(userID) + containerID := m.containerID(botID) versions, err := m.queries.ListVersionsByContainerID(ctx, containerID) if err != nil { return nil, err @@ -149,25 +239,28 @@ func (m *Manager) ListVersions(ctx context.Context, userID string) ([]VersionInf createdAt = row.CreatedAt.Time } out = append(out, VersionInfo{ - ID: row.ID, - Version: int(row.Version), - SnapshotID: row.SnapshotID, - CreatedAt: createdAt, + ID: uuidString(row.ID), + Version: int(row.Version), + SnapshotName: row.RuntimeSnapshotName, + CreatedAt: createdAt, }) } return out, nil } -func (m *Manager) RollbackVersion(ctx context.Context, userID string, version int) error { +func (m *Manager) RollbackVersion(ctx context.Context, botID string, version int) error { if m.db == nil || m.queries == nil { return fmt.Errorf("db is not configured") } - if err := validateBotID(userID); err != nil { + if err := validateBotID(botID); err != nil { return err } - containerID := m.containerID(userID) - snapshotID, err := m.queries.GetVersionSnapshotID(ctx, dbsqlc.GetVersionSnapshotIDParams{ + containerID := m.containerID(botID) + unlock := m.lockContainer(containerID) + defer unlock() + + snapshotName, err := m.queries.GetVersionSnapshotRuntimeName(ctx, dbsqlc.GetVersionSnapshotRuntimeNameParams{ ContainerID: containerID, Version: int32(version), }) @@ -188,8 +281,8 @@ func (m *Manager) RollbackVersion(ctx context.Context, userID string, version in return err } - activeSnapshotID := fmt.Sprintf("%s-rollback-%d", containerID, time.Now().UnixNano()) - if err := m.service.PrepareSnapshot(ctx, info.Snapshotter, activeSnapshotID, snapshotID); err != nil { + activeSnapshotName := fmt.Sprintf("%s-rollback-%d", containerID, time.Now().UnixNano()) + if err := m.service.PrepareSnapshot(ctx, info.Snapshotter, activeSnapshotName, snapshotName); err != nil { return err } @@ -197,7 +290,7 @@ func (m *Manager) RollbackVersion(ctx context.Context, userID string, version in return err } - dataDir, err := m.ensureBotDir(userID) + dataDir, err := m.ensureBotDir(botID) if err != nil { return err } @@ -229,7 +322,7 @@ func (m *Manager) RollbackVersion(ctx context.Context, userID string, version in _, err = m.service.CreateContainerFromSnapshot(ctx, ctr.CreateContainerRequest{ ID: containerID, ImageRef: info.Image, - SnapshotID: activeSnapshotID, + SnapshotID: activeSnapshotName, Snapshotter: info.Snapshotter, Labels: info.Labels, SpecOpts: specOpts, @@ -239,21 +332,22 @@ func (m *Manager) RollbackVersion(ctx context.Context, userID string, version in } return m.insertEvent(ctx, containerID, "version_rollback", map[string]any{ - "snapshot_id": snapshotID, - "version": version, + "snapshot_name": snapshotName, + "version": version, + "source": SnapshotSourceRollback, }) } -func (m *Manager) VersionSnapshotID(ctx context.Context, userID string, version int) (string, error) { +func (m *Manager) VersionSnapshotName(ctx context.Context, botID string, version int) (string, error) { if m.db == nil || m.queries == nil { return "", fmt.Errorf("db is not configured") } - if err := validateBotID(userID); err != nil { + if err := validateBotID(botID); err != nil { return "", err } - containerID := m.containerID(userID) - return m.queries.GetVersionSnapshotID(ctx, dbsqlc.GetVersionSnapshotIDParams{ + containerID := m.containerID(botID) + return m.queries.GetVersionSnapshotRuntimeName(ctx, dbsqlc.GetVersionSnapshotRuntimeNameParams{ ContainerID: containerID, Version: int32(version), }) @@ -310,7 +404,14 @@ func (m *Manager) ensureDBRecords(ctx context.Context, botID, containerID, runti return botUUID, nil } -func (m *Manager) insertVersion(ctx context.Context, containerID, snapshotID, snapshotter string) (string, int, time.Time, error) { +func (m *Manager) recordSnapshotVersion(ctx context.Context, containerID, runtimeSnapshotName, parentRuntimeSnapshotName, snapshotter, source string) (string, int, time.Time, error) { + containerID = strings.TrimSpace(containerID) + runtimeSnapshotName = strings.TrimSpace(runtimeSnapshotName) + snapshotter = strings.TrimSpace(snapshotter) + if containerID == "" || runtimeSnapshotName == "" || snapshotter == "" { + return "", 0, time.Time{}, ctr.ErrInvalidArgument + } + tx, err := m.db.Begin(ctx) if err != nil { return "", 0, time.Time{}, err @@ -319,26 +420,30 @@ func (m *Manager) insertVersion(ctx context.Context, containerID, snapshotID, sn qtx := m.queries.WithTx(tx) + parent := pgtype.Text{} + normalizedParent := strings.TrimSpace(parentRuntimeSnapshotName) + if normalizedParent != "" { + parent = pgtype.Text{String: normalizedParent, Valid: true} + } + snapshotRow, err := qtx.UpsertSnapshot(ctx, dbsqlc.UpsertSnapshotParams{ + ContainerID: containerID, + RuntimeSnapshotName: runtimeSnapshotName, + ParentRuntimeSnapshotName: parent, + Snapshotter: snapshotter, + Source: normalizeSnapshotSource(source), + }) + if err != nil { + return "", 0, time.Time{}, err + } + version, err := qtx.NextVersion(ctx, containerID) if err != nil { return "", 0, time.Time{}, err } - if err := qtx.InsertSnapshot(ctx, dbsqlc.InsertSnapshotParams{ - ID: snapshotID, - ContainerID: containerID, - ParentSnapshotID: pgtype.Text{}, - Snapshotter: snapshotter, - Digest: pgtype.Text{}, - }); err != nil { - return "", 0, time.Time{}, err - } - - id := fmt.Sprintf("%s-%d", containerID, version) versionRow, err := qtx.InsertVersion(ctx, dbsqlc.InsertVersionParams{ - ID: id, ContainerID: containerID, - SnapshotID: snapshotID, + SnapshotID: snapshotRow.ID, Version: version, }) if err != nil { @@ -354,7 +459,7 @@ func (m *Manager) insertVersion(ctx context.Context, containerID, snapshotID, sn createdAt = versionRow.CreatedAt.Time } - return id, int(version), createdAt, nil + return uuidString(versionRow.ID), int(version), createdAt, nil } func (m *Manager) insertEvent(ctx context.Context, containerID, eventType string, payload map[string]any) error { @@ -370,3 +475,17 @@ func (m *Manager) insertEvent(ctx context.Context, containerID, eventType string }) } +func normalizeSnapshotSource(source string) string { + s := strings.TrimSpace(source) + if s == "" { + return SnapshotSourceManual + } + return s +} + +func uuidString(v pgtype.UUID) string { + if !v.Valid { + return "" + } + return uuid.UUID(v.Bytes).String() +} diff --git a/internal/media/service.go b/internal/media/service.go index 30d911c1..d61b61cc 100644 --- a/internal/media/service.go +++ b/internal/media/service.go @@ -4,8 +4,6 @@ import ( "context" "crypto/sha256" "encoding/hex" - "encoding/json" - "errors" "fmt" "io" "log/slog" @@ -13,35 +11,29 @@ import ( "path" "strings" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - - dbpkg "github.com/memohai/memoh/internal/db" - "github.com/memohai/memoh/internal/db/sqlc" + "github.com/memohai/memoh/internal/storage" ) -// Service provides media asset persistence operations. +// Service provides content-addressed media asset persistence. +// All metadata is derived from the filesystem — no database, no sidecar files. type Service struct { - queries *sqlc.Queries - provider StorageProvider + provider storage.Provider logger *slog.Logger } // NewService creates a media service with the given storage provider. -func NewService(log *slog.Logger, queries *sqlc.Queries, provider StorageProvider) *Service { +func NewService(log *slog.Logger, provider storage.Provider) *Service { if log == nil { log = slog.Default() } return &Service{ - queries: queries, provider: provider, logger: log.With(slog.String("service", "media")), } } // Ingest persists a new media asset. It hashes the content, deduplicates by -// (bot_id, content_hash), stores the bytes via the provider, and writes the -// DB record. Returns the asset (existing or newly created). +// checking the filesystem, and stores the bytes. Returns a derived Asset. func (s *Service) Ingest(ctx context.Context, input IngestInput) (Asset, error) { if s.provider == nil { return Asset{}, ErrProviderUnavailable @@ -65,30 +57,21 @@ func (s *Service) Ingest(ctx context.Context, input IngestInput) (Asset, error) _ = os.Remove(tempPath) }() - pgBotID, err := dbpkg.ParseUUID(input.BotID) - if err != nil { - return Asset{}, fmt.Errorf("invalid bot id: %w", err) - } + mime := coalesce(input.Mime, "application/octet-stream") + ext := extensionFromMime(mime) + storageKey := path.Join(contentHash[:2], contentHash+ext) + routingKey := path.Join(input.BotID, storageKey) - // Dedup: only create when hash truly not found; propagate other DB errors. - existing, err := s.queries.GetMediaAssetByHash(ctx, sqlc.GetMediaAssetByHashParams{ - BotID: pgBotID, - ContentHash: contentHash, - }) - if err == nil { - return convertAsset(existing), nil + // Filesystem dedup: if the file already exists, skip write. + if _, openErr := s.provider.Open(ctx, routingKey); openErr == nil { + return Asset{ + ContentHash: contentHash, + BotID: input.BotID, + Mime: mime, + SizeBytes: sizeBytes, + StorageKey: storageKey, + }, nil } - if !errors.Is(err, pgx.ErrNoRows) { - return Asset{}, fmt.Errorf("check existing asset: %w", err) - } - - ext := extensionFromMime(input.Mime) - storageKey := path.Join( - input.BotID, - string(input.MediaType), - contentHash[:4], - contentHash+ext, - ) tempFile, err := os.Open(tempPath) if err != nil { @@ -97,169 +80,127 @@ func (s *Service) Ingest(ctx context.Context, input IngestInput) (Asset, error) defer func() { _ = tempFile.Close() }() - if err := s.provider.Put(ctx, storageKey, tempFile); err != nil { + if err := s.provider.Put(ctx, routingKey, tempFile); err != nil { return Asset{}, fmt.Errorf("store media: %w", err) } - metaBytes, err := json.Marshal(nonNilMap(input.Metadata)) - if err != nil { - metaBytes = []byte("{}") - } - - row, err := s.queries.CreateMediaAsset(ctx, sqlc.CreateMediaAssetParams{ - BotID: pgBotID, + return Asset{ ContentHash: contentHash, - MediaType: string(input.MediaType), - Mime: coalesce(input.Mime, "application/octet-stream"), + BotID: input.BotID, + Mime: mime, SizeBytes: sizeBytes, StorageKey: storageKey, - OriginalName: pgtype.Text{ - String: input.OriginalName, - Valid: strings.TrimSpace(input.OriginalName) != "", - }, - Width: toPgInt4(input.Width), - Height: toPgInt4(input.Height), - DurationMs: toPgInt8(input.DurationMs), - Metadata: metaBytes, - }) - if err != nil { - return Asset{}, fmt.Errorf("create asset record: %w", err) - } - return convertAsset(row), nil + }, nil } -// Open returns a reader for the media asset identified by ID. -func (s *Service) Open(ctx context.Context, assetID string) (io.ReadCloser, Asset, error) { +// Resolve finds an asset by content hash (no stream open). Used to fill mime/storage_key when DB has none. +func (s *Service) Resolve(ctx context.Context, botID, contentHash string) (Asset, error) { + if s.provider == nil { + return Asset{}, ErrProviderUnavailable + } + return s.resolveByContentHash(ctx, botID, contentHash) +} + +// Open returns a reader for the media asset identified by content hash. +// It locates the file by scanning extensions under the hash prefix and derives MIME from the extension. +func (s *Service) Open(ctx context.Context, botID, contentHash string) (io.ReadCloser, Asset, error) { if s.provider == nil { return nil, Asset{}, ErrProviderUnavailable } - pgID, err := dbpkg.ParseUUID(assetID) + asset, err := s.resolveByContentHash(ctx, botID, contentHash) if err != nil { - return nil, Asset{}, fmt.Errorf("invalid asset id: %w", err) + return nil, Asset{}, err } - row, err := s.queries.GetMediaAssetByID(ctx, pgID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, Asset{}, ErrAssetNotFound - } - return nil, Asset{}, fmt.Errorf("get asset: %w", err) - } - asset := convertAsset(row) - reader, err := s.provider.Open(ctx, asset.StorageKey) + routingKey := path.Join(botID, asset.StorageKey) + reader, err := s.provider.Open(ctx, routingKey) if err != nil { return nil, Asset{}, fmt.Errorf("open storage: %w", err) } return reader, asset, nil } -// GetByID returns an asset by its ID. -func (s *Service) GetByID(ctx context.Context, assetID string) (Asset, error) { - pgID, err := dbpkg.ParseUUID(assetID) +// GetByStorageKey returns an asset derived from a known storage key. +func (s *Service) GetByStorageKey(ctx context.Context, botID, storageKey string) (Asset, error) { + if s.provider == nil { + return Asset{}, ErrProviderUnavailable + } + routingKey := path.Join(botID, storageKey) + rc, err := s.provider.Open(ctx, routingKey) if err != nil { - return Asset{}, fmt.Errorf("invalid asset id: %w", err) + return Asset{}, ErrAssetNotFound } - row, err := s.queries.GetMediaAssetByID(ctx, pgID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return Asset{}, ErrAssetNotFound - } - return Asset{}, fmt.Errorf("get asset: %w", err) - } - return convertAsset(row), nil -} - -// LinkToMessage creates a message-asset relationship. -func (s *Service) LinkToMessage(ctx context.Context, messageID, assetID, role string, ordinal int) error { - pgMsgID, err := dbpkg.ParseUUID(messageID) - if err != nil { - return fmt.Errorf("invalid message id: %w", err) - } - pgAssetID, err := dbpkg.ParseUUID(assetID) - if err != nil { - return fmt.Errorf("invalid asset id: %w", err) - } - if strings.TrimSpace(role) == "" { - role = "attachment" - } - _, err = s.queries.CreateMessageAsset(ctx, sqlc.CreateMessageAssetParams{ - MessageID: pgMsgID, - AssetID: pgAssetID, - Role: role, - Ordinal: int32(ordinal), - }) - return err -} - -// ListMessageAssets returns all assets linked to a message. -func (s *Service) ListMessageAssets(ctx context.Context, messageID string) ([]Asset, error) { - pgMsgID, err := dbpkg.ParseUUID(messageID) - if err != nil { - return nil, fmt.Errorf("invalid message id: %w", err) - } - rows, err := s.queries.ListMessageAssets(ctx, pgMsgID) - if err != nil { - return nil, err - } - assets := make([]Asset, 0, len(rows)) - for _, row := range rows { - assets = append(assets, Asset{ - ID: row.AssetID.String(), - MediaType: MediaType(row.MediaType), - Mime: row.Mime, - SizeBytes: row.SizeBytes, - StorageKey: row.StorageKey, - OriginalName: dbpkg.TextToString(row.OriginalName), - Width: int(row.Width.Int32), - Height: int(row.Height.Int32), - DurationMs: row.DurationMs.Int64, - }) - } - return assets, nil + _ = rc.Close() + return deriveAssetFromKey(botID, storageKey), nil } // AccessPath returns a consumer-accessible reference for a persisted asset. -// Delegates to the storage provider to compute the format-appropriate path. func (s *Service) AccessPath(asset Asset) string { if s.provider == nil { return "" } - return s.provider.AccessPath(asset.StorageKey) + routingKey := path.Join(asset.BotID, asset.StorageKey) + return s.provider.AccessPath(routingKey) } -// --- helpers --- +// resolveByContentHash scans hash-prefix directory by extension to find the file. +func (s *Service) resolveByContentHash(ctx context.Context, botID, contentHash string) (Asset, error) { + if strings.TrimSpace(contentHash) == "" || len(contentHash) < 2 { + return Asset{}, ErrAssetNotFound + } + prefix := contentHash[:2] + for _, ext := range knownExtensions { + storageKey := path.Join(prefix, contentHash+ext) + routingKey := path.Join(botID, storageKey) + rc, err := s.provider.Open(ctx, routingKey) + if err != nil { + continue + } + _ = rc.Close() + return deriveAssetFromKey(botID, storageKey), nil + } + return Asset{}, ErrAssetNotFound +} -func convertAsset(row sqlc.MediaAsset) Asset { - a := Asset{ - ID: row.ID.String(), - BotID: row.BotID.String(), - ContentHash: row.ContentHash, - MediaType: MediaType(row.MediaType), - Mime: row.Mime, - SizeBytes: row.SizeBytes, - StorageKey: row.StorageKey, - CreatedAt: row.CreatedAt.Time, +// deriveAssetFromKey builds an Asset from the storage key (hash_2char_prefix/hash.ext). +func deriveAssetFromKey(botID, storageKey string) Asset { + base := path.Base(storageKey) + ext := path.Ext(base) + hash := strings.TrimSuffix(base, ext) + return Asset{ + ContentHash: hash, + BotID: botID, + Mime: mimeFromExtension(ext), + StorageKey: storageKey, } - if row.StorageProviderID.Valid { - a.StorageProviderID = row.StorageProviderID.String() +} + +var knownExtensions = []string{".jpg", ".png", ".gif", ".webp", ".mp3", ".wav", ".ogg", ".mp4", ".webm", ".pdf", ".bin"} + +func mimeFromExtension(ext string) string { + switch strings.ToLower(ext) { + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + case ".gif": + return "image/gif" + case ".webp": + return "image/webp" + case ".mp3": + return "audio/mpeg" + case ".wav": + return "audio/wav" + case ".ogg": + return "audio/ogg" + case ".mp4": + return "video/mp4" + case ".webm": + return "video/webm" + case ".pdf": + return "application/pdf" + default: + return "application/octet-stream" } - if row.OriginalName.Valid { - a.OriginalName = row.OriginalName.String - } - if row.Width.Valid { - a.Width = int(row.Width.Int32) - } - if row.Height.Valid { - a.Height = int(row.Height.Int32) - } - if row.DurationMs.Valid { - a.DurationMs = row.DurationMs.Int64 - } - var meta map[string]any - if len(row.Metadata) > 0 { - _ = json.Unmarshal(row.Metadata, &meta) - } - a.Metadata = meta - return a } func extensionFromMime(mime string) string { @@ -289,13 +230,6 @@ func extensionFromMime(mime string) string { } } -func nonNilMap(m map[string]any) map[string]any { - if m == nil { - return map[string]any{} - } - return m -} - func coalesce(values ...string) string { for _, v := range values { if strings.TrimSpace(v) != "" { @@ -305,20 +239,6 @@ func coalesce(values ...string) string { return "" } -func toPgInt4(v int) pgtype.Int4 { - if v == 0 { - return pgtype.Int4{} - } - return pgtype.Int4{Int32: int32(v), Valid: true} -} - -func toPgInt8(v int64) pgtype.Int8 { - if v == 0 { - return pgtype.Int8{} - } - return pgtype.Int8{Int64: v, Valid: true} -} - func spoolAndHashWithLimit(reader io.Reader, maxBytes int64) (string, int64, string, error) { if reader == nil { return "", 0, "", fmt.Errorf("reader is required") diff --git a/internal/media/types.go b/internal/media/types.go index 767486fb..d1dd7854 100644 --- a/internal/media/types.go +++ b/internal/media/types.go @@ -1,10 +1,6 @@ package media -import ( - "context" - "io" - "time" -) +import "io" // MediaType classifies the kind of media asset. type MediaType string @@ -17,55 +13,21 @@ const ( ) // Asset is the domain representation of a persisted media object. +// ContentHash is the content-addressed identifier (SHA-256 hex). type Asset struct { - ID string `json:"id"` - BotID string `json:"bot_id"` - StorageProviderID string `json:"storage_provider_id,omitempty"` - ContentHash string `json:"content_hash"` - MediaType MediaType `json:"media_type"` - Mime string `json:"mime"` - SizeBytes int64 `json:"size_bytes"` - StorageKey string `json:"storage_key"` - OriginalName string `json:"original_name,omitempty"` - Width int `json:"width,omitempty"` - Height int `json:"height,omitempty"` - DurationMs int64 `json:"duration_ms,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` + ContentHash string `json:"content_hash"` + BotID string `json:"bot_id"` + Mime string `json:"mime"` + SizeBytes int64 `json:"size_bytes"` + StorageKey string `json:"storage_key"` } // IngestInput carries the data needed to persist a new media asset. type IngestInput struct { - BotID string - MediaType MediaType - Mime string - OriginalName string - Width int - Height int - DurationMs int64 - Metadata map[string]any + BotID string + Mime string // Reader provides the raw bytes; caller is responsible for closing. Reader io.Reader - // MaxBytes optionally overrides the media-type default size limit. + // MaxBytes optionally overrides the default size limit. MaxBytes int64 } - -// MessageAssetLink represents the relationship between a message and an asset. -type MessageAssetLink struct { - AssetID string `json:"asset_id"` - Role string `json:"role"` - Ordinal int `json:"ordinal"` -} - -// StorageProvider abstracts object storage operations. -type StorageProvider interface { - // Put writes data to storage under the given key. - Put(ctx context.Context, key string, reader io.Reader) error - // Open returns a reader for the given storage key. - Open(ctx context.Context, key string) (io.ReadCloser, error) - // Delete removes the object at key. - Delete(ctx context.Context, key string) error - // AccessPath returns a consumer-accessible reference for a storage key. - // The format depends on the backend (e.g. container path, signed URL). - AccessPath(key string) string -} diff --git a/internal/message/service.go b/internal/message/service.go index e3f48b7f..9090b0e5 100644 --- a/internal/message/service.go +++ b/internal/message/service.go @@ -90,30 +90,44 @@ func (s *DBService) Persist(ctx context.Context, input PersistInput) (Message, e // Persist asset links if provided. for _, ref := range input.Assets { pgMsgID := row.ID - pgAssetID, assetErr := dbpkg.ParseUUID(ref.AssetID) - if assetErr != nil { - s.logger.Warn("skip invalid asset ref", slog.String("asset_id", ref.AssetID), slog.Any("error", assetErr)) - continue - } role := ref.Role if strings.TrimSpace(role) == "" { role = "attachment" } + contentHash := strings.TrimSpace(ref.ContentHash) + if contentHash == "" { + s.logger.Warn("skip asset ref without content_hash") + continue + } if _, assetErr := s.queries.CreateMessageAsset(ctx, sqlc.CreateMessageAssetParams{ - MessageID: pgMsgID, - AssetID: pgAssetID, - Role: role, - Ordinal: int32(ref.Ordinal), + MessageID: pgMsgID, + Role: role, + Ordinal: int32(ref.Ordinal), + ContentHash: contentHash, }); assetErr != nil { s.logger.Warn("create message asset link failed", slog.String("message_id", result.ID), slog.Any("error", assetErr)) } } - // Enrich assets before publishing so SSE consumers see them immediately. + // Populate assets from input refs for SSE so consumers see them immediately. + // DB only stores the link (content_hash); mime/size/storage_key come from the caller. if len(input.Assets) > 0 { - enriched := []Message{result} - s.enrichAssets(ctx, enriched) - result = enriched[0] + assets := make([]MessageAsset, 0, len(input.Assets)) + for _, ref := range input.Assets { + ch := strings.TrimSpace(ref.ContentHash) + if ch == "" { + continue + } + assets = append(assets, MessageAsset{ + ContentHash: ch, + Role: coalesce(ref.Role, "attachment"), + Ordinal: ref.Ordinal, + Mime: ref.Mime, + SizeBytes: ref.SizeBytes, + StorageKey: ref.StorageKey, + }) + } + result.Assets = assets } s.publishMessageCreated(result) @@ -390,6 +404,22 @@ func nonNilMap(m map[string]any) map[string]any { return m } +func coalesce(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +func toPgInt8(v int64) pgtype.Int8 { + if v == 0 { + return pgtype.Int8{} + } + return pgtype.Int8{Int64: v, Valid: true} +} + func parseJSONMap(data []byte) map[string]any { if len(data) == 0 { return nil @@ -419,7 +449,9 @@ func (s *DBService) publishMessageCreated(message Message) { }) } -// enrichAssets batch-loads asset links for a list of messages. +// enrichAssets batch-loads asset links for a list of messages (single-table query). +// On DB error (e.g. missing content_hash column), we skip enrichment and leave Assets empty +// so the list request still returns all messages and does not fail. func (s *DBService) enrichAssets(ctx context.Context, messages []Message) { if len(messages) == 0 { return @@ -437,29 +469,41 @@ func (s *DBService) enrichAssets(ctx context.Context, messages []Message) { } rows, err := s.queries.ListMessageAssetsBatch(ctx, ids) if err != nil { - s.logger.Warn("enrich assets failed", slog.Any("error", err)) + s.logger.Warn("enrich assets failed, returning messages without assets", slog.Any("error", err)) + ensureAssetsSlice(messages) return } assetMap := map[string][]MessageAsset{} for _, row := range rows { msgID := row.MessageID.String() + contentHash := strings.TrimSpace(row.ContentHash) + if contentHash == "" { + continue + } assetMap[msgID] = append(assetMap[msgID], MessageAsset{ - AssetID: row.AssetID.String(), - Role: row.Role, - Ordinal: int(row.Ordinal), - MediaType: row.MediaType, - Mime: row.Mime, - SizeBytes: row.SizeBytes, - StorageKey: row.StorageKey, - OriginalName: dbpkg.TextToString(row.OriginalName), - Width: int(row.Width.Int32), - Height: int(row.Height.Int32), - DurationMs: row.DurationMs.Int64, + ContentHash: contentHash, + Role: row.Role, + Ordinal: int(row.Ordinal), + Mime: "", + SizeBytes: 0, + StorageKey: "", }) } for i := range messages { if assets, ok := assetMap[messages[i].ID]; ok { messages[i].Assets = assets + } else { + messages[i].Assets = []MessageAsset{} + } + } +} + +// ensureAssetsSlice sets Assets to a non-nil empty slice for each message so JSON is "assets": []. +// Used when enrich fails so frontend gets a consistent shape and does not treat missing assets as broken. +func ensureAssetsSlice(messages []Message) { + for i := range messages { + if messages[i].Assets == nil { + messages[i].Assets = []MessageAsset{} } } } diff --git a/internal/message/types.go b/internal/message/types.go index bf91c07f..7cc87b9f 100644 --- a/internal/message/types.go +++ b/internal/message/types.go @@ -7,18 +7,14 @@ import ( ) // MessageAsset carries media asset metadata attached to a message. +// ContentHash is the content-addressed identifier for the media file. type MessageAsset struct { - AssetID string `json:"asset_id"` - Role string `json:"role"` - Ordinal int `json:"ordinal"` - MediaType string `json:"media_type"` - Mime string `json:"mime"` - SizeBytes int64 `json:"size_bytes"` - StorageKey string `json:"storage_key"` - OriginalName string `json:"original_name,omitempty"` - Width int `json:"width,omitempty"` - Height int `json:"height,omitempty"` - DurationMs int64 `json:"duration_ms,omitempty"` + ContentHash string `json:"content_hash"` + Role string `json:"role"` + Ordinal int `json:"ordinal"` + Mime string `json:"mime"` + SizeBytes int64 `json:"size_bytes"` + StorageKey string `json:"storage_key"` } // Message represents a single persisted bot message. @@ -42,10 +38,14 @@ type Message struct { } // AssetRef links a media asset to a persisted message. +// ContentHash is the content-addressed identifier for the media file. type AssetRef struct { - AssetID string `json:"asset_id"` - Role string `json:"role"` - Ordinal int `json:"ordinal"` + ContentHash string `json:"content_hash"` + Role string `json:"role"` + Ordinal int `json:"ordinal"` + Mime string `json:"mime,omitempty"` + SizeBytes int64 `json:"size_bytes,omitempty"` + StorageKey string `json:"storage_key,omitempty"` } // PersistInput is the input for persisting a message. diff --git a/internal/media/providers/containerfs/provider.go b/internal/storage/providers/containerfs/provider.go similarity index 81% rename from internal/media/providers/containerfs/provider.go rename to internal/storage/providers/containerfs/provider.go index aac2ced2..5042eada 100644 --- a/internal/media/providers/containerfs/provider.go +++ b/internal/storage/providers/containerfs/provider.go @@ -1,4 +1,4 @@ -// Package containerfs implements media.StorageProvider for bot containers +// Package containerfs implements storage.Provider for bot containers // backed by host-side bind mounts. Writing to /bots//media/ // on the host makes the file available at /data/media/ inside the container. package containerfs @@ -76,17 +76,14 @@ func (p *Provider) Delete(_ context.Context, key string) error { } // AccessPath returns the container-internal path for a storage key. -// Key format: "/" → "/data/media/". +// Routing key format: "/" → "/data/media/". func (p *Provider) AccessPath(key string) string { - sub := key - if idx := strings.IndexByte(sub, '/'); idx >= 0 { - sub = sub[idx+1:] - } + _, sub := splitRoutingKey(key) return containerMediaRoot + "/" + sub } -// hostPath converts a storage key into the host-side file path. -// Key format: "/" → "/bots//media/". +// hostPath converts a routing key into the host-side file path. +// Routing key format: "/" → "/bots//media/". func (p *Provider) hostPath(key string) (string, error) { clean := filepath.Clean(key) if filepath.IsAbs(clean) { @@ -95,12 +92,7 @@ func (p *Provider) hostPath(key string) (string, error) { if strings.HasPrefix(clean, ".."+string(filepath.Separator)) || clean == ".." { return "", fmt.Errorf("path traversal is forbidden: %s", key) } - idx := strings.IndexByte(clean, filepath.Separator) - if idx <= 0 { - return "", fmt.Errorf("storage key must contain bot_id prefix: %s", key) - } - botID := clean[:idx] - subPath := clean[idx+1:] + botID, subPath := splitRoutingKey(clean) if strings.TrimSpace(botID) == "" || strings.TrimSpace(subPath) == "" { return "", fmt.Errorf("invalid storage key: %s", key) } @@ -110,3 +102,12 @@ func (p *Provider) hostPath(key string) (string, error) { } return joined, nil } + +// splitRoutingKey splits a routing key "/" into its parts. +func splitRoutingKey(key string) (botID, storageKey string) { + idx := strings.IndexByte(key, filepath.Separator) + if idx <= 0 { + return "", key + } + return key[:idx], key[idx+1:] +} diff --git a/internal/media/providers/containerfs/provider_test.go b/internal/storage/providers/containerfs/provider_test.go similarity index 100% rename from internal/media/providers/containerfs/provider_test.go rename to internal/storage/providers/containerfs/provider_test.go diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 00000000..28c1b053 --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,20 @@ +// Package storage defines the Provider interface for object storage backends. +package storage + +import ( + "context" + "io" +) + +// Provider abstracts object storage operations. +type Provider interface { + // Put writes data to storage under the given key. + Put(ctx context.Context, key string, reader io.Reader) error + // Open returns a reader for the given storage key. + Open(ctx context.Context, key string) (io.ReadCloser, error) + // Delete removes the object at key. + Delete(ctx context.Context, key string) error + // AccessPath returns a consumer-accessible reference for a storage key. + // The format depends on the backend (e.g. container path, signed URL). + AccessPath(key string) string +} diff --git a/mise.toml b/mise.toml index a955eb41..ed4a1039 100644 --- a/mise.toml +++ b/mise.toml @@ -13,8 +13,6 @@ pnpm = "10" sqlc = "latest" # typos for spell check typos = "latest" -# Lima for macOS -lima = { version = "system", platform = "darwin" } [task_config] dir = "{{cwd}}" @@ -30,16 +28,6 @@ run = "pnpm install" description = "Install Go dependencies" run = "go mod download" -[tasks.lima-up] -run = "scripts/lima-up.sh" - -[tasks.lima-down] -run = """ -if [ "$(uname -s)" = "Darwin" ]; then - limactl stop default -fi -""" - [tasks.swagger-generate] description = "Generate Swagger documentation" run = "cd internal/handlers && go generate" @@ -53,6 +41,18 @@ depends = ["//:swagger-generate"] description = "Generate SQL code" run = "sqlc generate" +[tasks.infra] +description = "Start dev infrastructure (postgres + qdrant)" +run = "docker compose -f devenv/docker-compose.yml up -d" + +[tasks.infra-down] +description = "Stop dev infrastructure" +run = "docker compose -f devenv/docker-compose.yml down" + +[tasks.infra-logs] +description = "View dev infrastructure logs" +run = "docker compose -f devenv/docker-compose.yml logs -f" + [tasks.db-up] description = "Initialize and Migrate Database" run = "scripts/db-up.sh" @@ -70,15 +70,6 @@ description = "Install CLI" depends = ["//:pnpm-install"] run = "cd packages/cli && npm install -g" -[tasks.build-cli] -description = "Build Go CLI binary and install to local bin" -run = """ -mkdir -p ~/.local/bin -go build -trimpath -ldflags "-s -w" -o ~/.local/bin/memoh-cli ./cmd/cli -chmod +x ~/.local/bin/memoh-cli -echo "✓ CLI binary installed to ~/.local/bin/memoh-cli" -""" - [tasks.compile-mcp] description = "Build MCP binary into /app and signal container" run = "scripts/compile-mcp.sh" @@ -97,10 +88,33 @@ depends = [ description = "Setup development environment" depends = [ "//:sqlc-generate", - "//:db-up", "//:pnpm-install", "//:go-install", "//:install-cli", ] -run = "echo '✓ Setup complete! Next: Copy config.toml.example to config.toml and configure, then run: mise run dev'" +run = """ +#!/bin/bash +set -e + +# Auto-copy dev config if config.toml doesn't exist +if [ ! -f config.toml ]; then + cp conf/app.dev.toml config.toml + echo '✓ Copied conf/app.dev.toml → config.toml' +fi + +# Start dev infrastructure +docker compose -f devenv/docker-compose.yml up -d + +# Wait for postgres to be healthy +echo 'Waiting for postgres...' +until docker compose -f devenv/docker-compose.yml exec -T postgres pg_isready -U memoh >/dev/null 2>&1; do + sleep 1 +done +echo '✓ Postgres ready' + +# Run migrations +scripts/db-up.sh + +echo '✓ Setup complete! Run: mise run dev' +""" diff --git a/packages/cli/src/cli/stream.ts b/packages/cli/src/cli/stream.ts index e9e244be..1586aa93 100644 --- a/packages/cli/src/cli/stream.ts +++ b/packages/cli/src/cli/stream.ts @@ -1,5 +1,6 @@ import chalk from 'chalk' import { client } from '@memoh/sdk/client' +import { postBotsByBotIdCliMessages } from '@memoh/sdk' // --------------------------------------------------------------------------- // SSE stream types (aligned with frontend useChat.ts) @@ -86,11 +87,104 @@ function parseStreamPayload(payload: string): StreamEvent | null { return { type: 'text_delta', delta: current.trim() } as StreamEvent } if (current && typeof current === 'object') { - return current as StreamEvent + return normalizeStreamEvent(current as Record) } return null } +const LEGACY_STREAM_EVENT_TYPES = new Set([ + 'text_start', + 'text_delta', + 'text_end', + 'reasoning_start', + 'reasoning_delta', + 'reasoning_end', + 'tool_call_start', + 'tool_call_end', + 'attachment_delta', + 'agent_start', + 'agent_end', + 'processing_started', + 'processing_completed', + 'processing_failed', + 'error', +]) + +function normalizeStreamEvent(raw: Record): StreamEvent | null { + const eventType = String(raw.type ?? '').trim().toLowerCase() + if (!eventType) return null + if (LEGACY_STREAM_EVENT_TYPES.has(eventType)) { + return raw as StreamEvent + } + switch (eventType) { + case 'status': { + const status = String(raw.status ?? '').trim().toLowerCase() + if (status === 'started') return { type: 'processing_started' } + if (status === 'completed') return { type: 'processing_completed' } + if (status === 'failed') { + const err = String(raw.error ?? '').trim() + return { type: 'processing_failed', error: err, message: err } + } + return null + } + case 'delta': { + const delta = String(raw.delta ?? '') + const phase = String(raw.phase ?? '').trim().toLowerCase() + if (phase === 'reasoning') { + return { type: 'reasoning_delta', delta } + } + return { type: 'text_delta', delta } + } + case 'phase_start': { + const phase = String(raw.phase ?? '').trim().toLowerCase() + if (phase === 'reasoning') return { type: 'reasoning_start' } + if (phase === 'text') return { type: 'text_start' } + return null + } + case 'phase_end': { + const phase = String(raw.phase ?? '').trim().toLowerCase() + if (phase === 'reasoning') return { type: 'reasoning_end' } + if (phase === 'text') return { type: 'text_end' } + return null + } + case 'tool_call_start': + case 'tool_call_end': { + const toolCall = (raw.tool_call && typeof raw.tool_call === 'object') + ? raw.tool_call as Record + : {} + return { + type: eventType, + toolName: String(toolCall.name ?? ''), + toolCallId: String(toolCall.call_id ?? ''), + input: toolCall.input, + result: toolCall.result, + } as StreamEvent + } + case 'attachment': { + const attachments = Array.isArray(raw.attachments) + ? raw.attachments as Array> + : [] + if (!attachments.length) return null + return { type: 'attachment_delta', attachments } as StreamEvent + } + case 'processing_started': + case 'processing_completed': + case 'agent_start': + case 'agent_end': + return { type: eventType } as StreamEvent + case 'processing_failed': { + const err = String(raw.error ?? raw.message ?? '').trim() + return { type: 'processing_failed', error: err, message: err } as StreamEvent + } + case 'error': { + const err = String(raw.error ?? raw.message ?? 'Stream error').trim() + return { type: 'error', error: err, message: err } as StreamEvent + } + default: + return null + } +} + // --------------------------------------------------------------------------- // Tool display configuration // --------------------------------------------------------------------------- @@ -440,20 +534,21 @@ function handleStreamEventInner(type: string, event: StreamEvent): boolean { // --------------------------------------------------------------------------- // Stream chat -// Strictly follows frontend streamMessage() in useChat.ts: -// client.post({ url: '/bots/{bot_id}/messages/stream', path: { bot_id }, ... }) +// CLI channel flow: +// 1) open SSE subscription at /bots/{bot_id}/cli/stream +// 2) post message to /bots/{bot_id}/cli/messages // --------------------------------------------------------------------------- export const streamChat = async (query: string, botId: string) => { _printedText = false try { - // Exactly matches frontend: client.post() with parseAs: 'stream' - const { data: body } = await client.post({ - url: '/bots/{bot_id}/messages/stream', + const controller = new AbortController() + const { data: body } = await client.get({ + url: '/bots/{bot_id}/cli/stream', path: { bot_id: botId }, - body: { query, current_channel: 'cli', channels: ['cli'] }, parseAs: 'stream', + signal: controller.signal, throwOnError: true, }) as { data: ReadableStream } @@ -462,15 +557,53 @@ export const streamChat = async (query: string, botId: string) => { return false } - // Use the same readSSEStream + parseStreamPayload as frontend - await readSSEStream(body, (payload) => { + let completed = false + let failedMessage = '' + const streamTask = readSSEStream(body, (payload) => { const event = parseStreamPayload(payload) - if (event) handleStreamEvent(event) + if (!event) return + handleStreamEvent(event) + const type = (event.type ?? '').toLowerCase() + if (type === 'processing_completed') { + completed = true + controller.abort() + return + } + if (type === 'processing_failed' || type === 'error') { + const msg = typeof event.message === 'string' + ? event.message + : typeof event.error === 'string' + ? event.error + : 'Stream error' + failedMessage = msg + controller.abort() + } }) + .catch((err) => { + if ((err as Error).name !== 'AbortError') { + throw err + } + }) + + await postBotsByBotIdCliMessages({ + path: { bot_id: botId }, + body: { message: { text: query } }, + throwOnError: true, + }) + + await streamTask if (_printedText) { process.stdout.write('\n') } + if (failedMessage) { + console.log(chalk.red(`Stream error: ${failedMessage}`)) + return false + } + if (!completed) { + console.log(chalk.red('Stream ended before completion')) + return false + } return true } catch (err) { if (_printedText) { diff --git a/packages/sdk/src/@pinia/colada.gen.ts b/packages/sdk/src/@pinia/colada.gen.ts index a5a607d2..53f82d21 100644 --- a/packages/sdk/src/@pinia/colada.gen.ts +++ b/packages/sdk/src/@pinia/colada.gen.ts @@ -4,8 +4,8 @@ import { type _JSONValue, defineQueryOptions, type UseMutationOptions } from '@p import { serializeQueryKeyValue } from '../client'; import { client } from '../client.gen'; -import { deleteBotsByBotIdContainer, deleteBotsByBotIdContainerSkills, deleteBotsByBotIdMcpById, deleteBotsByBotIdMemory, deleteBotsByBotIdMemoryById, deleteBotsByBotIdScheduleById, deleteBotsByBotIdSettings, deleteBotsByBotIdSubagentsById, deleteBotsById, deleteBotsByIdChannelByPlatform, deleteBotsByIdMembersByUserId, deleteModelsById, deleteModelsModelByModelId, deleteProvidersById, deleteSearchProvidersById, getBots, getBotsByBotIdContainer, getBotsByBotIdContainerSkills, getBotsByBotIdContainerSnapshots, getBotsByBotIdMcp, getBotsByBotIdMcpById, getBotsByBotIdMcpExport, getBotsByBotIdMemory, getBotsByBotIdMemoryUsage, getBotsByBotIdMessages, getBotsByBotIdSchedule, getBotsByBotIdScheduleById, getBotsByBotIdSettings, getBotsByBotIdSubagents, getBotsByBotIdSubagentsById, getBotsByBotIdSubagentsByIdContext, getBotsByBotIdSubagentsByIdSkills, getBotsById, getBotsByIdChannelByPlatform, getBotsByIdChecks, getBotsByIdMembers, getChannels, getChannelsByPlatform, getModels, getModelsById, getModelsCount, getModelsModelByModelId, getProviders, getProvidersById, getProvidersByIdModels, getProvidersCount, getProvidersNameByName, getSearchProviders, getSearchProvidersById, getSearchProvidersMeta, getUsers, getUsersById, getUsersMe, getUsersMeChannelsByPlatform, getUsersMeIdentities, type Options, patchBotsByIdChannelByPlatformStatus, postAuthLogin, postBots, postBotsByBotIdContainer, postBotsByBotIdContainerSkills, postBotsByBotIdContainerSnapshots, postBotsByBotIdContainerStart, postBotsByBotIdContainerStop, postBotsByBotIdMcp, postBotsByBotIdMcpOpsBatchDelete, postBotsByBotIdMcpStdio, postBotsByBotIdMcpStdioByConnectionId, postBotsByBotIdMemory, postBotsByBotIdMemoryCompact, postBotsByBotIdMemoryRebuild, postBotsByBotIdMemorySearch, postBotsByBotIdSchedule, postBotsByBotIdSettings, postBotsByBotIdSubagents, postBotsByBotIdSubagentsByIdSkills, postBotsByBotIdTools, postBotsByIdChannelByPlatformSend, postBotsByIdChannelByPlatformSendChat, postEmbeddings, postModels, postProviders, postProvidersByIdTest, postSearchProviders, postUsers, putBotsByBotIdMcpById, putBotsByBotIdMcpImport, putBotsByBotIdScheduleById, putBotsByBotIdSettings, putBotsByBotIdSubagentsById, putBotsByBotIdSubagentsByIdContext, putBotsByBotIdSubagentsByIdSkills, putBotsById, putBotsByIdChannelByPlatform, putBotsByIdMembers, putBotsByIdOwner, putModelsById, putModelsModelByModelId, putProvidersById, putSearchProvidersById, putUsersById, putUsersByIdPassword, putUsersMe, putUsersMeChannelsByPlatform, putUsersMePassword } from '../sdk.gen'; -import type { DeleteBotsByBotIdContainerData, DeleteBotsByBotIdContainerError, DeleteBotsByBotIdContainerSkillsData, DeleteBotsByBotIdContainerSkillsError, DeleteBotsByBotIdContainerSkillsResponse, DeleteBotsByBotIdMcpByIdData, DeleteBotsByBotIdMcpByIdError, DeleteBotsByBotIdMemoryByIdData, DeleteBotsByBotIdMemoryByIdError, DeleteBotsByBotIdMemoryByIdResponse, DeleteBotsByBotIdMemoryData, DeleteBotsByBotIdMemoryError, DeleteBotsByBotIdMemoryResponse, DeleteBotsByBotIdScheduleByIdData, DeleteBotsByBotIdScheduleByIdError, DeleteBotsByBotIdSettingsData, DeleteBotsByBotIdSettingsError, DeleteBotsByBotIdSubagentsByIdData, DeleteBotsByBotIdSubagentsByIdError, DeleteBotsByIdChannelByPlatformData, DeleteBotsByIdChannelByPlatformError, DeleteBotsByIdData, DeleteBotsByIdError, DeleteBotsByIdMembersByUserIdData, DeleteBotsByIdMembersByUserIdError, DeleteBotsByIdResponse, DeleteModelsByIdData, DeleteModelsByIdError, DeleteModelsModelByModelIdData, DeleteModelsModelByModelIdError, DeleteProvidersByIdData, DeleteProvidersByIdError, DeleteSearchProvidersByIdData, DeleteSearchProvidersByIdError, GetBotsByBotIdContainerData, GetBotsByBotIdContainerSkillsData, GetBotsByBotIdContainerSnapshotsData, GetBotsByBotIdMcpByIdData, GetBotsByBotIdMcpData, GetBotsByBotIdMcpExportData, GetBotsByBotIdMemoryData, GetBotsByBotIdMemoryUsageData, GetBotsByBotIdMessagesData, GetBotsByBotIdScheduleByIdData, GetBotsByBotIdScheduleData, GetBotsByBotIdSettingsData, GetBotsByBotIdSubagentsByIdContextData, GetBotsByBotIdSubagentsByIdData, GetBotsByBotIdSubagentsByIdSkillsData, GetBotsByBotIdSubagentsData, GetBotsByIdChannelByPlatformData, GetBotsByIdChecksData, GetBotsByIdData, GetBotsByIdMembersData, GetBotsData, GetChannelsByPlatformData, GetChannelsData, GetModelsByIdData, GetModelsCountData, GetModelsData, GetModelsModelByModelIdData, GetProvidersByIdData, GetProvidersByIdModelsData, GetProvidersCountData, GetProvidersData, GetProvidersNameByNameData, GetSearchProvidersByIdData, GetSearchProvidersData, GetSearchProvidersMetaData, GetUsersByIdData, GetUsersData, GetUsersMeChannelsByPlatformData, GetUsersMeData, GetUsersMeIdentitiesData, PatchBotsByIdChannelByPlatformStatusData, PatchBotsByIdChannelByPlatformStatusError, PatchBotsByIdChannelByPlatformStatusResponse, PostAuthLoginData, PostAuthLoginError, PostAuthLoginResponse, PostBotsByBotIdContainerData, PostBotsByBotIdContainerError, PostBotsByBotIdContainerResponse, PostBotsByBotIdContainerSkillsData, PostBotsByBotIdContainerSkillsError, PostBotsByBotIdContainerSkillsResponse, PostBotsByBotIdContainerSnapshotsData, PostBotsByBotIdContainerSnapshotsError, PostBotsByBotIdContainerSnapshotsResponse, PostBotsByBotIdContainerStartData, PostBotsByBotIdContainerStartError, PostBotsByBotIdContainerStartResponse, PostBotsByBotIdContainerStopData, PostBotsByBotIdContainerStopError, PostBotsByBotIdContainerStopResponse, PostBotsByBotIdMcpData, PostBotsByBotIdMcpError, PostBotsByBotIdMcpOpsBatchDeleteData, PostBotsByBotIdMcpOpsBatchDeleteError, PostBotsByBotIdMcpResponse, PostBotsByBotIdMcpStdioByConnectionIdData, PostBotsByBotIdMcpStdioByConnectionIdError, PostBotsByBotIdMcpStdioByConnectionIdResponse, PostBotsByBotIdMcpStdioData, PostBotsByBotIdMcpStdioError, PostBotsByBotIdMcpStdioResponse, PostBotsByBotIdMemoryCompactData, PostBotsByBotIdMemoryCompactError, PostBotsByBotIdMemoryCompactResponse, PostBotsByBotIdMemoryData, PostBotsByBotIdMemoryError, PostBotsByBotIdMemoryRebuildData, PostBotsByBotIdMemoryRebuildError, PostBotsByBotIdMemoryRebuildResponse, PostBotsByBotIdMemoryResponse, PostBotsByBotIdMemorySearchData, PostBotsByBotIdMemorySearchError, PostBotsByBotIdMemorySearchResponse, PostBotsByBotIdScheduleData, PostBotsByBotIdScheduleError, PostBotsByBotIdScheduleResponse, PostBotsByBotIdSettingsData, PostBotsByBotIdSettingsError, PostBotsByBotIdSettingsResponse, PostBotsByBotIdSubagentsByIdSkillsData, PostBotsByBotIdSubagentsByIdSkillsError, PostBotsByBotIdSubagentsByIdSkillsResponse, PostBotsByBotIdSubagentsData, PostBotsByBotIdSubagentsError, PostBotsByBotIdSubagentsResponse, PostBotsByBotIdToolsData, PostBotsByBotIdToolsError, PostBotsByBotIdToolsResponse, PostBotsByIdChannelByPlatformSendChatData, PostBotsByIdChannelByPlatformSendChatError, PostBotsByIdChannelByPlatformSendChatResponse, PostBotsByIdChannelByPlatformSendData, PostBotsByIdChannelByPlatformSendError, PostBotsByIdChannelByPlatformSendResponse, PostBotsData, PostBotsError, PostBotsResponse, PostEmbeddingsData, PostEmbeddingsError, PostEmbeddingsResponse, PostModelsData, PostModelsError, PostModelsResponse, PostProvidersByIdTestData, PostProvidersByIdTestError, PostProvidersByIdTestResponse, PostProvidersData, PostProvidersError, PostProvidersResponse, PostSearchProvidersData, PostSearchProvidersError, PostSearchProvidersResponse, PostUsersData, PostUsersError, PostUsersResponse, PutBotsByBotIdMcpByIdData, PutBotsByBotIdMcpByIdError, PutBotsByBotIdMcpByIdResponse, PutBotsByBotIdMcpImportData, PutBotsByBotIdMcpImportError, PutBotsByBotIdMcpImportResponse, PutBotsByBotIdScheduleByIdData, PutBotsByBotIdScheduleByIdError, PutBotsByBotIdScheduleByIdResponse, PutBotsByBotIdSettingsData, PutBotsByBotIdSettingsError, PutBotsByBotIdSettingsResponse, PutBotsByBotIdSubagentsByIdContextData, PutBotsByBotIdSubagentsByIdContextError, PutBotsByBotIdSubagentsByIdContextResponse, PutBotsByBotIdSubagentsByIdData, PutBotsByBotIdSubagentsByIdError, PutBotsByBotIdSubagentsByIdResponse, PutBotsByBotIdSubagentsByIdSkillsData, PutBotsByBotIdSubagentsByIdSkillsError, PutBotsByBotIdSubagentsByIdSkillsResponse, PutBotsByIdChannelByPlatformData, PutBotsByIdChannelByPlatformError, PutBotsByIdChannelByPlatformResponse, PutBotsByIdData, PutBotsByIdError, PutBotsByIdMembersData, PutBotsByIdMembersError, PutBotsByIdMembersResponse, PutBotsByIdOwnerData, PutBotsByIdOwnerError, PutBotsByIdOwnerResponse, PutBotsByIdResponse, PutModelsByIdData, PutModelsByIdError, PutModelsByIdResponse, PutModelsModelByModelIdData, PutModelsModelByModelIdError, PutModelsModelByModelIdResponse, PutProvidersByIdData, PutProvidersByIdError, PutProvidersByIdResponse, PutSearchProvidersByIdData, PutSearchProvidersByIdError, PutSearchProvidersByIdResponse, PutUsersByIdData, PutUsersByIdError, PutUsersByIdPasswordData, PutUsersByIdPasswordError, PutUsersByIdResponse, PutUsersMeChannelsByPlatformData, PutUsersMeChannelsByPlatformError, PutUsersMeChannelsByPlatformResponse, PutUsersMeData, PutUsersMeError, PutUsersMePasswordData, PutUsersMePasswordError, PutUsersMeResponse } from '../types.gen'; +import { deleteBotsByBotIdContainer, deleteBotsByBotIdContainerSkills, deleteBotsByBotIdMcpById, deleteBotsByBotIdMemory, deleteBotsByBotIdMemoryById, deleteBotsByBotIdScheduleById, deleteBotsByBotIdSettings, deleteBotsByBotIdSubagentsById, deleteBotsById, deleteBotsByIdChannelByPlatform, deleteBotsByIdMembersByUserId, deleteModelsById, deleteModelsModelByModelId, deleteProvidersById, deleteSearchProvidersById, getBots, getBotsByBotIdContainer, getBotsByBotIdContainerSkills, getBotsByBotIdContainerSnapshots, getBotsByBotIdMcp, getBotsByBotIdMcpById, getBotsByBotIdMcpExport, getBotsByBotIdMemory, getBotsByBotIdMemoryUsage, getBotsByBotIdMessages, getBotsByBotIdSchedule, getBotsByBotIdScheduleById, getBotsByBotIdSettings, getBotsByBotIdSubagents, getBotsByBotIdSubagentsById, getBotsByBotIdSubagentsByIdContext, getBotsByBotIdSubagentsByIdSkills, getBotsById, getBotsByIdChannelByPlatform, getBotsByIdChecks, getBotsByIdMembers, getChannels, getChannelsByPlatform, getModels, getModelsById, getModelsCount, getModelsModelByModelId, getProviders, getProvidersById, getProvidersByIdModels, getProvidersCount, getProvidersNameByName, getSearchProviders, getSearchProvidersById, getSearchProvidersMeta, getUsers, getUsersById, getUsersMe, getUsersMeChannelsByPlatform, getUsersMeIdentities, type Options, patchBotsByIdChannelByPlatformStatus, postAuthLogin, postBots, postBotsByBotIdCliMessages, postBotsByBotIdContainer, postBotsByBotIdContainerSkills, postBotsByBotIdContainerSnapshots, postBotsByBotIdContainerStart, postBotsByBotIdContainerStop, postBotsByBotIdMcp, postBotsByBotIdMcpOpsBatchDelete, postBotsByBotIdMcpStdio, postBotsByBotIdMcpStdioByConnectionId, postBotsByBotIdMemory, postBotsByBotIdMemoryCompact, postBotsByBotIdMemoryRebuild, postBotsByBotIdMemorySearch, postBotsByBotIdSchedule, postBotsByBotIdSettings, postBotsByBotIdSubagents, postBotsByBotIdSubagentsByIdSkills, postBotsByBotIdTools, postBotsByBotIdWebMessages, postBotsByIdChannelByPlatformSend, postBotsByIdChannelByPlatformSendChat, postEmbeddings, postModels, postProviders, postProvidersByIdTest, postSearchProviders, postUsers, putBotsByBotIdMcpById, putBotsByBotIdMcpImport, putBotsByBotIdScheduleById, putBotsByBotIdSettings, putBotsByBotIdSubagentsById, putBotsByBotIdSubagentsByIdContext, putBotsByBotIdSubagentsByIdSkills, putBotsById, putBotsByIdChannelByPlatform, putBotsByIdMembers, putBotsByIdOwner, putModelsById, putModelsModelByModelId, putProvidersById, putSearchProvidersById, putUsersById, putUsersByIdPassword, putUsersMe, putUsersMeChannelsByPlatform, putUsersMePassword } from '../sdk.gen'; +import type { DeleteBotsByBotIdContainerData, DeleteBotsByBotIdContainerError, DeleteBotsByBotIdContainerSkillsData, DeleteBotsByBotIdContainerSkillsError, DeleteBotsByBotIdContainerSkillsResponse, DeleteBotsByBotIdMcpByIdData, DeleteBotsByBotIdMcpByIdError, DeleteBotsByBotIdMemoryByIdData, DeleteBotsByBotIdMemoryByIdError, DeleteBotsByBotIdMemoryByIdResponse, DeleteBotsByBotIdMemoryData, DeleteBotsByBotIdMemoryError, DeleteBotsByBotIdMemoryResponse, DeleteBotsByBotIdScheduleByIdData, DeleteBotsByBotIdScheduleByIdError, DeleteBotsByBotIdSettingsData, DeleteBotsByBotIdSettingsError, DeleteBotsByBotIdSubagentsByIdData, DeleteBotsByBotIdSubagentsByIdError, DeleteBotsByIdChannelByPlatformData, DeleteBotsByIdChannelByPlatformError, DeleteBotsByIdData, DeleteBotsByIdError, DeleteBotsByIdMembersByUserIdData, DeleteBotsByIdMembersByUserIdError, DeleteBotsByIdResponse, DeleteModelsByIdData, DeleteModelsByIdError, DeleteModelsModelByModelIdData, DeleteModelsModelByModelIdError, DeleteProvidersByIdData, DeleteProvidersByIdError, DeleteSearchProvidersByIdData, DeleteSearchProvidersByIdError, GetBotsByBotIdContainerData, GetBotsByBotIdContainerSkillsData, GetBotsByBotIdContainerSnapshotsData, GetBotsByBotIdMcpByIdData, GetBotsByBotIdMcpData, GetBotsByBotIdMcpExportData, GetBotsByBotIdMemoryData, GetBotsByBotIdMemoryUsageData, GetBotsByBotIdMessagesData, GetBotsByBotIdScheduleByIdData, GetBotsByBotIdScheduleData, GetBotsByBotIdSettingsData, GetBotsByBotIdSubagentsByIdContextData, GetBotsByBotIdSubagentsByIdData, GetBotsByBotIdSubagentsByIdSkillsData, GetBotsByBotIdSubagentsData, GetBotsByIdChannelByPlatformData, GetBotsByIdChecksData, GetBotsByIdData, GetBotsByIdMembersData, GetBotsData, GetChannelsByPlatformData, GetChannelsData, GetModelsByIdData, GetModelsCountData, GetModelsData, GetModelsModelByModelIdData, GetProvidersByIdData, GetProvidersByIdModelsData, GetProvidersCountData, GetProvidersData, GetProvidersNameByNameData, GetSearchProvidersByIdData, GetSearchProvidersData, GetSearchProvidersMetaData, GetUsersByIdData, GetUsersData, GetUsersMeChannelsByPlatformData, GetUsersMeData, GetUsersMeIdentitiesData, PatchBotsByIdChannelByPlatformStatusData, PatchBotsByIdChannelByPlatformStatusError, PatchBotsByIdChannelByPlatformStatusResponse, PostAuthLoginData, PostAuthLoginError, PostAuthLoginResponse, PostBotsByBotIdCliMessagesData, PostBotsByBotIdCliMessagesError, PostBotsByBotIdCliMessagesResponse, PostBotsByBotIdContainerData, PostBotsByBotIdContainerError, PostBotsByBotIdContainerResponse, PostBotsByBotIdContainerSkillsData, PostBotsByBotIdContainerSkillsError, PostBotsByBotIdContainerSkillsResponse, PostBotsByBotIdContainerSnapshotsData, PostBotsByBotIdContainerSnapshotsError, PostBotsByBotIdContainerSnapshotsResponse, PostBotsByBotIdContainerStartData, PostBotsByBotIdContainerStartError, PostBotsByBotIdContainerStartResponse, PostBotsByBotIdContainerStopData, PostBotsByBotIdContainerStopError, PostBotsByBotIdContainerStopResponse, PostBotsByBotIdMcpData, PostBotsByBotIdMcpError, PostBotsByBotIdMcpOpsBatchDeleteData, PostBotsByBotIdMcpOpsBatchDeleteError, PostBotsByBotIdMcpResponse, PostBotsByBotIdMcpStdioByConnectionIdData, PostBotsByBotIdMcpStdioByConnectionIdError, PostBotsByBotIdMcpStdioByConnectionIdResponse, PostBotsByBotIdMcpStdioData, PostBotsByBotIdMcpStdioError, PostBotsByBotIdMcpStdioResponse, PostBotsByBotIdMemoryCompactData, PostBotsByBotIdMemoryCompactError, PostBotsByBotIdMemoryCompactResponse, PostBotsByBotIdMemoryData, PostBotsByBotIdMemoryError, PostBotsByBotIdMemoryRebuildData, PostBotsByBotIdMemoryRebuildError, PostBotsByBotIdMemoryRebuildResponse, PostBotsByBotIdMemoryResponse, PostBotsByBotIdMemorySearchData, PostBotsByBotIdMemorySearchError, PostBotsByBotIdMemorySearchResponse, PostBotsByBotIdScheduleData, PostBotsByBotIdScheduleError, PostBotsByBotIdScheduleResponse, PostBotsByBotIdSettingsData, PostBotsByBotIdSettingsError, PostBotsByBotIdSettingsResponse, PostBotsByBotIdSubagentsByIdSkillsData, PostBotsByBotIdSubagentsByIdSkillsError, PostBotsByBotIdSubagentsByIdSkillsResponse, PostBotsByBotIdSubagentsData, PostBotsByBotIdSubagentsError, PostBotsByBotIdSubagentsResponse, PostBotsByBotIdToolsData, PostBotsByBotIdToolsError, PostBotsByBotIdToolsResponse, PostBotsByBotIdWebMessagesData, PostBotsByBotIdWebMessagesError, PostBotsByBotIdWebMessagesResponse, PostBotsByIdChannelByPlatformSendChatData, PostBotsByIdChannelByPlatformSendChatError, PostBotsByIdChannelByPlatformSendChatResponse, PostBotsByIdChannelByPlatformSendData, PostBotsByIdChannelByPlatformSendError, PostBotsByIdChannelByPlatformSendResponse, PostBotsData, PostBotsError, PostBotsResponse, PostEmbeddingsData, PostEmbeddingsError, PostEmbeddingsResponse, PostModelsData, PostModelsError, PostModelsResponse, PostProvidersByIdTestData, PostProvidersByIdTestError, PostProvidersByIdTestResponse, PostProvidersData, PostProvidersError, PostProvidersResponse, PostSearchProvidersData, PostSearchProvidersError, PostSearchProvidersResponse, PostUsersData, PostUsersError, PostUsersResponse, PutBotsByBotIdMcpByIdData, PutBotsByBotIdMcpByIdError, PutBotsByBotIdMcpByIdResponse, PutBotsByBotIdMcpImportData, PutBotsByBotIdMcpImportError, PutBotsByBotIdMcpImportResponse, PutBotsByBotIdScheduleByIdData, PutBotsByBotIdScheduleByIdError, PutBotsByBotIdScheduleByIdResponse, PutBotsByBotIdSettingsData, PutBotsByBotIdSettingsError, PutBotsByBotIdSettingsResponse, PutBotsByBotIdSubagentsByIdContextData, PutBotsByBotIdSubagentsByIdContextError, PutBotsByBotIdSubagentsByIdContextResponse, PutBotsByBotIdSubagentsByIdData, PutBotsByBotIdSubagentsByIdError, PutBotsByBotIdSubagentsByIdResponse, PutBotsByBotIdSubagentsByIdSkillsData, PutBotsByBotIdSubagentsByIdSkillsError, PutBotsByBotIdSubagentsByIdSkillsResponse, PutBotsByIdChannelByPlatformData, PutBotsByIdChannelByPlatformError, PutBotsByIdChannelByPlatformResponse, PutBotsByIdData, PutBotsByIdError, PutBotsByIdMembersData, PutBotsByIdMembersError, PutBotsByIdMembersResponse, PutBotsByIdOwnerData, PutBotsByIdOwnerError, PutBotsByIdOwnerResponse, PutBotsByIdResponse, PutModelsByIdData, PutModelsByIdError, PutModelsByIdResponse, PutModelsModelByModelIdData, PutModelsModelByModelIdError, PutModelsModelByModelIdResponse, PutProvidersByIdData, PutProvidersByIdError, PutProvidersByIdResponse, PutSearchProvidersByIdData, PutSearchProvidersByIdError, PutSearchProvidersByIdResponse, PutUsersByIdData, PutUsersByIdError, PutUsersByIdPasswordData, PutUsersByIdPasswordError, PutUsersByIdResponse, PutUsersMeChannelsByPlatformData, PutUsersMeChannelsByPlatformError, PutUsersMeChannelsByPlatformResponse, PutUsersMeData, PutUsersMeError, PutUsersMePasswordData, PutUsersMePasswordError, PutUsersMeResponse } from '../types.gen'; /** * Login @@ -93,6 +93,22 @@ export const postBotsMutation = (options?: Partial>): UseM } }); +/** + * Send a message to a local channel + * + * Post a user message (with optional attachments) through the local channel pipeline. + */ +export const postBotsByBotIdCliMessagesMutation = (options?: Partial>): UseMutationOptions, PostBotsByBotIdCliMessagesError> => ({ + mutation: async (vars) => { + const { data } = await postBotsByBotIdCliMessages({ + ...options, + ...vars, + throwOnError: true + }); + return data; + } +}); + /** * Delete MCP container for bot */ @@ -912,6 +928,22 @@ export const postBotsByBotIdToolsMutation = (options?: Partial>): UseMutationOptions, PostBotsByBotIdWebMessagesError> => ({ + mutation: async (vars) => { + const { data } = await postBotsByBotIdWebMessages({ + ...options, + ...vars, + throwOnError: true + }); + return data; + } +}); + /** * Delete bot * diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index e16c3519..4e29c7ae 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { deleteBotsByBotIdContainer, deleteBotsByBotIdContainerSkills, deleteBotsByBotIdMcpById, deleteBotsByBotIdMemory, deleteBotsByBotIdMemoryById, deleteBotsByBotIdScheduleById, deleteBotsByBotIdSettings, deleteBotsByBotIdSubagentsById, deleteBotsById, deleteBotsByIdChannelByPlatform, deleteBotsByIdMembersByUserId, deleteModelsById, deleteModelsModelByModelId, deleteProvidersById, deleteSearchProvidersById, getBots, getBotsByBotIdContainer, getBotsByBotIdContainerSkills, getBotsByBotIdContainerSnapshots, getBotsByBotIdMcp, getBotsByBotIdMcpById, getBotsByBotIdMcpExport, getBotsByBotIdMemory, getBotsByBotIdMemoryUsage, getBotsByBotIdMessages, getBotsByBotIdSchedule, getBotsByBotIdScheduleById, getBotsByBotIdSettings, getBotsByBotIdSubagents, getBotsByBotIdSubagentsById, getBotsByBotIdSubagentsByIdContext, getBotsByBotIdSubagentsByIdSkills, getBotsById, getBotsByIdChannelByPlatform, getBotsByIdChecks, getBotsByIdMembers, getChannels, getChannelsByPlatform, getModels, getModelsById, getModelsCount, getModelsModelByModelId, getProviders, getProvidersById, getProvidersByIdModels, getProvidersCount, getProvidersNameByName, getSearchProviders, getSearchProvidersById, getSearchProvidersMeta, getUsers, getUsersById, getUsersMe, getUsersMeChannelsByPlatform, getUsersMeIdentities, type Options, patchBotsByIdChannelByPlatformStatus, postAuthLogin, postBots, postBotsByBotIdContainer, postBotsByBotIdContainerSkills, postBotsByBotIdContainerSnapshots, postBotsByBotIdContainerStart, postBotsByBotIdContainerStop, postBotsByBotIdMcp, postBotsByBotIdMcpOpsBatchDelete, postBotsByBotIdMcpStdio, postBotsByBotIdMcpStdioByConnectionId, postBotsByBotIdMemory, postBotsByBotIdMemoryCompact, postBotsByBotIdMemoryRebuild, postBotsByBotIdMemorySearch, postBotsByBotIdSchedule, postBotsByBotIdSettings, postBotsByBotIdSubagents, postBotsByBotIdSubagentsByIdSkills, postBotsByBotIdTools, postBotsByIdChannelByPlatformSend, postBotsByIdChannelByPlatformSendChat, postEmbeddings, postModels, postProviders, postProvidersByIdTest, postSearchProviders, postUsers, putBotsByBotIdMcpById, putBotsByBotIdMcpImport, putBotsByBotIdScheduleById, putBotsByBotIdSettings, putBotsByBotIdSubagentsById, putBotsByBotIdSubagentsByIdContext, putBotsByBotIdSubagentsByIdSkills, putBotsById, putBotsByIdChannelByPlatform, putBotsByIdMembers, putBotsByIdOwner, putModelsById, putModelsModelByModelId, putProvidersById, putSearchProvidersById, putUsersById, putUsersByIdPassword, putUsersMe, putUsersMeChannelsByPlatform, putUsersMePassword } from './sdk.gen'; -export type { AccountsAccount, AccountsCreateAccountRequest, AccountsListAccountsResponse, AccountsResetPasswordRequest, AccountsUpdateAccountRequest, AccountsUpdatePasswordRequest, AccountsUpdateProfileRequest, BotsBot, BotsBotCheck, BotsBotMember, BotsCreateBotRequest, BotsListBotsResponse, BotsListChecksResponse, BotsListMembersResponse, BotsTransferBotRequest, BotsUpdateBotRequest, BotsUpsertMemberRequest, ChannelAction, ChannelAttachment, ChannelAttachmentType, ChannelChannelCapabilities, ChannelChannelConfig, ChannelChannelIdentityBinding, ChannelConfigSchema, ChannelFieldSchema, ChannelFieldType, ChannelMessage, ChannelMessageFormat, ChannelMessagePart, ChannelMessagePartType, ChannelMessageTextStyle, ChannelReplyRef, ChannelSendRequest, ChannelTargetHint, ChannelTargetSpec, ChannelThreadRef, ChannelUpdateChannelStatusRequest, ChannelUpsertChannelIdentityConfigRequest, ChannelUpsertConfigRequest, ClientOptions, DeleteBotsByBotIdContainerData, DeleteBotsByBotIdContainerError, DeleteBotsByBotIdContainerErrors, DeleteBotsByBotIdContainerResponses, DeleteBotsByBotIdContainerSkillsData, DeleteBotsByBotIdContainerSkillsError, DeleteBotsByBotIdContainerSkillsErrors, DeleteBotsByBotIdContainerSkillsResponse, DeleteBotsByBotIdContainerSkillsResponses, DeleteBotsByBotIdMcpByIdData, DeleteBotsByBotIdMcpByIdError, DeleteBotsByBotIdMcpByIdErrors, DeleteBotsByBotIdMcpByIdResponses, DeleteBotsByBotIdMemoryByIdData, DeleteBotsByBotIdMemoryByIdError, DeleteBotsByBotIdMemoryByIdErrors, DeleteBotsByBotIdMemoryByIdResponse, DeleteBotsByBotIdMemoryByIdResponses, DeleteBotsByBotIdMemoryData, DeleteBotsByBotIdMemoryError, DeleteBotsByBotIdMemoryErrors, DeleteBotsByBotIdMemoryResponse, DeleteBotsByBotIdMemoryResponses, DeleteBotsByBotIdScheduleByIdData, DeleteBotsByBotIdScheduleByIdError, DeleteBotsByBotIdScheduleByIdErrors, DeleteBotsByBotIdScheduleByIdResponses, DeleteBotsByBotIdSettingsData, DeleteBotsByBotIdSettingsError, DeleteBotsByBotIdSettingsErrors, DeleteBotsByBotIdSettingsResponses, DeleteBotsByBotIdSubagentsByIdData, DeleteBotsByBotIdSubagentsByIdError, DeleteBotsByBotIdSubagentsByIdErrors, DeleteBotsByBotIdSubagentsByIdResponses, DeleteBotsByIdChannelByPlatformData, DeleteBotsByIdChannelByPlatformError, DeleteBotsByIdChannelByPlatformErrors, DeleteBotsByIdChannelByPlatformResponses, DeleteBotsByIdData, DeleteBotsByIdError, DeleteBotsByIdErrors, DeleteBotsByIdMembersByUserIdData, DeleteBotsByIdMembersByUserIdError, DeleteBotsByIdMembersByUserIdErrors, DeleteBotsByIdMembersByUserIdResponses, DeleteBotsByIdResponse, DeleteBotsByIdResponses, DeleteModelsByIdData, DeleteModelsByIdError, DeleteModelsByIdErrors, DeleteModelsByIdResponses, DeleteModelsModelByModelIdData, DeleteModelsModelByModelIdError, DeleteModelsModelByModelIdErrors, DeleteModelsModelByModelIdResponses, DeleteProvidersByIdData, DeleteProvidersByIdError, DeleteProvidersByIdErrors, DeleteProvidersByIdResponses, DeleteSearchProvidersByIdData, DeleteSearchProvidersByIdError, DeleteSearchProvidersByIdErrors, DeleteSearchProvidersByIdResponses, GetBotsByBotIdContainerData, GetBotsByBotIdContainerError, GetBotsByBotIdContainerErrors, GetBotsByBotIdContainerResponse, GetBotsByBotIdContainerResponses, GetBotsByBotIdContainerSkillsData, GetBotsByBotIdContainerSkillsError, GetBotsByBotIdContainerSkillsErrors, GetBotsByBotIdContainerSkillsResponse, GetBotsByBotIdContainerSkillsResponses, GetBotsByBotIdContainerSnapshotsData, GetBotsByBotIdContainerSnapshotsResponse, GetBotsByBotIdContainerSnapshotsResponses, GetBotsByBotIdMcpByIdData, GetBotsByBotIdMcpByIdError, GetBotsByBotIdMcpByIdErrors, GetBotsByBotIdMcpByIdResponse, GetBotsByBotIdMcpByIdResponses, GetBotsByBotIdMcpData, GetBotsByBotIdMcpError, GetBotsByBotIdMcpErrors, GetBotsByBotIdMcpExportData, GetBotsByBotIdMcpExportError, GetBotsByBotIdMcpExportErrors, GetBotsByBotIdMcpExportResponse, GetBotsByBotIdMcpExportResponses, GetBotsByBotIdMcpResponse, GetBotsByBotIdMcpResponses, GetBotsByBotIdMemoryData, GetBotsByBotIdMemoryError, GetBotsByBotIdMemoryErrors, GetBotsByBotIdMemoryResponse, GetBotsByBotIdMemoryResponses, GetBotsByBotIdMemoryUsageData, GetBotsByBotIdMemoryUsageError, GetBotsByBotIdMemoryUsageErrors, GetBotsByBotIdMemoryUsageResponse, GetBotsByBotIdMemoryUsageResponses, GetBotsByBotIdMessagesData, GetBotsByBotIdMessagesError, GetBotsByBotIdMessagesErrors, GetBotsByBotIdMessagesResponse, GetBotsByBotIdMessagesResponses, GetBotsByBotIdScheduleByIdData, GetBotsByBotIdScheduleByIdError, GetBotsByBotIdScheduleByIdErrors, GetBotsByBotIdScheduleByIdResponse, GetBotsByBotIdScheduleByIdResponses, GetBotsByBotIdScheduleData, GetBotsByBotIdScheduleError, GetBotsByBotIdScheduleErrors, GetBotsByBotIdScheduleResponse, GetBotsByBotIdScheduleResponses, GetBotsByBotIdSettingsData, GetBotsByBotIdSettingsError, GetBotsByBotIdSettingsErrors, GetBotsByBotIdSettingsResponse, GetBotsByBotIdSettingsResponses, GetBotsByBotIdSubagentsByIdContextData, GetBotsByBotIdSubagentsByIdContextError, GetBotsByBotIdSubagentsByIdContextErrors, GetBotsByBotIdSubagentsByIdContextResponse, GetBotsByBotIdSubagentsByIdContextResponses, GetBotsByBotIdSubagentsByIdData, GetBotsByBotIdSubagentsByIdError, GetBotsByBotIdSubagentsByIdErrors, GetBotsByBotIdSubagentsByIdResponse, GetBotsByBotIdSubagentsByIdResponses, GetBotsByBotIdSubagentsByIdSkillsData, GetBotsByBotIdSubagentsByIdSkillsError, GetBotsByBotIdSubagentsByIdSkillsErrors, GetBotsByBotIdSubagentsByIdSkillsResponse, GetBotsByBotIdSubagentsByIdSkillsResponses, GetBotsByBotIdSubagentsData, GetBotsByBotIdSubagentsError, GetBotsByBotIdSubagentsErrors, GetBotsByBotIdSubagentsResponse, GetBotsByBotIdSubagentsResponses, GetBotsByIdChannelByPlatformData, GetBotsByIdChannelByPlatformError, GetBotsByIdChannelByPlatformErrors, GetBotsByIdChannelByPlatformResponse, GetBotsByIdChannelByPlatformResponses, GetBotsByIdChecksData, GetBotsByIdChecksError, GetBotsByIdChecksErrors, GetBotsByIdChecksResponse, GetBotsByIdChecksResponses, GetBotsByIdData, GetBotsByIdError, GetBotsByIdErrors, GetBotsByIdMembersData, GetBotsByIdMembersError, GetBotsByIdMembersErrors, GetBotsByIdMembersResponse, GetBotsByIdMembersResponses, GetBotsByIdResponse, GetBotsByIdResponses, GetBotsData, GetBotsError, GetBotsErrors, GetBotsResponse, GetBotsResponses, GetChannelsByPlatformData, GetChannelsByPlatformError, GetChannelsByPlatformErrors, GetChannelsByPlatformResponse, GetChannelsByPlatformResponses, GetChannelsData, GetChannelsError, GetChannelsErrors, GetChannelsResponse, GetChannelsResponses, GetModelsByIdData, GetModelsByIdError, GetModelsByIdErrors, GetModelsByIdResponse, GetModelsByIdResponses, GetModelsCountData, GetModelsCountError, GetModelsCountErrors, GetModelsCountResponse, GetModelsCountResponses, GetModelsData, GetModelsError, GetModelsErrors, GetModelsModelByModelIdData, GetModelsModelByModelIdError, GetModelsModelByModelIdErrors, GetModelsModelByModelIdResponse, GetModelsModelByModelIdResponses, GetModelsResponse, GetModelsResponses, GetProvidersByIdData, GetProvidersByIdError, GetProvidersByIdErrors, GetProvidersByIdModelsData, GetProvidersByIdModelsError, GetProvidersByIdModelsErrors, GetProvidersByIdModelsResponse, GetProvidersByIdModelsResponses, GetProvidersByIdResponse, GetProvidersByIdResponses, GetProvidersCountData, GetProvidersCountError, GetProvidersCountErrors, GetProvidersCountResponse, GetProvidersCountResponses, GetProvidersData, GetProvidersError, GetProvidersErrors, GetProvidersNameByNameData, GetProvidersNameByNameError, GetProvidersNameByNameErrors, GetProvidersNameByNameResponse, GetProvidersNameByNameResponses, GetProvidersResponse, GetProvidersResponses, GetSearchProvidersByIdData, GetSearchProvidersByIdError, GetSearchProvidersByIdErrors, GetSearchProvidersByIdResponse, GetSearchProvidersByIdResponses, GetSearchProvidersData, GetSearchProvidersError, GetSearchProvidersErrors, GetSearchProvidersMetaData, GetSearchProvidersMetaResponse, GetSearchProvidersMetaResponses, GetSearchProvidersResponse, GetSearchProvidersResponses, GetUsersByIdData, GetUsersByIdError, GetUsersByIdErrors, GetUsersByIdResponse, GetUsersByIdResponses, GetUsersData, GetUsersError, GetUsersErrors, GetUsersMeChannelsByPlatformData, GetUsersMeChannelsByPlatformError, GetUsersMeChannelsByPlatformErrors, GetUsersMeChannelsByPlatformResponse, GetUsersMeChannelsByPlatformResponses, GetUsersMeData, GetUsersMeError, GetUsersMeErrors, GetUsersMeIdentitiesData, GetUsersMeIdentitiesError, GetUsersMeIdentitiesErrors, GetUsersMeIdentitiesResponse, GetUsersMeIdentitiesResponses, GetUsersMeResponse, GetUsersMeResponses, GetUsersResponse, GetUsersResponses, GithubComMemohaiMemohInternalMcpConnection, HandlersBatchDeleteRequest, HandlersChannelMeta, HandlersCreateContainerRequest, HandlersCreateContainerResponse, HandlersCreateSnapshotRequest, HandlersCreateSnapshotResponse, HandlersEmbeddingsInput, HandlersEmbeddingsRequest, HandlersEmbeddingsResponse, HandlersEmbeddingsUsage, HandlersErrorResponse, HandlersGetContainerResponse, HandlersListMyIdentitiesResponse, HandlersListSnapshotsResponse, HandlersLoginRequest, HandlersLoginResponse, HandlersMcpStdioRequest, HandlersMcpStdioResponse, HandlersMemoryAddPayload, HandlersMemoryCompactPayload, HandlersMemoryDeletePayload, HandlersMemorySearchPayload, HandlersSkillItem, HandlersSkillsDeleteRequest, HandlersSkillsOpResponse, HandlersSkillsResponse, HandlersSkillsUpsertRequest, HandlersSnapshotInfo, IdentitiesChannelIdentity, McpExportResponse, McpImportRequest, McpListResponse, McpMcpServerEntry, McpUpsertRequest, MemoryCdfPoint, MemoryCompactResult, MemoryDeleteResponse, MemoryMemoryItem, MemoryMessage, MemoryRebuildResult, MemorySearchResponse, MemoryTopKBucket, MemoryUsageResponse, MessageMessage, MessageMessageAsset, ModelsAddRequest, ModelsAddResponse, ModelsClientType, ModelsCountResponse, ModelsGetResponse, ModelsModelType, ModelsUpdateRequest, PatchBotsByIdChannelByPlatformStatusData, PatchBotsByIdChannelByPlatformStatusError, PatchBotsByIdChannelByPlatformStatusErrors, PatchBotsByIdChannelByPlatformStatusResponse, PatchBotsByIdChannelByPlatformStatusResponses, PostAuthLoginData, PostAuthLoginError, PostAuthLoginErrors, PostAuthLoginResponse, PostAuthLoginResponses, PostBotsByBotIdContainerData, PostBotsByBotIdContainerError, PostBotsByBotIdContainerErrors, PostBotsByBotIdContainerResponse, PostBotsByBotIdContainerResponses, PostBotsByBotIdContainerSkillsData, PostBotsByBotIdContainerSkillsError, PostBotsByBotIdContainerSkillsErrors, PostBotsByBotIdContainerSkillsResponse, PostBotsByBotIdContainerSkillsResponses, PostBotsByBotIdContainerSnapshotsData, PostBotsByBotIdContainerSnapshotsError, PostBotsByBotIdContainerSnapshotsErrors, PostBotsByBotIdContainerSnapshotsResponse, PostBotsByBotIdContainerSnapshotsResponses, PostBotsByBotIdContainerStartData, PostBotsByBotIdContainerStartError, PostBotsByBotIdContainerStartErrors, PostBotsByBotIdContainerStartResponse, PostBotsByBotIdContainerStartResponses, PostBotsByBotIdContainerStopData, PostBotsByBotIdContainerStopError, PostBotsByBotIdContainerStopErrors, PostBotsByBotIdContainerStopResponse, PostBotsByBotIdContainerStopResponses, PostBotsByBotIdMcpData, PostBotsByBotIdMcpError, PostBotsByBotIdMcpErrors, PostBotsByBotIdMcpOpsBatchDeleteData, PostBotsByBotIdMcpOpsBatchDeleteError, PostBotsByBotIdMcpOpsBatchDeleteErrors, PostBotsByBotIdMcpOpsBatchDeleteResponses, PostBotsByBotIdMcpResponse, PostBotsByBotIdMcpResponses, PostBotsByBotIdMcpStdioByConnectionIdData, PostBotsByBotIdMcpStdioByConnectionIdError, PostBotsByBotIdMcpStdioByConnectionIdErrors, PostBotsByBotIdMcpStdioByConnectionIdResponse, PostBotsByBotIdMcpStdioByConnectionIdResponses, PostBotsByBotIdMcpStdioData, PostBotsByBotIdMcpStdioError, PostBotsByBotIdMcpStdioErrors, PostBotsByBotIdMcpStdioResponse, PostBotsByBotIdMcpStdioResponses, PostBotsByBotIdMemoryCompactData, PostBotsByBotIdMemoryCompactError, PostBotsByBotIdMemoryCompactErrors, PostBotsByBotIdMemoryCompactResponse, PostBotsByBotIdMemoryCompactResponses, PostBotsByBotIdMemoryData, PostBotsByBotIdMemoryError, PostBotsByBotIdMemoryErrors, PostBotsByBotIdMemoryRebuildData, PostBotsByBotIdMemoryRebuildError, PostBotsByBotIdMemoryRebuildErrors, PostBotsByBotIdMemoryRebuildResponse, PostBotsByBotIdMemoryRebuildResponses, PostBotsByBotIdMemoryResponse, PostBotsByBotIdMemoryResponses, PostBotsByBotIdMemorySearchData, PostBotsByBotIdMemorySearchError, PostBotsByBotIdMemorySearchErrors, PostBotsByBotIdMemorySearchResponse, PostBotsByBotIdMemorySearchResponses, PostBotsByBotIdScheduleData, PostBotsByBotIdScheduleError, PostBotsByBotIdScheduleErrors, PostBotsByBotIdScheduleResponse, PostBotsByBotIdScheduleResponses, PostBotsByBotIdSettingsData, PostBotsByBotIdSettingsError, PostBotsByBotIdSettingsErrors, PostBotsByBotIdSettingsResponse, PostBotsByBotIdSettingsResponses, PostBotsByBotIdSubagentsByIdSkillsData, PostBotsByBotIdSubagentsByIdSkillsError, PostBotsByBotIdSubagentsByIdSkillsErrors, PostBotsByBotIdSubagentsByIdSkillsResponse, PostBotsByBotIdSubagentsByIdSkillsResponses, PostBotsByBotIdSubagentsData, PostBotsByBotIdSubagentsError, PostBotsByBotIdSubagentsErrors, PostBotsByBotIdSubagentsResponse, PostBotsByBotIdSubagentsResponses, PostBotsByBotIdToolsData, PostBotsByBotIdToolsError, PostBotsByBotIdToolsErrors, PostBotsByBotIdToolsResponse, PostBotsByBotIdToolsResponses, PostBotsByIdChannelByPlatformSendChatData, PostBotsByIdChannelByPlatformSendChatError, PostBotsByIdChannelByPlatformSendChatErrors, PostBotsByIdChannelByPlatformSendChatResponse, PostBotsByIdChannelByPlatformSendChatResponses, PostBotsByIdChannelByPlatformSendData, PostBotsByIdChannelByPlatformSendError, PostBotsByIdChannelByPlatformSendErrors, PostBotsByIdChannelByPlatformSendResponse, PostBotsByIdChannelByPlatformSendResponses, PostBotsData, PostBotsError, PostBotsErrors, PostBotsResponse, PostBotsResponses, PostEmbeddingsData, PostEmbeddingsError, PostEmbeddingsErrors, PostEmbeddingsResponse, PostEmbeddingsResponses, PostModelsData, PostModelsError, PostModelsErrors, PostModelsResponse, PostModelsResponses, PostProvidersByIdTestData, PostProvidersByIdTestError, PostProvidersByIdTestErrors, PostProvidersByIdTestResponse, PostProvidersByIdTestResponses, PostProvidersData, PostProvidersError, PostProvidersErrors, PostProvidersResponse, PostProvidersResponses, PostSearchProvidersData, PostSearchProvidersError, PostSearchProvidersErrors, PostSearchProvidersResponse, PostSearchProvidersResponses, PostUsersData, PostUsersError, PostUsersErrors, PostUsersResponse, PostUsersResponses, ProvidersCheckResult, ProvidersCheckStatus, ProvidersCountResponse, ProvidersCreateRequest, ProvidersGetResponse, ProvidersTestResponse, ProvidersUpdateRequest, PutBotsByBotIdMcpByIdData, PutBotsByBotIdMcpByIdError, PutBotsByBotIdMcpByIdErrors, PutBotsByBotIdMcpByIdResponse, PutBotsByBotIdMcpByIdResponses, PutBotsByBotIdMcpImportData, PutBotsByBotIdMcpImportError, PutBotsByBotIdMcpImportErrors, PutBotsByBotIdMcpImportResponse, PutBotsByBotIdMcpImportResponses, PutBotsByBotIdScheduleByIdData, PutBotsByBotIdScheduleByIdError, PutBotsByBotIdScheduleByIdErrors, PutBotsByBotIdScheduleByIdResponse, PutBotsByBotIdScheduleByIdResponses, PutBotsByBotIdSettingsData, PutBotsByBotIdSettingsError, PutBotsByBotIdSettingsErrors, PutBotsByBotIdSettingsResponse, PutBotsByBotIdSettingsResponses, PutBotsByBotIdSubagentsByIdContextData, PutBotsByBotIdSubagentsByIdContextError, PutBotsByBotIdSubagentsByIdContextErrors, PutBotsByBotIdSubagentsByIdContextResponse, PutBotsByBotIdSubagentsByIdContextResponses, PutBotsByBotIdSubagentsByIdData, PutBotsByBotIdSubagentsByIdError, PutBotsByBotIdSubagentsByIdErrors, PutBotsByBotIdSubagentsByIdResponse, PutBotsByBotIdSubagentsByIdResponses, PutBotsByBotIdSubagentsByIdSkillsData, PutBotsByBotIdSubagentsByIdSkillsError, PutBotsByBotIdSubagentsByIdSkillsErrors, PutBotsByBotIdSubagentsByIdSkillsResponse, PutBotsByBotIdSubagentsByIdSkillsResponses, PutBotsByIdChannelByPlatformData, PutBotsByIdChannelByPlatformError, PutBotsByIdChannelByPlatformErrors, PutBotsByIdChannelByPlatformResponse, PutBotsByIdChannelByPlatformResponses, PutBotsByIdData, PutBotsByIdError, PutBotsByIdErrors, PutBotsByIdMembersData, PutBotsByIdMembersError, PutBotsByIdMembersErrors, PutBotsByIdMembersResponse, PutBotsByIdMembersResponses, PutBotsByIdOwnerData, PutBotsByIdOwnerError, PutBotsByIdOwnerErrors, PutBotsByIdOwnerResponse, PutBotsByIdOwnerResponses, PutBotsByIdResponse, PutBotsByIdResponses, PutModelsByIdData, PutModelsByIdError, PutModelsByIdErrors, PutModelsByIdResponse, PutModelsByIdResponses, PutModelsModelByModelIdData, PutModelsModelByModelIdError, PutModelsModelByModelIdErrors, PutModelsModelByModelIdResponse, PutModelsModelByModelIdResponses, PutProvidersByIdData, PutProvidersByIdError, PutProvidersByIdErrors, PutProvidersByIdResponse, PutProvidersByIdResponses, PutSearchProvidersByIdData, PutSearchProvidersByIdError, PutSearchProvidersByIdErrors, PutSearchProvidersByIdResponse, PutSearchProvidersByIdResponses, PutUsersByIdData, PutUsersByIdError, PutUsersByIdErrors, PutUsersByIdPasswordData, PutUsersByIdPasswordError, PutUsersByIdPasswordErrors, PutUsersByIdPasswordResponses, PutUsersByIdResponse, PutUsersByIdResponses, PutUsersMeChannelsByPlatformData, PutUsersMeChannelsByPlatformError, PutUsersMeChannelsByPlatformErrors, PutUsersMeChannelsByPlatformResponse, PutUsersMeChannelsByPlatformResponses, PutUsersMeData, PutUsersMeError, PutUsersMeErrors, PutUsersMePasswordData, PutUsersMePasswordError, PutUsersMePasswordErrors, PutUsersMePasswordResponses, PutUsersMeResponse, PutUsersMeResponses, ScheduleCreateRequest, ScheduleListResponse, ScheduleNullableInt, ScheduleSchedule, ScheduleUpdateRequest, SearchprovidersCreateRequest, SearchprovidersGetResponse, SearchprovidersProviderConfigSchema, SearchprovidersProviderFieldSchema, SearchprovidersProviderMeta, SearchprovidersProviderName, SearchprovidersUpdateRequest, SettingsSettings, SettingsUpsertRequest, SubagentAddSkillsRequest, SubagentContextResponse, SubagentCreateRequest, SubagentListResponse, SubagentSkillsResponse, SubagentSubagent, SubagentUpdateContextRequest, SubagentUpdateRequest, SubagentUpdateSkillsRequest } from './types.gen'; +export { deleteBotsByBotIdContainer, deleteBotsByBotIdContainerSkills, deleteBotsByBotIdMcpById, deleteBotsByBotIdMemory, deleteBotsByBotIdMemoryById, deleteBotsByBotIdScheduleById, deleteBotsByBotIdSettings, deleteBotsByBotIdSubagentsById, deleteBotsById, deleteBotsByIdChannelByPlatform, deleteBotsByIdMembersByUserId, deleteModelsById, deleteModelsModelByModelId, deleteProvidersById, deleteSearchProvidersById, getBots, getBotsByBotIdCliStream, getBotsByBotIdContainer, getBotsByBotIdContainerSkills, getBotsByBotIdContainerSnapshots, getBotsByBotIdMcp, getBotsByBotIdMcpById, getBotsByBotIdMcpExport, getBotsByBotIdMemory, getBotsByBotIdMemoryUsage, getBotsByBotIdMessages, getBotsByBotIdSchedule, getBotsByBotIdScheduleById, getBotsByBotIdSettings, getBotsByBotIdSubagents, getBotsByBotIdSubagentsById, getBotsByBotIdSubagentsByIdContext, getBotsByBotIdSubagentsByIdSkills, getBotsByBotIdWebStream, getBotsById, getBotsByIdChannelByPlatform, getBotsByIdChecks, getBotsByIdMembers, getChannels, getChannelsByPlatform, getModels, getModelsById, getModelsCount, getModelsModelByModelId, getProviders, getProvidersById, getProvidersByIdModels, getProvidersCount, getProvidersNameByName, getSearchProviders, getSearchProvidersById, getSearchProvidersMeta, getUsers, getUsersById, getUsersMe, getUsersMeChannelsByPlatform, getUsersMeIdentities, type Options, patchBotsByIdChannelByPlatformStatus, postAuthLogin, postBots, postBotsByBotIdCliMessages, postBotsByBotIdContainer, postBotsByBotIdContainerSkills, postBotsByBotIdContainerSnapshots, postBotsByBotIdContainerStart, postBotsByBotIdContainerStop, postBotsByBotIdMcp, postBotsByBotIdMcpOpsBatchDelete, postBotsByBotIdMcpStdio, postBotsByBotIdMcpStdioByConnectionId, postBotsByBotIdMemory, postBotsByBotIdMemoryCompact, postBotsByBotIdMemoryRebuild, postBotsByBotIdMemorySearch, postBotsByBotIdSchedule, postBotsByBotIdSettings, postBotsByBotIdSubagents, postBotsByBotIdSubagentsByIdSkills, postBotsByBotIdTools, postBotsByBotIdWebMessages, postBotsByIdChannelByPlatformSend, postBotsByIdChannelByPlatformSendChat, postEmbeddings, postModels, postProviders, postProvidersByIdTest, postSearchProviders, postUsers, putBotsByBotIdMcpById, putBotsByBotIdMcpImport, putBotsByBotIdScheduleById, putBotsByBotIdSettings, putBotsByBotIdSubagentsById, putBotsByBotIdSubagentsByIdContext, putBotsByBotIdSubagentsByIdSkills, putBotsById, putBotsByIdChannelByPlatform, putBotsByIdMembers, putBotsByIdOwner, putModelsById, putModelsModelByModelId, putProvidersById, putSearchProvidersById, putUsersById, putUsersByIdPassword, putUsersMe, putUsersMeChannelsByPlatform, putUsersMePassword } from './sdk.gen'; +export type { AccountsAccount, AccountsCreateAccountRequest, AccountsListAccountsResponse, AccountsResetPasswordRequest, AccountsUpdateAccountRequest, AccountsUpdatePasswordRequest, AccountsUpdateProfileRequest, BotsBot, BotsBotCheck, BotsBotMember, BotsCreateBotRequest, BotsListBotsResponse, BotsListChecksResponse, BotsListMembersResponse, BotsTransferBotRequest, BotsUpdateBotRequest, BotsUpsertMemberRequest, ChannelAction, ChannelAttachment, ChannelAttachmentType, ChannelChannelCapabilities, ChannelChannelConfig, ChannelChannelIdentityBinding, ChannelConfigSchema, ChannelFieldSchema, ChannelFieldType, ChannelMessage, ChannelMessageFormat, ChannelMessagePart, ChannelMessagePartType, ChannelMessageTextStyle, ChannelReplyRef, ChannelSendRequest, ChannelTargetHint, ChannelTargetSpec, ChannelThreadRef, ChannelUpdateChannelStatusRequest, ChannelUpsertChannelIdentityConfigRequest, ChannelUpsertConfigRequest, ClientOptions, DeleteBotsByBotIdContainerData, DeleteBotsByBotIdContainerError, DeleteBotsByBotIdContainerErrors, DeleteBotsByBotIdContainerResponses, DeleteBotsByBotIdContainerSkillsData, DeleteBotsByBotIdContainerSkillsError, DeleteBotsByBotIdContainerSkillsErrors, DeleteBotsByBotIdContainerSkillsResponse, DeleteBotsByBotIdContainerSkillsResponses, DeleteBotsByBotIdMcpByIdData, DeleteBotsByBotIdMcpByIdError, DeleteBotsByBotIdMcpByIdErrors, DeleteBotsByBotIdMcpByIdResponses, DeleteBotsByBotIdMemoryByIdData, DeleteBotsByBotIdMemoryByIdError, DeleteBotsByBotIdMemoryByIdErrors, DeleteBotsByBotIdMemoryByIdResponse, DeleteBotsByBotIdMemoryByIdResponses, DeleteBotsByBotIdMemoryData, DeleteBotsByBotIdMemoryError, DeleteBotsByBotIdMemoryErrors, DeleteBotsByBotIdMemoryResponse, DeleteBotsByBotIdMemoryResponses, DeleteBotsByBotIdScheduleByIdData, DeleteBotsByBotIdScheduleByIdError, DeleteBotsByBotIdScheduleByIdErrors, DeleteBotsByBotIdScheduleByIdResponses, DeleteBotsByBotIdSettingsData, DeleteBotsByBotIdSettingsError, DeleteBotsByBotIdSettingsErrors, DeleteBotsByBotIdSettingsResponses, DeleteBotsByBotIdSubagentsByIdData, DeleteBotsByBotIdSubagentsByIdError, DeleteBotsByBotIdSubagentsByIdErrors, DeleteBotsByBotIdSubagentsByIdResponses, DeleteBotsByIdChannelByPlatformData, DeleteBotsByIdChannelByPlatformError, DeleteBotsByIdChannelByPlatformErrors, DeleteBotsByIdChannelByPlatformResponses, DeleteBotsByIdData, DeleteBotsByIdError, DeleteBotsByIdErrors, DeleteBotsByIdMembersByUserIdData, DeleteBotsByIdMembersByUserIdError, DeleteBotsByIdMembersByUserIdErrors, DeleteBotsByIdMembersByUserIdResponses, DeleteBotsByIdResponse, DeleteBotsByIdResponses, DeleteModelsByIdData, DeleteModelsByIdError, DeleteModelsByIdErrors, DeleteModelsByIdResponses, DeleteModelsModelByModelIdData, DeleteModelsModelByModelIdError, DeleteModelsModelByModelIdErrors, DeleteModelsModelByModelIdResponses, DeleteProvidersByIdData, DeleteProvidersByIdError, DeleteProvidersByIdErrors, DeleteProvidersByIdResponses, DeleteSearchProvidersByIdData, DeleteSearchProvidersByIdError, DeleteSearchProvidersByIdErrors, DeleteSearchProvidersByIdResponses, GetBotsByBotIdCliStreamData, GetBotsByBotIdCliStreamError, GetBotsByBotIdCliStreamErrors, GetBotsByBotIdCliStreamResponse, GetBotsByBotIdCliStreamResponses, GetBotsByBotIdContainerData, GetBotsByBotIdContainerError, GetBotsByBotIdContainerErrors, GetBotsByBotIdContainerResponse, GetBotsByBotIdContainerResponses, GetBotsByBotIdContainerSkillsData, GetBotsByBotIdContainerSkillsError, GetBotsByBotIdContainerSkillsErrors, GetBotsByBotIdContainerSkillsResponse, GetBotsByBotIdContainerSkillsResponses, GetBotsByBotIdContainerSnapshotsData, GetBotsByBotIdContainerSnapshotsResponse, GetBotsByBotIdContainerSnapshotsResponses, GetBotsByBotIdMcpByIdData, GetBotsByBotIdMcpByIdError, GetBotsByBotIdMcpByIdErrors, GetBotsByBotIdMcpByIdResponse, GetBotsByBotIdMcpByIdResponses, GetBotsByBotIdMcpData, GetBotsByBotIdMcpError, GetBotsByBotIdMcpErrors, GetBotsByBotIdMcpExportData, GetBotsByBotIdMcpExportError, GetBotsByBotIdMcpExportErrors, GetBotsByBotIdMcpExportResponse, GetBotsByBotIdMcpExportResponses, GetBotsByBotIdMcpResponse, GetBotsByBotIdMcpResponses, GetBotsByBotIdMemoryData, GetBotsByBotIdMemoryError, GetBotsByBotIdMemoryErrors, GetBotsByBotIdMemoryResponse, GetBotsByBotIdMemoryResponses, GetBotsByBotIdMemoryUsageData, GetBotsByBotIdMemoryUsageError, GetBotsByBotIdMemoryUsageErrors, GetBotsByBotIdMemoryUsageResponse, GetBotsByBotIdMemoryUsageResponses, GetBotsByBotIdMessagesData, GetBotsByBotIdMessagesError, GetBotsByBotIdMessagesErrors, GetBotsByBotIdMessagesResponse, GetBotsByBotIdMessagesResponses, GetBotsByBotIdScheduleByIdData, GetBotsByBotIdScheduleByIdError, GetBotsByBotIdScheduleByIdErrors, GetBotsByBotIdScheduleByIdResponse, GetBotsByBotIdScheduleByIdResponses, GetBotsByBotIdScheduleData, GetBotsByBotIdScheduleError, GetBotsByBotIdScheduleErrors, GetBotsByBotIdScheduleResponse, GetBotsByBotIdScheduleResponses, GetBotsByBotIdSettingsData, GetBotsByBotIdSettingsError, GetBotsByBotIdSettingsErrors, GetBotsByBotIdSettingsResponse, GetBotsByBotIdSettingsResponses, GetBotsByBotIdSubagentsByIdContextData, GetBotsByBotIdSubagentsByIdContextError, GetBotsByBotIdSubagentsByIdContextErrors, GetBotsByBotIdSubagentsByIdContextResponse, GetBotsByBotIdSubagentsByIdContextResponses, GetBotsByBotIdSubagentsByIdData, GetBotsByBotIdSubagentsByIdError, GetBotsByBotIdSubagentsByIdErrors, GetBotsByBotIdSubagentsByIdResponse, GetBotsByBotIdSubagentsByIdResponses, GetBotsByBotIdSubagentsByIdSkillsData, GetBotsByBotIdSubagentsByIdSkillsError, GetBotsByBotIdSubagentsByIdSkillsErrors, GetBotsByBotIdSubagentsByIdSkillsResponse, GetBotsByBotIdSubagentsByIdSkillsResponses, GetBotsByBotIdSubagentsData, GetBotsByBotIdSubagentsError, GetBotsByBotIdSubagentsErrors, GetBotsByBotIdSubagentsResponse, GetBotsByBotIdSubagentsResponses, GetBotsByBotIdWebStreamData, GetBotsByBotIdWebStreamError, GetBotsByBotIdWebStreamErrors, GetBotsByBotIdWebStreamResponse, GetBotsByBotIdWebStreamResponses, GetBotsByIdChannelByPlatformData, GetBotsByIdChannelByPlatformError, GetBotsByIdChannelByPlatformErrors, GetBotsByIdChannelByPlatformResponse, GetBotsByIdChannelByPlatformResponses, GetBotsByIdChecksData, GetBotsByIdChecksError, GetBotsByIdChecksErrors, GetBotsByIdChecksResponse, GetBotsByIdChecksResponses, GetBotsByIdData, GetBotsByIdError, GetBotsByIdErrors, GetBotsByIdMembersData, GetBotsByIdMembersError, GetBotsByIdMembersErrors, GetBotsByIdMembersResponse, GetBotsByIdMembersResponses, GetBotsByIdResponse, GetBotsByIdResponses, GetBotsData, GetBotsError, GetBotsErrors, GetBotsResponse, GetBotsResponses, GetChannelsByPlatformData, GetChannelsByPlatformError, GetChannelsByPlatformErrors, GetChannelsByPlatformResponse, GetChannelsByPlatformResponses, GetChannelsData, GetChannelsError, GetChannelsErrors, GetChannelsResponse, GetChannelsResponses, GetModelsByIdData, GetModelsByIdError, GetModelsByIdErrors, GetModelsByIdResponse, GetModelsByIdResponses, GetModelsCountData, GetModelsCountError, GetModelsCountErrors, GetModelsCountResponse, GetModelsCountResponses, GetModelsData, GetModelsError, GetModelsErrors, GetModelsModelByModelIdData, GetModelsModelByModelIdError, GetModelsModelByModelIdErrors, GetModelsModelByModelIdResponse, GetModelsModelByModelIdResponses, GetModelsResponse, GetModelsResponses, GetProvidersByIdData, GetProvidersByIdError, GetProvidersByIdErrors, GetProvidersByIdModelsData, GetProvidersByIdModelsError, GetProvidersByIdModelsErrors, GetProvidersByIdModelsResponse, GetProvidersByIdModelsResponses, GetProvidersByIdResponse, GetProvidersByIdResponses, GetProvidersCountData, GetProvidersCountError, GetProvidersCountErrors, GetProvidersCountResponse, GetProvidersCountResponses, GetProvidersData, GetProvidersError, GetProvidersErrors, GetProvidersNameByNameData, GetProvidersNameByNameError, GetProvidersNameByNameErrors, GetProvidersNameByNameResponse, GetProvidersNameByNameResponses, GetProvidersResponse, GetProvidersResponses, GetSearchProvidersByIdData, GetSearchProvidersByIdError, GetSearchProvidersByIdErrors, GetSearchProvidersByIdResponse, GetSearchProvidersByIdResponses, GetSearchProvidersData, GetSearchProvidersError, GetSearchProvidersErrors, GetSearchProvidersMetaData, GetSearchProvidersMetaResponse, GetSearchProvidersMetaResponses, GetSearchProvidersResponse, GetSearchProvidersResponses, GetUsersByIdData, GetUsersByIdError, GetUsersByIdErrors, GetUsersByIdResponse, GetUsersByIdResponses, GetUsersData, GetUsersError, GetUsersErrors, GetUsersMeChannelsByPlatformData, GetUsersMeChannelsByPlatformError, GetUsersMeChannelsByPlatformErrors, GetUsersMeChannelsByPlatformResponse, GetUsersMeChannelsByPlatformResponses, GetUsersMeData, GetUsersMeError, GetUsersMeErrors, GetUsersMeIdentitiesData, GetUsersMeIdentitiesError, GetUsersMeIdentitiesErrors, GetUsersMeIdentitiesResponse, GetUsersMeIdentitiesResponses, GetUsersMeResponse, GetUsersMeResponses, GetUsersResponse, GetUsersResponses, GithubComMemohaiMemohInternalMcpConnection, HandlersBatchDeleteRequest, HandlersChannelMeta, HandlersCreateContainerRequest, HandlersCreateContainerResponse, HandlersCreateSnapshotRequest, HandlersCreateSnapshotResponse, HandlersEmbeddingsInput, HandlersEmbeddingsRequest, HandlersEmbeddingsResponse, HandlersEmbeddingsUsage, HandlersErrorResponse, HandlersGetContainerResponse, HandlersListMyIdentitiesResponse, HandlersListSnapshotsResponse, HandlersLocalChannelMessageRequest, HandlersLoginRequest, HandlersLoginResponse, HandlersMcpStdioRequest, HandlersMcpStdioResponse, HandlersMemoryAddPayload, HandlersMemoryCompactPayload, HandlersMemoryDeletePayload, HandlersMemorySearchPayload, HandlersSkillItem, HandlersSkillsDeleteRequest, HandlersSkillsOpResponse, HandlersSkillsResponse, HandlersSkillsUpsertRequest, HandlersSnapshotInfo, IdentitiesChannelIdentity, McpExportResponse, McpImportRequest, McpListResponse, McpMcpServerEntry, McpUpsertRequest, MemoryCdfPoint, MemoryCompactResult, MemoryDeleteResponse, MemoryMemoryItem, MemoryMessage, MemoryRebuildResult, MemorySearchResponse, MemoryTopKBucket, MemoryUsageResponse, MessageMessage, MessageMessageAsset, ModelsAddRequest, ModelsAddResponse, ModelsClientType, ModelsCountResponse, ModelsGetResponse, ModelsModelType, ModelsUpdateRequest, PatchBotsByIdChannelByPlatformStatusData, PatchBotsByIdChannelByPlatformStatusError, PatchBotsByIdChannelByPlatformStatusErrors, PatchBotsByIdChannelByPlatformStatusResponse, PatchBotsByIdChannelByPlatformStatusResponses, PostAuthLoginData, PostAuthLoginError, PostAuthLoginErrors, PostAuthLoginResponse, PostAuthLoginResponses, PostBotsByBotIdCliMessagesData, PostBotsByBotIdCliMessagesError, PostBotsByBotIdCliMessagesErrors, PostBotsByBotIdCliMessagesResponse, PostBotsByBotIdCliMessagesResponses, PostBotsByBotIdContainerData, PostBotsByBotIdContainerError, PostBotsByBotIdContainerErrors, PostBotsByBotIdContainerResponse, PostBotsByBotIdContainerResponses, PostBotsByBotIdContainerSkillsData, PostBotsByBotIdContainerSkillsError, PostBotsByBotIdContainerSkillsErrors, PostBotsByBotIdContainerSkillsResponse, PostBotsByBotIdContainerSkillsResponses, PostBotsByBotIdContainerSnapshotsData, PostBotsByBotIdContainerSnapshotsError, PostBotsByBotIdContainerSnapshotsErrors, PostBotsByBotIdContainerSnapshotsResponse, PostBotsByBotIdContainerSnapshotsResponses, PostBotsByBotIdContainerStartData, PostBotsByBotIdContainerStartError, PostBotsByBotIdContainerStartErrors, PostBotsByBotIdContainerStartResponse, PostBotsByBotIdContainerStartResponses, PostBotsByBotIdContainerStopData, PostBotsByBotIdContainerStopError, PostBotsByBotIdContainerStopErrors, PostBotsByBotIdContainerStopResponse, PostBotsByBotIdContainerStopResponses, PostBotsByBotIdMcpData, PostBotsByBotIdMcpError, PostBotsByBotIdMcpErrors, PostBotsByBotIdMcpOpsBatchDeleteData, PostBotsByBotIdMcpOpsBatchDeleteError, PostBotsByBotIdMcpOpsBatchDeleteErrors, PostBotsByBotIdMcpOpsBatchDeleteResponses, PostBotsByBotIdMcpResponse, PostBotsByBotIdMcpResponses, PostBotsByBotIdMcpStdioByConnectionIdData, PostBotsByBotIdMcpStdioByConnectionIdError, PostBotsByBotIdMcpStdioByConnectionIdErrors, PostBotsByBotIdMcpStdioByConnectionIdResponse, PostBotsByBotIdMcpStdioByConnectionIdResponses, PostBotsByBotIdMcpStdioData, PostBotsByBotIdMcpStdioError, PostBotsByBotIdMcpStdioErrors, PostBotsByBotIdMcpStdioResponse, PostBotsByBotIdMcpStdioResponses, PostBotsByBotIdMemoryCompactData, PostBotsByBotIdMemoryCompactError, PostBotsByBotIdMemoryCompactErrors, PostBotsByBotIdMemoryCompactResponse, PostBotsByBotIdMemoryCompactResponses, PostBotsByBotIdMemoryData, PostBotsByBotIdMemoryError, PostBotsByBotIdMemoryErrors, PostBotsByBotIdMemoryRebuildData, PostBotsByBotIdMemoryRebuildError, PostBotsByBotIdMemoryRebuildErrors, PostBotsByBotIdMemoryRebuildResponse, PostBotsByBotIdMemoryRebuildResponses, PostBotsByBotIdMemoryResponse, PostBotsByBotIdMemoryResponses, PostBotsByBotIdMemorySearchData, PostBotsByBotIdMemorySearchError, PostBotsByBotIdMemorySearchErrors, PostBotsByBotIdMemorySearchResponse, PostBotsByBotIdMemorySearchResponses, PostBotsByBotIdScheduleData, PostBotsByBotIdScheduleError, PostBotsByBotIdScheduleErrors, PostBotsByBotIdScheduleResponse, PostBotsByBotIdScheduleResponses, PostBotsByBotIdSettingsData, PostBotsByBotIdSettingsError, PostBotsByBotIdSettingsErrors, PostBotsByBotIdSettingsResponse, PostBotsByBotIdSettingsResponses, PostBotsByBotIdSubagentsByIdSkillsData, PostBotsByBotIdSubagentsByIdSkillsError, PostBotsByBotIdSubagentsByIdSkillsErrors, PostBotsByBotIdSubagentsByIdSkillsResponse, PostBotsByBotIdSubagentsByIdSkillsResponses, PostBotsByBotIdSubagentsData, PostBotsByBotIdSubagentsError, PostBotsByBotIdSubagentsErrors, PostBotsByBotIdSubagentsResponse, PostBotsByBotIdSubagentsResponses, PostBotsByBotIdToolsData, PostBotsByBotIdToolsError, PostBotsByBotIdToolsErrors, PostBotsByBotIdToolsResponse, PostBotsByBotIdToolsResponses, PostBotsByBotIdWebMessagesData, PostBotsByBotIdWebMessagesError, PostBotsByBotIdWebMessagesErrors, PostBotsByBotIdWebMessagesResponse, PostBotsByBotIdWebMessagesResponses, PostBotsByIdChannelByPlatformSendChatData, PostBotsByIdChannelByPlatformSendChatError, PostBotsByIdChannelByPlatformSendChatErrors, PostBotsByIdChannelByPlatformSendChatResponse, PostBotsByIdChannelByPlatformSendChatResponses, PostBotsByIdChannelByPlatformSendData, PostBotsByIdChannelByPlatformSendError, PostBotsByIdChannelByPlatformSendErrors, PostBotsByIdChannelByPlatformSendResponse, PostBotsByIdChannelByPlatformSendResponses, PostBotsData, PostBotsError, PostBotsErrors, PostBotsResponse, PostBotsResponses, PostEmbeddingsData, PostEmbeddingsError, PostEmbeddingsErrors, PostEmbeddingsResponse, PostEmbeddingsResponses, PostModelsData, PostModelsError, PostModelsErrors, PostModelsResponse, PostModelsResponses, PostProvidersByIdTestData, PostProvidersByIdTestError, PostProvidersByIdTestErrors, PostProvidersByIdTestResponse, PostProvidersByIdTestResponses, PostProvidersData, PostProvidersError, PostProvidersErrors, PostProvidersResponse, PostProvidersResponses, PostSearchProvidersData, PostSearchProvidersError, PostSearchProvidersErrors, PostSearchProvidersResponse, PostSearchProvidersResponses, PostUsersData, PostUsersError, PostUsersErrors, PostUsersResponse, PostUsersResponses, ProvidersCheckResult, ProvidersCheckStatus, ProvidersCountResponse, ProvidersCreateRequest, ProvidersGetResponse, ProvidersTestResponse, ProvidersUpdateRequest, PutBotsByBotIdMcpByIdData, PutBotsByBotIdMcpByIdError, PutBotsByBotIdMcpByIdErrors, PutBotsByBotIdMcpByIdResponse, PutBotsByBotIdMcpByIdResponses, PutBotsByBotIdMcpImportData, PutBotsByBotIdMcpImportError, PutBotsByBotIdMcpImportErrors, PutBotsByBotIdMcpImportResponse, PutBotsByBotIdMcpImportResponses, PutBotsByBotIdScheduleByIdData, PutBotsByBotIdScheduleByIdError, PutBotsByBotIdScheduleByIdErrors, PutBotsByBotIdScheduleByIdResponse, PutBotsByBotIdScheduleByIdResponses, PutBotsByBotIdSettingsData, PutBotsByBotIdSettingsError, PutBotsByBotIdSettingsErrors, PutBotsByBotIdSettingsResponse, PutBotsByBotIdSettingsResponses, PutBotsByBotIdSubagentsByIdContextData, PutBotsByBotIdSubagentsByIdContextError, PutBotsByBotIdSubagentsByIdContextErrors, PutBotsByBotIdSubagentsByIdContextResponse, PutBotsByBotIdSubagentsByIdContextResponses, PutBotsByBotIdSubagentsByIdData, PutBotsByBotIdSubagentsByIdError, PutBotsByBotIdSubagentsByIdErrors, PutBotsByBotIdSubagentsByIdResponse, PutBotsByBotIdSubagentsByIdResponses, PutBotsByBotIdSubagentsByIdSkillsData, PutBotsByBotIdSubagentsByIdSkillsError, PutBotsByBotIdSubagentsByIdSkillsErrors, PutBotsByBotIdSubagentsByIdSkillsResponse, PutBotsByBotIdSubagentsByIdSkillsResponses, PutBotsByIdChannelByPlatformData, PutBotsByIdChannelByPlatformError, PutBotsByIdChannelByPlatformErrors, PutBotsByIdChannelByPlatformResponse, PutBotsByIdChannelByPlatformResponses, PutBotsByIdData, PutBotsByIdError, PutBotsByIdErrors, PutBotsByIdMembersData, PutBotsByIdMembersError, PutBotsByIdMembersErrors, PutBotsByIdMembersResponse, PutBotsByIdMembersResponses, PutBotsByIdOwnerData, PutBotsByIdOwnerError, PutBotsByIdOwnerErrors, PutBotsByIdOwnerResponse, PutBotsByIdOwnerResponses, PutBotsByIdResponse, PutBotsByIdResponses, PutModelsByIdData, PutModelsByIdError, PutModelsByIdErrors, PutModelsByIdResponse, PutModelsByIdResponses, PutModelsModelByModelIdData, PutModelsModelByModelIdError, PutModelsModelByModelIdErrors, PutModelsModelByModelIdResponse, PutModelsModelByModelIdResponses, PutProvidersByIdData, PutProvidersByIdError, PutProvidersByIdErrors, PutProvidersByIdResponse, PutProvidersByIdResponses, PutSearchProvidersByIdData, PutSearchProvidersByIdError, PutSearchProvidersByIdErrors, PutSearchProvidersByIdResponse, PutSearchProvidersByIdResponses, PutUsersByIdData, PutUsersByIdError, PutUsersByIdErrors, PutUsersByIdPasswordData, PutUsersByIdPasswordError, PutUsersByIdPasswordErrors, PutUsersByIdPasswordResponses, PutUsersByIdResponse, PutUsersByIdResponses, PutUsersMeChannelsByPlatformData, PutUsersMeChannelsByPlatformError, PutUsersMeChannelsByPlatformErrors, PutUsersMeChannelsByPlatformResponse, PutUsersMeChannelsByPlatformResponses, PutUsersMeData, PutUsersMeError, PutUsersMeErrors, PutUsersMePasswordData, PutUsersMePasswordError, PutUsersMePasswordErrors, PutUsersMePasswordResponses, PutUsersMeResponse, PutUsersMeResponses, ScheduleCreateRequest, ScheduleListResponse, ScheduleNullableInt, ScheduleSchedule, ScheduleUpdateRequest, SearchprovidersCreateRequest, SearchprovidersGetResponse, SearchprovidersProviderConfigSchema, SearchprovidersProviderFieldSchema, SearchprovidersProviderMeta, SearchprovidersProviderName, SearchprovidersUpdateRequest, SettingsSettings, SettingsUpsertRequest, SubagentAddSkillsRequest, SubagentContextResponse, SubagentCreateRequest, SubagentListResponse, SubagentSkillsResponse, SubagentSubagent, SubagentUpdateContextRequest, SubagentUpdateRequest, SubagentUpdateSkillsRequest } from './types.gen'; diff --git a/packages/sdk/src/sdk.gen.ts b/packages/sdk/src/sdk.gen.ts index eda131fd..7e041767 100644 --- a/packages/sdk/src/sdk.gen.ts +++ b/packages/sdk/src/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { DeleteBotsByBotIdContainerData, DeleteBotsByBotIdContainerErrors, DeleteBotsByBotIdContainerResponses, DeleteBotsByBotIdContainerSkillsData, DeleteBotsByBotIdContainerSkillsErrors, DeleteBotsByBotIdContainerSkillsResponses, DeleteBotsByBotIdMcpByIdData, DeleteBotsByBotIdMcpByIdErrors, DeleteBotsByBotIdMcpByIdResponses, DeleteBotsByBotIdMemoryByIdData, DeleteBotsByBotIdMemoryByIdErrors, DeleteBotsByBotIdMemoryByIdResponses, DeleteBotsByBotIdMemoryData, DeleteBotsByBotIdMemoryErrors, DeleteBotsByBotIdMemoryResponses, DeleteBotsByBotIdScheduleByIdData, DeleteBotsByBotIdScheduleByIdErrors, DeleteBotsByBotIdScheduleByIdResponses, DeleteBotsByBotIdSettingsData, DeleteBotsByBotIdSettingsErrors, DeleteBotsByBotIdSettingsResponses, DeleteBotsByBotIdSubagentsByIdData, DeleteBotsByBotIdSubagentsByIdErrors, DeleteBotsByBotIdSubagentsByIdResponses, DeleteBotsByIdChannelByPlatformData, DeleteBotsByIdChannelByPlatformErrors, DeleteBotsByIdChannelByPlatformResponses, DeleteBotsByIdData, DeleteBotsByIdErrors, DeleteBotsByIdMembersByUserIdData, DeleteBotsByIdMembersByUserIdErrors, DeleteBotsByIdMembersByUserIdResponses, DeleteBotsByIdResponses, DeleteModelsByIdData, DeleteModelsByIdErrors, DeleteModelsByIdResponses, DeleteModelsModelByModelIdData, DeleteModelsModelByModelIdErrors, DeleteModelsModelByModelIdResponses, DeleteProvidersByIdData, DeleteProvidersByIdErrors, DeleteProvidersByIdResponses, DeleteSearchProvidersByIdData, DeleteSearchProvidersByIdErrors, DeleteSearchProvidersByIdResponses, GetBotsByBotIdContainerData, GetBotsByBotIdContainerErrors, GetBotsByBotIdContainerResponses, GetBotsByBotIdContainerSkillsData, GetBotsByBotIdContainerSkillsErrors, GetBotsByBotIdContainerSkillsResponses, GetBotsByBotIdContainerSnapshotsData, GetBotsByBotIdContainerSnapshotsResponses, GetBotsByBotIdMcpByIdData, GetBotsByBotIdMcpByIdErrors, GetBotsByBotIdMcpByIdResponses, GetBotsByBotIdMcpData, GetBotsByBotIdMcpErrors, GetBotsByBotIdMcpExportData, GetBotsByBotIdMcpExportErrors, GetBotsByBotIdMcpExportResponses, GetBotsByBotIdMcpResponses, GetBotsByBotIdMemoryData, GetBotsByBotIdMemoryErrors, GetBotsByBotIdMemoryResponses, GetBotsByBotIdMemoryUsageData, GetBotsByBotIdMemoryUsageErrors, GetBotsByBotIdMemoryUsageResponses, GetBotsByBotIdMessagesData, GetBotsByBotIdMessagesErrors, GetBotsByBotIdMessagesResponses, GetBotsByBotIdScheduleByIdData, GetBotsByBotIdScheduleByIdErrors, GetBotsByBotIdScheduleByIdResponses, GetBotsByBotIdScheduleData, GetBotsByBotIdScheduleErrors, GetBotsByBotIdScheduleResponses, GetBotsByBotIdSettingsData, GetBotsByBotIdSettingsErrors, GetBotsByBotIdSettingsResponses, GetBotsByBotIdSubagentsByIdContextData, GetBotsByBotIdSubagentsByIdContextErrors, GetBotsByBotIdSubagentsByIdContextResponses, GetBotsByBotIdSubagentsByIdData, GetBotsByBotIdSubagentsByIdErrors, GetBotsByBotIdSubagentsByIdResponses, GetBotsByBotIdSubagentsByIdSkillsData, GetBotsByBotIdSubagentsByIdSkillsErrors, GetBotsByBotIdSubagentsByIdSkillsResponses, GetBotsByBotIdSubagentsData, GetBotsByBotIdSubagentsErrors, GetBotsByBotIdSubagentsResponses, GetBotsByIdChannelByPlatformData, GetBotsByIdChannelByPlatformErrors, GetBotsByIdChannelByPlatformResponses, GetBotsByIdChecksData, GetBotsByIdChecksErrors, GetBotsByIdChecksResponses, GetBotsByIdData, GetBotsByIdErrors, GetBotsByIdMembersData, GetBotsByIdMembersErrors, GetBotsByIdMembersResponses, GetBotsByIdResponses, GetBotsData, GetBotsErrors, GetBotsResponses, GetChannelsByPlatformData, GetChannelsByPlatformErrors, GetChannelsByPlatformResponses, GetChannelsData, GetChannelsErrors, GetChannelsResponses, GetModelsByIdData, GetModelsByIdErrors, GetModelsByIdResponses, GetModelsCountData, GetModelsCountErrors, GetModelsCountResponses, GetModelsData, GetModelsErrors, GetModelsModelByModelIdData, GetModelsModelByModelIdErrors, GetModelsModelByModelIdResponses, GetModelsResponses, GetProvidersByIdData, GetProvidersByIdErrors, GetProvidersByIdModelsData, GetProvidersByIdModelsErrors, GetProvidersByIdModelsResponses, GetProvidersByIdResponses, GetProvidersCountData, GetProvidersCountErrors, GetProvidersCountResponses, GetProvidersData, GetProvidersErrors, GetProvidersNameByNameData, GetProvidersNameByNameErrors, GetProvidersNameByNameResponses, GetProvidersResponses, GetSearchProvidersByIdData, GetSearchProvidersByIdErrors, GetSearchProvidersByIdResponses, GetSearchProvidersData, GetSearchProvidersErrors, GetSearchProvidersMetaData, GetSearchProvidersMetaResponses, GetSearchProvidersResponses, GetUsersByIdData, GetUsersByIdErrors, GetUsersByIdResponses, GetUsersData, GetUsersErrors, GetUsersMeChannelsByPlatformData, GetUsersMeChannelsByPlatformErrors, GetUsersMeChannelsByPlatformResponses, GetUsersMeData, GetUsersMeErrors, GetUsersMeIdentitiesData, GetUsersMeIdentitiesErrors, GetUsersMeIdentitiesResponses, GetUsersMeResponses, GetUsersResponses, PatchBotsByIdChannelByPlatformStatusData, PatchBotsByIdChannelByPlatformStatusErrors, PatchBotsByIdChannelByPlatformStatusResponses, PostAuthLoginData, PostAuthLoginErrors, PostAuthLoginResponses, PostBotsByBotIdContainerData, PostBotsByBotIdContainerErrors, PostBotsByBotIdContainerResponses, PostBotsByBotIdContainerSkillsData, PostBotsByBotIdContainerSkillsErrors, PostBotsByBotIdContainerSkillsResponses, PostBotsByBotIdContainerSnapshotsData, PostBotsByBotIdContainerSnapshotsErrors, PostBotsByBotIdContainerSnapshotsResponses, PostBotsByBotIdContainerStartData, PostBotsByBotIdContainerStartErrors, PostBotsByBotIdContainerStartResponses, PostBotsByBotIdContainerStopData, PostBotsByBotIdContainerStopErrors, PostBotsByBotIdContainerStopResponses, PostBotsByBotIdMcpData, PostBotsByBotIdMcpErrors, PostBotsByBotIdMcpOpsBatchDeleteData, PostBotsByBotIdMcpOpsBatchDeleteErrors, PostBotsByBotIdMcpOpsBatchDeleteResponses, PostBotsByBotIdMcpResponses, PostBotsByBotIdMcpStdioByConnectionIdData, PostBotsByBotIdMcpStdioByConnectionIdErrors, PostBotsByBotIdMcpStdioByConnectionIdResponses, PostBotsByBotIdMcpStdioData, PostBotsByBotIdMcpStdioErrors, PostBotsByBotIdMcpStdioResponses, PostBotsByBotIdMemoryCompactData, PostBotsByBotIdMemoryCompactErrors, PostBotsByBotIdMemoryCompactResponses, PostBotsByBotIdMemoryData, PostBotsByBotIdMemoryErrors, PostBotsByBotIdMemoryRebuildData, PostBotsByBotIdMemoryRebuildErrors, PostBotsByBotIdMemoryRebuildResponses, PostBotsByBotIdMemoryResponses, PostBotsByBotIdMemorySearchData, PostBotsByBotIdMemorySearchErrors, PostBotsByBotIdMemorySearchResponses, PostBotsByBotIdScheduleData, PostBotsByBotIdScheduleErrors, PostBotsByBotIdScheduleResponses, PostBotsByBotIdSettingsData, PostBotsByBotIdSettingsErrors, PostBotsByBotIdSettingsResponses, PostBotsByBotIdSubagentsByIdSkillsData, PostBotsByBotIdSubagentsByIdSkillsErrors, PostBotsByBotIdSubagentsByIdSkillsResponses, PostBotsByBotIdSubagentsData, PostBotsByBotIdSubagentsErrors, PostBotsByBotIdSubagentsResponses, PostBotsByBotIdToolsData, PostBotsByBotIdToolsErrors, PostBotsByBotIdToolsResponses, PostBotsByIdChannelByPlatformSendChatData, PostBotsByIdChannelByPlatformSendChatErrors, PostBotsByIdChannelByPlatformSendChatResponses, PostBotsByIdChannelByPlatformSendData, PostBotsByIdChannelByPlatformSendErrors, PostBotsByIdChannelByPlatformSendResponses, PostBotsData, PostBotsErrors, PostBotsResponses, PostEmbeddingsData, PostEmbeddingsErrors, PostEmbeddingsResponses, PostModelsData, PostModelsErrors, PostModelsResponses, PostProvidersByIdTestData, PostProvidersByIdTestErrors, PostProvidersByIdTestResponses, PostProvidersData, PostProvidersErrors, PostProvidersResponses, PostSearchProvidersData, PostSearchProvidersErrors, PostSearchProvidersResponses, PostUsersData, PostUsersErrors, PostUsersResponses, PutBotsByBotIdMcpByIdData, PutBotsByBotIdMcpByIdErrors, PutBotsByBotIdMcpByIdResponses, PutBotsByBotIdMcpImportData, PutBotsByBotIdMcpImportErrors, PutBotsByBotIdMcpImportResponses, PutBotsByBotIdScheduleByIdData, PutBotsByBotIdScheduleByIdErrors, PutBotsByBotIdScheduleByIdResponses, PutBotsByBotIdSettingsData, PutBotsByBotIdSettingsErrors, PutBotsByBotIdSettingsResponses, PutBotsByBotIdSubagentsByIdContextData, PutBotsByBotIdSubagentsByIdContextErrors, PutBotsByBotIdSubagentsByIdContextResponses, PutBotsByBotIdSubagentsByIdData, PutBotsByBotIdSubagentsByIdErrors, PutBotsByBotIdSubagentsByIdResponses, PutBotsByBotIdSubagentsByIdSkillsData, PutBotsByBotIdSubagentsByIdSkillsErrors, PutBotsByBotIdSubagentsByIdSkillsResponses, PutBotsByIdChannelByPlatformData, PutBotsByIdChannelByPlatformErrors, PutBotsByIdChannelByPlatformResponses, PutBotsByIdData, PutBotsByIdErrors, PutBotsByIdMembersData, PutBotsByIdMembersErrors, PutBotsByIdMembersResponses, PutBotsByIdOwnerData, PutBotsByIdOwnerErrors, PutBotsByIdOwnerResponses, PutBotsByIdResponses, PutModelsByIdData, PutModelsByIdErrors, PutModelsByIdResponses, PutModelsModelByModelIdData, PutModelsModelByModelIdErrors, PutModelsModelByModelIdResponses, PutProvidersByIdData, PutProvidersByIdErrors, PutProvidersByIdResponses, PutSearchProvidersByIdData, PutSearchProvidersByIdErrors, PutSearchProvidersByIdResponses, PutUsersByIdData, PutUsersByIdErrors, PutUsersByIdPasswordData, PutUsersByIdPasswordErrors, PutUsersByIdPasswordResponses, PutUsersByIdResponses, PutUsersMeChannelsByPlatformData, PutUsersMeChannelsByPlatformErrors, PutUsersMeChannelsByPlatformResponses, PutUsersMeData, PutUsersMeErrors, PutUsersMePasswordData, PutUsersMePasswordErrors, PutUsersMePasswordResponses, PutUsersMeResponses } from './types.gen'; +import type { DeleteBotsByBotIdContainerData, DeleteBotsByBotIdContainerErrors, DeleteBotsByBotIdContainerResponses, DeleteBotsByBotIdContainerSkillsData, DeleteBotsByBotIdContainerSkillsErrors, DeleteBotsByBotIdContainerSkillsResponses, DeleteBotsByBotIdMcpByIdData, DeleteBotsByBotIdMcpByIdErrors, DeleteBotsByBotIdMcpByIdResponses, DeleteBotsByBotIdMemoryByIdData, DeleteBotsByBotIdMemoryByIdErrors, DeleteBotsByBotIdMemoryByIdResponses, DeleteBotsByBotIdMemoryData, DeleteBotsByBotIdMemoryErrors, DeleteBotsByBotIdMemoryResponses, DeleteBotsByBotIdScheduleByIdData, DeleteBotsByBotIdScheduleByIdErrors, DeleteBotsByBotIdScheduleByIdResponses, DeleteBotsByBotIdSettingsData, DeleteBotsByBotIdSettingsErrors, DeleteBotsByBotIdSettingsResponses, DeleteBotsByBotIdSubagentsByIdData, DeleteBotsByBotIdSubagentsByIdErrors, DeleteBotsByBotIdSubagentsByIdResponses, DeleteBotsByIdChannelByPlatformData, DeleteBotsByIdChannelByPlatformErrors, DeleteBotsByIdChannelByPlatformResponses, DeleteBotsByIdData, DeleteBotsByIdErrors, DeleteBotsByIdMembersByUserIdData, DeleteBotsByIdMembersByUserIdErrors, DeleteBotsByIdMembersByUserIdResponses, DeleteBotsByIdResponses, DeleteModelsByIdData, DeleteModelsByIdErrors, DeleteModelsByIdResponses, DeleteModelsModelByModelIdData, DeleteModelsModelByModelIdErrors, DeleteModelsModelByModelIdResponses, DeleteProvidersByIdData, DeleteProvidersByIdErrors, DeleteProvidersByIdResponses, DeleteSearchProvidersByIdData, DeleteSearchProvidersByIdErrors, DeleteSearchProvidersByIdResponses, GetBotsByBotIdCliStreamData, GetBotsByBotIdCliStreamErrors, GetBotsByBotIdCliStreamResponses, GetBotsByBotIdContainerData, GetBotsByBotIdContainerErrors, GetBotsByBotIdContainerResponses, GetBotsByBotIdContainerSkillsData, GetBotsByBotIdContainerSkillsErrors, GetBotsByBotIdContainerSkillsResponses, GetBotsByBotIdContainerSnapshotsData, GetBotsByBotIdContainerSnapshotsResponses, GetBotsByBotIdMcpByIdData, GetBotsByBotIdMcpByIdErrors, GetBotsByBotIdMcpByIdResponses, GetBotsByBotIdMcpData, GetBotsByBotIdMcpErrors, GetBotsByBotIdMcpExportData, GetBotsByBotIdMcpExportErrors, GetBotsByBotIdMcpExportResponses, GetBotsByBotIdMcpResponses, GetBotsByBotIdMemoryData, GetBotsByBotIdMemoryErrors, GetBotsByBotIdMemoryResponses, GetBotsByBotIdMemoryUsageData, GetBotsByBotIdMemoryUsageErrors, GetBotsByBotIdMemoryUsageResponses, GetBotsByBotIdMessagesData, GetBotsByBotIdMessagesErrors, GetBotsByBotIdMessagesResponses, GetBotsByBotIdScheduleByIdData, GetBotsByBotIdScheduleByIdErrors, GetBotsByBotIdScheduleByIdResponses, GetBotsByBotIdScheduleData, GetBotsByBotIdScheduleErrors, GetBotsByBotIdScheduleResponses, GetBotsByBotIdSettingsData, GetBotsByBotIdSettingsErrors, GetBotsByBotIdSettingsResponses, GetBotsByBotIdSubagentsByIdContextData, GetBotsByBotIdSubagentsByIdContextErrors, GetBotsByBotIdSubagentsByIdContextResponses, GetBotsByBotIdSubagentsByIdData, GetBotsByBotIdSubagentsByIdErrors, GetBotsByBotIdSubagentsByIdResponses, GetBotsByBotIdSubagentsByIdSkillsData, GetBotsByBotIdSubagentsByIdSkillsErrors, GetBotsByBotIdSubagentsByIdSkillsResponses, GetBotsByBotIdSubagentsData, GetBotsByBotIdSubagentsErrors, GetBotsByBotIdSubagentsResponses, GetBotsByBotIdWebStreamData, GetBotsByBotIdWebStreamErrors, GetBotsByBotIdWebStreamResponses, GetBotsByIdChannelByPlatformData, GetBotsByIdChannelByPlatformErrors, GetBotsByIdChannelByPlatformResponses, GetBotsByIdChecksData, GetBotsByIdChecksErrors, GetBotsByIdChecksResponses, GetBotsByIdData, GetBotsByIdErrors, GetBotsByIdMembersData, GetBotsByIdMembersErrors, GetBotsByIdMembersResponses, GetBotsByIdResponses, GetBotsData, GetBotsErrors, GetBotsResponses, GetChannelsByPlatformData, GetChannelsByPlatformErrors, GetChannelsByPlatformResponses, GetChannelsData, GetChannelsErrors, GetChannelsResponses, GetModelsByIdData, GetModelsByIdErrors, GetModelsByIdResponses, GetModelsCountData, GetModelsCountErrors, GetModelsCountResponses, GetModelsData, GetModelsErrors, GetModelsModelByModelIdData, GetModelsModelByModelIdErrors, GetModelsModelByModelIdResponses, GetModelsResponses, GetProvidersByIdData, GetProvidersByIdErrors, GetProvidersByIdModelsData, GetProvidersByIdModelsErrors, GetProvidersByIdModelsResponses, GetProvidersByIdResponses, GetProvidersCountData, GetProvidersCountErrors, GetProvidersCountResponses, GetProvidersData, GetProvidersErrors, GetProvidersNameByNameData, GetProvidersNameByNameErrors, GetProvidersNameByNameResponses, GetProvidersResponses, GetSearchProvidersByIdData, GetSearchProvidersByIdErrors, GetSearchProvidersByIdResponses, GetSearchProvidersData, GetSearchProvidersErrors, GetSearchProvidersMetaData, GetSearchProvidersMetaResponses, GetSearchProvidersResponses, GetUsersByIdData, GetUsersByIdErrors, GetUsersByIdResponses, GetUsersData, GetUsersErrors, GetUsersMeChannelsByPlatformData, GetUsersMeChannelsByPlatformErrors, GetUsersMeChannelsByPlatformResponses, GetUsersMeData, GetUsersMeErrors, GetUsersMeIdentitiesData, GetUsersMeIdentitiesErrors, GetUsersMeIdentitiesResponses, GetUsersMeResponses, GetUsersResponses, PatchBotsByIdChannelByPlatformStatusData, PatchBotsByIdChannelByPlatformStatusErrors, PatchBotsByIdChannelByPlatformStatusResponses, PostAuthLoginData, PostAuthLoginErrors, PostAuthLoginResponses, PostBotsByBotIdCliMessagesData, PostBotsByBotIdCliMessagesErrors, PostBotsByBotIdCliMessagesResponses, PostBotsByBotIdContainerData, PostBotsByBotIdContainerErrors, PostBotsByBotIdContainerResponses, PostBotsByBotIdContainerSkillsData, PostBotsByBotIdContainerSkillsErrors, PostBotsByBotIdContainerSkillsResponses, PostBotsByBotIdContainerSnapshotsData, PostBotsByBotIdContainerSnapshotsErrors, PostBotsByBotIdContainerSnapshotsResponses, PostBotsByBotIdContainerStartData, PostBotsByBotIdContainerStartErrors, PostBotsByBotIdContainerStartResponses, PostBotsByBotIdContainerStopData, PostBotsByBotIdContainerStopErrors, PostBotsByBotIdContainerStopResponses, PostBotsByBotIdMcpData, PostBotsByBotIdMcpErrors, PostBotsByBotIdMcpOpsBatchDeleteData, PostBotsByBotIdMcpOpsBatchDeleteErrors, PostBotsByBotIdMcpOpsBatchDeleteResponses, PostBotsByBotIdMcpResponses, PostBotsByBotIdMcpStdioByConnectionIdData, PostBotsByBotIdMcpStdioByConnectionIdErrors, PostBotsByBotIdMcpStdioByConnectionIdResponses, PostBotsByBotIdMcpStdioData, PostBotsByBotIdMcpStdioErrors, PostBotsByBotIdMcpStdioResponses, PostBotsByBotIdMemoryCompactData, PostBotsByBotIdMemoryCompactErrors, PostBotsByBotIdMemoryCompactResponses, PostBotsByBotIdMemoryData, PostBotsByBotIdMemoryErrors, PostBotsByBotIdMemoryRebuildData, PostBotsByBotIdMemoryRebuildErrors, PostBotsByBotIdMemoryRebuildResponses, PostBotsByBotIdMemoryResponses, PostBotsByBotIdMemorySearchData, PostBotsByBotIdMemorySearchErrors, PostBotsByBotIdMemorySearchResponses, PostBotsByBotIdScheduleData, PostBotsByBotIdScheduleErrors, PostBotsByBotIdScheduleResponses, PostBotsByBotIdSettingsData, PostBotsByBotIdSettingsErrors, PostBotsByBotIdSettingsResponses, PostBotsByBotIdSubagentsByIdSkillsData, PostBotsByBotIdSubagentsByIdSkillsErrors, PostBotsByBotIdSubagentsByIdSkillsResponses, PostBotsByBotIdSubagentsData, PostBotsByBotIdSubagentsErrors, PostBotsByBotIdSubagentsResponses, PostBotsByBotIdToolsData, PostBotsByBotIdToolsErrors, PostBotsByBotIdToolsResponses, PostBotsByBotIdWebMessagesData, PostBotsByBotIdWebMessagesErrors, PostBotsByBotIdWebMessagesResponses, PostBotsByIdChannelByPlatformSendChatData, PostBotsByIdChannelByPlatformSendChatErrors, PostBotsByIdChannelByPlatformSendChatResponses, PostBotsByIdChannelByPlatformSendData, PostBotsByIdChannelByPlatformSendErrors, PostBotsByIdChannelByPlatformSendResponses, PostBotsData, PostBotsErrors, PostBotsResponses, PostEmbeddingsData, PostEmbeddingsErrors, PostEmbeddingsResponses, PostModelsData, PostModelsErrors, PostModelsResponses, PostProvidersByIdTestData, PostProvidersByIdTestErrors, PostProvidersByIdTestResponses, PostProvidersData, PostProvidersErrors, PostProvidersResponses, PostSearchProvidersData, PostSearchProvidersErrors, PostSearchProvidersResponses, PostUsersData, PostUsersErrors, PostUsersResponses, PutBotsByBotIdMcpByIdData, PutBotsByBotIdMcpByIdErrors, PutBotsByBotIdMcpByIdResponses, PutBotsByBotIdMcpImportData, PutBotsByBotIdMcpImportErrors, PutBotsByBotIdMcpImportResponses, PutBotsByBotIdScheduleByIdData, PutBotsByBotIdScheduleByIdErrors, PutBotsByBotIdScheduleByIdResponses, PutBotsByBotIdSettingsData, PutBotsByBotIdSettingsErrors, PutBotsByBotIdSettingsResponses, PutBotsByBotIdSubagentsByIdContextData, PutBotsByBotIdSubagentsByIdContextErrors, PutBotsByBotIdSubagentsByIdContextResponses, PutBotsByBotIdSubagentsByIdData, PutBotsByBotIdSubagentsByIdErrors, PutBotsByBotIdSubagentsByIdResponses, PutBotsByBotIdSubagentsByIdSkillsData, PutBotsByBotIdSubagentsByIdSkillsErrors, PutBotsByBotIdSubagentsByIdSkillsResponses, PutBotsByIdChannelByPlatformData, PutBotsByIdChannelByPlatformErrors, PutBotsByIdChannelByPlatformResponses, PutBotsByIdData, PutBotsByIdErrors, PutBotsByIdMembersData, PutBotsByIdMembersErrors, PutBotsByIdMembersResponses, PutBotsByIdOwnerData, PutBotsByIdOwnerErrors, PutBotsByIdOwnerResponses, PutBotsByIdResponses, PutModelsByIdData, PutModelsByIdErrors, PutModelsByIdResponses, PutModelsModelByModelIdData, PutModelsModelByModelIdErrors, PutModelsModelByModelIdResponses, PutProvidersByIdData, PutProvidersByIdErrors, PutProvidersByIdResponses, PutSearchProvidersByIdData, PutSearchProvidersByIdErrors, PutSearchProvidersByIdResponses, PutUsersByIdData, PutUsersByIdErrors, PutUsersByIdPasswordData, PutUsersByIdPasswordErrors, PutUsersByIdPasswordResponses, PutUsersByIdResponses, PutUsersMeChannelsByPlatformData, PutUsersMeChannelsByPlatformErrors, PutUsersMeChannelsByPlatformResponses, PutUsersMeData, PutUsersMeErrors, PutUsersMePasswordData, PutUsersMePasswordErrors, PutUsersMePasswordResponses, PutUsersMeResponses } from './types.gen'; export type Options = Options2 & { /** @@ -53,6 +53,27 @@ export const postBots = (options: Options< } }); +/** + * Send a message to a local channel + * + * Post a user message (with optional attachments) through the local channel pipeline. + */ +export const postBotsByBotIdCliMessages = (options: Options) => (options.client ?? client).post({ + url: '/bots/{bot_id}/cli/messages', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Subscribe to local channel events via SSE + * + * Open a persistent SSE connection to receive real-time stream events for the given bot. + */ +export const getBotsByBotIdCliStream = (options: Options) => (options.client ?? client).sse.get({ url: '/bots/{bot_id}/cli/stream', ...options }); + /** * Delete MCP container for bot */ @@ -551,6 +572,27 @@ export const postBotsByBotIdTools = (optio } }); +/** + * Send a message to a local channel + * + * Post a user message (with optional attachments) through the local channel pipeline. + */ +export const postBotsByBotIdWebMessages = (options: Options) => (options.client ?? client).post({ + url: '/bots/{bot_id}/web/messages', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Subscribe to local channel events via SSE + * + * Open a persistent SSE connection to receive real-time stream events for the given bot. + */ +export const getBotsByBotIdWebStream = (options: Options) => (options.client ?? client).sse.get({ url: '/bots/{bot_id}/web/stream', ...options }); + /** * Delete bot * diff --git a/packages/sdk/src/types.gen.ts b/packages/sdk/src/types.gen.ts index 79199423..495ee9ed 100644 --- a/packages/sdk/src/types.gen.ts +++ b/packages/sdk/src/types.gen.ts @@ -138,12 +138,12 @@ export type ChannelAction = { }; export type ChannelAttachment = { - asset_id?: string; /** * data URL for agent delivery */ base64?: string; caption?: string; + content_hash?: string; duration_ms?: number; height?: number; metadata?: { @@ -359,6 +359,8 @@ export type HandlersCreateSnapshotResponse = { container_id?: string; snapshot_name?: string; snapshotter?: string; + source?: string; + version?: number; }; export type HandlersEmbeddingsInput = { @@ -412,6 +414,10 @@ export type HandlersListSnapshotsResponse = { snapshotter?: string; }; +export type HandlersLocalChannelMessageRequest = { + message?: ChannelMessage; +}; + export type HandlersLoginRequest = { password?: string; username?: string; @@ -470,10 +476,13 @@ export type HandlersSnapshotInfo = { labels?: { [key: string]: string; }; + managed?: boolean; name?: string; parent?: string; snapshotter?: string; + source?: string; updated_at?: string; + version?: number; }; export type HandlersListMyIdentitiesResponse = { @@ -677,17 +686,12 @@ export type MessageMessage = { }; export type MessageMessageAsset = { - asset_id?: string; - duration_ms?: number; - height?: number; - media_type?: string; + content_hash?: string; mime?: string; ordinal?: number; - original_name?: string; role?: string; size_bytes?: number; storage_key?: string; - width?: number; }; export type ModelsAddRequest = { @@ -1077,6 +1081,87 @@ export type PostBotsResponses = { export type PostBotsResponse = PostBotsResponses[keyof PostBotsResponses]; +export type PostBotsByBotIdCliMessagesData = { + /** + * Message payload + */ + body: HandlersLocalChannelMessageRequest; + path: { + /** + * Bot ID + */ + bot_id: string; + }; + query?: never; + url: '/bots/{bot_id}/cli/messages'; +}; + +export type PostBotsByBotIdCliMessagesErrors = { + /** + * Bad Request + */ + 400: HandlersErrorResponse; + /** + * Forbidden + */ + 403: HandlersErrorResponse; + /** + * Internal Server Error + */ + 500: HandlersErrorResponse; +}; + +export type PostBotsByBotIdCliMessagesError = PostBotsByBotIdCliMessagesErrors[keyof PostBotsByBotIdCliMessagesErrors]; + +export type PostBotsByBotIdCliMessagesResponses = { + /** + * OK + */ + 200: { + [key: string]: string; + }; +}; + +export type PostBotsByBotIdCliMessagesResponse = PostBotsByBotIdCliMessagesResponses[keyof PostBotsByBotIdCliMessagesResponses]; + +export type GetBotsByBotIdCliStreamData = { + body?: never; + path: { + /** + * Bot ID + */ + bot_id: string; + }; + query?: never; + url: '/bots/{bot_id}/cli/stream'; +}; + +export type GetBotsByBotIdCliStreamErrors = { + /** + * Bad Request + */ + 400: HandlersErrorResponse; + /** + * Forbidden + */ + 403: HandlersErrorResponse; + /** + * Internal Server Error + */ + 500: HandlersErrorResponse; +}; + +export type GetBotsByBotIdCliStreamError = GetBotsByBotIdCliStreamErrors[keyof GetBotsByBotIdCliStreamErrors]; + +export type GetBotsByBotIdCliStreamResponses = { + /** + * SSE stream + */ + 200: string; +}; + +export type GetBotsByBotIdCliStreamResponse = GetBotsByBotIdCliStreamResponses[keyof GetBotsByBotIdCliStreamResponses]; + export type DeleteBotsByBotIdContainerData = { body?: never; path: { @@ -2950,6 +3035,87 @@ export type PostBotsByBotIdToolsResponses = { export type PostBotsByBotIdToolsResponse = PostBotsByBotIdToolsResponses[keyof PostBotsByBotIdToolsResponses]; +export type PostBotsByBotIdWebMessagesData = { + /** + * Message payload + */ + body: HandlersLocalChannelMessageRequest; + path: { + /** + * Bot ID + */ + bot_id: string; + }; + query?: never; + url: '/bots/{bot_id}/web/messages'; +}; + +export type PostBotsByBotIdWebMessagesErrors = { + /** + * Bad Request + */ + 400: HandlersErrorResponse; + /** + * Forbidden + */ + 403: HandlersErrorResponse; + /** + * Internal Server Error + */ + 500: HandlersErrorResponse; +}; + +export type PostBotsByBotIdWebMessagesError = PostBotsByBotIdWebMessagesErrors[keyof PostBotsByBotIdWebMessagesErrors]; + +export type PostBotsByBotIdWebMessagesResponses = { + /** + * OK + */ + 200: { + [key: string]: string; + }; +}; + +export type PostBotsByBotIdWebMessagesResponse = PostBotsByBotIdWebMessagesResponses[keyof PostBotsByBotIdWebMessagesResponses]; + +export type GetBotsByBotIdWebStreamData = { + body?: never; + path: { + /** + * Bot ID + */ + bot_id: string; + }; + query?: never; + url: '/bots/{bot_id}/web/stream'; +}; + +export type GetBotsByBotIdWebStreamErrors = { + /** + * Bad Request + */ + 400: HandlersErrorResponse; + /** + * Forbidden + */ + 403: HandlersErrorResponse; + /** + * Internal Server Error + */ + 500: HandlersErrorResponse; +}; + +export type GetBotsByBotIdWebStreamError = GetBotsByBotIdWebStreamErrors[keyof GetBotsByBotIdWebStreamErrors]; + +export type GetBotsByBotIdWebStreamResponses = { + /** + * SSE stream + */ + 200: string; +}; + +export type GetBotsByBotIdWebStreamResponse = GetBotsByBotIdWebStreamResponses[keyof GetBotsByBotIdWebStreamResponses]; + export type DeleteBotsByIdData = { body?: never; path: { diff --git a/packages/web/src/composables/api/useChat.ts b/packages/web/src/composables/api/useChat.ts index a6224580..6b609a7d 100644 --- a/packages/web/src/composables/api/useChat.ts +++ b/packages/web/src/composables/api/useChat.ts @@ -1,6 +1,6 @@ import { client } from '@memoh/sdk/client' -import { getBots } from '@memoh/sdk' -import type { BotsBot } from '@memoh/sdk' +import { getBots, postBotsByBotIdWebMessages } from '@memoh/sdk' +import type { BotsBot, ChannelAttachment, ChannelMessage } from '@memoh/sdk' // ---- Types ---- @@ -19,17 +19,12 @@ export interface ChatSummary { } export interface MessageAsset { - asset_id: string + content_hash: string role: string ordinal: number - media_type: string mime: string size_bytes: number storage_key: string - original_name?: string - width?: number - height?: number - duration_ms?: number } export interface Message { @@ -150,11 +145,119 @@ function parseStreamPayload(payload: string): StreamEvent | null { return { type: 'text_delta', delta: current.trim() } as StreamEvent } if (current && typeof current === 'object') { - return current as StreamEvent + return normalizeStreamEvent(current as Record) } return null } +const LEGACY_STREAM_EVENT_TYPES = new Set([ + 'text_start', + 'text_delta', + 'text_end', + 'reasoning_start', + 'reasoning_delta', + 'reasoning_end', + 'attachment_delta', + 'agent_start', + 'agent_end', + 'processing_started', + 'processing_completed', + 'processing_failed', + 'error', +]) + +function normalizeStreamEvent(raw: Record): StreamEvent | null { + const eventType = String(raw.type ?? '').trim().toLowerCase() + if (!eventType) return null + + // Preserve metadata across all normalization paths so cross-channel + // source_channel / role tags survive to the store handler. + const metadata = (raw.metadata && typeof raw.metadata === 'object') + ? raw.metadata as Record + : undefined + + function withMeta(event: StreamEvent): StreamEvent { + if (metadata) (event as Record).metadata = metadata + return event + } + + if (LEGACY_STREAM_EVENT_TYPES.has(eventType)) { + return raw as StreamEvent + } + switch (eventType) { + case 'status': { + const status = String(raw.status ?? '').trim().toLowerCase() + if (status === 'started') return withMeta({ type: 'processing_started' }) + if (status === 'completed') return withMeta({ type: 'processing_completed' }) + if (status === 'failed') { + const err = String(raw.error ?? '').trim() + return withMeta({ type: 'processing_failed', error: err, message: err }) + } + return null + } + case 'delta': { + const delta = String(raw.delta ?? '') + const phase = String(raw.phase ?? '').trim().toLowerCase() + if (phase === 'reasoning') { + return withMeta({ type: 'reasoning_delta', delta }) + } + return withMeta({ type: 'text_delta', delta }) + } + case 'phase_start': { + const phase = String(raw.phase ?? '').trim().toLowerCase() + if (phase === 'reasoning') return withMeta({ type: 'reasoning_start' }) + if (phase === 'text') return withMeta({ type: 'text_start' }) + return null + } + case 'phase_end': { + const phase = String(raw.phase ?? '').trim().toLowerCase() + if (phase === 'reasoning') return withMeta({ type: 'reasoning_end' }) + if (phase === 'text') return withMeta({ type: 'text_end' }) + return null + } + case 'tool_call_start': + case 'tool_call_end': { + const toolCall = (raw.tool_call && typeof raw.tool_call === 'object') + ? raw.tool_call as Record + : {} + return withMeta({ + type: eventType as StreamEvent['type'], + toolCallId: String(toolCall.call_id ?? ''), + toolName: String(toolCall.name ?? ''), + input: toolCall.input, + result: toolCall.result, + }) + } + case 'attachment': { + const attachments = Array.isArray(raw.attachments) + ? raw.attachments as Array> + : [] + if (!attachments.length) return null + return withMeta({ type: 'attachment_delta', attachments }) + } + case 'final': { + // Cross-channel final messages (user inbound or assistant reply). + // Pass through the full raw event so the store can extract final.message. + return raw as StreamEvent + } + case 'processing_started': + case 'processing_completed': + case 'agent_start': + case 'agent_end': + return withMeta({ type: eventType as StreamEvent['type'] }) + case 'processing_failed': { + const err = String(raw.error ?? raw.message ?? '').trim() + return withMeta({ type: 'processing_failed', error: err, message: err }) + } + case 'error': { + const err = String(raw.error ?? raw.message ?? 'Stream error').trim() + return withMeta({ type: 'error', error: err, message: err }) + } + default: + return null + } +} + // ---- Bot API ---- export async function fetchBots(): Promise { @@ -213,10 +316,6 @@ export async function fetchMessages( // ---- Stream API ---- -/** - * Stream a chat message via SSE. Sends parsed StreamEvents to onEvent callback. - * Returns an abort function. - */ export interface ChatAttachment { type: string base64: string @@ -224,51 +323,53 @@ export interface ChatAttachment { name?: string } -export function streamMessage( +export async function sendLocalChannelMessage( botId: string, - _chatId: string, text: string, - onEvent: StreamEventHandler, - onDone: () => void, - onError: (err: Error) => void, attachments?: ChatAttachment[], -): () => void { - const controller = new AbortController() +): Promise { + const msg: ChannelMessage = {} + const trimmedText = text.trim() + if (trimmedText) { + msg.text = trimmedText + } + if (attachments?.length) { + msg.attachments = attachments.map((item): ChannelAttachment => ({ + type: item.type as ChannelAttachment['type'], + base64: item.base64, + mime: item.mime ?? '', + name: item.name ?? '', + })) + } + await postBotsByBotIdWebMessages({ + path: { bot_id: botId }, + body: { message: msg }, + throwOnError: true, + }) +} - ;(async () => { - try { - const reqBody: Record = { query: text, current_channel: 'web', channels: ['web'] } - if (attachments?.length) { - reqBody.attachments = attachments - } - const { data: body } = await client.post({ - url: '/bots/{bot_id}/messages/stream', - path: { bot_id: botId }, - body: reqBody, - parseAs: 'stream', - signal: controller.signal, - throwOnError: true, - }) as { data: ReadableStream } +export async function streamLocalChannel( + botId: string, + signal: AbortSignal, + onEvent: StreamEventHandler, +): Promise { + const id = botId.trim() + if (!id) throw new Error('bot id is required') - if (!body) { - onError(new Error('No response body')) - return - } + const { data: body } = await client.get({ + url: '/bots/{bot_id}/web/stream', + path: { bot_id: id }, + parseAs: 'stream', + signal, + throwOnError: true, + }) as { data: ReadableStream } - await readSSEStream(body, (payload) => { - const event = parseStreamPayload(payload) - if (event) onEvent(event) - }) + if (!body) throw new Error('No response body') - onDone() - } catch (err) { - if ((err as Error).name !== 'AbortError') { - onError(err instanceof Error ? err : new Error(String(err))) - } - } - })() - - return () => controller.abort() + await readSSEStream(body, (payload) => { + const event = parseStreamPayload(payload) + if (event) onEvent(event) + }) } /** diff --git a/packages/web/src/pages/chat/composables/useMediaGallery.ts b/packages/web/src/pages/chat/composables/useMediaGallery.ts index 97c9885a..8333311d 100644 --- a/packages/web/src/pages/chat/composables/useMediaGallery.ts +++ b/packages/web/src/pages/chat/composables/useMediaGallery.ts @@ -1,4 +1,5 @@ import { computed, ref, type Ref } from 'vue' +import { useChatStore } from '@/store/chat-list' import type { ChatMessage } from '@/store/chat-list' import type { MediaGalleryItem } from '../components/media-gallery-lightbox.vue' @@ -9,15 +10,48 @@ function isMediaType(att: Record): boolean { return mime.startsWith('image/') || mime.startsWith('video/') } -function resolveUrl(att: Record): string { - const url = String(att.url ?? '').trim() - if (url) return url - const assetId = String(att.asset_id ?? '').trim() - if (!assetId) return '' - const botId = String(att.bot_id ?? '').trim() +function isBrowserAccessibleUrl(url: string): boolean { + if (!url) return false + const lower = url.toLowerCase() + return lower.startsWith('http://') || lower.startsWith('https://') || lower.startsWith('data:') +} + +function resolveBotId(att: Record): string { + let botId = String(att.bot_id ?? '').trim() + if (botId) return botId + const meta = att.metadata as Record | undefined + botId = String(meta?.bot_id ?? '').trim() + if (botId) return botId + // Fall back to the currently active bot in the chat store. + try { + const store = useChatStore() + return (store.currentBotId ?? '').trim() + } catch { + return '' + } +} + +function resolveAssetApiUrl(att: Record): string { + const contentHash = String(att.content_hash ?? '').trim() + if (!contentHash) return '' + const botId = resolveBotId(att) if (!botId) return '' const token = localStorage.getItem('token') || '' - return `/api/bots/${botId}/media/${assetId}?token=${encodeURIComponent(token)}` + return `/api/bots/${botId}/media/${contentHash}?token=${encodeURIComponent(token)}` +} + +function resolveUrl(att: Record): string { + // Prefer asset API when content_hash is available (reliable, auth-aware). + const assetUrl = resolveAssetApiUrl(att) + if (assetUrl) return assetUrl + // Fall back to direct URL if browser-accessible (http/https/data). + const url = String(att.url ?? '').trim() + if (isBrowserAccessibleUrl(url)) return url + const base64 = String(att.base64 ?? '').trim() + if (isBrowserAccessibleUrl(base64)) return base64 + // Container-internal paths or other non-HTTP URLs cannot be loaded directly. + // Return empty so the attachment-block shows the fallback (file name display). + return '' } function normalizeSrc(src: string): string { diff --git a/packages/web/src/store/chat-list.ts b/packages/web/src/store/chat-list.ts index c84fc466..63f96624 100644 --- a/packages/web/src/store/chat-list.ts +++ b/packages/web/src/store/chat-list.ts @@ -15,7 +15,8 @@ import { extractMessageText, extractToolCalls, extractAllToolResults, - streamMessage, + sendLocalChannelMessage, + streamLocalChannel, streamMessageEvents, type ChatAttachment, } from '@/composables/api/useChat' @@ -61,6 +62,15 @@ export interface ChatMessage { isSelf?: boolean } +interface PendingAssistantStream { + assistantMsg: ChatMessage + textBlockIdx: number + thinkingBlockIdx: number + done: boolean + resolve: () => void + reject: (err: Error) => void +} + // ---- Store ---- export const useChatStore = defineStore('chat', () => { @@ -80,6 +90,9 @@ export const useChatStore = defineStore('chat', () => { let messageEventsController: AbortController | null = null let messageEventsLoopVersion = 0 let messageEventsSince = '' + let localStreamController: AbortController | null = null + let localStreamLoopVersion = 0 + let pendingAssistantStream: PendingAssistantStream | null = null const participantChats = computed(() => chats.value.filter((c) => (c.access_mode ?? 'participant') === 'participant'), @@ -99,6 +112,8 @@ export const useChatStore = defineStore('chat', () => { void initialize() } else { stopMessageEvents() + stopLocalStream() + rejectPendingAssistantStream(new Error('Bot stream stopped')) messageEventsSince = '' chats.value = [] chatId.value = null @@ -115,18 +130,23 @@ export const useChatStore = defineStore('chat', () => { // ---- Message adapter: convert server Message to ChatMessage ---- + function mediaTypeFromMime(mime: string): string { + const m = (mime || '').toLowerCase().trim() + if (m.startsWith('image/')) return 'image' + if (m.startsWith('audio/')) return 'audio' + if (m.startsWith('video/')) return 'video' + return 'file' + } + function buildAssetBlocks(raw: Message): AttachmentBlock[] { if (!raw.assets?.length) return [] const items: Array> = raw.assets.map((a) => ({ - type: a.media_type, - asset_id: a.asset_id, + type: mediaTypeFromMime(a.mime), + content_hash: a.content_hash, bot_id: raw.bot_id, mime: a.mime, size: a.size_bytes, - name: a.original_name ?? '', storage_key: a.storage_key, - width: a.width, - height: a.height, })) return [{ type: 'attachment', attachments: items }] } @@ -282,6 +302,14 @@ export const useChatStore = defineStore('chat', () => { return senderUserId === currentUserId } + function resolveCrossChannelIsSelf(senderUserId: string): boolean { + if (!senderUserId) return false + const userStore = useUserStore() + const currentUserId = (userStore.userInfo.id ?? '').trim() + if (!currentUserId) return false + return senderUserId === currentUserId + } + // ---- Abort ---- function abort() { @@ -309,6 +337,284 @@ export const useChatStore = defineStore('chat', () => { } } + function stopLocalStream() { + localStreamLoopVersion += 1 + if (localStreamController) { + localStreamController.abort() + localStreamController = null + } + } + + function pushAssistantBlock(session: PendingAssistantStream, block: ContentBlock): number { + session.assistantMsg.blocks.push(block) + return session.assistantMsg.blocks.length - 1 + } + + function appendAssistantError(session: PendingAssistantStream, errorMessage: string) { + const message = errorMessage.trim() + if (!message) return + if (session.textBlockIdx < 0 || session.assistantMsg.blocks[session.textBlockIdx]?.type !== 'text') { + session.textBlockIdx = pushAssistantBlock(session, { type: 'text', content: '' }) + } + ;(session.assistantMsg.blocks[session.textBlockIdx] as TextBlock).content += `\n\n**Error:** ${message}` + } + + function resolvePendingAssistantStream() { + if (!pendingAssistantStream || pendingAssistantStream.done) return + const session = pendingAssistantStream + session.done = true + pendingAssistantStream = null + session.resolve() + } + + function rejectPendingAssistantStream(err: Error) { + if (!pendingAssistantStream || pendingAssistantStream.done) return + const session = pendingAssistantStream + session.done = true + pendingAssistantStream = null + session.reject(err) + } + + function handleLocalStreamEvent(event: StreamEvent) { + // Cross-channel events arrive without a pending session. Detect them via + // source_channel metadata injected by the RouteHubBroadcaster. + const meta = (event as Record).metadata as Record | undefined + const sourceChannel = meta?.source_channel as string | undefined + const isCrossChannel = !!sourceChannel + + // Cross-channel user message (the inbound message from Telegram/Feishu user). + if (isCrossChannel && (event.type ?? '').toLowerCase() === 'final' && meta?.role === 'user') { + const finalPayload = (event as Record).final as Record | undefined + const msg = finalPayload?.message as Record | undefined + if (msg) { + const text = String(msg.text ?? '').trim() + const msgMeta = (msg.metadata as Record | undefined) + const senderName = (msgMeta?.sender_display_name as string) ?? sourceChannel + const senderUserId = String(meta?.sender_user_id ?? '').trim() + const blocks: ContentBlock[] = [] + if (text) blocks.push({ type: 'text', content: text }) + const rawAtts = (msg.attachments ?? msg.Attachments) as Array> | undefined + if (Array.isArray(rawAtts) && rawAtts.length > 0) { + const items = rawAtts.map((a) => ({ + type: mediaTypeFromMime(String(a.mime ?? '')), + content_hash: String(a.content_hash ?? ''), + bot_id: currentBotId.value ?? '', + mime: String(a.mime ?? ''), + size: Number(a.size ?? 0), + storage_key: String((a.metadata as Record | undefined)?.storage_key ?? ''), + })) + blocks.push({ type: 'attachment', attachments: items }) + } + if (blocks.length > 0) { + messages.push({ + id: nextId(), + role: 'user', + blocks, + timestamp: new Date(), + streaming: false, + isSelf: resolveCrossChannelIsSelf(senderUserId), + platform: sourceChannel, + senderDisplayName: senderName, + }) + } + } + return + } + + // Cross-channel assistant events: auto-create a session when none exists. + if (isCrossChannel && !pendingAssistantStream) { + const type = (event.type ?? '').toLowerCase() + // Only start a session for events that carry actual content. + if (type === 'delta' || type === 'text_delta' || type === 'text_start' + || type === 'reasoning_start' || type === 'reasoning_delta' + || type === 'tool_call_start' || type === 'attachment_delta' + || type === 'status' || type === 'processing_started') { + messages.push({ + id: nextId(), + role: 'assistant', + blocks: [], + timestamp: new Date(), + streaming: true, + platform: sourceChannel, + }) + // IMPORTANT: get the reactive proxy from the array, not the plain object. + // Vue 3 wraps pushed objects in a Proxy; using the original ref bypasses reactivity. + const reactiveMsg = messages[messages.length - 1]! + pendingAssistantStream = { + assistantMsg: reactiveMsg, + textBlockIdx: -1, + thinkingBlockIdx: -1, + done: false, + resolve: () => { reactiveMsg.streaming = false }, + reject: () => { reactiveMsg.streaming = false }, + } + } else { + return + } + } + + const session = pendingAssistantStream + if (!session || session.done) return + + const type = (event.type ?? '').toLowerCase() + switch (type) { + case 'text_start': + session.textBlockIdx = pushAssistantBlock(session, { type: 'text', content: '' }) + break + case 'text_delta': + if (typeof event.delta === 'string') { + if (session.textBlockIdx < 0 || session.assistantMsg.blocks[session.textBlockIdx]?.type !== 'text') { + session.textBlockIdx = pushAssistantBlock(session, { type: 'text', content: '' }) + } + ;(session.assistantMsg.blocks[session.textBlockIdx] as TextBlock).content += event.delta + } + break + case 'text_end': + session.textBlockIdx = -1 + break + case 'reasoning_start': + session.thinkingBlockIdx = pushAssistantBlock(session, { type: 'thinking', content: '', done: false }) + break + case 'reasoning_delta': + if (typeof event.delta === 'string') { + if (session.thinkingBlockIdx < 0 || session.assistantMsg.blocks[session.thinkingBlockIdx]?.type !== 'thinking') { + session.thinkingBlockIdx = pushAssistantBlock(session, { type: 'thinking', content: '', done: false }) + } + ;(session.assistantMsg.blocks[session.thinkingBlockIdx] as ThinkingBlock).content += event.delta + } + break + case 'reasoning_end': + if (session.thinkingBlockIdx >= 0 && session.assistantMsg.blocks[session.thinkingBlockIdx]?.type === 'thinking') { + ;(session.assistantMsg.blocks[session.thinkingBlockIdx] as ThinkingBlock).done = true + } + session.thinkingBlockIdx = -1 + break + case 'tool_call_start': + pushAssistantBlock(session, { + type: 'tool_call', + toolCallId: (event.toolCallId as string) ?? '', + toolName: (event.toolName as string) ?? 'unknown', + input: event.input ?? null, + result: null, + done: false, + }) + session.textBlockIdx = -1 + break + case 'tool_call_end': { + const callId = (event.toolCallId as string) ?? '' + let matched = false + if (callId) { + for (let i = 0; i < session.assistantMsg.blocks.length; i++) { + const block = session.assistantMsg.blocks[i] + if (block && block.type === 'tool_call' && block.toolCallId === callId && !block.done) { + block.result = event.result ?? null + block.input = event.input ?? block.input + block.done = true + matched = true + break + } + } + } + if (!matched) { + for (let i = 0; i < session.assistantMsg.blocks.length; i++) { + const block = session.assistantMsg.blocks[i] + if (block && block.type === 'tool_call' && block.toolName === event.toolName && !block.done) { + block.result = event.result ?? null + block.input = event.input ?? block.input + block.done = true + break + } + } + } + break + } + case 'attachment_delta': { + const items = event.attachments + if (Array.isArray(items) && items.length > 0) { + const lastBlock = session.assistantMsg.blocks[session.assistantMsg.blocks.length - 1] + if (lastBlock && lastBlock.type === 'attachment') { + lastBlock.attachments.push(...items) + } else { + pushAssistantBlock(session, { type: 'attachment', attachments: [...items] }) + } + } + break + } + case 'final': + // Text and attachments already accumulated via deltas/attachment_delta. + // For cross-channel, finalize via processing_completed instead. + break + case 'processing_started': + if (session.assistantMsg.blocks.length === 0) { + session.textBlockIdx = pushAssistantBlock(session, { type: 'text', content: '' }) + } + break + case 'processing_completed': + resolvePendingAssistantStream() + break + case 'processing_failed': { + const message = typeof event.message === 'string' + ? event.message + : typeof event.error === 'string' + ? event.error + : 'processing failed' + appendAssistantError(session, message) + rejectPendingAssistantStream(new Error(message)) + break + } + case 'error': { + const message = typeof event.message === 'string' + ? event.message + : typeof event.error === 'string' + ? event.error + : 'stream error' + appendAssistantError(session, message) + rejectPendingAssistantStream(new Error(message)) + break + } + case 'agent_start': + case 'agent_end': + default: { + const fallback = extractFallbackText(event) + if (fallback) { + if (session.textBlockIdx < 0 || session.assistantMsg.blocks[session.textBlockIdx]?.type !== 'text') { + session.textBlockIdx = pushAssistantBlock(session, { type: 'text', content: '' }) + } + ;(session.assistantMsg.blocks[session.textBlockIdx] as TextBlock).content += fallback + } + break + } + } + } + + function startLocalStream(targetBotId: string) { + const bid = targetBotId.trim() + stopLocalStream() + if (!bid) return + + const controller = new AbortController() + localStreamController = controller + const version = localStreamLoopVersion + + const run = async () => { + let delay = 1000 + while (!controller.signal.aborted && localStreamLoopVersion === version) { + try { + await streamLocalChannel(bid, controller.signal, handleLocalStreamEvent) + delay = 1000 + if (!controller.signal.aborted && localStreamLoopVersion === version) { + await sleep(300) + } + } catch { + if (controller.signal.aborted || localStreamLoopVersion !== version) return + await sleep(delay) + delay = Math.min(delay * 2, 5000) + } + } + } + void run() + } + function updateSince(createdAt?: string) { const v = (createdAt ?? '').trim() if (!v) return @@ -467,6 +773,7 @@ export const useChatStore = defineStore('chat', () => { initializing.value = true loadingChats.value = true stopMessageEvents() + stopLocalStream() try { const bid = await ensureBot() if (!bid) { @@ -490,6 +797,7 @@ export const useChatStore = defineStore('chat', () => { chatId.value = activeChatId await loadMessages(bid, activeChatId) startMessageEvents(bid) + startLocalStream(bid) } finally { loadingChats.value = false initializing.value = false @@ -602,192 +910,39 @@ export const useChatStore = defineStore('chat', () => { streaming: true, }) const assistantMsg = messages[messages.length - 1]! + const completion = new Promise((resolve, reject) => { + pendingAssistantStream = { + assistantMsg, + textBlockIdx: -1, + thinkingBlockIdx: -1, + done: false, + resolve, + reject, + } + }) - let textBlockIdx = -1 - let thinkingBlockIdx = -1 - - function pushBlock(block: ContentBlock): number { - assistantMsg.blocks.push(block) - return assistantMsg.blocks.length - 1 + abortFn = () => { + const abortError = new Error('aborted') + abortError.name = 'AbortError' + rejectPendingAssistantStream(abortError) } - abortFn = streamMessage( - bid, cid, trimmed, - (event: StreamEvent) => { - const type = (event.type ?? '').toLowerCase() + await sendLocalChannelMessage(bid, trimmed, attachments) + await completion - switch (type) { - case 'text_start': - textBlockIdx = pushBlock({ type: 'text', content: '' }) - break - - case 'text_delta': - if (typeof event.delta === 'string') { - if (textBlockIdx < 0 || assistantMsg.blocks[textBlockIdx]?.type !== 'text') { - textBlockIdx = pushBlock({ type: 'text', content: '' }) - } - ;(assistantMsg.blocks[textBlockIdx] as TextBlock).content += event.delta - } - break - - case 'text_end': - textBlockIdx = -1 - break - - case 'reasoning_start': - thinkingBlockIdx = pushBlock({ type: 'thinking', content: '', done: false }) - break - - case 'reasoning_delta': - if (typeof event.delta === 'string') { - if (thinkingBlockIdx < 0 || assistantMsg.blocks[thinkingBlockIdx]?.type !== 'thinking') { - thinkingBlockIdx = pushBlock({ type: 'thinking', content: '', done: false }) - } - ;(assistantMsg.blocks[thinkingBlockIdx] as ThinkingBlock).content += event.delta - } - break - - case 'reasoning_end': - if (thinkingBlockIdx >= 0 && assistantMsg.blocks[thinkingBlockIdx]?.type === 'thinking') { - ;(assistantMsg.blocks[thinkingBlockIdx] as ThinkingBlock).done = true - } - thinkingBlockIdx = -1 - break - - case 'tool_call_start': - pushBlock({ - type: 'tool_call', - toolCallId: (event.toolCallId as string) ?? '', - toolName: (event.toolName as string) ?? 'unknown', - input: event.input ?? null, - result: null, - done: false, - }) - textBlockIdx = -1 - break - - case 'tool_call_end': { - const callId = (event.toolCallId as string) ?? '' - let matched = false - if (callId) { - for (let i = 0; i < assistantMsg.blocks.length; i++) { - const b = assistantMsg.blocks[i] - if (b && b.type === 'tool_call' && b.toolCallId === callId && !b.done) { - b.result = event.result ?? null - b.input = event.input ?? b.input - b.done = true - matched = true - break - } - } - } - if (!matched) { - for (let i = 0; i < assistantMsg.blocks.length; i++) { - const b = assistantMsg.blocks[i] - if (b && b.type === 'tool_call' && b.toolName === event.toolName && !b.done) { - b.result = event.result ?? null - b.input = event.input ?? b.input - b.done = true - break - } - } - } - break - } - - case 'attachment_delta': { - const items = event.attachments - if (Array.isArray(items) && items.length > 0) { - const lastBlock = assistantMsg.blocks[assistantMsg.blocks.length - 1] - if (lastBlock && lastBlock.type === 'attachment') { - lastBlock.attachments.push(...items) - } else { - pushBlock({ type: 'attachment', attachments: [...items] }) - } - } - break - } - - case 'processing_started': - if (assistantMsg.blocks.length === 0) { - pushBlock({ type: 'text', content: '' }) - textBlockIdx = 0 - } - break - - case 'processing_completed': - case 'agent_start': - case 'agent_end': - break - - case 'processing_failed': { - const failMsg = typeof event.message === 'string' - ? event.message - : typeof event.error === 'string' - ? event.error - : '' - if (failMsg) { - if (textBlockIdx < 0 || assistantMsg.blocks[textBlockIdx]?.type !== 'text') { - textBlockIdx = pushBlock({ type: 'text', content: '' }) - } - ;(assistantMsg.blocks[textBlockIdx] as TextBlock).content += `\n\n**Error:** ${failMsg}` - } - break - } - - case 'error': { - const errMsg = typeof event.message === 'string' - ? event.message - : typeof event.error === 'string' - ? event.error - : 'Stream error' - if (textBlockIdx < 0 || assistantMsg.blocks[textBlockIdx]?.type !== 'text') { - textBlockIdx = pushBlock({ type: 'text', content: '' }) - } - ;(assistantMsg.blocks[textBlockIdx] as TextBlock).content += `\n\n**Error:** ${errMsg}` - break - } - - default: { - const fallback = extractFallbackText(event) - if (fallback) { - if (textBlockIdx < 0 || assistantMsg.blocks[textBlockIdx]?.type !== 'text') { - textBlockIdx = pushBlock({ type: 'text', content: '' }) - } - ;(assistantMsg.blocks[textBlockIdx] as TextBlock).content += fallback - } - break - } - } - }, - () => { - assistantMsg.streaming = false - streaming.value = false - loading.value = false - abortFn = null - touchChat(cid) - }, - (err) => { - if (assistantMsg.blocks.length === 0) { - assistantMsg.blocks.push({ - type: 'text', - content: `Failed to send message: ${err.message}`, - }) - } - assistantMsg.streaming = false - streaming.value = false - loading.value = false - abortFn = null - }, - attachments, - ) + assistantMsg.streaming = false + streaming.value = false + loading.value = false + abortFn = null + touchChat(cid) } catch (err) { + const isAbort = err instanceof Error && err.name === 'AbortError' const reason = err instanceof Error ? err.message : 'Unknown error' const last = messages[messages.length - 1] - if (last?.role === 'assistant' && last.streaming) { + if (!isAbort && last?.role === 'assistant' && last.streaming) { last.blocks = [{ type: 'text', content: `Failed to send message: ${reason}` }] last.streaming = false - } else { + } else if (!isAbort) { messages.push({ id: nextId(), role: 'assistant', @@ -796,8 +951,10 @@ export const useChatStore = defineStore('chat', () => { streaming: false, }) } + pendingAssistantStream = null streaming.value = false loading.value = false + abortFn = null } } diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index b0ce3c99..262aab9f 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -27,7 +27,7 @@ export default defineConfig(({ command }) => { try { config = loadConfig('../../config.toml') } catch { - config = loadConfig('../../docker/config/config.docker.toml') + config = loadConfig('../../conf/app.docker.toml') } port = config.web?.port ?? defaultPort host = config.web?.host ?? defaultHost diff --git a/scripts/compile-mcp.sh b/scripts/compile-mcp.sh index 23b1c793..a6ea037c 100755 --- a/scripts/compile-mcp.sh +++ b/scripts/compile-mcp.sh @@ -28,11 +28,7 @@ GOOS="$TARGET_OS" GOARCH="$TARGET_ARCH" go build -trimpath -ldflags "-s -w" -o " mv -f "${APP_DIR}/${BIN_NAME}.new" "${APP_DIR}/${BIN_NAME}" if [ -n "$CONTAINER_NAME" ]; then - if [ "$(uname -s)" = "Darwin" ]; then - limactl shell default -- nerdctl kill -s "$STOP_SIGNAL" "$CONTAINER_NAME" - else - nerdctl kill -s "$STOP_SIGNAL" "$CONTAINER_NAME" - fi + nerdctl kill -s "$STOP_SIGNAL" "$CONTAINER_NAME" else echo "CONTAINER_NAME is empty; skip sending stop signal." fi diff --git a/scripts/db-drop.sh b/scripts/db-drop.sh index c2dbf59d..25fd3588 100755 --- a/scripts/db-drop.sh +++ b/scripts/db-drop.sh @@ -1,47 +1,9 @@ #!/bin/bash - set -e PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -CONFIG_FILE="${PROJECT_ROOT}/config.toml" -MIGRATIONS_DIR="${PROJECT_ROOT}/db/migrations" -GREEN='\033[0;32m' -RED='\033[0;31m' -YELLOW='\033[1;33m' -NC='\033[0m' - -if [ ! -f "$CONFIG_FILE" ]; then - echo -e "${RED}Error: Config file not found${NC}" - exit 1 -fi - -if [ ! -d "$MIGRATIONS_DIR" ]; then - echo -e "${RED}Error: Migrations directory not found${NC}" - exit 1 -fi - -parse_toml_value() { - local key=$1 - local section=$2 - grep -A 20 "^\[$section\]" "$CONFIG_FILE" | grep "^$key" | head -1 | sed 's/.*=[ ]*//' | tr -d '"' | tr -d "'" -} - -DB_HOST=$(parse_toml_value "host" "postgres") -DB_PORT=$(parse_toml_value "port" "postgres") -DB_USER=$(parse_toml_value "user" "postgres") -DB_PASSWORD=$(parse_toml_value "password" "postgres") -DB_NAME=$(parse_toml_value "database" "postgres") -DB_SSLMODE=$(parse_toml_value "sslmode" "postgres") - -if [ -z "$DB_HOST" ] || [ -z "$DB_PORT" ] || [ -z "$DB_USER" ] || [ -z "$DB_NAME" ]; then - echo -e "${RED}Error: Invalid database configuration${NC}" - exit 1 -fi - -DB_SSLMODE=${DB_SSLMODE:-disable} - -echo -e "${YELLOW}WARNING: This will drop all database tables!${NC}" +echo "WARNING: This will roll back all database migrations!" read -p "Type 'yes' to confirm: " confirmation if [ "$confirmation" != "yes" ]; then @@ -49,24 +11,4 @@ if [ "$confirmation" != "yes" ]; then exit 0 fi -export PGPASSWORD="$DB_PASSWORD" -PSQL_CMD="psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME" - -if ! $PSQL_CMD -c "SELECT 1;" > /dev/null 2>&1; then - echo -e "${RED}Error: Cannot connect to database${NC}" - exit 1 -fi - -for migration_file in $(ls -r "$MIGRATIONS_DIR"/*.down.sql 2>/dev/null); do - if [ -f "$migration_file" ]; then - if ! $PSQL_CMD -f "$migration_file" > /dev/null 2>&1; then - echo -e "${RED}Error: Drop failed - $(basename "$migration_file")${NC}" - exit 1 - fi - fi -done - -echo -e "${GREEN}✓ Database tables dropped${NC}" - -unset PGPASSWORD - +go run "${PROJECT_ROOT}/cmd/agent/main.go" migrate down diff --git a/scripts/db-up.sh b/scripts/db-up.sh index 99ef2434..d7b32053 100755 --- a/scripts/db-up.sh +++ b/scripts/db-up.sh @@ -1,63 +1,6 @@ #!/bin/bash - set -e PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -CONFIG_FILE="${PROJECT_ROOT}/config.toml" -MIGRATIONS_DIR="${PROJECT_ROOT}/db/migrations" - -GREEN='\033[0;32m' -RED='\033[0;31m' -NC='\033[0m' - -if [ ! -f "$CONFIG_FILE" ]; then - echo -e "${RED}Error: Config file not found${NC}" - exit 1 -fi - -if [ ! -d "$MIGRATIONS_DIR" ]; then - echo -e "${RED}Error: Migrations directory not found${NC}" - exit 1 -fi - -parse_toml_value() { - local key=$1 - local section=$2 - grep -A 20 "^\[$section\]" "$CONFIG_FILE" | grep "^$key" | head -1 | sed 's/.*=[ ]*//' | tr -d '"' | tr -d "'" -} - -DB_HOST=$(parse_toml_value "host" "postgres") -DB_PORT=$(parse_toml_value "port" "postgres") -DB_USER=$(parse_toml_value "user" "postgres") -DB_PASSWORD=$(parse_toml_value "password" "postgres") -DB_NAME=$(parse_toml_value "database" "postgres") -DB_SSLMODE=$(parse_toml_value "sslmode" "postgres") - -if [ -z "$DB_HOST" ] || [ -z "$DB_PORT" ] || [ -z "$DB_USER" ] || [ -z "$DB_NAME" ]; then - echo -e "${RED}Error: Invalid database configuration${NC}" - exit 1 -fi - -DB_SSLMODE=${DB_SSLMODE:-disable} - -export PGPASSWORD="$DB_PASSWORD" -PSQL_CMD="psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME" - -if ! $PSQL_CMD -c "SELECT 1;" > /dev/null 2>&1; then - echo -e "${RED}Error: Cannot connect to database${NC}" - exit 1 -fi - -for migration_file in "$MIGRATIONS_DIR"/*.up.sql; do - if [ -f "$migration_file" ]; then - if ! $PSQL_CMD -f "$migration_file" > /dev/null 2>&1; then - echo -e "${RED}Error: Migration failed - $(basename "$migration_file")${NC}" - exit 1 - fi - fi -done - -echo -e "${GREEN}✓ Database migration completed${NC}" - -unset PGPASSWORD +go run "${PROJECT_ROOT}/cmd/agent/main.go" migrate up diff --git a/scripts/install.sh b/scripts/install.sh index f8cc44d9..6b4dd3a6 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -28,13 +28,23 @@ echo "${GREEN} Memoh One-Click Install${NC}" echo "${GREEN}========================================${NC}" echo "" -# Check Docker +# Check Docker and determine if sudo is needed +DOCKER="docker" if ! command -v docker >/dev/null 2>&1; then echo "${RED}Error: Docker is not installed${NC}" echo "Install Docker first: https://docs.docker.com/get-docker/" exit 1 fi -if ! docker compose version >/dev/null 2>&1; then +if ! docker info >/dev/null 2>&1; then + if sudo docker info >/dev/null 2>&1; then + DOCKER="sudo docker" + else + echo "${RED}Error: Cannot connect to Docker daemon${NC}" + echo "Try: sudo usermod -aG docker \$USER && newgrp docker" + exit 1 + fi +fi +if ! $DOCKER compose version >/dev/null 2>&1; then echo "${RED}Error: Docker Compose v2 is required${NC}" echo "Install: https://docs.docker.com/compose/install/" exit 1 @@ -122,7 +132,7 @@ else fi # Generate config.toml from template -cp docker/config/config.docker.toml config.toml +cp conf/app.docker.toml config.toml sed -i.bak "s|username = \"admin\"|username = \"${ADMIN_USER}\"|" config.toml sed -i.bak "s|password = \"admin123\"|password = \"${ADMIN_PASS}\"|" config.toml sed -i.bak "s|jwt_secret = \".*\"|jwt_secret = \"${JWT_SECRET}\"|" config.toml @@ -138,7 +148,7 @@ mkdir -p "$MEMOH_DATA_DIR" echo "" echo "${GREEN}Starting services (first build may take a few minutes)...${NC}" -docker compose up -d --build +$DOCKER compose up -d --build echo "" echo "${GREEN}========================================${NC}" @@ -152,8 +162,8 @@ echo "" echo " Admin login: ${ADMIN_USER} / ${ADMIN_PASS}" echo "" echo "Commands:" -echo " cd ${INSTALL_DIR} && docker compose ps # Status" -echo " cd ${INSTALL_DIR} && docker compose logs -f # Logs" -echo " cd ${INSTALL_DIR} && docker compose down # Stop" +echo " cd ${INSTALL_DIR} && $DOCKER compose ps # Status" +echo " cd ${INSTALL_DIR} && $DOCKER compose logs -f # Logs" +echo " cd ${INSTALL_DIR} && $DOCKER compose down # Stop" echo "" echo "${YELLOW}First startup may take 1-2 minutes, please be patient.${NC}" diff --git a/scripts/lima-up.sh b/scripts/lima-up.sh deleted file mode 100644 index 8829d8e7..00000000 --- a/scripts/lima-up.sh +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env sh -set -e - -if [ "$(uname -s)" != "Darwin" ]; then - exit 0 -fi - -host_yaml="$HOME/.lima/default/lima.yaml" -if [ -f "$host_yaml" ]; then - if ! awk ' - BEGIN {in=0; entries=0} - /^portForwards:/ {in=1; next} - in && /^[^ ]/ {in=0} - in && /^ - / {entries=1; exit} - END {if (entries) exit 0; else exit 1} - ' "$host_yaml"; then - tmp="${host_yaml}.tmp" - awk ' - BEGIN {found=0; inserted=0} - /^portForwards: *\[/ { - found=1 - print "portForwards:" - if (!inserted) { - print " - guestSocket: \"/run/containerd/containerd.sock\"" - print " hostSocket: \"{{.Dir}}/sock/containerd/containerd.sock\"" - inserted=1 - } - next - } - /^portForwards:/ { - found=1 - print - if (!inserted) { - print " - guestSocket: \"/run/containerd/containerd.sock\"" - print " hostSocket: \"{{.Dir}}/sock/containerd/containerd.sock\"" - inserted=1 - } - next - } - {print} - END { - if (!found) { - print "portForwards:" - print " - guestSocket: \"/run/containerd/containerd.sock\"" - print " hostSocket: \"{{.Dir}}/sock/containerd/containerd.sock\"" - } - } - ' "$host_yaml" > "$tmp" && mv "$tmp" "$host_yaml" - fi -fi - -limactl start default -limactl shell default -- sudo -n chmod 666 /run/containerd/containerd.sock - -if ! limactl shell default -- sh -lc 'command -v memoh-cli >/dev/null 2>&1'; then - vm_arch=$(limactl shell default -- uname -m) - if [ "$vm_arch" = "aarch64" ] || [ "$vm_arch" = "arm64" ]; then - go_arch="arm64" - else - go_arch="amd64" - fi - bin_path="/tmp/memoh-cli-linux-$go_arch" - GOOS=linux GOARCH=$go_arch go build -trimpath -ldflags "-s -w" -o "$bin_path" ./cmd/cli - limactl shell default -- sudo -n mkdir -p /usr/local/bin - limactl shell default -- sudo -n tee /usr/local/bin/memoh-cli >/dev/null < "$bin_path" - limactl shell default -- sudo -n chmod +x /usr/local/bin/memoh-cli -fi - -limactl shell default -- sh -lc 'command -v curl >/dev/null 2>&1' || { - echo "curl not found in Lima VM; install curl and rerun" - exit 1 -} - -limactl shell default -- sh -lc 'test -x /opt/cni/bin/bridge' || { - vm_arch=$(limactl shell default -- uname -m) - if [ "$vm_arch" = "aarch64" ] || [ "$vm_arch" = "arm64" ]; then - cni_arch="arm64" - else - cni_arch="amd64" - fi - url="https://github.com/containernetworking/plugins/releases/download/v1.9.0/cni-plugins-linux-${cni_arch}-v1.9.0.tgz" - limactl shell default -- sudo -n mkdir -p /opt/cni/bin - limactl shell default -- sudo -n curl -L -o /tmp/cni-plugins.tgz "$url" - limactl shell default -- sudo -n tar -C /opt/cni/bin -xzf /tmp/cni-plugins.tgz -} - -limactl shell default -- sudo -n mkdir -p /etc/cni/net.d -limactl shell default -- sudo -n sh -lc 'test -f /etc/cni/net.d/10-memoh-bridge.conflist' || \ -limactl shell default -- sudo -n sh -lc 'printf "%s\n" "{" " \"cniVersion\": \"0.4.0\"," " \"name\": \"memoh-bridge\"," " \"plugins\": [" " {" " \"type\": \"bridge\"," " \"bridge\": \"cni0\"," " \"isGateway\": true," " \"ipMasq\": true," " \"promiscMode\": false," " \"hairpinMode\": true," " \"ipam\": {" " \"type\": \"host-local\"," " \"subnet\": \"10.88.0.0/16\"," " \"routes\": [" " {\"dst\": \"0.0.0.0/0\"}" " ]" " }" " }," " {\"type\": \"portmap\", \"capabilities\": {\"portMappings\": true}}," " {\"type\": \"firewall\"}," " {\"type\": \"tuning\"}" " ]" "}" > /etc/cni/net.d/10-memoh-bridge.conflist' diff --git a/spec/docs.go b/spec/docs.go index ae6a8554..6d6f7673 100644 --- a/spec/docs.go +++ b/spec/docs.go @@ -148,6 +148,115 @@ const docTemplate = `{ } } }, + "/bots/{bot_id}/cli/messages": { + "post": { + "description": "Post a user message (with optional attachments) through the local channel pipeline.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "local-channel" + ], + "summary": "Send a message to a local channel", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "description": "Message payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.LocalChannelMessageRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/bots/{bot_id}/cli/stream": { + "get": { + "description": "Open a persistent SSE connection to receive real-time stream events for the given bot.", + "produces": [ + "text/event-stream" + ], + "tags": [ + "local-channel" + ], + "summary": "Subscribe to local channel events via SSE", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "SSE stream", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, "/bots/{bot_id}/container": { "get": { "tags": [ @@ -2397,6 +2506,115 @@ const docTemplate = `{ } } }, + "/bots/{bot_id}/web/messages": { + "post": { + "description": "Post a user message (with optional attachments) through the local channel pipeline.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "local-channel" + ], + "summary": "Send a message to a local channel", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "description": "Message payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.LocalChannelMessageRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/bots/{bot_id}/web/stream": { + "get": { + "description": "Open a persistent SSE connection to receive real-time stream events for the given bot.", + "produces": [ + "text/event-stream" + ], + "tags": [ + "local-channel" + ], + "summary": "Subscribe to local channel events via SSE", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "SSE stream", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, "/bots/{id}": { "get": { "description": "Get a bot by ID (owner/admin only)", @@ -5137,9 +5355,6 @@ const docTemplate = `{ "channel.Attachment": { "type": "object", "properties": { - "asset_id": { - "type": "string" - }, "base64": { "description": "data URL for agent delivery", "type": "string" @@ -5147,6 +5362,9 @@ const docTemplate = `{ "caption": { "type": "string" }, + "content_hash": { + "type": "string" + }, "duration_ms": { "type": "integer" }, @@ -5713,6 +5931,12 @@ const docTemplate = `{ }, "snapshotter": { "type": "string" + }, + "source": { + "type": "string" + }, + "version": { + "type": "integer" } } }, @@ -5847,6 +6071,14 @@ const docTemplate = `{ } } }, + "handlers.LocalChannelMessageRequest": { + "type": "object", + "properties": { + "message": { + "$ref": "#/definitions/channel.Message" + } + } + }, "handlers.LoginRequest": { "type": "object", "properties": { @@ -5993,6 +6225,9 @@ const docTemplate = `{ "type": "string" } }, + "managed": { + "type": "boolean" + }, "name": { "type": "string" }, @@ -6002,8 +6237,14 @@ const docTemplate = `{ "snapshotter": { "type": "string" }, + "source": { + "type": "string" + }, "updated_at": { "type": "string" + }, + "version": { + "type": "integer" } } }, @@ -6485,16 +6726,7 @@ const docTemplate = `{ "message.MessageAsset": { "type": "object", "properties": { - "asset_id": { - "type": "string" - }, - "duration_ms": { - "type": "integer" - }, - "height": { - "type": "integer" - }, - "media_type": { + "content_hash": { "type": "string" }, "mime": { @@ -6503,9 +6735,6 @@ const docTemplate = `{ "ordinal": { "type": "integer" }, - "original_name": { - "type": "string" - }, "role": { "type": "string" }, @@ -6514,9 +6743,6 @@ const docTemplate = `{ }, "storage_key": { "type": "string" - }, - "width": { - "type": "integer" } } }, diff --git a/spec/swagger.json b/spec/swagger.json index 58a4b7ee..6c7bb580 100644 --- a/spec/swagger.json +++ b/spec/swagger.json @@ -139,6 +139,115 @@ } } }, + "/bots/{bot_id}/cli/messages": { + "post": { + "description": "Post a user message (with optional attachments) through the local channel pipeline.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "local-channel" + ], + "summary": "Send a message to a local channel", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "description": "Message payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.LocalChannelMessageRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/bots/{bot_id}/cli/stream": { + "get": { + "description": "Open a persistent SSE connection to receive real-time stream events for the given bot.", + "produces": [ + "text/event-stream" + ], + "tags": [ + "local-channel" + ], + "summary": "Subscribe to local channel events via SSE", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "SSE stream", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, "/bots/{bot_id}/container": { "get": { "tags": [ @@ -2388,6 +2497,115 @@ } } }, + "/bots/{bot_id}/web/messages": { + "post": { + "description": "Post a user message (with optional attachments) through the local channel pipeline.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "local-channel" + ], + "summary": "Send a message to a local channel", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + }, + { + "description": "Message payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.LocalChannelMessageRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/bots/{bot_id}/web/stream": { + "get": { + "description": "Open a persistent SSE connection to receive real-time stream events for the given bot.", + "produces": [ + "text/event-stream" + ], + "tags": [ + "local-channel" + ], + "summary": "Subscribe to local channel events via SSE", + "parameters": [ + { + "type": "string", + "description": "Bot ID", + "name": "bot_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "SSE stream", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, "/bots/{id}": { "get": { "description": "Get a bot by ID (owner/admin only)", @@ -5128,9 +5346,6 @@ "channel.Attachment": { "type": "object", "properties": { - "asset_id": { - "type": "string" - }, "base64": { "description": "data URL for agent delivery", "type": "string" @@ -5138,6 +5353,9 @@ "caption": { "type": "string" }, + "content_hash": { + "type": "string" + }, "duration_ms": { "type": "integer" }, @@ -5704,6 +5922,12 @@ }, "snapshotter": { "type": "string" + }, + "source": { + "type": "string" + }, + "version": { + "type": "integer" } } }, @@ -5838,6 +6062,14 @@ } } }, + "handlers.LocalChannelMessageRequest": { + "type": "object", + "properties": { + "message": { + "$ref": "#/definitions/channel.Message" + } + } + }, "handlers.LoginRequest": { "type": "object", "properties": { @@ -5984,6 +6216,9 @@ "type": "string" } }, + "managed": { + "type": "boolean" + }, "name": { "type": "string" }, @@ -5993,8 +6228,14 @@ "snapshotter": { "type": "string" }, + "source": { + "type": "string" + }, "updated_at": { "type": "string" + }, + "version": { + "type": "integer" } } }, @@ -6476,16 +6717,7 @@ "message.MessageAsset": { "type": "object", "properties": { - "asset_id": { - "type": "string" - }, - "duration_ms": { - "type": "integer" - }, - "height": { - "type": "integer" - }, - "media_type": { + "content_hash": { "type": "string" }, "mime": { @@ -6494,9 +6726,6 @@ "ordinal": { "type": "integer" }, - "original_name": { - "type": "string" - }, "role": { "type": "string" }, @@ -6505,9 +6734,6 @@ }, "storage_key": { "type": "string" - }, - "width": { - "type": "integer" } } }, diff --git a/spec/swagger.yaml b/spec/swagger.yaml index eb66e164..fbf58cff 100644 --- a/spec/swagger.yaml +++ b/spec/swagger.yaml @@ -209,13 +209,13 @@ definitions: type: object channel.Attachment: properties: - asset_id: - type: string base64: description: data URL for agent delivery type: string caption: type: string + content_hash: + type: string duration_ms: type: integer height: @@ -603,6 +603,10 @@ definitions: type: string snapshotter: type: string + source: + type: string + version: + type: integer type: object handlers.EmbeddingsInput: properties: @@ -689,6 +693,11 @@ definitions: snapshotter: type: string type: object + handlers.LocalChannelMessageRequest: + properties: + message: + $ref: '#/definitions/channel.Message' + type: object handlers.LoginRequest: properties: password: @@ -784,14 +793,20 @@ definitions: additionalProperties: type: string type: object + managed: + type: boolean name: type: string parent: type: string snapshotter: type: string + source: + type: string updated_at: type: string + version: + type: integer type: object handlers.listMyIdentitiesResponse: properties: @@ -1108,28 +1123,18 @@ definitions: type: object message.MessageAsset: properties: - asset_id: - type: string - duration_ms: - type: integer - height: - type: integer - media_type: + content_hash: type: string mime: type: string ordinal: type: integer - original_name: - type: string role: type: string size_bytes: type: integer storage_key: type: string - width: - type: integer type: object models.AddRequest: properties: @@ -1684,6 +1689,80 @@ paths: summary: Create bot user tags: - bots + /bots/{bot_id}/cli/messages: + post: + consumes: + - application/json + description: Post a user message (with optional attachments) through the local + channel pipeline. + parameters: + - description: Bot ID + in: path + name: bot_id + required: true + type: string + - description: Message payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/handlers.LocalChannelMessageRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Send a message to a local channel + tags: + - local-channel + /bots/{bot_id}/cli/stream: + get: + description: Open a persistent SSE connection to receive real-time stream events + for the given bot. + parameters: + - description: Bot ID + in: path + name: bot_id + required: true + type: string + produces: + - text/event-stream + responses: + "200": + description: SSE stream + schema: + type: string + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Subscribe to local channel events via SSE + tags: + - local-channel /bots/{bot_id}/container: delete: parameters: @@ -3184,6 +3263,80 @@ paths: summary: Unified MCP tools gateway tags: - containerd + /bots/{bot_id}/web/messages: + post: + consumes: + - application/json + description: Post a user message (with optional attachments) through the local + channel pipeline. + parameters: + - description: Bot ID + in: path + name: bot_id + required: true + type: string + - description: Message payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/handlers.LocalChannelMessageRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Send a message to a local channel + tags: + - local-channel + /bots/{bot_id}/web/stream: + get: + description: Open a persistent SSE connection to receive real-time stream events + for the given bot. + parameters: + - description: Bot ID + in: path + name: bot_id + required: true + type: string + produces: + - text/event-stream + responses: + "200": + description: SSE stream + schema: + type: string + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Subscribe to local channel events via SSE + tags: + - local-channel /bots/{id}: delete: description: Delete a bot user (owner/admin only)