ConversationService.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. const crypto = require('crypto')
  2. const db = require('../../plugin/DataBase/db')
  3. const Logger = require('../Logger')
  4. const ACTIVE_STATE = 1
  5. const DELETED_STATE = 2
  6. const ASSISTANT_UUID = 'e4fe0277-0b1a-41a1-b25f-8b6e4cec3281'
  7. const DEFAULT_TITLE = '新对话'
  8. const MAX_MESSAGE_IMAGES = 3
  9. const logger = new Logger()
  10. function toPositiveInt(value, fallback = null) {
  11. const n = Number(value)
  12. if (!Number.isInteger(n) || n <= 0) return fallback
  13. return n
  14. }
  15. function normalizePage(current = 1, pagesize = 20, maxPageSize = 50) {
  16. const c = toPositiveInt(current, 1)
  17. const p = Math.min(toPositiveInt(pagesize, 20), maxPageSize)
  18. return { current: c, pagesize: p, offset: (c - 1) * p }
  19. }
  20. function normalizeText(value, max = 2000) {
  21. return String(value || '').trim().slice(0, max)
  22. }
  23. function normalizeChannel(channel) {
  24. return channel === 'wechat' ? 'wechat' : 'web'
  25. }
  26. function normalizeImages(images) {
  27. if (!Array.isArray(images)) return []
  28. return images
  29. .map((item) => {
  30. if (typeof item === 'string') return item
  31. return item?.url || item?.response?.data?.picturePath || item?.response?.picturePath || ''
  32. })
  33. .map((item) => String(item || '').trim())
  34. .filter(Boolean)
  35. .slice(0, MAX_MESSAGE_IMAGES)
  36. }
  37. function buildPreview(content, images = []) {
  38. const text = normalizeText(content, 160).replace(/\s+/g, ' ')
  39. if (text) return text
  40. return images.length > 0 ? `[图片] x${images.length}` : ''
  41. }
  42. function parseImages(value) {
  43. if (!value) return []
  44. if (Array.isArray(value)) return value
  45. try {
  46. const parsed = JSON.parse(value)
  47. return Array.isArray(parsed) ? parsed : []
  48. } catch (_) {
  49. return []
  50. }
  51. }
  52. function normalizeMessage(row) {
  53. return {
  54. ...row,
  55. channel: normalizeChannel(row.channel),
  56. images: parseImages(row.images)
  57. }
  58. }
  59. function normalizeConversation(row) {
  60. if (!row) return row
  61. const channel = normalizeChannel(row.channel)
  62. return {
  63. ...row,
  64. channel,
  65. readonly: channel === 'wechat'
  66. }
  67. }
  68. async function generateConversationNo() {
  69. for (let i = 0; i < 8; i++) {
  70. const conversationNo = String(crypto.randomInt(100000000000, 999999999999))
  71. const rows = await db.query('SELECT id FROM ai_chat_conversation WHERE conversation_no = ? LIMIT 1', [conversationNo])
  72. if (!rows || rows.length === 0) return conversationNo
  73. }
  74. return `${String(Date.now()).slice(1)}${crypto.randomInt(100, 999)}`
  75. }
  76. async function assertUserConversation(conversationId, uuid) {
  77. const id = toPositiveInt(conversationId)
  78. if (!id) return null
  79. const rows = await db.query(
  80. 'SELECT id, conversation_no, create_user, title, channel, state, last_message_preview, last_message_time, create_time, update_time FROM ai_chat_conversation WHERE id = ? AND create_user = ? AND state = ? LIMIT 1',
  81. [id, uuid, ACTIVE_STATE]
  82. )
  83. return rows && rows.length === 1 ? normalizeConversation(rows[0]) : null
  84. }
  85. class ConversationService {
  86. async listUserConversations({ uuid, current, pagesize }) {
  87. const page = normalizePage(current, pagesize)
  88. const rows = await db.query(
  89. `SELECT id, conversation_no, title, channel, state, last_message_preview, last_message_time, create_time, update_time
  90. FROM ai_chat_conversation
  91. WHERE create_user = ? AND state = ?
  92. ORDER BY update_time DESC
  93. LIMIT ? OFFSET ?`,
  94. [uuid, ACTIVE_STATE, String(page.pagesize), String(page.offset)]
  95. )
  96. const countRows = await db.query(
  97. 'SELECT COUNT(*) AS total FROM ai_chat_conversation WHERE create_user = ? AND state = ?',
  98. [uuid, ACTIVE_STATE]
  99. )
  100. return {
  101. data: (rows || []).map(normalizeConversation),
  102. pagination: {
  103. current: page.current,
  104. pagesize: page.pagesize,
  105. total: Number(countRows?.[0]?.total || 0)
  106. }
  107. }
  108. }
  109. async createConversation({ uuid, title, channel = 'web' }) {
  110. const safeChannel = normalizeChannel(channel)
  111. const emptyRows = await db.query(
  112. `SELECT id, conversation_no, title, channel, state, last_message_preview, last_message_time, create_time, update_time
  113. FROM ai_chat_conversation
  114. WHERE create_user = ? AND state = ? AND last_message_time = 0 AND channel = ?
  115. ORDER BY update_time DESC
  116. LIMIT 1`,
  117. [uuid, ACTIVE_STATE, safeChannel]
  118. )
  119. if (emptyRows && emptyRows.length === 1) {
  120. return normalizeConversation({ ...emptyRows[0], reused: true })
  121. }
  122. const now = Date.now()
  123. const safeTitle = normalizeText(title, 80) || DEFAULT_TITLE
  124. const conversationNo = await generateConversationNo()
  125. const result = await db.query(
  126. `INSERT INTO ai_chat_conversation
  127. (conversation_no, create_user, title, channel, state, last_message_preview, last_message_time, create_time, update_time)
  128. VALUES (?, ?, ?, ?, ?, '', 0, ?, ?)`,
  129. [conversationNo, uuid, safeTitle, safeChannel, ACTIVE_STATE, now, now]
  130. )
  131. return normalizeConversation({
  132. id: result?.insertId,
  133. conversation_no: conversationNo,
  134. title: safeTitle,
  135. channel: safeChannel,
  136. state: ACTIVE_STATE,
  137. last_message_preview: '',
  138. last_message_time: 0,
  139. create_time: now,
  140. update_time: now,
  141. reused: false
  142. })
  143. }
  144. async getOrCreateChannelConversation({ uuid, channel, title }) {
  145. const safeChannel = normalizeChannel(channel)
  146. const rows = await db.query(
  147. `SELECT id, conversation_no, title, channel, state, last_message_preview, last_message_time, create_time, update_time
  148. FROM ai_chat_conversation
  149. WHERE create_user = ? AND state = ? AND channel = ?
  150. ORDER BY create_time ASC
  151. LIMIT 1`,
  152. [uuid, ACTIVE_STATE, safeChannel]
  153. )
  154. if (rows && rows.length === 1) return normalizeConversation(rows[0])
  155. return this.createConversation({ uuid, title, channel: safeChannel })
  156. }
  157. async deleteConversation({ uuid, conversationId }) {
  158. const now = Date.now()
  159. const result = await db.query(
  160. 'UPDATE ai_chat_conversation SET state = ?, update_time = ? WHERE id = ? AND create_user = ? AND state = ?',
  161. [DELETED_STATE, now, conversationId, uuid, ACTIVE_STATE]
  162. )
  163. return result?.affectedRows === 1
  164. }
  165. async listMessages({ uuid, conversationId, afterId = 0, current, pagesize }) {
  166. const conv = await assertUserConversation(conversationId, uuid)
  167. if (!conv) return null
  168. const after = Number(afterId || 0)
  169. if (Number.isFinite(after) && after > 0) {
  170. const rows = await db.query(
  171. `SELECT id, conversation_id, role, channel, content, images, status, error_msg, create_time, update_time
  172. FROM ai_chat_message
  173. WHERE conversation_id = ? AND create_user = ? AND id > ?
  174. ORDER BY id ASC
  175. LIMIT 100`,
  176. [conversationId, uuid, after]
  177. )
  178. return { data: (rows || []).map(normalizeMessage), pagination: null }
  179. }
  180. const page = normalizePage(current, pagesize, 100)
  181. const rows = await db.query(
  182. `SELECT id, conversation_id, role, channel, content, images, status, error_msg, create_time, update_time
  183. FROM ai_chat_message
  184. WHERE conversation_id = ? AND create_user = ?
  185. ORDER BY id DESC
  186. LIMIT ? OFFSET ?`,
  187. [conversationId, uuid, String(page.pagesize), String(page.offset)]
  188. )
  189. const countRows = await db.query(
  190. 'SELECT COUNT(*) AS total FROM ai_chat_message WHERE conversation_id = ? AND create_user = ?',
  191. [conversationId, uuid]
  192. )
  193. return {
  194. data: (rows || []).map(normalizeMessage).reverse(),
  195. pagination: {
  196. current: page.current,
  197. pagesize: page.pagesize,
  198. total: Number(countRows?.[0]?.total || 0)
  199. }
  200. }
  201. }
  202. async addUserMessage({ uuid, conversationId, content, images, channel = 'web' }) {
  203. const text = normalizeText(content, 2000)
  204. const imgs = normalizeImages(images)
  205. if (!text && imgs.length === 0) return { missingContent: true }
  206. const preview = buildPreview(text, imgs)
  207. const safeChannel = normalizeChannel(channel)
  208. const conv = conversationId
  209. ? await assertUserConversation(conversationId, uuid)
  210. : await this.createConversation({ uuid, title: preview ? preview.slice(0, 40) : DEFAULT_TITLE, channel: safeChannel })
  211. if (!conv) return null
  212. if (conv.channel === 'wechat' && safeChannel !== 'wechat') return { readonly: true }
  213. const now = Date.now()
  214. const result = await db.query(
  215. `INSERT INTO ai_chat_message
  216. (conversation_id, create_user, role, channel, content, images, status, error_msg, create_time, update_time)
  217. VALUES (?, ?, 'user', ?, ?, ?, 'done', '', ?, ?)`,
  218. [conv.id, uuid, safeChannel, text, JSON.stringify(imgs), now, now]
  219. )
  220. const title = (!conv.title || conv.title === DEFAULT_TITLE || Number(conv.last_message_time || 0) === 0) && preview
  221. ? preview.slice(0, 40)
  222. : conv.title
  223. await db.query(
  224. 'UPDATE ai_chat_conversation SET title = ?, last_message_preview = ?, last_message_time = ?, update_time = ? WHERE id = ?',
  225. [title, preview, now, now, conv.id]
  226. )
  227. return {
  228. conversationId: conv.id,
  229. conversationNo: conv.conversation_no,
  230. messageId: result?.insertId,
  231. content: text,
  232. images: imgs,
  233. channel: safeChannel,
  234. conversation: normalizeConversation({
  235. id: conv.id,
  236. conversation_no: conv.conversation_no,
  237. title,
  238. channel: conv.channel,
  239. state: ACTIVE_STATE,
  240. last_message_preview: preview,
  241. last_message_time: now,
  242. create_time: conv.create_time || now,
  243. update_time: now
  244. }),
  245. message: {
  246. id: result?.insertId,
  247. conversation_id: conv.id,
  248. role: 'user',
  249. channel: safeChannel,
  250. content: text,
  251. images: imgs,
  252. status: 'done',
  253. error_msg: '',
  254. create_time: now,
  255. update_time: now
  256. }
  257. }
  258. }
  259. async addAssistantMessage({ conversationId, conversationNo, content, images = [], status = 'done', errorMsg = '', channel = null }) {
  260. const whereSql = conversationNo ? 'conversation_no = ?' : 'id = ?'
  261. const whereValue = conversationNo || conversationId
  262. const rows = await db.query(
  263. `SELECT id, create_user, channel FROM ai_chat_conversation WHERE ${whereSql} AND state = ? LIMIT 1`,
  264. [whereValue, ACTIVE_STATE]
  265. )
  266. if (!rows || rows.length !== 1) return false
  267. const targetConversationId = rows[0].id
  268. const uuid = rows[0].create_user
  269. const text = normalizeText(content, 4000)
  270. const imgs = normalizeImages(images)
  271. if (!text && imgs.length === 0 && status !== 'error') return false
  272. const now = Date.now()
  273. const safeChannel = normalizeChannel(channel || rows[0].channel)
  274. await db.query(
  275. `INSERT INTO ai_chat_message
  276. (conversation_id, create_user, role, channel, content, images, status, error_msg, create_time, update_time)
  277. VALUES (?, ?, 'assistant', ?, ?, ?, ?, ?, ?, ?)`,
  278. [targetConversationId, uuid, safeChannel, text, JSON.stringify(imgs), status, normalizeText(errorMsg, 200), now, now]
  279. )
  280. await db.query(
  281. 'UPDATE ai_chat_conversation SET last_message_preview = ?, last_message_time = ?, update_time = ? WHERE id = ?',
  282. [buildPreview(text, imgs), now, now, targetConversationId]
  283. )
  284. if (safeChannel === 'wechat' && text && status === 'done') {
  285. try {
  286. const { sendTextToUser } = require('./WeixinBindingService')
  287. await sendTextToUser(uuid, text)
  288. } catch (err) {
  289. logger.error(`WeChat AIChat reply send failed user=${uuid} conversation=${targetConversationId}: ${err.stack || err.message || err}`)
  290. }
  291. }
  292. return true
  293. }
  294. async addSystemMessage({ conversationId, content, status = 'done', errorMsg = '', channel = null }) {
  295. const rows = await db.query(
  296. 'SELECT id, create_user, channel FROM ai_chat_conversation WHERE id = ? AND state = ? LIMIT 1',
  297. [conversationId, ACTIVE_STATE]
  298. )
  299. if (!rows || rows.length !== 1) return false
  300. const now = Date.now()
  301. await db.query(
  302. `INSERT INTO ai_chat_message
  303. (conversation_id, create_user, role, channel, content, images, status, error_msg, create_time, update_time)
  304. VALUES (?, ?, 'system', ?, ?, JSON_ARRAY(), ?, ?, ?, ?)`,
  305. [conversationId, rows[0].create_user, normalizeChannel(channel || rows[0].channel), normalizeText(content, 1000), status, normalizeText(errorMsg, 200), now, now]
  306. )
  307. return true
  308. }
  309. async listAdminConversations({ id, conversation_no, create_user, username, state, channel, queryTime, current, pagesize }) {
  310. const page = normalizePage(current, pagesize)
  311. const where = ['1 = 1']
  312. const params = []
  313. const countParams = []
  314. if (id) {
  315. where.push('c.id = ?')
  316. params.push(id)
  317. countParams.push(id)
  318. }
  319. if (conversation_no) {
  320. where.push('c.conversation_no = ?')
  321. params.push(conversation_no)
  322. countParams.push(conversation_no)
  323. }
  324. if (create_user) {
  325. where.push('c.create_user LIKE ?')
  326. params.push(`%${create_user}%`)
  327. countParams.push(`%${create_user}%`)
  328. }
  329. if (username) {
  330. where.push('u.username LIKE ?')
  331. params.push(`%${username}%`)
  332. countParams.push(`%${username}%`)
  333. }
  334. if (state !== undefined && state !== null && state !== '' && Number(state) !== -1) {
  335. where.push('c.state = ?')
  336. params.push(state)
  337. countParams.push(state)
  338. }
  339. if (channel && ['web', 'wechat'].includes(channel)) {
  340. where.push('c.channel = ?')
  341. params.push(channel)
  342. countParams.push(channel)
  343. }
  344. if (Array.isArray(queryTime) && queryTime.length === 2) {
  345. where.push('c.update_time >= ? AND c.update_time < ?')
  346. params.push(queryTime[0], queryTime[1])
  347. countParams.push(queryTime[0], queryTime[1])
  348. }
  349. const whereSql = where.join(' AND ')
  350. const rows = await db.query(
  351. `SELECT c.id, c.conversation_no, c.create_user, c.title, c.channel, c.state, c.last_message_preview, c.last_message_time,
  352. c.create_time, c.update_time, u.username
  353. FROM ai_chat_conversation c
  354. LEFT JOIN users u ON u.uuid = c.create_user
  355. WHERE ${whereSql}
  356. ORDER BY c.update_time DESC
  357. LIMIT ? OFFSET ?`,
  358. [...params, String(page.pagesize), String(page.offset)]
  359. )
  360. const countRows = await db.query(
  361. `SELECT COUNT(*) AS total
  362. FROM ai_chat_conversation c
  363. LEFT JOIN users u ON u.uuid = c.create_user
  364. WHERE ${whereSql}`,
  365. countParams
  366. )
  367. return {
  368. data: (rows || []).map(normalizeConversation),
  369. pagination: {
  370. current: page.current,
  371. pagesize: page.pagesize,
  372. total: Number(countRows?.[0]?.total || 0)
  373. }
  374. }
  375. }
  376. async getAdminConversationDetail({ conversationId }) {
  377. const convRows = await db.query(
  378. `SELECT c.id, c.conversation_no, c.create_user, c.title, c.channel, c.state, c.last_message_preview, c.last_message_time,
  379. c.create_time, c.update_time, u.username
  380. FROM ai_chat_conversation c
  381. LEFT JOIN users u ON u.uuid = c.create_user
  382. WHERE c.id = ?
  383. LIMIT 1`,
  384. [conversationId]
  385. )
  386. if (!convRows || convRows.length !== 1) return null
  387. const msgRows = await db.query(
  388. `SELECT id, conversation_id, role, channel, content, images, status, error_msg, create_time, update_time
  389. FROM ai_chat_message
  390. WHERE conversation_id = ?
  391. ORDER BY id ASC`,
  392. [conversationId]
  393. )
  394. return {
  395. ...normalizeConversation(convRows[0]),
  396. messages: (msgRows || []).map(normalizeMessage),
  397. assistantUuid: ASSISTANT_UUID
  398. }
  399. }
  400. }
  401. module.exports = {
  402. ConversationService: new ConversationService(),
  403. normalizeImages,
  404. normalizeText,
  405. normalizeChannel,
  406. ASSISTANT_UUID
  407. }