chatgpt-plugin/models/memory/router.js
ikechan8370 8bfce5402f
试验性的记忆功能 (#812)
* feat: memory basic

* fix: chaite ver

* fix: update prompt

* fix: memory cursor and extract prompt

* fix: memory retrieval bug

* fix: memory retrieval bug

* fix: one more attempt by codex

* fix: messages prompt error

* fix: one more time by codex

* fix: metrics by codex

* fix: memory forward

* fix: memory show update time
2025-11-07 16:40:26 +08:00

726 lines
27 KiB
JavaScript

import express from 'express'
import fs from 'fs'
import os from 'os'
import path from 'path'
import https from 'https'
import { pipeline } from 'stream'
import { promisify } from 'util'
let AdmZip
try {
AdmZip = (await import('adm-zip')).default
} catch (e) {
logger.warn('Failed to load AdmZip, maybe you need to install it manually:', e)
}
import { execSync } from "child_process"
import {
Chaite,
ChaiteResponse,
FrontEndAuthHandler
} from 'chaite'
import ChatGPTConfig from '../../config/config.js'
import { memoryService } from './service.js'
import {
resetCachedDimension,
resetMemoryDatabaseInstance,
getSimpleExtensionState,
resolvePluginPath,
toPluginRelativePath,
resetVectorTableDimension
} from './database.js'
const streamPipeline = promisify(pipeline)
const SIMPLE_DOWNLOAD_BASE_URL = 'https://github.com/wangfenjin/simple/releases/latest/download'
const SIMPLE_ASSET_MAP = {
'linux-x64': 'libsimple-linux-ubuntu-latest.zip',
'linux-arm64': 'libsimple-linux-ubuntu-24.04-arm.zip',
'linux-arm': 'libsimple-linux-ubuntu-24.04-arm.zip',
'darwin-x64': 'libsimple-osx-x64.zip',
'darwin-arm64': 'libsimple-osx-x64.zip',
'win32-x64': 'libsimple-windows-x64.zip',
'win32-ia32': 'libsimple-windows-x86.zip',
'win32-arm64': 'libsimple-windows-arm64.zip'
}
const DEFAULT_SIMPLE_INSTALL_DIR = 'resources/simple'
export function authenticateMemoryRequest (req, res, next) {
const bearer = req.header('Authorization') || ''
const token = bearer.replace(/^Bearer\s+/i, '').trim()
if (!token) {
res.status(401).json({ message: 'Access denied, token missing' })
return
}
try {
const authKey = Chaite.getInstance()?.getGlobalConfig()?.getAuthKey()
if (authKey && FrontEndAuthHandler.validateJWT(authKey, token)) {
next()
return
}
res.status(401).json({ message: 'Invalid token' })
} catch (error) {
res.status(401).json({ message: 'Invalid token format' })
}
}
function parsePositiveInt (value, fallback) {
const num = Number(value)
return Number.isInteger(num) && num >= 0 ? num : fallback
}
function parseNumber (value, fallback) {
const num = Number(value)
return Number.isFinite(num) ? num : fallback
}
function toStringArray (value) {
if (!Array.isArray(value)) {
return []
}
return value
.map(item => {
if (item === undefined || item === null) {
return null
}
return String(item).trim()
})
.filter(item => item)
}
function parseOptionalStringParam (value) {
if (Array.isArray(value)) {
value = value[0]
}
if (value === undefined || value === null) {
return null
}
const trimmed = String(value).trim()
if (!trimmed || trimmed.toLowerCase() === 'null' || trimmed.toLowerCase() === 'undefined') {
return null
}
return trimmed
}
function detectAssetKey (platform, arch) {
const normalizedArch = arch === 'arm64' ? 'arm64' : (arch === 'arm' ? 'arm' : (arch === 'ia32' ? 'ia32' : 'x64'))
const key = `${platform}-${normalizedArch}`
if (SIMPLE_ASSET_MAP[key]) {
return key
}
if (platform === 'darwin' && SIMPLE_ASSET_MAP['darwin-x64']) {
return 'darwin-x64'
}
if (platform === 'linux' && SIMPLE_ASSET_MAP['linux-x64']) {
return 'linux-x64'
}
if (platform === 'win32' && SIMPLE_ASSET_MAP['win32-x64']) {
return 'win32-x64'
}
return null
}
function resolveSimpleAsset (requestedKey, requestedAsset) {
if (requestedAsset) {
return {
key: requestedKey || 'custom',
asset: requestedAsset
}
}
if (requestedKey && SIMPLE_ASSET_MAP[requestedKey]) {
return {
key: requestedKey,
asset: SIMPLE_ASSET_MAP[requestedKey]
}
}
const autoKey = detectAssetKey(process.platform, process.arch)
if (autoKey && SIMPLE_ASSET_MAP[autoKey]) {
return { key: autoKey, asset: SIMPLE_ASSET_MAP[autoKey] }
}
return { key: null, asset: null }
}
function ensureDirectoryExists (dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true })
}
}
async function downloadToFile (url, destination, redirectCount = 0) {
if (redirectCount > 5) {
throw new Error('Too many redirects while downloading extension')
}
await new Promise((resolve, reject) => {
const request = https.get(url, {
headers: {
'User-Agent': 'chatgpt-plugin-memory-extension-downloader'
}
}, async res => {
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume()
try {
await downloadToFile(res.headers.location, destination, redirectCount + 1)
resolve()
} catch (err) {
reject(err)
}
return
}
if (res.statusCode !== 200) {
reject(new Error(`Failed to download extension: HTTP ${res.statusCode}`))
res.resume()
return
}
const fileStream = fs.createWriteStream(destination)
streamPipeline(res, fileStream).then(resolve).catch(reject)
})
request.on('error', error => reject(error))
})
}
function removeDirectoryIfExists (dirPath) {
if (fs.existsSync(dirPath)) {
fs.rmSync(dirPath, { recursive: true, force: true })
}
}
function findLibraryFile (rootDir) {
const entries = fs.readdirSync(rootDir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(rootDir, entry.name)
if (entry.isDirectory()) {
const found = findLibraryFile(fullPath)
if (found) {
return found
}
} else if (/simple\.(so|dylib|dll)$/i.test(entry.name) || /^libsimple/i.test(entry.name)) {
return fullPath
}
}
return null
}
function findDictDirectory (rootDir) {
const directDictPath = path.join(rootDir, 'dict')
if (fs.existsSync(directDictPath) && fs.statSync(directDictPath).isDirectory()) {
return directDictPath
}
const entries = fs.readdirSync(rootDir, { withFileTypes: true })
for (const entry of entries) {
if (entry.isDirectory()) {
const match = findDictDirectory(path.join(rootDir, entry.name))
if (match) {
return match
}
}
}
return null
}
async function downloadSimpleExtensionArchive ({ assetKey, assetName, targetDir }) {
if (!assetName) {
throw new Error('Simple extension asset name is required.')
}
const downloadUrl = `${SIMPLE_DOWNLOAD_BASE_URL}/${assetName}`
const tempFile = path.join(os.tmpdir(), `libsimple-${Date.now()}-${Math.random().toString(16).slice(2)}.zip`)
ensureDirectoryExists(path.dirname(tempFile))
await downloadToFile(downloadUrl, tempFile)
removeDirectoryIfExists(targetDir)
ensureDirectoryExists(targetDir)
if (AdmZip) {
try {
const zip = new AdmZip(tempFile)
zip.extractAllTo(targetDir, true)
} finally {
if (fs.existsSync(tempFile)) {
fs.unlinkSync(tempFile)
}
}
} else {
// 尝试使用 unzip 命令解压
try {
execSync(`unzip "${tempFile}" -d "${targetDir}"`, { stdio: 'inherit' })
} catch (error) {
throw new Error(`Failed to extract zip file: ${error.message}. Please install adm-zip manually: pnpm i`)
} finally {
if (fs.existsSync(tempFile)) {
fs.unlinkSync(tempFile)
}
}
}
const libraryFile = findLibraryFile(targetDir)
if (!libraryFile) {
throw new Error('Downloaded extension package does not contain libsimple library.')
}
const dictDir = findDictDirectory(targetDir)
if (!ChatGPTConfig.memory.extensions) {
ChatGPTConfig.memory.extensions = {}
}
if (!ChatGPTConfig.memory.extensions.simple) {
ChatGPTConfig.memory.extensions.simple = {
enable: false,
libraryPath: '',
dictPath: '',
useJieba: false
}
}
const relativeLibraryPath = toPluginRelativePath(libraryFile)
const relativeDictPath = dictDir ? toPluginRelativePath(dictDir) : ''
ChatGPTConfig.memory.extensions.simple.libraryPath = relativeLibraryPath
ChatGPTConfig.memory.extensions.simple.dictPath = relativeDictPath
return {
assetKey,
assetName,
installDir: toPluginRelativePath(targetDir),
libraryPath: relativeLibraryPath,
dictPath: ChatGPTConfig.memory.extensions.simple.dictPath
}
}
function updateMemoryConfig (payload = {}) {
const current = ChatGPTConfig.memory || {}
const previousDatabase = current.database
const previousDimension = current.vectorDimensions
const nextConfig = {
...current,
group: {
...(current.group || {})
},
user: {
...(current.user || {})
},
extensions: {
...(current.extensions || {}),
simple: {
...(current.extensions?.simple || {})
}
}
}
const previousSimpleConfig = JSON.stringify(current.extensions?.simple || {})
if (Object.prototype.hasOwnProperty.call(payload, 'database') && typeof payload.database === 'string') {
nextConfig.database = payload.database.trim()
}
if (Object.prototype.hasOwnProperty.call(payload, 'vectorDimensions')) {
const dimension = parsePositiveInt(payload.vectorDimensions, current.vectorDimensions || 1536)
if (dimension > 0) {
nextConfig.vectorDimensions = dimension
}
}
if (payload.group && typeof payload.group === 'object') {
const incomingGroup = payload.group
if (Object.prototype.hasOwnProperty.call(incomingGroup, 'enable')) {
nextConfig.group.enable = Boolean(incomingGroup.enable)
}
if (Object.prototype.hasOwnProperty.call(incomingGroup, 'enabledGroups')) {
nextConfig.group.enabledGroups = toStringArray(incomingGroup.enabledGroups)
}
if (Object.prototype.hasOwnProperty.call(incomingGroup, 'extractionModel') && typeof incomingGroup.extractionModel === 'string') {
nextConfig.group.extractionModel = incomingGroup.extractionModel.trim()
}
if (Object.prototype.hasOwnProperty.call(incomingGroup, 'extractionPresetId') && typeof incomingGroup.extractionPresetId === 'string') {
nextConfig.group.extractionPresetId = incomingGroup.extractionPresetId.trim()
}
if (Object.prototype.hasOwnProperty.call(incomingGroup, 'minMessageCount')) {
nextConfig.group.minMessageCount = parsePositiveInt(incomingGroup.minMessageCount, nextConfig.group.minMessageCount || 0)
}
if (Object.prototype.hasOwnProperty.call(incomingGroup, 'maxMessageWindow')) {
nextConfig.group.maxMessageWindow = parsePositiveInt(incomingGroup.maxMessageWindow, nextConfig.group.maxMessageWindow || 0)
}
if (Object.prototype.hasOwnProperty.call(incomingGroup, 'retrievalMode')) {
const mode = String(incomingGroup.retrievalMode || '').toLowerCase()
if (['vector', 'keyword', 'hybrid'].includes(mode)) {
nextConfig.group.retrievalMode = mode
}
}
if (Object.prototype.hasOwnProperty.call(incomingGroup, 'hybridPrefer')) {
const prefer = String(incomingGroup.hybridPrefer || '').toLowerCase()
if (prefer === 'keyword-first') {
nextConfig.group.hybridPrefer = 'keyword-first'
} else if (prefer === 'vector-first') {
nextConfig.group.hybridPrefer = 'vector-first'
}
}
if (Object.prototype.hasOwnProperty.call(incomingGroup, 'historyPollInterval')) {
nextConfig.group.historyPollInterval = parsePositiveInt(incomingGroup.historyPollInterval,
nextConfig.group.historyPollInterval || 0)
}
if (Object.prototype.hasOwnProperty.call(incomingGroup, 'historyBatchSize')) {
nextConfig.group.historyBatchSize = parsePositiveInt(incomingGroup.historyBatchSize,
nextConfig.group.historyBatchSize || 0)
}
if (Object.prototype.hasOwnProperty.call(incomingGroup, 'promptHeader') && typeof incomingGroup.promptHeader === 'string') {
nextConfig.group.promptHeader = incomingGroup.promptHeader
}
if (Object.prototype.hasOwnProperty.call(incomingGroup, 'promptItemTemplate') && typeof incomingGroup.promptItemTemplate === 'string') {
nextConfig.group.promptItemTemplate = incomingGroup.promptItemTemplate
}
if (Object.prototype.hasOwnProperty.call(incomingGroup, 'promptFooter') && typeof incomingGroup.promptFooter === 'string') {
nextConfig.group.promptFooter = incomingGroup.promptFooter
}
if (Object.prototype.hasOwnProperty.call(incomingGroup, 'vectorMaxDistance')) {
const distance = parseNumber(incomingGroup.vectorMaxDistance,
nextConfig.group.vectorMaxDistance ?? 0)
nextConfig.group.vectorMaxDistance = distance
}
if (Object.prototype.hasOwnProperty.call(incomingGroup, 'textMaxBm25Score')) {
const bm25 = parseNumber(incomingGroup.textMaxBm25Score,
nextConfig.group.textMaxBm25Score ?? 0)
nextConfig.group.textMaxBm25Score = bm25
}
if (Object.prototype.hasOwnProperty.call(incomingGroup, 'maxFactsPerInjection')) {
nextConfig.group.maxFactsPerInjection = parsePositiveInt(incomingGroup.maxFactsPerInjection,
nextConfig.group.maxFactsPerInjection || 0)
}
if (Object.prototype.hasOwnProperty.call(incomingGroup, 'minImportanceForInjection')) {
const importance = parseNumber(incomingGroup.minImportanceForInjection,
nextConfig.group.minImportanceForInjection ?? 0)
nextConfig.group.minImportanceForInjection = importance
}
}
if (payload.user && typeof payload.user === 'object') {
const incomingUser = payload.user
if (Object.prototype.hasOwnProperty.call(incomingUser, 'enable')) {
nextConfig.user.enable = Boolean(incomingUser.enable)
}
if (Object.prototype.hasOwnProperty.call(incomingUser, 'whitelist')) {
nextConfig.user.whitelist = toStringArray(incomingUser.whitelist)
}
if (Object.prototype.hasOwnProperty.call(incomingUser, 'blacklist')) {
nextConfig.user.blacklist = toStringArray(incomingUser.blacklist)
}
if (Object.prototype.hasOwnProperty.call(incomingUser, 'extractionModel') && typeof incomingUser.extractionModel === 'string') {
nextConfig.user.extractionModel = incomingUser.extractionModel.trim()
}
if (Object.prototype.hasOwnProperty.call(incomingUser, 'extractionPresetId') && typeof incomingUser.extractionPresetId === 'string') {
nextConfig.user.extractionPresetId = incomingUser.extractionPresetId.trim()
}
if (Object.prototype.hasOwnProperty.call(incomingUser, 'maxItemsPerInjection')) {
nextConfig.user.maxItemsPerInjection = parsePositiveInt(incomingUser.maxItemsPerInjection,
nextConfig.user.maxItemsPerInjection || 0)
}
if (Object.prototype.hasOwnProperty.call(incomingUser, 'maxRelevantItemsPerQuery')) {
nextConfig.user.maxRelevantItemsPerQuery = parsePositiveInt(incomingUser.maxRelevantItemsPerQuery,
nextConfig.user.maxRelevantItemsPerQuery || 0)
}
if (Object.prototype.hasOwnProperty.call(incomingUser, 'minImportanceForInjection')) {
const importance = parseNumber(incomingUser.minImportanceForInjection,
nextConfig.user.minImportanceForInjection ?? 0)
nextConfig.user.minImportanceForInjection = importance
}
if (Object.prototype.hasOwnProperty.call(incomingUser, 'promptHeader') && typeof incomingUser.promptHeader === 'string') {
nextConfig.user.promptHeader = incomingUser.promptHeader
}
if (Object.prototype.hasOwnProperty.call(incomingUser, 'promptItemTemplate') && typeof incomingUser.promptItemTemplate === 'string') {
nextConfig.user.promptItemTemplate = incomingUser.promptItemTemplate
}
if (Object.prototype.hasOwnProperty.call(incomingUser, 'promptFooter') && typeof incomingUser.promptFooter === 'string') {
nextConfig.user.promptFooter = incomingUser.promptFooter
}
}
if (payload.extensions && typeof payload.extensions === 'object' && !Array.isArray(payload.extensions)) {
const incomingExtensions = payload.extensions
if (incomingExtensions.simple && typeof incomingExtensions.simple === 'object' && !Array.isArray(incomingExtensions.simple)) {
const incomingSimple = incomingExtensions.simple
if (Object.prototype.hasOwnProperty.call(incomingSimple, 'enable')) {
nextConfig.extensions.simple.enable = Boolean(incomingSimple.enable)
}
if (Object.prototype.hasOwnProperty.call(incomingSimple, 'libraryPath') && typeof incomingSimple.libraryPath === 'string') {
nextConfig.extensions.simple.libraryPath = incomingSimple.libraryPath.trim()
}
if (Object.prototype.hasOwnProperty.call(incomingSimple, 'dictPath') && typeof incomingSimple.dictPath === 'string') {
nextConfig.extensions.simple.dictPath = incomingSimple.dictPath.trim()
}
if (Object.prototype.hasOwnProperty.call(incomingSimple, 'useJieba')) {
nextConfig.extensions.simple.useJieba = Boolean(incomingSimple.useJieba)
}
} else if (Object.prototype.hasOwnProperty.call(incomingExtensions, 'simple')) {
logger.warn('[Memory] Unexpected value for extensions.simple, ignoring:', incomingExtensions.simple)
}
}
ChatGPTConfig.memory.database = nextConfig.database
ChatGPTConfig.memory.vectorDimensions = nextConfig.vectorDimensions
if (!ChatGPTConfig.memory.group) ChatGPTConfig.memory.group = {}
if (!ChatGPTConfig.memory.user) ChatGPTConfig.memory.user = {}
if (!ChatGPTConfig.memory.extensions) ChatGPTConfig.memory.extensions = {}
if (!ChatGPTConfig.memory.extensions.simple) {
ChatGPTConfig.memory.extensions.simple = {
enable: false,
libraryPath: '',
dictPath: '',
useJieba: false
}
}
Object.assign(ChatGPTConfig.memory.group, nextConfig.group)
Object.assign(ChatGPTConfig.memory.user, nextConfig.user)
Object.assign(ChatGPTConfig.memory.extensions.simple, nextConfig.extensions.simple)
if (nextConfig.vectorDimensions !== previousDimension) {
resetCachedDimension()
const targetDimension = Number(nextConfig.vectorDimensions)
if (Number.isFinite(targetDimension) && targetDimension > 0) {
try {
resetVectorTableDimension(targetDimension)
} catch (err) {
logger?.error?.('[Memory] failed to apply vector dimension change:', err)
}
}
}
const currentSimpleConfig = JSON.stringify(ChatGPTConfig.memory.extensions?.simple || {})
if (nextConfig.database !== previousDatabase) {
resetMemoryDatabaseInstance()
} else if (currentSimpleConfig !== previousSimpleConfig) {
resetMemoryDatabaseInstance()
}
if (typeof ChatGPTConfig._triggerSave === 'function') {
ChatGPTConfig._triggerSave('memory')
}
return ChatGPTConfig.memory
}
export const MemoryRouter = (() => {
const router = express.Router()
router.get('/config', (_req, res) => {
res.status(200).json(ChaiteResponse.ok(ChatGPTConfig.memory))
})
router.post('/config', (req, res) => {
try {
const updated = updateMemoryConfig(req.body || {})
res.status(200).json(ChaiteResponse.ok(updated))
} catch (error) {
logger.error('Failed to update memory config:', error)
res.status(500).json(ChaiteResponse.fail(null, 'Failed to update memory config'))
}
})
router.get('/group/:groupId/facts', (req, res) => {
const { groupId } = req.params
const limit = parsePositiveInt(req.query.limit, 50)
const offset = parsePositiveInt(req.query.offset, 0)
try {
const facts = memoryService.listGroupFacts(groupId, limit, offset)
res.status(200).json(ChaiteResponse.ok(facts))
} catch (error) {
logger.error('Failed to fetch group facts:', error)
res.status(500).json(ChaiteResponse.fail(null, 'Failed to fetch group facts'))
}
})
router.get('/extensions/simple/status', (_req, res) => {
try {
logger?.debug?.('[Memory] simple extension status requested')
const state = getSimpleExtensionState()
const simpleConfig = ChatGPTConfig.memory?.extensions?.simple || {}
const libraryPath = simpleConfig.libraryPath || state.libraryPath || ''
const dictPath = simpleConfig.dictPath || state.dictPath || ''
const resolvedLibraryPath = libraryPath ? resolvePluginPath(libraryPath) : ''
const resolvedDictPath = dictPath ? resolvePluginPath(dictPath) : ''
res.status(200).json(ChaiteResponse.ok({
...state,
enabled: Boolean(simpleConfig.enable),
libraryPath,
dictPath,
platform: process.platform,
arch: process.arch,
resolvedLibraryPath,
libraryExists: resolvedLibraryPath ? fs.existsSync(resolvedLibraryPath) : false,
resolvedDictPath,
dictExists: resolvedDictPath ? fs.existsSync(resolvedDictPath) : false
}))
} catch (error) {
logger.error('Failed to read simple extension status:', error)
res.status(500).json(ChaiteResponse.fail(null, 'Failed to read simple extension status'))
}
})
router.post('/extensions/simple/download', async (req, res) => {
const { assetKey, assetName, installDir } = req.body || {}
try {
const resolvedAsset = resolveSimpleAsset(assetKey, assetName)
if (!resolvedAsset.asset) {
res.status(400).json(ChaiteResponse.fail(null, '无法确定当前平台的扩展文件,请手动指定 assetName。'))
return
}
logger?.info?.('[Memory] downloading simple extension asset=%s (key=%s)', resolvedAsset.asset, resolvedAsset.key)
const targetRelativeDir = installDir || path.join(DEFAULT_SIMPLE_INSTALL_DIR, resolvedAsset.key || 'downloaded')
const targetDir = resolvePluginPath(targetRelativeDir)
const result = await downloadSimpleExtensionArchive({
assetKey: resolvedAsset.key || assetKey || 'custom',
assetName: resolvedAsset.asset,
targetDir
})
resetMemoryDatabaseInstance()
logger?.info?.('[Memory] simple extension downloaded and memory DB scheduled for reload')
res.status(200).json(ChaiteResponse.ok({
...result,
assetName: resolvedAsset.asset,
assetKey: resolvedAsset.key || assetKey || 'custom'
}))
} catch (error) {
logger.error('Failed to download simple extension:', error)
res.status(500).json(ChaiteResponse.fail(null, error?.message || 'Failed to download simple extension'))
}
})
router.post('/group/:groupId/facts', async (req, res) => {
const { groupId } = req.params
const facts = Array.isArray(req.body?.facts) ? req.body.facts : []
if (facts.length === 0) {
res.status(400).json(ChaiteResponse.fail(null, 'facts is required'))
return
}
try {
const saved = await memoryService.saveGroupFacts(groupId, facts)
res.status(200).json(ChaiteResponse.ok(saved))
} catch (error) {
logger.error('Failed to save group facts:', error)
res.status(500).json(ChaiteResponse.fail(null, 'Failed to save group facts'))
}
})
router.post('/group/:groupId/query', async (req, res) => {
const { groupId } = req.params
const { query, limit, minImportance } = req.body || {}
if (!query || typeof query !== 'string') {
res.status(400).json(ChaiteResponse.fail(null, 'query is required'))
return
}
try {
const facts = await memoryService.queryGroupFacts(groupId, query, {
limit: parsePositiveInt(limit, undefined),
minImportance: minImportance !== undefined ? parseNumber(minImportance, undefined) : undefined
})
res.status(200).json(ChaiteResponse.ok(facts))
} catch (error) {
logger.error('Failed to query group memory:', error)
res.status(500).json(ChaiteResponse.fail(null, 'Failed to query group memory'))
}
})
router.delete('/group/:groupId/facts/:factId', (req, res) => {
const { groupId, factId } = req.params
try {
const removed = memoryService.deleteGroupFact(groupId, factId)
if (!removed) {
res.status(404).json(ChaiteResponse.fail(null, 'Fact not found'))
return
}
res.status(200).json(ChaiteResponse.ok({ removed }))
} catch (error) {
logger.error('Failed to delete group fact:', error)
res.status(500).json(ChaiteResponse.fail(null, 'Failed to delete group fact'))
}
})
router.get('/user/memories', (req, res) => {
const userId = parseOptionalStringParam(req.query.userId)
const groupId = parseOptionalStringParam(req.query.groupId)
const limit = parsePositiveInt(req.query.limit, 50)
const offset = parsePositiveInt(req.query.offset, 0)
try {
const memories = memoryService.listUserMemories(userId, groupId, limit, offset)
res.status(200).json(ChaiteResponse.ok(memories))
} catch (error) {
logger.error('Failed to fetch user memories:', error)
res.status(500).json(ChaiteResponse.fail(null, 'Failed to fetch user memories'))
}
})
router.get('/user/:userId/memories', (req, res) => {
const { userId } = req.params
const groupId = req.query.groupId ?? null
const limit = parsePositiveInt(req.query.limit, 50)
const offset = parsePositiveInt(req.query.offset, 0)
try {
const memories = memoryService.listUserMemories(userId, groupId, limit, offset)
res.status(200).json(ChaiteResponse.ok(memories))
} catch (error) {
logger.error('Failed to fetch user memories:', error)
res.status(500).json(ChaiteResponse.fail(null, 'Failed to fetch user memories'))
}
})
router.post('/user/:userId/query', (req, res) => {
const { userId } = req.params
const groupId = req.body?.groupId ?? req.query.groupId ?? null
const query = req.body?.query
const totalLimit = parsePositiveInt(req.body?.totalLimit, undefined)
const searchLimit = parsePositiveInt(req.body?.searchLimit, undefined)
const minImportance = req.body?.minImportance !== undefined
? parseNumber(req.body.minImportance, undefined)
: undefined
if (!query || typeof query !== 'string') {
res.status(400).json(ChaiteResponse.fail(null, 'query is required'))
return
}
try {
const memories = memoryService.queryUserMemories(userId, groupId, query, {
totalLimit,
searchLimit,
minImportance
})
res.status(200).json(ChaiteResponse.ok(memories))
} catch (error) {
logger.error('Failed to query user memory:', error)
res.status(500).json(ChaiteResponse.fail(null, 'Failed to query user memory'))
}
})
router.post('/user/:userId/memories', (req, res) => {
const { userId } = req.params
const groupId = req.body?.groupId ?? null
const memories = Array.isArray(req.body?.memories) ? req.body.memories : []
if (memories.length === 0) {
res.status(400).json(ChaiteResponse.fail(null, 'memories is required'))
return
}
try {
const updated = memoryService.upsertUserMemories(userId, groupId, memories)
res.status(200).json(ChaiteResponse.ok({ updated }))
} catch (error) {
logger.error('Failed to upsert user memories:', error)
res.status(500).json(ChaiteResponse.fail(null, 'Failed to upsert user memories'))
}
})
router.delete('/user/:userId/memories/:memoryId', (req, res) => {
const { userId, memoryId } = req.params
try {
const removed = memoryService.deleteUserMemory(memoryId, userId)
if (!removed) {
res.status(404).json(ChaiteResponse.fail(null, 'Memory not found'))
return
}
res.status(200).json(ChaiteResponse.ok({ removed }))
} catch (error) {
logger.error('Failed to delete user memory:', error)
res.status(500).json(ChaiteResponse.fail(null, 'Failed to delete user memory'))
}
})
router.delete('/memories/:memoryId', (req, res) => {
const { memoryId } = req.params
try {
const removed = memoryService.deleteUserMemory(memoryId)
if (!removed) {
res.status(404).json(ChaiteResponse.fail(null, 'Memory not found'))
return
}
res.status(200).json(ChaiteResponse.ok({ removed }))
} catch (error) {
logger.error('Failed to delete memory:', error)
res.status(500).json(ChaiteResponse.fail(null, 'Failed to delete memory'))
}
})
return router
})()