refactor: content-addressed assets, cross-channel multimodal, infra simplification (#63)

* refactor(attachment): multimodal attachment refactor with snapshot schema and storage layer

- Add snapshot schema migration (0008) and update init/versions/snapshots
- Add internal/attachment and internal/channel normalize for unified attachment handling
- Move containerfs provider from internal/media to internal/storage
- Update agent types, channel adapters (Telegram/Feishu), inbound and handlers
- Add containerd snapshot lineage and local_channel tests
- Regenerate sqlc, swagger and SDK

* refactor(media): content-addressed asset system with unified naming

- Replace asset_id foreign key with content_hash as sole identifier
  for bot_history_message_assets (pure soft-link model)
- Remove mime, size_bytes, storage_key from DB; derive at read time
  via media.Resolve from actual storage
- Merge migrations 0008/0009 into single 0008; keep 0001 as canonical schema
- Add Docker initdb script for deterministic migration execution order
- Fix cross-channel real-time image display (Telegram → WebUI SSE)
- Fix message disappearing on refresh (null assets fallback)
- Fix file icon instead of image preview (mime derivation from storage)
- Unify AssetID → ContentHash naming across Go, Agent, and Frontend
- Change storage key prefix from 4-char to 2-char for directory sharding
- Add server-entrypoint.sh for Docker deployment migration handling

* refactor(infra): embedded migrations, Docker simplification, and config consolidation

- Embed SQL migrations into Go binary, removing shell-based migration scripts
- Consolidate config files into conf/ directory (app.example.toml, app.docker.toml, app.dev.toml)
- Simplify Docker setup: remove initdb.d scripts, streamline nginx config and entrypoint
- Remove legacy CLI, feishu-echo commands, and obsolete incremental migration files
- Update install script and docs to require sudo for one-click install
- Add mise tasks for dev environment orchestration

* chore: recover migrations

---------

Co-authored-by: Acbox <acbox0328@gmail.com>
This commit is contained in:
BBQ
2026-02-19 00:20:27 +08:00
committed by GitHub
parent 740f620fe4
commit bc374fe8cd
104 changed files with 6133 additions and 2987 deletions
+143 -10
View File
@@ -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<string, unknown>)
}
return null
}
const LEGACY_STREAM_EVENT_TYPES = new Set<string>([
'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<string, unknown>): 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<string, unknown>
: {}
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<Record<string, unknown>>
: []
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<Uint8Array> }
@@ -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) {