277 lines
16 KiB
JavaScript
277 lines
16 KiB
JavaScript
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { readFileSync } from 'node:fs'
|
||
import { runInNewContext } from 'node:vm'
|
||
import * as Vue from 'vue'
|
||
import ts from 'typescript'
|
||
|
||
// Exercise the real composable and Vue reactivity with an in-memory Wails bridge.
|
||
// HTTP and durable storage behavior are covered by the Go conversation tests.
|
||
const source = readFileSync(new URL('../src/lib/useConversations.ts', import.meta.url), 'utf8')
|
||
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
|
||
const clone = value => JSON.parse(JSON.stringify(value))
|
||
const settle = async () => { for (let i = 0; i < 30; i++) await Promise.resolve() }
|
||
|
||
function storage() { return { records: new Map(), selection: new Map(), attachments: new Map(), nextFile: 1, next: 1, failSave: false, beforeSave: null } }
|
||
function harness(store = storage()) {
|
||
const listeners = new Set(), timers = new Map(), cleanup = [], calls = []
|
||
let timerID = 0
|
||
class CancelError extends Error {}
|
||
const summary = c => ({ id: c.id, title: c.title, createdAt: c.createdAt, updatedAt: c.updatedAt, preview: c.messages.at(-1)?.content || '暂无消息', messageCount: c.messages.length })
|
||
const api = {
|
||
async ListConversations() { return [...store.records.values()].map(summary) },
|
||
async CreateConversation() {
|
||
const c = { id: `session-${store.next++}`, title: '新会话', createdAt: Date.now(), updatedAt: Date.now(), draft: '', messages: [] }
|
||
store.records.set(c.id, clone(c)); return clone(c)
|
||
},
|
||
async GetConversation(id) { if (!store.records.has(id)) throw new Error('missing'); return clone(store.records.get(id)) },
|
||
async SaveConversation(input) {
|
||
if (store.beforeSave) await store.beforeSave(input)
|
||
if (store.failSave) throw new Error('disk full')
|
||
const previous = store.records.get(input.id)
|
||
if (!previous) throw new Error('deleted')
|
||
const c = { ...clone(input), title: previous.title, memory: previous.memory, updatedAt: Date.now() }
|
||
if (c.title === '新会话' && c.messages.some(m => m.role === 'user')) c.title = c.messages.find(m => m.role === 'user').content
|
||
store.records.set(c.id, c); return summary(c)
|
||
},
|
||
async RenameConversation(id, title) { const c = store.records.get(id); c.title = title; return summary(c) },
|
||
async DeleteConversation(id) { store.records.delete(id) },
|
||
async UploadAttachment(sessionID, input) {
|
||
if (store.beforeUpload) await store.beforeUpload()
|
||
const file = { id: `file-${store.nextFile++}`, name: input.name, size: Buffer.from(input.data, 'base64').length, mimeType: 'text/plain', kind: 'text', textLength: Buffer.from(input.data, 'base64').length, sha256: 'fixture-hash' }
|
||
store.attachments.set(file.id, { sessionID, file, data: input.data }); return clone(file)
|
||
},
|
||
async RemoveAttachment(sessionID, id) { store.attachments.delete(id) },
|
||
ChatStreamWithFiles(requestID, sessionID, message, attachmentIDs) { const promise = api.ChatStream(requestID, sessionID, message); calls.at(-1).attachmentIDs = [...attachmentIDs]; return promise },
|
||
ChatStream(requestID, sessionID, message) {
|
||
let resolve, reject
|
||
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
|
||
promise.cancel = async () => reject(new CancelError('stopped'))
|
||
calls.push({ requestID, sessionID, message, resolve, reject })
|
||
return promise
|
||
},
|
||
}
|
||
const module = { exports: {} }
|
||
runInNewContext(compiled, {
|
||
module, exports: module.exports,
|
||
require(name) {
|
||
if (name === 'vue') return { ...Vue, onUnmounted: fn => cleanup.push(fn) }
|
||
if (name === '@wailsio/runtime') return { CancelError, Events: { On: (_, fn) => { listeners.add(fn); return () => listeners.delete(fn) } } }
|
||
return { Service: api }
|
||
},
|
||
crypto: globalThis.crypto,
|
||
btoa: globalThis.btoa,
|
||
Uint8Array,
|
||
localStorage: { getItem: key => store.selection.get(key) || null, setItem: (key, value) => store.selection.set(key, value) },
|
||
setTimeout: fn => { const id = ++timerID; timers.set(id, fn); return id },
|
||
clearTimeout: id => timers.delete(id),
|
||
})
|
||
const chat = module.exports.useConversations(() => {})
|
||
return { chat, store, calls, listeners,
|
||
emit(requestID, delta) { for (const fn of [...listeners]) fn({ data: { requestID, delta } }) },
|
||
emitTool(requestID, tool) { for (const fn of [...listeners]) fn({ data: { requestID, tool } }) },
|
||
emitReasoning(requestID, reasoningDelta) { for (const fn of [...listeners]) fn({ data: { requestID, reasoningDelta } }) },
|
||
emitMemory(requestID, memory) { for (const fn of [...listeners]) fn({ data: { requestID, memory } }) },
|
||
tick() { for (const [id, fn] of [...timers]) { timers.delete(id); fn() } },
|
||
async dispose() { for (const fn of cleanup) fn(); await settle() },
|
||
}
|
||
}
|
||
|
||
test('separate drafts and saved conversations survive a controller restart', async () => {
|
||
const h = harness(); await h.chat.load()
|
||
const a = h.chat.activeID.value
|
||
h.chat.updateDraft('draft A'); await h.chat.saveCurrent()
|
||
await h.chat.create(); const b = h.chat.activeID.value
|
||
assert.notEqual(a, b); assert.equal(h.chat.current.value.draft, '')
|
||
h.chat.updateDraft('draft B'); await h.chat.saveCurrent()
|
||
await h.chat.select(a); assert.equal(h.chat.current.value.draft, 'draft A')
|
||
await h.dispose()
|
||
const restarted = harness(h.store); await restarted.chat.load()
|
||
assert.equal(restarted.chat.activeID.value, a)
|
||
assert.equal(restarted.chat.current.value.draft, 'draft A')
|
||
await restarted.chat.select(b); assert.equal(restarted.chat.current.value.draft, 'draft B')
|
||
await restarted.dispose()
|
||
})
|
||
|
||
test('switching while two conversations stream never mixes their messages', async () => {
|
||
const h = harness(); await h.chat.load(); const a = h.chat.activeID.value
|
||
const first = h.chat.send('question A'); await settle()
|
||
await h.chat.create(); const b = h.chat.activeID.value
|
||
const second = h.chat.send('question B'); await settle()
|
||
assert.equal(h.calls.length, 2)
|
||
const callA = h.calls.find(c => c.sessionID === a), callB = h.calls.find(c => c.sessionID === b)
|
||
h.emit(callA.requestID, 'answer A'); h.emit(callB.requestID, 'answer B')
|
||
assert.equal(h.chat.messages.value.at(-1).content, 'answer B')
|
||
await h.chat.select(a); assert.equal(h.chat.messages.value.at(-1).content, 'answer A')
|
||
h.tick(); await settle()
|
||
assert.equal(h.store.records.get(a).messages.at(-1).content, 'answer A')
|
||
callB.resolve({ content: 'answer B', finishReason: 'stop' }); await second
|
||
callA.resolve({ content: 'answer A', finishReason: 'stop' }); await first
|
||
assert.equal(h.store.records.get(a).messages.at(-1).status, 'complete')
|
||
assert.equal(h.store.records.get(b).messages.at(-1).status, 'complete')
|
||
assert.equal(h.listeners.size, 0)
|
||
await h.dispose()
|
||
})
|
||
|
||
test('stopping and deleting an active conversation preserves partial text then removes it', async () => {
|
||
const h = harness(); await h.chat.load(); const id = h.chat.activeID.value
|
||
const sending = h.chat.send('question'); await settle()
|
||
h.emit(h.calls[0].requestID, 'partial')
|
||
h.chat.stop(); await sending
|
||
assert.equal(h.store.records.get(id).messages.at(-1).status, 'stopped')
|
||
assert.equal(h.store.records.get(id).messages.at(-1).content, 'partial')
|
||
await h.chat.remove(id)
|
||
h.emit(h.calls[0].requestID, 'late token'); h.tick(); await settle()
|
||
assert.equal(h.store.records.has(id), false)
|
||
assert.notEqual(h.chat.activeID.value, id)
|
||
await h.dispose()
|
||
})
|
||
|
||
test('queued autosaves cannot overwrite a newer draft and failed saves can retry', async () => {
|
||
const h = harness(); await h.chat.load(); const id = h.chat.activeID.value
|
||
let release
|
||
const blocked = new Promise(resolve => { release = resolve })
|
||
let writes = 0
|
||
h.store.beforeSave = async () => { if (++writes === 1) await blocked }
|
||
h.chat.updateDraft('old'); const first = h.chat.saveCurrent(); await settle()
|
||
h.chat.updateDraft('new'); const second = h.chat.saveCurrent(); await settle()
|
||
assert.equal(writes, 1)
|
||
release(); await Promise.all([first, second])
|
||
assert.equal(h.store.records.get(id).draft, 'new')
|
||
assert.equal(h.chat.saveLabel.value, '已保存到本机')
|
||
h.store.failSave = true; h.chat.updateDraft('retry'); await h.chat.saveCurrent()
|
||
assert.match(h.chat.saveError.value, /disk full/)
|
||
assert.equal(h.store.records.get(id).draft, 'new')
|
||
h.store.failSave = false; await h.chat.saveCurrent()
|
||
assert.equal(h.store.records.get(id).draft, 'retry')
|
||
assert.equal(h.chat.saveError.value, '')
|
||
await h.dispose()
|
||
})
|
||
|
||
test('reopened incomplete replies are marked stopped and saved without losing content', async () => {
|
||
const store = storage()
|
||
store.records.set('saved', { id: 'saved', title: 'saved', createdAt: 1, updatedAt: 2, draft: '', messages: [{ id: 'a', role: 'assistant', content: 'partial', createdAt: 2, status: 'streaming' }] })
|
||
const h = harness(store); await h.chat.load(); await h.chat.saveCurrent()
|
||
assert.equal(h.chat.messages.value[0].status, 'stopped')
|
||
assert.equal(store.records.get('saved').messages[0].content, 'partial')
|
||
assert.equal(store.records.get('saved').messages[0].status, 'stopped')
|
||
await h.dispose()
|
||
})
|
||
|
||
test('tool progress stays in its conversation, persists and stops with generation', async () => {
|
||
const h = harness(); await h.chat.load(); const first = h.chat.activeID.value
|
||
const sending = h.chat.send('打开手机设置'); await settle()
|
||
const requestID = h.calls[0].requestID
|
||
const tool = { id: 'tool-1', server: 'Mobile', tool: 'launch_app', status: 'running', arguments: '{}' }
|
||
h.emitTool(requestID, tool)
|
||
assert.equal(h.chat.messages.value.at(-1).tools[0].status, 'running')
|
||
await h.chat.create()
|
||
h.emitTool(requestID, { ...tool, status: 'complete', result: 'opened', elapsedMs: 123, images: [{ id: 'screenshot', name: 'screen.png', kind: 'image' }] })
|
||
assert.equal(h.chat.messages.value.length, 0)
|
||
await h.chat.select(first)
|
||
assert.equal(h.chat.messages.value.at(-1).tools[0].status, 'complete')
|
||
h.emitTool(requestID, { ...tool, id: 'tool-2' })
|
||
h.chat.stop(); await sending
|
||
assert.deepEqual(h.store.records.get(first).messages.at(-1).tools.map(t => t.status), ['complete', 'stopped'])
|
||
const restarted = harness(h.store); await restarted.chat.load()
|
||
assert.equal(restarted.chat.messages.value.at(-1).tools[0].result, 'opened')
|
||
assert.equal(restarted.chat.messages.value.at(-1).tools[0].images[0].id, 'screenshot')
|
||
await restarted.dispose(); await h.dispose()
|
||
})
|
||
|
||
test('summary progress and server-managed memory stay with their conversation', async () => {
|
||
const h = harness(); await h.chat.load(); const first = h.chat.activeID.value
|
||
const sending = h.chat.send('继续讨论'); await settle()
|
||
const requestID = h.calls[0].requestID
|
||
h.emitMemory(requestID, { status: 'summarizing', message: '正在整理历史…' })
|
||
assert.equal(h.chat.memoryStatus.value, '正在整理历史…')
|
||
await h.chat.create()
|
||
assert.equal(h.chat.memoryStatus.value, '')
|
||
const memory = { text: '预算3500,数量500,只起草不发送', throughId: 'old-answer', prefixHash: 'hash', messageCount: 20, updatedAt: 100, version: 1 }
|
||
h.store.records.get(first).memory = memory
|
||
h.emitMemory(requestID, { status: 'complete', memory })
|
||
assert.equal(h.chat.current.value.memory, undefined)
|
||
await h.chat.select(first)
|
||
assert.equal(h.chat.current.value.memory.text, memory.text)
|
||
assert.equal(h.chat.memoryStatus.value, '')
|
||
h.calls[0].resolve({ content: '预算3500', finishReason: 'stop' }); await sending
|
||
assert.equal(h.store.records.get(first).memory.text, memory.text)
|
||
const restarted = harness(h.store); await restarted.chat.load()
|
||
assert.equal(restarted.chat.current.value.memory.text, memory.text)
|
||
await restarted.dispose(); await h.dispose()
|
||
})
|
||
|
||
test('file drafts survive restart, send without text, and remain with their original conversation', async () => {
|
||
const h = harness(); await h.chat.load(); const first = h.chat.activeID.value
|
||
await h.chat.uploadFiles([new File(['预算3500,数量500'], '需求.txt', { type: 'text/plain' })])
|
||
assert.equal(h.chat.uploadError.value, '')
|
||
assert.equal(h.chat.current.value.draftAttachments[0].name, '需求.txt')
|
||
assert.equal(h.store.records.get(first).draftAttachments.length, 1)
|
||
await h.chat.create(); assert.equal(h.chat.current.value.draftAttachments, undefined)
|
||
await h.chat.select(first)
|
||
const restarted = harness(h.store); await restarted.chat.load()
|
||
assert.equal(restarted.chat.current.value.draftAttachments[0].name, '需求.txt')
|
||
const send = restarted.chat.send(''); await settle()
|
||
assert.equal(restarted.calls[0].attachmentIDs.length, 1)
|
||
assert.match(restarted.calls[0].message, /分析这些附件/)
|
||
assert.equal(restarted.chat.messages.value[0].attachments[0].name, '需求.txt')
|
||
assert.equal(restarted.chat.current.value.draftAttachments.length, 0)
|
||
restarted.calls[0].resolve({ content: '预算3500,数量500', finishReason: 'stop' }); await send
|
||
assert.equal(h.store.records.get(first).messages[0].attachments.length, 1)
|
||
const sentFiles = restarted.chat.messages.value[0].attachments
|
||
restarted.chat.setDraftAttachments(sentFiles)
|
||
assert.equal(restarted.chat.current.value.draftAttachments[0].id, sentFiles[0].id)
|
||
await restarted.chat.removeDraftAttachment(sentFiles[0].id)
|
||
assert.equal(h.store.attachments.has(sentFiles[0].id), true)
|
||
await restarted.dispose(); await h.dispose()
|
||
})
|
||
|
||
test('upload can finish in a background conversation and oversized selections stay out', async () => {
|
||
const h = harness(); await h.chat.load(); const first = h.chat.activeID.value
|
||
let release; h.store.beforeUpload = () => new Promise(resolve => { release = resolve })
|
||
const uploading = h.chat.uploadFiles([new File(['text'], 'draft.md')]); await settle()
|
||
assert.equal(h.chat.uploading.value, true)
|
||
await h.chat.create(); assert.equal(h.chat.uploading.value, false)
|
||
release(); await uploading
|
||
assert.equal(h.chat.current.value.draftAttachments, undefined)
|
||
await h.chat.select(first)
|
||
assert.equal(h.chat.current.value.draftAttachments[0].name, 'draft.md')
|
||
const id = h.chat.current.value.draftAttachments[0].id
|
||
await h.chat.removeDraftAttachment(id)
|
||
assert.equal(h.store.attachments.has(id), false)
|
||
assert.equal(h.chat.current.value.draftAttachments.length, 0)
|
||
await h.chat.uploadFiles([{ name: 'huge.txt', size: 21 * 1024 * 1024 }])
|
||
assert.match(h.chat.uploadError.value, /20 MB/)
|
||
assert.equal(h.chat.uploading.value, false)
|
||
await h.dispose()
|
||
})
|
||
|
||
test('reasoning streams separately, stays in its conversation, and survives stopping and restart', async () => {
|
||
const h = harness(); await h.chat.load(); const first = h.chat.activeID.value
|
||
const sending = h.chat.send('检查当前页面'); await settle()
|
||
const requestID = h.calls[0].requestID
|
||
h.emitReasoning(requestID, '先确认设备,'); h.emitReasoning(requestID, '再读取页面。')
|
||
assert.equal(h.chat.messages.value.at(-1).reasoning, '先确认设备,再读取页面。')
|
||
assert.equal(h.chat.messages.value.at(-1).content, '')
|
||
await h.chat.create(); h.emitReasoning(requestID, '核对应用。')
|
||
assert.equal(h.chat.messages.value.length, 0)
|
||
await h.chat.select(first); h.chat.stop()
|
||
h.emitReasoning(requestID, '停止后的内容应忽略'); await sending
|
||
assert.equal(h.store.records.get(first).messages.at(-1).reasoning, '先确认设备,再读取页面。核对应用。')
|
||
const restarted = harness(h.store); await restarted.chat.load()
|
||
assert.equal(restarted.chat.messages.value.at(-1).reasoning, '先确认设备,再读取页面。核对应用。')
|
||
assert.equal(restarted.chat.messages.value.at(-1).status, 'stopped')
|
||
await restarted.dispose(); await h.dispose()
|
||
})
|
||
|
||
test('completed reasoning uses the final result and does not enter answer text', async () => {
|
||
const h = harness(); await h.chat.load()
|
||
const sending = h.chat.send('整理方案'); await settle()
|
||
h.emitReasoning(h.calls[0].requestID, '部分思考')
|
||
h.calls[0].resolve({ content: '完整回答', reasoning: '完整思考', finishReason: 'stop' }); await sending
|
||
assert.equal(h.chat.messages.value.at(-1).content, '完整回答')
|
||
assert.equal(h.chat.messages.value.at(-1).reasoning, '完整思考')
|
||
assert.equal(h.store.records.get(h.chat.activeID.value).messages.at(-1).reasoning, '完整思考')
|
||
await h.dispose()
|
||
})
|