feat: add secure remote channel media
This commit is contained in:
@@ -6,6 +6,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
const channelDefaultProfileId =
|
||||
'00000000-0000-4000-8000-000000000001'
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers()
|
||||
@@ -109,6 +111,7 @@ describe('AssistantDatabase', () => {
|
||||
const oldDatabase = new DatabaseSync(databasePath)
|
||||
oldDatabase.exec(`
|
||||
DROP TABLE model_usage_calls;
|
||||
ALTER TABLE projects DROP COLUMN runtime_selection_json;
|
||||
PRAGMA user_version = 3;
|
||||
`)
|
||||
oldDatabase.close()
|
||||
@@ -124,7 +127,7 @@ describe('AssistantDatabase', () => {
|
||||
user_version: number
|
||||
}
|
||||
).user_version
|
||||
).toBe(15)
|
||||
).toBe(16)
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
@@ -133,6 +136,15 @@ describe('AssistantDatabase', () => {
|
||||
)
|
||||
.get()
|
||||
).toEqual({ name: 'model_usage_calls' })
|
||||
expect(
|
||||
current
|
||||
.prepare('PRAGMA table_info(projects)')
|
||||
.all()
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'runtime_selection_json' })
|
||||
])
|
||||
)
|
||||
const foreignKeys = current
|
||||
.prepare('PRAGMA foreign_key_list(model_usage_calls)')
|
||||
.all() as Array<{
|
||||
@@ -219,7 +231,7 @@ describe('AssistantDatabase', () => {
|
||||
user_version: number
|
||||
}
|
||||
).user_version
|
||||
).toBe(15)
|
||||
).toBe(16)
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
@@ -354,14 +366,24 @@ describe('AssistantDatabase', () => {
|
||||
defaultWorkMode: 'execute'
|
||||
})
|
||||
|
||||
const first = database.ensureChannelProjects('C:\\Users\\test')
|
||||
const second = database.ensureChannelProjects('C:\\Ignored')
|
||||
const first = database.ensureChannelProjects(
|
||||
'C:\\Users\\test',
|
||||
channelDefaultProfileId
|
||||
)
|
||||
const second = database.ensureChannelProjects(
|
||||
'C:\\Ignored',
|
||||
channelDefaultProfileId
|
||||
)
|
||||
|
||||
expect(first).toEqual([
|
||||
expect.objectContaining({
|
||||
name: '微信 ClawBot',
|
||||
rootPath: 'C:\\Users\\test',
|
||||
defaultWorkMode: 'ask',
|
||||
runtimeSelection: {
|
||||
provider: 'model',
|
||||
profileId: channelDefaultProfileId
|
||||
},
|
||||
kind: 'channel',
|
||||
channel: 'weixin'
|
||||
}),
|
||||
@@ -388,13 +410,21 @@ describe('AssistantDatabase', () => {
|
||||
name: '不可重命名',
|
||||
description: '更新后的通道说明',
|
||||
rootPath: 'C:\\Remote',
|
||||
defaultWorkMode: 'execute'
|
||||
defaultWorkMode: 'execute',
|
||||
runtimeSelection: {
|
||||
provider: 'opencode',
|
||||
profileId: '00000000-0000-4000-8000-000000000019'
|
||||
}
|
||||
})
|
||||
expect(updated).toMatchObject({
|
||||
name: '微信 ClawBot',
|
||||
description: '更新后的通道说明',
|
||||
rootPath: 'C:\\Remote',
|
||||
defaultWorkMode: 'execute'
|
||||
defaultWorkMode: 'execute',
|
||||
runtimeSelection: {
|
||||
provider: 'opencode',
|
||||
profileId: '00000000-0000-4000-8000-000000000019'
|
||||
}
|
||||
})
|
||||
expect(() =>
|
||||
database.updateProject(weixin.id, {
|
||||
@@ -416,7 +446,8 @@ describe('AssistantDatabase', () => {
|
||||
it('persists one protected remote conversation per channel identity', async () => {
|
||||
const database = await createDatabase()
|
||||
const project = database.ensureChannelProjects(
|
||||
'C:\\Users\\test'
|
||||
'C:\\Users\\test',
|
||||
channelDefaultProfileId
|
||||
)[0]!
|
||||
const first = database.getOrCreateRemoteConversation({
|
||||
projectId: project.id,
|
||||
@@ -425,7 +456,8 @@ describe('AssistantDatabase', () => {
|
||||
externalConversationId: 'remote-user-1',
|
||||
conversationType: 'direct',
|
||||
title: '微信 ClawBot · ****0001',
|
||||
accountDisplay: '发送者 ****0001'
|
||||
accountDisplay: '发送者 ****0001',
|
||||
runtimeSelection: { provider: 'continue' }
|
||||
})
|
||||
const second = database.getOrCreateRemoteConversation({
|
||||
projectId: project.id,
|
||||
@@ -434,7 +466,8 @@ describe('AssistantDatabase', () => {
|
||||
externalConversationId: 'remote-user-1',
|
||||
conversationType: 'direct',
|
||||
title: '微信 ClawBot · ****0001',
|
||||
accountDisplay: '发送者 ****0001'
|
||||
accountDisplay: '发送者 ****0001',
|
||||
runtimeSelection: { provider: 'continue' }
|
||||
})
|
||||
expect(second.id).toBe(first.id)
|
||||
|
||||
@@ -442,16 +475,29 @@ describe('AssistantDatabase', () => {
|
||||
conversationId: first.id,
|
||||
role: 'user',
|
||||
content: '请分析状态',
|
||||
attachments: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000090',
|
||||
name: '状态.txt',
|
||||
size: 12,
|
||||
preview: '状态',
|
||||
kind: 'text'
|
||||
}
|
||||
],
|
||||
status: '微信 ClawBot · 对话'
|
||||
})
|
||||
database.appendRemoteConversationMessage({
|
||||
conversationId: first.id,
|
||||
role: 'assistant',
|
||||
content: '状态正常',
|
||||
artifactIds: [
|
||||
'00000000-0000-4000-8000-000000000091'
|
||||
],
|
||||
status: '微信 ClawBot · 已完成'
|
||||
})
|
||||
expect(database.getConversation(first.id)).toMatchObject({
|
||||
projectId: project.id,
|
||||
runtimeSelection: { provider: 'continue' },
|
||||
remote: {
|
||||
channel: 'weixin',
|
||||
accountDisplay: '发送者 ****0001',
|
||||
@@ -461,11 +507,17 @@ describe('AssistantDatabase', () => {
|
||||
{
|
||||
role: 'user',
|
||||
content: '请分析状态',
|
||||
attachments: [
|
||||
expect.objectContaining({ name: '状态.txt' })
|
||||
],
|
||||
status: '微信 ClawBot · 对话'
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '状态正常',
|
||||
artifactIds: [
|
||||
'00000000-0000-4000-8000-000000000091'
|
||||
],
|
||||
status: '微信 ClawBot · 已完成'
|
||||
}
|
||||
]
|
||||
@@ -1107,6 +1159,8 @@ describe('AssistantDatabase', () => {
|
||||
'00000000-0000-4000-8000-000000000292'
|
||||
const runtimeProfileId =
|
||||
'00000000-0000-4000-8000-000000000293'
|
||||
const imageProfileId =
|
||||
'00000000-0000-4000-8000-000000000294'
|
||||
database.replaceConversations(
|
||||
([
|
||||
['model', removedProfileId],
|
||||
@@ -1121,12 +1175,66 @@ describe('AssistantDatabase', () => {
|
||||
messages: []
|
||||
}))
|
||||
)
|
||||
const channelProject = database.ensureChannelProjects(
|
||||
'C:\\Users\\test',
|
||||
defaultProfileId
|
||||
)[0]!
|
||||
database.updateProject(channelProject.id, {
|
||||
name: channelProject.name,
|
||||
description: channelProject.description,
|
||||
rootPath: channelProject.rootPath,
|
||||
defaultWorkMode: channelProject.defaultWorkMode,
|
||||
runtimeSelection: {
|
||||
provider: 'model',
|
||||
profileId: removedProfileId
|
||||
}
|
||||
})
|
||||
const imageChannelProject = database.ensureChannelProjects(
|
||||
'C:\\Users\\test',
|
||||
defaultProfileId
|
||||
)[1]!
|
||||
database.updateProject(imageChannelProject.id, {
|
||||
name: imageChannelProject.name,
|
||||
description: imageChannelProject.description,
|
||||
rootPath: imageChannelProject.rootPath,
|
||||
defaultWorkMode: imageChannelProject.defaultWorkMode,
|
||||
runtimeSelection: {
|
||||
provider: 'model',
|
||||
profileId: imageProfileId
|
||||
}
|
||||
})
|
||||
const automaticChannelProject = database.ensureChannelProjects(
|
||||
'C:\\Users\\test',
|
||||
defaultProfileId
|
||||
)[2]!
|
||||
database.updateProject(automaticChannelProject.id, {
|
||||
name: automaticChannelProject.name,
|
||||
description: automaticChannelProject.description,
|
||||
rootPath: automaticChannelProject.rootPath,
|
||||
defaultWorkMode: automaticChannelProject.defaultWorkMode,
|
||||
runtimeSelection: { provider: 'auto' }
|
||||
})
|
||||
const automaticRemoteConversation =
|
||||
database.getOrCreateRemoteConversation({
|
||||
projectId: automaticChannelProject.id,
|
||||
channel: 'dingtalk',
|
||||
accountId: 'default',
|
||||
externalConversationId: 'legacy-auto-conversation',
|
||||
conversationType: 'direct',
|
||||
title: '钉钉 · 旧版自动后端',
|
||||
accountDisplay: '发送者 ****0001',
|
||||
runtimeSelection: { provider: 'auto' }
|
||||
})
|
||||
|
||||
expect(
|
||||
database.repairConversationRuntimeSelections({
|
||||
modelProfiles: [
|
||||
{ id: defaultProfileId },
|
||||
{ id: runtimeProfileId }
|
||||
{ id: runtimeProfileId },
|
||||
{
|
||||
id: imageProfileId,
|
||||
protocol: 'openai-images-generations'
|
||||
}
|
||||
],
|
||||
defaultModelProfileId: defaultProfileId,
|
||||
opencodeModelSource: {
|
||||
@@ -1135,10 +1243,11 @@ describe('AssistantDatabase', () => {
|
||||
},
|
||||
continueModelSource: { kind: 'platform' }
|
||||
})
|
||||
).toBe(3)
|
||||
).toBe(7)
|
||||
expect(
|
||||
database
|
||||
.listConversations()
|
||||
.filter((conversation) => !conversation.remote)
|
||||
.sort((left, right) => left.title.localeCompare(right.title))
|
||||
.map((conversation) => conversation.runtimeSelection)
|
||||
).toEqual([
|
||||
@@ -1147,6 +1256,30 @@ describe('AssistantDatabase', () => {
|
||||
{ provider: 'continue' },
|
||||
{ provider: 'model', profileId: runtimeProfileId }
|
||||
])
|
||||
expect(database.getProject(channelProject.id).runtimeSelection).toEqual({
|
||||
provider: 'model',
|
||||
profileId: defaultProfileId
|
||||
})
|
||||
expect(
|
||||
database.getProject(imageChannelProject.id).runtimeSelection
|
||||
).toEqual({
|
||||
provider: 'model',
|
||||
profileId: defaultProfileId
|
||||
})
|
||||
expect(
|
||||
database.getProject(automaticChannelProject.id).runtimeSelection
|
||||
).toEqual({
|
||||
provider: 'model',
|
||||
profileId: defaultProfileId
|
||||
})
|
||||
expect(
|
||||
database.getConversation(
|
||||
automaticRemoteConversation.id
|
||||
).runtimeSelection
|
||||
).toEqual({
|
||||
provider: 'model',
|
||||
profileId: defaultProfileId
|
||||
})
|
||||
database.close()
|
||||
})
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
agentRuntimeSelectionKey,
|
||||
agentRuntimeSelectionSchema,
|
||||
repairAgentRuntimeSelection,
|
||||
repairChannelRuntimeSelection,
|
||||
type AgentRuntimeSelection,
|
||||
type RuntimeSelectionRepairSettings
|
||||
} from '../../shared/runtime-selection-contracts'
|
||||
import {
|
||||
@@ -65,6 +67,7 @@ type ProjectRow = {
|
||||
description: string
|
||||
root_path: string
|
||||
default_work_mode: ProjectCreateInput['defaultWorkMode']
|
||||
runtime_selection_json: string | null
|
||||
kind: AssistantProject['kind']
|
||||
channel: ProjectChannel | null
|
||||
status: AssistantProject['status']
|
||||
@@ -167,6 +170,9 @@ type MessageMetadata = {
|
||||
attachments?: ConversationSnapshot['messages'][number]['attachments']
|
||||
}
|
||||
|
||||
const MAX_CHANNEL_OUTBOX_RETRY_BYTES = 20 * 1024 * 1024
|
||||
const MAX_CHANNEL_OUTBOX_MEDIA_ENTRIES = 8
|
||||
|
||||
function parseRuntimeSelection(value: string | null):
|
||||
| ConversationSnapshot['runtimeSelection']
|
||||
| undefined {
|
||||
@@ -354,6 +360,12 @@ function toProject(row: ProjectRow): AssistantProject {
|
||||
description: row.description,
|
||||
rootPath: row.root_path,
|
||||
defaultWorkMode: row.default_work_mode,
|
||||
runtimeSelection:
|
||||
row.kind === 'channel'
|
||||
? parseRuntimeSelection(row.runtime_selection_json) ?? {
|
||||
provider: 'auto'
|
||||
}
|
||||
: parseRuntimeSelection(row.runtime_selection_json),
|
||||
kind: row.kind,
|
||||
channel: row.channel ?? undefined,
|
||||
status: row.status,
|
||||
@@ -952,7 +964,10 @@ export class AssistantDatabase {
|
||||
return rows.map(toProject)
|
||||
}
|
||||
|
||||
ensureChannelProjects(defaultRootPath: string): AssistantProject[] {
|
||||
ensureChannelProjects(
|
||||
defaultRootPath: string,
|
||||
defaultModelProfileId: string
|
||||
): AssistantProject[] {
|
||||
const database = this.requireDatabase()
|
||||
const definitions: ReadonlyArray<{
|
||||
channel: ProjectChannel
|
||||
@@ -980,9 +995,10 @@ export class AssistantDatabase {
|
||||
)
|
||||
const insert = database.prepare(
|
||||
`INSERT INTO projects
|
||||
(id, name, description, root_path, default_work_mode, kind,
|
||||
channel, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'ask', 'channel', ?, 'active', ?, ?)`
|
||||
(id, name, description, root_path, default_work_mode,
|
||||
runtime_selection_json, kind, channel, status, created_at,
|
||||
updated_at)
|
||||
VALUES (?, ?, ?, ?, 'ask', ?, 'channel', ?, 'active', ?, ?)`
|
||||
)
|
||||
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
@@ -997,6 +1013,10 @@ export class AssistantDatabase {
|
||||
definition.name,
|
||||
definition.description,
|
||||
defaultRootPath,
|
||||
JSON.stringify({
|
||||
provider: 'model',
|
||||
profileId: defaultModelProfileId
|
||||
}),
|
||||
definition.channel,
|
||||
now,
|
||||
now
|
||||
@@ -1024,9 +1044,10 @@ export class AssistantDatabase {
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO projects
|
||||
(id, name, description, root_path, default_work_mode, kind,
|
||||
channel, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'user', NULL, 'active', ?, ?)`
|
||||
(id, name, description, root_path, default_work_mode,
|
||||
runtime_selection_json, kind, channel, status, created_at,
|
||||
updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'user', NULL, 'active', ?, ?)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
@@ -1034,6 +1055,9 @@ export class AssistantDatabase {
|
||||
input.description,
|
||||
input.rootPath,
|
||||
input.defaultWorkMode,
|
||||
input.runtimeSelection
|
||||
? JSON.stringify(input.runtimeSelection)
|
||||
: null,
|
||||
now,
|
||||
now
|
||||
)
|
||||
@@ -1056,7 +1080,8 @@ export class AssistantDatabase {
|
||||
.prepare(
|
||||
`UPDATE projects
|
||||
SET name = ?, description = ?, root_path = ?,
|
||||
default_work_mode = ?, updated_at = ?
|
||||
default_work_mode = ?, runtime_selection_json = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(
|
||||
@@ -1064,6 +1089,11 @@ export class AssistantDatabase {
|
||||
input.description,
|
||||
input.rootPath,
|
||||
input.defaultWorkMode,
|
||||
input.runtimeSelection || current.runtimeSelection
|
||||
? JSON.stringify(
|
||||
input.runtimeSelection ?? current.runtimeSelection
|
||||
)
|
||||
: null,
|
||||
new Date().toISOString(),
|
||||
projectId
|
||||
)
|
||||
@@ -1279,24 +1309,60 @@ export class AssistantDatabase {
|
||||
settings: RuntimeSelectionRepairSettings
|
||||
): number {
|
||||
const database = this.requireDatabase()
|
||||
const conversations = database
|
||||
const projects = database
|
||||
.prepare(
|
||||
`SELECT id, runtime_selection_json
|
||||
FROM projects
|
||||
WHERE kind = 'channel'`
|
||||
)
|
||||
.all() as Array<{
|
||||
id: string
|
||||
runtime_selection_json: string | null
|
||||
}>
|
||||
const conversations = database
|
||||
.prepare(
|
||||
`SELECT id, runtime_selection_json, channel
|
||||
FROM conversations
|
||||
WHERE runtime_selection_json IS NOT NULL`
|
||||
)
|
||||
.all() as Array<{
|
||||
id: string
|
||||
runtime_selection_json: string
|
||||
channel: ProjectChannel | null
|
||||
}>
|
||||
const update = database.prepare(
|
||||
`UPDATE conversations
|
||||
SET runtime_selection_json = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
const updateProject = database.prepare(
|
||||
`UPDATE projects
|
||||
SET runtime_selection_json = ?, updated_at = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
let repaired = 0
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
for (const project of projects) {
|
||||
const stored = parseRuntimeSelection(
|
||||
project.runtime_selection_json
|
||||
)
|
||||
const current = stored ?? { provider: 'auto' as const }
|
||||
const next = repairChannelRuntimeSelection(current, settings)
|
||||
if (
|
||||
stored &&
|
||||
agentRuntimeSelectionKey(next) ===
|
||||
agentRuntimeSelectionKey(current)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
updateProject.run(
|
||||
JSON.stringify(next),
|
||||
new Date().toISOString(),
|
||||
project.id
|
||||
)
|
||||
repaired += 1
|
||||
}
|
||||
for (const conversation of conversations) {
|
||||
const current = parseRuntimeSelection(
|
||||
conversation.runtime_selection_json
|
||||
@@ -1304,7 +1370,9 @@ export class AssistantDatabase {
|
||||
if (!current) {
|
||||
continue
|
||||
}
|
||||
const next = repairAgentRuntimeSelection(current, settings)
|
||||
const next = conversation.channel
|
||||
? repairChannelRuntimeSelection(current, settings)
|
||||
: repairAgentRuntimeSelection(current, settings)
|
||||
if (
|
||||
agentRuntimeSelectionKey(next) ===
|
||||
agentRuntimeSelectionKey(current)
|
||||
@@ -1402,6 +1470,7 @@ export class AssistantDatabase {
|
||||
conversationType: 'direct' | 'group'
|
||||
title: string
|
||||
accountDisplay: string
|
||||
runtimeSelection?: AgentRuntimeSelection
|
||||
}): ConversationSnapshot {
|
||||
const database = this.requireDatabase()
|
||||
const existing = database
|
||||
@@ -1422,7 +1491,9 @@ export class AssistantDatabase {
|
||||
.prepare(
|
||||
`UPDATE conversations
|
||||
SET project_id = ?, title = ?, conversation_type = ?,
|
||||
account_display = ?, status = 'active', updated_at = ?
|
||||
account_display = ?,
|
||||
runtime_selection_json = COALESCE(?, runtime_selection_json),
|
||||
status = 'active', updated_at = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(
|
||||
@@ -1430,6 +1501,9 @@ export class AssistantDatabase {
|
||||
input.title,
|
||||
input.conversationType,
|
||||
input.accountDisplay,
|
||||
input.runtimeSelection
|
||||
? JSON.stringify(input.runtimeSelection)
|
||||
: null,
|
||||
new Date().toISOString(),
|
||||
existing.id
|
||||
)
|
||||
@@ -1444,11 +1518,14 @@ export class AssistantDatabase {
|
||||
(id, project_id, runtime_selection_json, work_mode, title, status,
|
||||
channel, external_account_id, external_conversation_id,
|
||||
conversation_type, account_display, created_at, updated_at)
|
||||
VALUES (?, ?, NULL, 'ask', ?, 'active', ?, ?, ?, ?, ?, ?, ?)`
|
||||
VALUES (?, ?, ?, 'ask', ?, 'active', ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
input.projectId,
|
||||
input.runtimeSelection
|
||||
? JSON.stringify(input.runtimeSelection)
|
||||
: null,
|
||||
input.title,
|
||||
input.channel,
|
||||
input.accountId,
|
||||
@@ -1466,6 +1543,8 @@ export class AssistantDatabase {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
status?: string
|
||||
attachments?: ConversationSnapshot['messages'][number]['attachments']
|
||||
artifactIds?: string[]
|
||||
}): void {
|
||||
const database = this.requireDatabase()
|
||||
const now = Date.now()
|
||||
@@ -1493,7 +1572,13 @@ export class AssistantDatabase {
|
||||
sequence.sequence,
|
||||
JSON.stringify({
|
||||
createdAt: now,
|
||||
...(input.status ? { status: input.status } : {})
|
||||
...(input.status ? { status: input.status } : {}),
|
||||
...(input.attachments?.length
|
||||
? { attachments: input.attachments }
|
||||
: {}),
|
||||
...(input.artifactIds?.length
|
||||
? { artifactIds: input.artifactIds }
|
||||
: {})
|
||||
}),
|
||||
new Date(now).toISOString()
|
||||
)
|
||||
@@ -1553,6 +1638,22 @@ export class AssistantDatabase {
|
||||
createdAt: number
|
||||
} {
|
||||
const parsed = channelResultMessageSchema.parse(message)
|
||||
if (parsed.attachments?.length) {
|
||||
const pendingMedia = (
|
||||
this.requireDatabase()
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM channel_outbox
|
||||
WHERE state != 'delivered'
|
||||
AND attempts < 5
|
||||
AND json_type(message_json, '$.attachments') = 'array'`
|
||||
)
|
||||
.get() as { count: number }
|
||||
).count
|
||||
if (pendingMedia >= MAX_CHANNEL_OUTBOX_MEDIA_ENTRIES) {
|
||||
throw new Error('媒体结果等待发送过多,请恢复通道连接后重试')
|
||||
}
|
||||
}
|
||||
const entry = {
|
||||
id: randomUUID(),
|
||||
message: parsed,
|
||||
@@ -1600,10 +1701,16 @@ export class AssistantDatabase {
|
||||
this.requireDatabase()
|
||||
.prepare(
|
||||
`UPDATE channel_outbox
|
||||
SET state = ?, attempts = attempts + 1
|
||||
SET state = ?,
|
||||
attempts = attempts + 1,
|
||||
message_json = CASE
|
||||
WHEN ? = 'delivered' OR attempts + 1 >= 5
|
||||
THEN json_remove(message_json, '$.attachments')
|
||||
ELSE message_json
|
||||
END
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(state, id)
|
||||
.run(state, state, id)
|
||||
}
|
||||
|
||||
listUndeliveredChannelResults(
|
||||
@@ -1622,15 +1729,34 @@ export class AssistantDatabase {
|
||||
)
|
||||
const rows = this.requireDatabase()
|
||||
.prepare(
|
||||
`SELECT id, message_json, state, attempts, created_at
|
||||
FROM channel_outbox
|
||||
WHERE state != 'delivered'
|
||||
AND attempts < 5
|
||||
${channel === undefined ? '' : 'AND channel = ?'}
|
||||
`WITH pending AS (
|
||||
SELECT id, message_json, state, attempts, created_at,
|
||||
ROW_NUMBER() OVER (
|
||||
ORDER BY attempts ASC, created_at ASC
|
||||
) AS position,
|
||||
SUM(LENGTH(CAST(message_json AS BLOB))) OVER (
|
||||
ORDER BY attempts ASC, created_at ASC
|
||||
) AS cumulative_bytes
|
||||
FROM channel_outbox
|
||||
WHERE state != 'delivered'
|
||||
AND attempts < 5
|
||||
${channel === undefined ? '' : 'AND channel = ?'}
|
||||
)
|
||||
SELECT id, message_json, state, attempts, created_at
|
||||
FROM pending
|
||||
WHERE position = 1 OR cumulative_bytes <= ?
|
||||
ORDER BY attempts ASC, created_at ASC
|
||||
LIMIT ?`
|
||||
)
|
||||
.all(...(channel === undefined ? [safeLimit] : [channel, safeLimit])) as Array<{
|
||||
.all(
|
||||
...(channel === undefined
|
||||
? [MAX_CHANNEL_OUTBOX_RETRY_BYTES, safeLimit]
|
||||
: [
|
||||
channel,
|
||||
MAX_CHANNEL_OUTBOX_RETRY_BYTES,
|
||||
safeLimit
|
||||
])
|
||||
) as Array<{
|
||||
id: string
|
||||
message_json: string
|
||||
state: 'pending' | 'failed'
|
||||
@@ -4136,12 +4262,12 @@ export class AssistantDatabase {
|
||||
const version = database
|
||||
.prepare('PRAGMA user_version')
|
||||
.get() as { user_version: number }
|
||||
if (version.user_version > 15) {
|
||||
if (version.user_version > 16) {
|
||||
throw new Error(
|
||||
`当前 GoodBuddy 不支持助理数据库版本 ${version.user_version},请升级应用后重试`
|
||||
)
|
||||
}
|
||||
if (version.user_version === 15) {
|
||||
if (version.user_version === 16) {
|
||||
return
|
||||
}
|
||||
if (version.user_version < 1) {
|
||||
@@ -4154,6 +4280,7 @@ export class AssistantDatabase {
|
||||
root_path TEXT NOT NULL DEFAULT '',
|
||||
default_work_mode TEXT NOT NULL
|
||||
CHECK(default_work_mode IN ('ask', 'plan', 'execute')),
|
||||
runtime_selection_json TEXT,
|
||||
status TEXT NOT NULL CHECK(status IN ('active', 'archived')),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
@@ -4843,6 +4970,33 @@ export class AssistantDatabase {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (version.user_version < 16) {
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const projectColumns = new Set(
|
||||
(
|
||||
database.prepare('PRAGMA table_info(projects)').all() as Array<{
|
||||
name: string
|
||||
}>
|
||||
).map((column) => column.name)
|
||||
)
|
||||
if (!projectColumns.has('runtime_selection_json')) {
|
||||
database.exec(`
|
||||
ALTER TABLE projects ADD COLUMN runtime_selection_json TEXT;
|
||||
`)
|
||||
}
|
||||
database.exec(`
|
||||
UPDATE projects
|
||||
SET runtime_selection_json = '{"provider":"auto"}'
|
||||
WHERE kind = 'channel' AND runtime_selection_json IS NULL;
|
||||
PRAGMA user_version = 16;
|
||||
COMMIT;
|
||||
`)
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private requireDatabase(): DatabaseSync {
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
|
||||
).count
|
||||
check.close()
|
||||
migrated.close()
|
||||
expect(version).toBe(15)
|
||||
expect(version).toBe(16)
|
||||
expect(heartbeatTableCount).toBe(3)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user