study-browser-host.mjs 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
  1. /** Isolated browser acceptance fixture. Never loaded by the production profile. */
  2. import * as fs from 'node:fs'
  3. import * as path from 'node:path'
  4. import { LlmAdapter, createUserMessage } from '@deepseek-ai/dsh-llm'
  5. import { attachIndexFixture } from './index-browser-extension.mjs'
  6. import { seedGraphFixture } from './graph-data.mjs'
  7. export const name = 'webnovel-browser-acceptance'
  8. export const inject = ['agents', 'agentLoop', 'sessionPersistence', 'workspaceRegistry', 'llm', 'sessionController', 'webServer', ...(process.env.WEBNOVEL_INDEX_CHECK ? ['embeddings', 'settings', 'credentials'] : [])]
  9. export async function apply(ctx) {
  10. const root = process.env.WEBNOVEL_BROWSER_WORKSPACE
  11. const reportPath = process.env.WEBNOVEL_BROWSER_REPORT
  12. if (!root || !path.isAbsolute(root) || !reportPath) throw new Error('Missing isolated browser acceptance paths')
  13. const put = (relative, text) => {
  14. const target = path.join(root, relative)
  15. if (fs.existsSync(target)) return
  16. fs.mkdirSync(path.dirname(target), { recursive: true })
  17. fs.writeFileSync(target, text)
  18. }
  19. const longText = '林舟在渡口停下脚步。潮声越过石阶,信封上的字迹依然清晰。\n\n'.repeat(35)
  20. for (const [book, id] of [['验收作品甲', 'acceptance-a'], ['验收作品乙', 'acceptance-b']]) {
  21. put(`${book}/作品契约/契约.md`, `---\n书id: ${id}\n版本: 1\n---\n# ${book}\n\n浏览器验收临时作品。\n`)
  22. put(`${book}/大纲/故事骨架.md`, '---\n版本: 1\n---\n# 故事骨架\n\n一封来信改变了渡口的日常。\n')
  23. put(`${book}/草稿区/草稿/卷01-来信/稿1.md`, `---\n身份:\n 卷: 1\n 章: 1\n 章名: 来信\n版本: 1\n父版本: null\n角色: 待审稿\n选定: true\n---\n# 来信\n\n${longText}`)
  24. put(`${book}/定稿/卷01/0002-归航.md`, '---\n身份:\n 卷: 1\n 章: 2\n 章名: 归航\n角色: 已定稿\n版本: 1\n---\n# 归航\n\n已定稿的原文,只读验证。\n')
  25. }
  26. seedGraphFixture(path.join(root, '验收作品甲'))
  27. put('书房/知识库/写作笔记.md', '# 写作笔记\n\n共享资料的原始内容。\n')
  28. put('书房/作者记忆/偏好.md', '# 作者偏好\n\n保留作者自己的句子。\n')
  29. const report = { requests: 0, savesReceived: 0, indexErrorsReceived: 0, complete: 0, customEvents: 0 }
  30. const persist = () => fs.writeFileSync(reportPath, JSON.stringify(report, null, 2))
  31. if (process.env.WEBNOVEL_INDEX_CHECK) await attachIndexFixture(ctx, root, report, persist)
  32. class AcceptanceAdapter extends LlmAdapter {
  33. providerInfo(provider) { return { id: provider, name: '本地验收提供方' } }
  34. async listModels() { return [{ id: 'fixture', name: '本地验收' }] }
  35. async * stream(options) {
  36. report.requests++
  37. if (options.system?.includes('你为小说原文识别连续场景')) {
  38. const paragraphs = JSON.parse(options.messages[0].content[0].text)
  39. const text = JSON.stringify({ ends: [paragraphs.length] })
  40. report.sceneRequests = (report.sceneRequests ?? 0) + 1
  41. yield { type: 'block-start', index: 0, blockType: 'text' }
  42. yield { type: 'text-delta', index: 0, text }
  43. yield { type: 'block-end', index: 0, block: { type: 'text', text } }
  44. yield { type: 'finish', reason: { kind: 'stop' } }
  45. persist()
  46. return
  47. }
  48. const messages = JSON.stringify(options.messages)
  49. if (messages.includes('作者通过书房编辑器保存了文档')) report.savesReceived++
  50. if (messages.includes('后台检索索引需要处理')) report.indexErrorsReceived++
  51. const document = new URL(`http://127.0.0.1:${ctx.webServer.port}/`)
  52. document.searchParams.set('webnovel', JSON.stringify({ sessionId: 'webnovel-browser-acceptance', ref: { space: 'book:acceptance-a', path: '草稿区/草稿/卷01-来信/稿1.md' } }))
  53. const text = '浏览器验收会话。\n\n[打开原文](<' + document + '>)\n\n保存通知已进入本次原生对话。'
  54. yield { type: 'block-start', index: 0, blockType: 'text' }
  55. yield { type: 'text-delta', index: 0, text }
  56. yield { type: 'block-end', index: 0, block: { type: 'text', text } }
  57. yield { type: 'usage', usage: { inputTokens: 0, outputTokens: 0 } }
  58. yield { type: 'finish', reason: { kind: 'stop' } }
  59. persist()
  60. }
  61. }
  62. ctx.llm.registerAdapter(['webnovel-acceptance'], new AcceptanceAdapter())
  63. const workspace = await ctx.workspaceRegistry.create(root, 'S5 临时验收书房')
  64. const sessionId = 'webnovel-browser-acceptance'
  65. const exists = (await ctx.sessionPersistence.list()).some(snapshot => snapshot.header.id === sessionId)
  66. const options = { provider: 'webnovel-acceptance', model: 'fixture' }
  67. const handle = exists ? await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: options })
  68. : await ctx.agents.create({ sessionId, meta: { cwd: root, title: 'S5 书房验收' }, agentOptions: options })
  69. const off = ctx.on('agent/status', ({ agent, status }) => {
  70. if (agent !== handle.agent || status !== 'idle') return
  71. report.complete++
  72. report.customEvents = Array.from({ length: agent.session.seq }, (_, index) => agent.session.eventAt(index)).filter(event => event.type.startsWith('novel/')).length
  73. persist()
  74. })
  75. if (!exists) await new Promise((resolve, reject) => {
  76. const timer = setTimeout(() => { done(); reject(new Error('Acceptance session did not finish')) }, 10000)
  77. const done = ctx.on('agent/status', ({ agent, status }) => {
  78. if (agent !== handle.agent || status !== 'idle') return
  79. clearTimeout(timer); done(); resolve()
  80. })
  81. handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: '打开验收作品,验证文档链接。' }], source: { kind: 'user' } }))
  82. })
  83. await ctx.sessionPersistence.flush()
  84. await workspace.attachSession(sessionId)
  85. await ctx.sessionController.rename({ sessionId, title: process.env.WEBNOVEL_INDEX_CHECK ? 'S6 后台索引验收' : 'S5 书房验收' })
  86. const secondRoot = path.join(root, '另一工作范围')
  87. fs.mkdirSync(secondRoot, { recursive: true })
  88. const secondWorkspace = await ctx.workspaceRegistry.create(secondRoot, 'S5 空工作范围')
  89. const secondId = 'webnovel-browser-second'
  90. const secondExists = (await ctx.sessionPersistence.list()).some(snapshot => snapshot.header.id === secondId)
  91. const second = secondExists ? await ctx.agents.resume({ resumeSessionId: secondId, agentOptions: options })
  92. : await ctx.agents.create({ sessionId: secondId, meta: { cwd: secondRoot, title: 'S5 会话隔离验收' }, agentOptions: options })
  93. await ctx.sessionPersistence.flush()
  94. await secondWorkspace.attachSession(secondId)
  95. await ctx.sessionController.rename({ sessionId: secondId, title: 'S5 会话隔离验收' })
  96. persist()
  97. ctx.on('dispose', () => { off(); return Promise.all([handle.dispose(), second.dispose()]) })
  98. }