fix: preserve drafts, canvas edits, and remote Continue tools
Keep paragraph formatting and checklist todos when removing canvas pages, and preserve accepted undo/redo history after oversized input is rejected. Merge restored queued text into the latest conversation draft and retain image delivery when OCR returns image references and generated warnings only. Deliver session MCP servers to bridged remote Continue Execute while keeping Ask read-only and preserving local model-profile isolation. Add regression coverage and update feature documentation. Correct stale conversation-limit and forced-color assertions and the measured Electron layout test deadline. Validation: 4,826 tests passed, 67 skipped; full typecheck and lint passed. Current-source Linux x64 Execute/Ask/Execute MCP delivery passed, using 10 real text-provider requests and no image-provider requests. Image generation itself was not exercised. Staged whitespace checks passed before commit. Release note: 修复恢复队列时覆盖新输入、画布删页丢失清单格式及超限输入清空撤销历史的问题;图片 OCR 无正文时保留原图发送方式,远程 Continue Execute 正确保留会话 MCP 工具。
This commit is contained in:
@@ -253,6 +253,13 @@ export type ContinueHostAdapterDependencies = {
|
||||
export type ContinueHostRunOptions = {
|
||||
workMode?: 'ask' | 'execute'
|
||||
images?: AgentImage[]
|
||||
/** Explicit session capabilities, not MCP servers from a local model config. */
|
||||
sessionMcpServers?: Array<{
|
||||
name: string
|
||||
type: 'streamable-http'
|
||||
url: string
|
||||
requestOptions: { headers: Record<string, string> }
|
||||
}>
|
||||
knowledgeCapability?: {
|
||||
endpoint: string
|
||||
token: string
|
||||
@@ -1257,6 +1264,9 @@ export class ContinueHostAdapter {
|
||||
)
|
||||
}
|
||||
const capabilityServers = [
|
||||
...(runOptions.workMode === 'execute'
|
||||
? runOptions.sessionMcpServers ?? []
|
||||
: []),
|
||||
...(knowledgeCapability
|
||||
? [
|
||||
createLoopbackMcpServer(
|
||||
@@ -1274,6 +1284,11 @@ export class ContinueHostAdapter {
|
||||
]
|
||||
: [])
|
||||
]
|
||||
if (capabilityServers.length > maximumConfiguredMcpServers) {
|
||||
throw new Error(
|
||||
`Continue 配置文件中的 MCP Server 不能超过 ${maximumConfiguredMcpServers} 个`
|
||||
)
|
||||
}
|
||||
const selectedPreset = presetConfig(runOptions.preset)
|
||||
const hasPresetContent =
|
||||
selectedPreset.rules.length > 0 ||
|
||||
|
||||
@@ -140,14 +140,16 @@ it('includes older pinned conversations beyond the recent 100 and sorts pins by
|
||||
database.saveLocalConversations(headers.map(header => ({ header, messages: [] })))
|
||||
database.setConversationPinned({ conversationId: headers[0]!.id, pinned: true })
|
||||
database.setConversationPinned({ conversationId: headers[1]!.id, pinned: true })
|
||||
const expectedIds = [headers[1]!.id, headers[0]!.id, ...headers.slice(3).reverse().map(header => header.id)]
|
||||
const expectedIds = [headers[1]!.id, headers[0]!.id, ...headers.slice(2).reverse().map(header => header.id)]
|
||||
expect(database.listConversations().map(item => item.id)).toEqual(expectedIds)
|
||||
const summaries = database.listConversationSummaries()
|
||||
expect(summaries.map(item => item.id)).toEqual(expectedIds)
|
||||
expect(summaries.slice(0, 2).every(item => item.pinned)).toBe(true)
|
||||
expect(database.searchConversations('Conversation 0')).toEqual([headers[0]!.id])
|
||||
database.setConversationPinned({ conversationId: headers[0]!.id, pinned: false })
|
||||
expect(database.listConversations().map(item => item.id)).toEqual(expectedIds.filter(id => id !== headers[0]!.id))
|
||||
expect(database.listConversations().map(item => item.id)).toEqual([
|
||||
headers[1]!.id, ...headers.slice(2).reverse().map(header => header.id), headers[0]!.id
|
||||
])
|
||||
expect(database.getConversation(headers[0]!.id)).toMatchObject({ pinned: false, updatedAt: 1000 })
|
||||
} finally {
|
||||
database.close()
|
||||
@@ -450,7 +452,7 @@ describe('AssistantDatabase', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('includes old live schedule and streaming conversations beyond recent history without exposing hidden or archived entries', async () => {
|
||||
it('lists all active conversations while preserving task visibility and excluding archived conversations', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-live-history-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const databasePath = join(directory, 'assistant.sqlite')
|
||||
@@ -466,7 +468,7 @@ describe('AssistantDatabase', () => {
|
||||
projectId, title, prompt: title, recurrence: 'daily',
|
||||
nextRunAt: '2027-01-01T00:00:00Z'
|
||||
}))
|
||||
const [running, approval, pending, , hidden, archived] = schedules
|
||||
const [running, approval, pending, idle, hidden, archived] = schedules
|
||||
for (const schedule of [running!, approval!, hidden!, archived!]) {
|
||||
claimManualScheduleQueueItem(database, schedule.id)
|
||||
}
|
||||
@@ -516,6 +518,7 @@ describe('AssistantDatabase', () => {
|
||||
})
|
||||
const tasks = database.listTasks(1)
|
||||
expect(tasks).toHaveLength(6)
|
||||
expect(tasks.some(task => task.id === hidden!.taskId)).toBe(false)
|
||||
expect(tasks).toEqual(expect.arrayContaining([
|
||||
historyTask,
|
||||
expect.objectContaining({ id: running!.taskId, status: 'running' }),
|
||||
@@ -523,18 +526,19 @@ describe('AssistantDatabase', () => {
|
||||
expect.objectContaining({ id: archived!.taskId, status: 'running' })
|
||||
]))
|
||||
const conversations = database.listConversations()
|
||||
expect(conversations).toHaveLength(105)
|
||||
expect(conversations).toHaveLength(109)
|
||||
expect(new Set(conversations.map((conversation) => conversation.id))).toEqual(
|
||||
new Set([
|
||||
...recent.slice(1).map((conversation) => conversation.header.id),
|
||||
running!.conversationId, approval!.conversationId, streamingId,
|
||||
...recent.map((conversation) => conversation.header.id),
|
||||
running!.conversationId, approval!.conversationId, pending!.conversationId,
|
||||
idle!.conversationId, hidden!.conversationId, streamingId,
|
||||
...liveConversations
|
||||
])
|
||||
)
|
||||
expect(conversations.find((conversation) => conversation.id === streamingId))
|
||||
.toMatchObject({ projectId, messages: [streamingMessage] })
|
||||
expect(conversations.slice(0, 100).map((conversation) => conversation.id))
|
||||
.toEqual(recent.slice(1).reverse().map((conversation) => conversation.header.id))
|
||||
expect(conversations.slice(0, recent.length).map((conversation) => conversation.id))
|
||||
.toEqual(recent.slice().reverse().map((conversation) => conversation.header.id))
|
||||
} finally {
|
||||
raw.close()
|
||||
database.close()
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { hasExtractedDocumentText } from './document-extracted-text'
|
||||
|
||||
describe('extracted document text', () => {
|
||||
it.each([
|
||||
'',
|
||||
' \n ',
|
||||
'',
|
||||
'[第 1 页图片未保存:未返回内联图片字节]',
|
||||
'\n[第 1 页图片未保存:图片格式不支持]',
|
||||
'<p> </p>\n[第 2 页图片未保存:Base64 无效]'
|
||||
])('does not count image references or generated warnings as text: %s', (content) => {
|
||||
expect(hasExtractedDocumentText(content)).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'Recognized text',
|
||||
'\n实际正文\n[第 1 页图片未保存:Base64 无效]',
|
||||
'<table><tr><td>实际表格</td></tr></table>'
|
||||
])('retains recognized text: %s', (content) => {
|
||||
expect(hasExtractedDocumentText(content)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { convert } from 'html-to-text'
|
||||
|
||||
export function hasExtractedDocumentText(content: string): boolean {
|
||||
const text = content
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/gu, '')
|
||||
.replace(/\[第 \d+ 页图片未保存:[^\]]*\]/gu, '')
|
||||
return Boolean(convert(text, { wordwrap: false }).trim())
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { extname } from 'node:path'
|
||||
import { convert } from 'html-to-text'
|
||||
import {
|
||||
documentParsingDiagnosticSchema,
|
||||
documentParsingSettingsUpdateSchema,
|
||||
@@ -16,6 +15,7 @@ import type { DocumentOcrBroker } from './document-ocr-broker'
|
||||
import type { DocumentOcrModelManager } from './document-ocr-model-manager'
|
||||
import type { DocumentParsingSettingsStore } from './document-parsing-settings-store'
|
||||
import { HttpDocumentOcr } from './http-document-ocr'
|
||||
import { hasExtractedDocumentText } from './document-extracted-text'
|
||||
import { renderOcrPdf, renderSelectedOcrPdf } from './render-ocr-pdf'
|
||||
import type { DocumentResultStorage } from './document-result-storage'
|
||||
import { extractPptxPages } from './knowledge/pptx-parser'
|
||||
@@ -242,7 +242,7 @@ export class DocumentParsingService {
|
||||
warnings = result.warnings
|
||||
}
|
||||
const content = sections.map((section) => section.content).join('\n\n')
|
||||
if (!convert(content.replace(/!\[[^\]]*\]\([^)]*\)/gu, ''), { wordwrap: false }).trim()) throw new Error('未提取到文字,原图片发送方式保持不变')
|
||||
if (!hasExtractedDocumentText(content)) throw new Error('未提取到文字,原图片发送方式保持不变')
|
||||
ensureNotAborted(signal)
|
||||
return { title: name, sourceFormat: extname(name).toLowerCase(), content, sections, warnings, pageCount: 1, images, missingImages, parsingSettings: settings, parsingDurationMs: Date.now() - startedAt }
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { basename, dirname, extname, join } from 'node:path'
|
||||
import { convert } from 'html-to-text'
|
||||
import { createCanvas, loadImage } from '@napi-rs/canvas'
|
||||
import { documentResultSchema, type DocumentResult } from '../shared/document-result-contracts'
|
||||
import type { DocumentParsingSettings } from '../shared/document-parsing-contracts'
|
||||
import type { ParsedDocument } from './knowledge/document-parser'
|
||||
import { hasExtractedDocumentText } from './document-extracted-text'
|
||||
|
||||
export function parsedCompleteness(parsed: ParsedDocument): DocumentResult['completeness'] {
|
||||
const text = convert(parsed.content.replace(/!\[[^\]]*\]\([^)]*\)/gu, ''), { wordwrap: false }).trim()
|
||||
return !text && parsed.images?.length ? 'images-only' : parsed.warnings.length ? 'partial' : 'complete'
|
||||
return !hasExtractedDocumentText(parsed.content) && parsed.images?.length
|
||||
? 'images-only' : parsed.warnings.length ? 'partial' : 'complete'
|
||||
}
|
||||
|
||||
export function originalImageMime(data: Buffer): 'image/png' | 'image/jpeg' | 'image/webp' | undefined {
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
// @vitest-environment node
|
||||
import { createServer } from 'node:http'
|
||||
import { once } from 'node:events'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createCanvas } from '@napi-rs/canvas'
|
||||
import { expect, it } from 'vitest'
|
||||
import { HttpDocumentOcr } from './http-document-ocr'
|
||||
import { defaultHttpOcrSettings } from '../shared/document-parsing-contracts'
|
||||
import { defaultDocumentParsingSettings } from './document-parsing-settings-store'
|
||||
import { DocumentParsingService } from './document-parsing-service'
|
||||
import { DocumentResultStorage } from './document-result-storage'
|
||||
|
||||
it('uses the production HTTP client for Bearer authentication, errors, cancellation and limits', async () => {
|
||||
const calls: Array<{ authorization?: string; path?: string }> = []
|
||||
@@ -39,3 +46,60 @@ it('uses the production HTTP client for Bearer authentication, errors, cancellat
|
||||
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
||||
}
|
||||
})
|
||||
|
||||
it.each([false, true])('classifies missing-image warnings consistently through HTTP parsing and storage (text=%s)', async (hasText) => {
|
||||
const image = createCanvas(4, 4).toBuffer('image/png')
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-ocr-text-'))
|
||||
const results = new DocumentResultStorage(directory)
|
||||
const paths: string[] = []
|
||||
const server = createServer(async (request, response) => {
|
||||
for await (const chunk of request) void chunk
|
||||
paths.push(request.url ?? '')
|
||||
response.setHeader('Content-Type', 'application/json')
|
||||
response.end(JSON.stringify({ result: { layoutParsingResults: [{
|
||||
prunedResult: {},
|
||||
markdown: {
|
||||
text: `${hasText ? 'Recognized body\n' : ''}\n`,
|
||||
images: { 'valid.png': image.toString('base64') }
|
||||
}
|
||||
}] } }))
|
||||
})
|
||||
server.listen(0, '127.0.0.1')
|
||||
await once(server, 'listening')
|
||||
try {
|
||||
const settings = {
|
||||
...defaultDocumentParsingSettings,
|
||||
ocrProvider: 'paddleocr-vl' as const,
|
||||
httpOcr: {
|
||||
...defaultHttpOcrSettings,
|
||||
baseUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}`
|
||||
}
|
||||
}
|
||||
const parsed = await new HttpDocumentOcr(settings.httpOcr).recognize(image, 1, [1])
|
||||
expect(parsed.images).toHaveLength(1)
|
||||
expect(parsed.missingImages).toHaveLength(1)
|
||||
const saved = await results.save('scan.png', image, {
|
||||
...parsed, title: 'scan.png', sourceFormat: '.png',
|
||||
content: parsed.sections.map(section => section.content).join('\n\n'),
|
||||
pageCount: 1
|
||||
}, settings, 0)
|
||||
expect((await results.get(saved.id)).completeness).toBe(hasText ? 'partial' : 'images-only')
|
||||
expect(saved.content).toContain('图片未保存')
|
||||
|
||||
const service = new DocumentParsingService({
|
||||
forOperation: async () => ({ settings, apiKey: () => undefined })
|
||||
} as never, {} as never, {} as never)
|
||||
const extracted = service.extractImage('scan.png', image)
|
||||
if (hasText) {
|
||||
await expect(extracted).resolves.toMatchObject({ content: expect.stringContaining('Recognized body') })
|
||||
} else {
|
||||
await expect(extracted).rejects.toThrow('未提取到文字,原图片发送方式保持不变')
|
||||
}
|
||||
expect(paths).toEqual(['/layout-parsing', '/layout-parsing'])
|
||||
} finally {
|
||||
server.closeAllConnections()
|
||||
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
||||
await results.close()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { loadImage } from '@napi-rs/canvas'
|
||||
import { convert } from 'html-to-text'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
maximumDocumentExtractedCharacters,
|
||||
type httpOcrSettingsSchema
|
||||
} from '../shared/document-parsing-contracts'
|
||||
import type { ParsedDocument, ParsedSection } from './knowledge/document-parser'
|
||||
import { hasExtractedDocumentText } from './document-extracted-text'
|
||||
|
||||
type Settings = z.infer<typeof httpOcrSettingsSchema>
|
||||
export type ParsedDocumentImage = {
|
||||
@@ -203,8 +203,9 @@ export class HttpDocumentOcr {
|
||||
}
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
const text = sections.map((section) => convert(section.content.replace(/!\[[^\]]*\]\([^)]*\)/gu, '').replace(/\[第 \d+ 页图片未保存:[^\]]*\]/gu, ''), { wordwrap: false })).join('').trim()
|
||||
if (!text && images.length === 0) throw new Error('HTTP OCR 未返回文字或有效图片')
|
||||
if (!hasExtractedDocumentText(sections.map((section) => section.content).join('\n\n')) && images.length === 0) {
|
||||
throw new Error('HTTP OCR 未返回文字或有效图片')
|
||||
}
|
||||
return { sections, images, missingImages, warnings, ...(restructure ? { restructure } : {}) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user