mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
refactor(core): restructure conversation, channel and message domains
- Rename chat module to conversation with flow-based architecture - Move channelidentities into channel/identities subpackage - Add channel/route for routing logic - Add message service with event hub - Add MCP providers: container, directory, schedule - Refactor Feishu/Telegram adapters with directory and stream support - Add platform management page and channel badges in web UI - Update database schema for conversations, messages and channel routes - Add @memoh/shared package for cross-package type definitions
This commit is contained in:
+60
-16
@@ -24,7 +24,6 @@ export const createAgent = ({
|
||||
currentChannel = 'Unknown Channel',
|
||||
identity = {
|
||||
botId: '',
|
||||
sessionId: '',
|
||||
containerId: '',
|
||||
channelIdentityId: '',
|
||||
displayName: '',
|
||||
@@ -53,18 +52,43 @@ export const createAgent = ({
|
||||
toolsContent: '',
|
||||
}
|
||||
}
|
||||
const fetchFile = async (path: string) => {
|
||||
const response = await fetch(`/bots/${identity.botId}/container/fs/file?path=${encodeURIComponent(path)}`)
|
||||
if (!response.ok) {
|
||||
return ''
|
||||
const readViaMCP = async (path: string): Promise<string> => {
|
||||
const url = `${auth.baseUrl.replace(/\/$/, '')}/bots/${identity.botId}/tools`
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json, text/event-stream',
|
||||
'Authorization': `Bearer ${auth.bearer}`,
|
||||
}
|
||||
const data = await response.json().catch(() => ({} as { content?: string }))
|
||||
return typeof data?.content === 'string' ? data.content : ''
|
||||
if (identity.channelIdentityId) {
|
||||
headers['X-Memoh-Channel-Identity-Id'] = identity.channelIdentityId
|
||||
}
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: `read-${path}`,
|
||||
method: 'tools/call',
|
||||
params: { name: 'read', arguments: { path } },
|
||||
})
|
||||
const response = await fetch(url, { method: 'POST', headers, body })
|
||||
if (!response.ok) return ''
|
||||
const data = await response.json().catch(() => ({} as any))
|
||||
const structured = data?.result?.structuredContent ?? data?.result?.content?.[0]?.text
|
||||
if (typeof structured === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(structured)
|
||||
return typeof parsed?.content === 'string' ? parsed.content : ''
|
||||
} catch {
|
||||
return structured
|
||||
}
|
||||
}
|
||||
if (typeof structured === 'object' && structured?.content) {
|
||||
return typeof structured.content === 'string' ? structured.content : ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
const [identityContent, soulContent, toolsContent] = await Promise.all([
|
||||
fetchFile('IDENTITY.md'),
|
||||
fetchFile('SOUL.md'),
|
||||
fetchFile('TOOLS.md'),
|
||||
readViaMCP('IDENTITY.md'),
|
||||
readViaMCP('SOUL.md'),
|
||||
readViaMCP('TOOLS.md'),
|
||||
])
|
||||
return {
|
||||
identityContent,
|
||||
@@ -80,6 +104,7 @@ export const createAgent = ({
|
||||
language,
|
||||
maxContextLoadTime: activeContextTime,
|
||||
channels,
|
||||
currentChannel,
|
||||
skills,
|
||||
enabledSkills,
|
||||
identityContent,
|
||||
@@ -100,9 +125,6 @@ export const createAgent = ({
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': `Bearer ${auth.bearer}`,
|
||||
}
|
||||
if (identity.sessionId) {
|
||||
headers['X-Memoh-Chat-Id'] = identity.sessionId
|
||||
}
|
||||
if (identity.channelIdentityId) {
|
||||
headers['X-Memoh-Channel-Identity-Id'] = identity.channelIdentityId
|
||||
}
|
||||
@@ -115,9 +137,6 @@ export const createAgent = ({
|
||||
if (identity.replyTarget) {
|
||||
headers['X-Memoh-Reply-Target'] = identity.replyTarget
|
||||
}
|
||||
if (identity.displayName) {
|
||||
headers['X-Memoh-Display-Name'] = identity.displayName
|
||||
}
|
||||
const { tools: mcpTools, close: closeMCP } = await getMCPTools(`${baseUrl}/bots/${botId}/tools`, headers)
|
||||
return {
|
||||
tools: mcpTools,
|
||||
@@ -257,6 +276,28 @@ export const createAgent = ({
|
||||
}
|
||||
}
|
||||
|
||||
const resolveStreamErrorMessage = (raw: unknown): string => {
|
||||
if (raw instanceof Error && raw.message.trim()) {
|
||||
return raw.message
|
||||
}
|
||||
if (typeof raw === 'string' && raw.trim()) {
|
||||
return raw
|
||||
}
|
||||
if (raw && typeof raw === 'object') {
|
||||
const candidate = raw as { message?: unknown; error?: unknown }
|
||||
if (typeof candidate.message === 'string' && candidate.message.trim()) {
|
||||
return candidate.message
|
||||
}
|
||||
if (typeof candidate.error === 'string' && candidate.error.trim()) {
|
||||
return candidate.error
|
||||
}
|
||||
if (candidate.error instanceof Error && candidate.error.message.trim()) {
|
||||
return candidate.error.message
|
||||
}
|
||||
}
|
||||
return 'Model stream failed'
|
||||
}
|
||||
|
||||
async function* stream(input: AgentInput): AsyncGenerator<AgentAction> {
|
||||
const userPrompt = generateUserPrompt(input)
|
||||
const messages = [...input.messages, userPrompt]
|
||||
@@ -296,6 +337,9 @@ export const createAgent = ({
|
||||
input,
|
||||
}
|
||||
for await (const chunk of fullStream) {
|
||||
if (chunk.type === 'error') {
|
||||
throw new Error(resolveStreamErrorMessage((chunk as { error?: unknown }).error))
|
||||
}
|
||||
switch (chunk.type) {
|
||||
case 'reasoning-start': yield {
|
||||
type: 'reasoning_start',
|
||||
|
||||
@@ -22,7 +22,6 @@ export const AllowedActionModel = z.enum(allActions)
|
||||
|
||||
export const IdentityContextModel = z.object({
|
||||
botId: z.string().min(1, 'Bot ID is required'),
|
||||
sessionId: z.string().min(1, 'Session ID is required'),
|
||||
containerId: z.string().min(1, 'Container ID is required'),
|
||||
channelIdentityId: z.string().min(1, 'Channel identity ID is required'),
|
||||
displayName: z.string().min(1, 'Display name is required'),
|
||||
|
||||
@@ -78,9 +78,12 @@ export const chatModule = new Elysia({ prefix: '/chat' })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
const message = error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: 'Internal server error'
|
||||
yield sse(JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'Internal server error',
|
||||
message,
|
||||
}))
|
||||
}
|
||||
}, {
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface SystemParams {
|
||||
language: string
|
||||
maxContextLoadTime: number
|
||||
channels: string[]
|
||||
/** Channel where the current session/message is from (e.g. telegram, feishu, web). */
|
||||
currentChannel: string
|
||||
skills: AgentSkill[]
|
||||
enabledSkills: AgentSkill[]
|
||||
identityContent?: string
|
||||
@@ -23,11 +25,12 @@ ${skill.content}
|
||||
`.trim()
|
||||
}
|
||||
|
||||
export const system = ({
|
||||
export const system = ({
|
||||
date,
|
||||
language,
|
||||
maxContextLoadTime,
|
||||
channels,
|
||||
currentChannel,
|
||||
skills,
|
||||
enabledSkills,
|
||||
identityContent,
|
||||
@@ -37,6 +40,7 @@ export const system = ({
|
||||
const headers = {
|
||||
'language': language,
|
||||
'available-channels': channels.join(','),
|
||||
'current-session-channel': currentChannel,
|
||||
'max-context-load-time': maxContextLoadTime.toString(),
|
||||
'time-now': date.toISOString(),
|
||||
}
|
||||
@@ -97,8 +101,12 @@ You have a contacts book to record them that you do not need to worry about who
|
||||
|
||||
## Channels
|
||||
|
||||
The current session (and the latest user message) is from channel: ${quote(currentChannel)}. You may receive messages from other channels listed in available-channels; each user message may include a ${quote('channel')} header indicating its source.
|
||||
|
||||
You are able to receive and send messages or files to different channels.
|
||||
|
||||
When you need to resolve a user or group on a channel (e.g. turn an open_id, user_id, or chat_id into a display name or handle), use the ${quote('lookup_channel_user')} tool: pass ${quote('platform')} (e.g. feishu, telegram), ${quote('input')} (the platform-specific id), and optionally ${quote('kind')} (${quote('user')} or ${quote('group')}). It returns name, handle, and id for that entry.
|
||||
|
||||
## Attachments
|
||||
|
||||
### Receive
|
||||
|
||||
@@ -74,7 +74,6 @@ describe('getMCPTools (unified endpoint)', () => {
|
||||
const endpoint = `http://127.0.0.1:${server.port}/bots/bot-1/tools`
|
||||
const { tools, close } = await getMCPTools(endpoint, {
|
||||
Authorization: 'Bearer test-token',
|
||||
'X-Memoh-Chat-Id': 'chat-1',
|
||||
})
|
||||
|
||||
expect(Object.keys(tools)).toContain('search_memory')
|
||||
|
||||
@@ -26,11 +26,11 @@ export const getMemoryTools = ({ fetch, identity }: MemoryToolParams) => {
|
||||
limit: z.number().int().positive().max(50).optional(),
|
||||
}),
|
||||
execute: async ({ query, limit }) => {
|
||||
const chatId = identity.sessionId.trim()
|
||||
if (!chatId) {
|
||||
throw new Error('sessionId is required to search memory')
|
||||
const botId = identity.botId.trim()
|
||||
if (!botId) {
|
||||
throw new Error('botId is required to search memory')
|
||||
}
|
||||
const response = await fetch(`/chats/${chatId}/memory/search`, {
|
||||
const response = await fetch(`/bots/${botId}/memory/search`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -4,13 +4,11 @@ import { AgentAttachment } from './attachment'
|
||||
|
||||
export interface IdentityContext {
|
||||
botId: string
|
||||
sessionId: string
|
||||
containerId: string
|
||||
|
||||
channelIdentityId: string
|
||||
displayName: string
|
||||
|
||||
// Deprecated compatibility fields kept optional for older callers.
|
||||
contactId?: string
|
||||
contactName?: string
|
||||
contactAlias?: string
|
||||
|
||||
Reference in New Issue
Block a user