feat: expand secure assistant workflows
Harden runtime execution and add local knowledge, Smart Heartbeat, usage visibility, responsive product surfaces, and cross-platform packaging support. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
6ef1795b81
commit
b3fdf96962
+576
-59
@@ -1,19 +1,27 @@
|
||||
import type {
|
||||
AgentEvent,
|
||||
AgentRuntimeStatus
|
||||
AgentRuntimeStatus,
|
||||
ModelAuthentication,
|
||||
ModelProtocol
|
||||
} from '../../shared/contracts'
|
||||
import { createAnthropicMessagesUrl } from './anthropic-endpoint'
|
||||
import {
|
||||
createOpenAIChatCompletionsUrl,
|
||||
createOpenAIImagesGenerationsUrl
|
||||
} from './openai-endpoint'
|
||||
import type {
|
||||
AgentExecutionRequest,
|
||||
AgentRuntime
|
||||
AgentRuntime,
|
||||
RuntimeEvent,
|
||||
RuntimeModelUsageEvent
|
||||
} from './runtime'
|
||||
import { redactSensitiveText } from './approval-summary'
|
||||
|
||||
type ConversationMessage = {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
type ApiMessage = {
|
||||
type AnthropicApiMessage = {
|
||||
role: 'user' | 'assistant'
|
||||
content:
|
||||
| string
|
||||
@@ -33,10 +41,29 @@ type ApiMessage = {
|
||||
>
|
||||
}
|
||||
|
||||
type ModelUsageUpdate = {
|
||||
callId?: string
|
||||
model?: string
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
cacheReadTokens?: number
|
||||
cacheWriteTokens?: number
|
||||
reportedTotalTokens?: number
|
||||
}
|
||||
|
||||
type ModelUsageAccumulator = ModelUsageUpdate & {
|
||||
reported: boolean
|
||||
}
|
||||
|
||||
const maxGeneratedImageBytes = 3_900_000
|
||||
const maxImageResponseBytes = 5_300_000
|
||||
|
||||
export type ModelRuntimeOptions = {
|
||||
apiKey: string
|
||||
apiKey?: string
|
||||
baseUrl: string
|
||||
model: string
|
||||
protocol: ModelProtocol
|
||||
authentication: ModelAuthentication
|
||||
skillInstructions?: string
|
||||
fetcher?: typeof fetch
|
||||
}
|
||||
@@ -46,18 +73,27 @@ function getErrorMessage(value: unknown): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
const error = 'error' in value ? value.error : undefined
|
||||
if (typeof error === 'string') {
|
||||
return redactSensitiveText(error).slice(0, 1_000)
|
||||
}
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'message' in error &&
|
||||
typeof error.message === 'string'
|
||||
) {
|
||||
return error.message
|
||||
return redactSensitiveText(error.message).slice(0, 1_000)
|
||||
}
|
||||
if (
|
||||
'message' in value &&
|
||||
typeof value.message === 'string'
|
||||
) {
|
||||
return redactSensitiveText(value.message).slice(0, 1_000)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getTextDelta(value: unknown): string | undefined {
|
||||
function getAnthropicTextDelta(value: unknown): string | undefined {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
@@ -80,18 +116,282 @@ function getTextDelta(value: unknown): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function parseStreamBlock(block: string): {
|
||||
function getOpenAITextDelta(value: unknown): string | undefined {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
!('choices' in value) ||
|
||||
!Array.isArray(value.choices)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
const first = value.choices[0]
|
||||
if (
|
||||
!first ||
|
||||
typeof first !== 'object' ||
|
||||
!('delta' in first) ||
|
||||
!first.delta ||
|
||||
typeof first.delta !== 'object' ||
|
||||
!('content' in first.delta) ||
|
||||
typeof first.delta.content !== 'string'
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return first.delta.content
|
||||
}
|
||||
|
||||
function getRecord(
|
||||
value: unknown
|
||||
): Record<string, unknown> | undefined {
|
||||
return value !== null && typeof value === 'object'
|
||||
? value as Record<string, unknown>
|
||||
: undefined
|
||||
}
|
||||
|
||||
function getSafeTokenCount(value: unknown): number | undefined {
|
||||
return Number.isSafeInteger(value) && (value as number) >= 0
|
||||
? value as number
|
||||
: undefined
|
||||
}
|
||||
|
||||
function getProviderIdentifier(value: unknown): string | undefined {
|
||||
return typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
value.length <= 512
|
||||
? value
|
||||
: undefined
|
||||
}
|
||||
|
||||
function getUsageUpdate(
|
||||
value: unknown,
|
||||
protocol: 'anthropic' | 'openai'
|
||||
): ModelUsageUpdate {
|
||||
const event = getRecord(value)
|
||||
if (!event) {
|
||||
return {}
|
||||
}
|
||||
|
||||
let metadata = event
|
||||
let usage: Record<string, unknown> | undefined
|
||||
if (protocol === 'anthropic') {
|
||||
if (event.type === 'message_start') {
|
||||
metadata = getRecord(event.message) ?? event
|
||||
usage = getRecord(metadata.usage)
|
||||
} else if (event.type === 'message_delta') {
|
||||
usage = getRecord(event.usage)
|
||||
}
|
||||
} else {
|
||||
usage = getRecord(event.usage)
|
||||
}
|
||||
|
||||
const promptDetails =
|
||||
protocol === 'openai'
|
||||
? getRecord(usage?.prompt_tokens_details)
|
||||
: undefined
|
||||
return {
|
||||
callId: getProviderIdentifier(metadata.id),
|
||||
model: getProviderIdentifier(metadata.model),
|
||||
inputTokens: getSafeTokenCount(
|
||||
protocol === 'anthropic'
|
||||
? usage?.input_tokens
|
||||
: usage?.prompt_tokens ?? usage?.input_tokens
|
||||
),
|
||||
outputTokens: getSafeTokenCount(
|
||||
protocol === 'anthropic'
|
||||
? usage?.output_tokens
|
||||
: usage?.completion_tokens ?? usage?.output_tokens
|
||||
),
|
||||
cacheReadTokens: getSafeTokenCount(
|
||||
protocol === 'anthropic'
|
||||
? usage?.cache_read_input_tokens
|
||||
: usage?.cache_read_tokens ?? promptDetails?.cached_tokens
|
||||
),
|
||||
cacheWriteTokens: getSafeTokenCount(
|
||||
protocol === 'anthropic'
|
||||
? usage?.cache_creation_input_tokens
|
||||
: usage?.cache_write_tokens
|
||||
),
|
||||
reportedTotalTokens: getSafeTokenCount(usage?.total_tokens)
|
||||
}
|
||||
}
|
||||
|
||||
function applyUsageUpdate(
|
||||
accumulator: ModelUsageAccumulator,
|
||||
update: ModelUsageUpdate
|
||||
): void {
|
||||
for (const key of [
|
||||
'callId',
|
||||
'model',
|
||||
'inputTokens',
|
||||
'outputTokens',
|
||||
'cacheReadTokens',
|
||||
'cacheWriteTokens',
|
||||
'reportedTotalTokens'
|
||||
] as const) {
|
||||
const value = update[key]
|
||||
if (value !== undefined) {
|
||||
Object.assign(accumulator, { [key]: value })
|
||||
if (
|
||||
key !== 'callId' &&
|
||||
key !== 'model'
|
||||
) {
|
||||
accumulator.reported = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createUsageEvent(
|
||||
requestId: string,
|
||||
provider: 'anthropic' | 'openai',
|
||||
fallbackModel: string,
|
||||
usage: ModelUsageAccumulator
|
||||
): RuntimeModelUsageEvent | undefined {
|
||||
if (!usage.reported) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
requestId,
|
||||
type: 'model-usage',
|
||||
callId: (usage.callId ?? requestId).slice(0, 256),
|
||||
runtime: 'model',
|
||||
provider,
|
||||
model: (usage.model ?? fallbackModel).slice(0, 500),
|
||||
inputTokens: usage.inputTokens ?? 0,
|
||||
outputTokens: usage.outputTokens ?? 0,
|
||||
cacheReadTokens: usage.cacheReadTokens ?? 0,
|
||||
cacheWriteTokens: usage.cacheWriteTokens ?? 0,
|
||||
...(usage.reportedTotalTokens === undefined
|
||||
? {}
|
||||
: { reportedTotalTokens: usage.reportedTotalTokens })
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedText(
|
||||
response: Response,
|
||||
maxBytes: number
|
||||
): Promise<string> {
|
||||
if (!response.body) {
|
||||
throw new Error('模型接口未返回响应内容')
|
||||
}
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let total = 0
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
total += value.byteLength
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel().catch(() => undefined)
|
||||
throw new Error('图像生成响应超过安全限制')
|
||||
}
|
||||
chunks.push(value)
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
return Buffer.concat(chunks, total).toString('utf8')
|
||||
}
|
||||
|
||||
function parseGeneratedImage(value: unknown): {
|
||||
data: string
|
||||
mimeType: 'image/png' | 'image/jpeg' | 'image/webp'
|
||||
} {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
!('data' in value) ||
|
||||
!Array.isArray(value.data) ||
|
||||
value.data.length !== 1
|
||||
) {
|
||||
throw new Error('图像生成接口返回格式无效')
|
||||
}
|
||||
const first = value.data[0]
|
||||
if (
|
||||
!first ||
|
||||
typeof first !== 'object'
|
||||
) {
|
||||
throw new Error('图像生成接口未返回 base64 图片')
|
||||
}
|
||||
const inlineUrl =
|
||||
'url' in first && typeof first.url === 'string'
|
||||
? /^data:image\/(?:png|jpeg|webp);base64,([A-Za-z0-9+/]+={0,2})$/u.exec(
|
||||
first.url
|
||||
)
|
||||
: undefined
|
||||
const encoded =
|
||||
'b64_json' in first && typeof first.b64_json === 'string'
|
||||
? first.b64_json
|
||||
: inlineUrl?.[1]
|
||||
if (!encoded) {
|
||||
throw new Error('图像生成接口未返回 base64 图片')
|
||||
}
|
||||
if (
|
||||
encoded.length === 0 ||
|
||||
encoded.length > maxImageResponseBytes ||
|
||||
encoded.length % 4 !== 0 ||
|
||||
!/^[A-Za-z0-9+/]+={0,2}$/u.test(encoded)
|
||||
) {
|
||||
throw new Error('图像生成接口返回了无效图片数据')
|
||||
}
|
||||
const data = Buffer.from(encoded, 'base64')
|
||||
if (
|
||||
data.length === 0 ||
|
||||
data.length > maxGeneratedImageBytes ||
|
||||
data.toString('base64') !== encoded
|
||||
) {
|
||||
throw new Error('图像生成图片无效或超过安全限制')
|
||||
}
|
||||
if (
|
||||
data.length >= 8 &&
|
||||
data.subarray(0, 8).equals(
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
)
|
||||
) {
|
||||
return { data: encoded, mimeType: 'image/png' }
|
||||
}
|
||||
if (
|
||||
data.length >= 3 &&
|
||||
data[0] === 0xff &&
|
||||
data[1] === 0xd8 &&
|
||||
data[2] === 0xff
|
||||
) {
|
||||
return { data: encoded, mimeType: 'image/jpeg' }
|
||||
}
|
||||
if (
|
||||
data.length >= 12 &&
|
||||
data.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
data.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
return { data: encoded, mimeType: 'image/webp' }
|
||||
}
|
||||
throw new Error('图像生成接口返回了不支持的图片格式')
|
||||
}
|
||||
|
||||
function parseStreamBlock(
|
||||
block: string,
|
||||
protocol: ModelProtocol
|
||||
): {
|
||||
delta?: string
|
||||
stopped: boolean
|
||||
usage?: ModelUsageUpdate
|
||||
} {
|
||||
const data = block
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).trimStart())
|
||||
.join('\n')
|
||||
if (!data || data === '[DONE]') {
|
||||
if (!data) {
|
||||
return { stopped: false }
|
||||
}
|
||||
if (data === '[DONE]') {
|
||||
return {
|
||||
stopped: protocol === 'openai-chat-completions'
|
||||
}
|
||||
}
|
||||
let event: unknown
|
||||
try {
|
||||
event = JSON.parse(data)
|
||||
@@ -103,8 +403,16 @@ function parseStreamBlock(block: string): {
|
||||
throw new Error(error.slice(0, 1_000))
|
||||
}
|
||||
return {
|
||||
delta: getTextDelta(event),
|
||||
delta:
|
||||
protocol === 'anthropic-messages'
|
||||
? getAnthropicTextDelta(event)
|
||||
: getOpenAITextDelta(event),
|
||||
usage: getUsageUpdate(
|
||||
event,
|
||||
protocol === 'anthropic-messages' ? 'anthropic' : 'openai'
|
||||
),
|
||||
stopped:
|
||||
protocol === 'anthropic-messages' &&
|
||||
event !== null &&
|
||||
typeof event === 'object' &&
|
||||
'type' in event &&
|
||||
@@ -114,6 +422,7 @@ function parseStreamBlock(block: string): {
|
||||
|
||||
export class ModelAgentRuntime implements AgentRuntime {
|
||||
readonly requiresToolApproval = false
|
||||
readonly supportsToolExecution = false
|
||||
private readonly conversations = new Map<string, ConversationMessage[]>()
|
||||
private readonly fetcher: typeof fetch
|
||||
|
||||
@@ -121,36 +430,85 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
this.fetcher = options.fetcher ?? fetch
|
||||
}
|
||||
|
||||
get capability(): 'chat' | 'image-generation' {
|
||||
return this.options.protocol === 'openai-images-generations'
|
||||
? 'image-generation'
|
||||
: 'chat'
|
||||
}
|
||||
|
||||
private isConfigured(): boolean {
|
||||
return (
|
||||
this.options.authentication === 'none' ||
|
||||
Boolean(this.options.apiKey)
|
||||
)
|
||||
}
|
||||
|
||||
private getEndpoint(): URL {
|
||||
if (this.options.protocol === 'anthropic-messages') {
|
||||
return createAnthropicMessagesUrl(this.options.baseUrl)
|
||||
}
|
||||
return this.options.protocol === 'openai-images-generations'
|
||||
? createOpenAIImagesGenerationsUrl(this.options.baseUrl)
|
||||
: createOpenAIChatCompletionsUrl(this.options.baseUrl)
|
||||
}
|
||||
|
||||
private getHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
if (
|
||||
this.options.authentication === 'api-key' &&
|
||||
this.options.apiKey
|
||||
) {
|
||||
if (this.options.protocol === 'anthropic-messages') {
|
||||
headers['anthropic-version'] = '2023-06-01'
|
||||
headers['x-api-key'] = this.options.apiKey
|
||||
} else {
|
||||
headers.authorization = `Bearer ${this.options.apiKey}`
|
||||
}
|
||||
} else if (this.options.protocol === 'anthropic-messages') {
|
||||
headers['anthropic-version'] = '2023-06-01'
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
async getStatus(): Promise<AgentRuntimeStatus> {
|
||||
const imageGeneration = this.capability === 'image-generation'
|
||||
return {
|
||||
id: 'model',
|
||||
label: this.options.model,
|
||||
available: Boolean(this.options.apiKey),
|
||||
detail: `Anthropic Messages 兼容模型接口 · ${this.options.baseUrl}`
|
||||
available: this.isConfigured(),
|
||||
supportsToolExecution: this.supportsToolExecution,
|
||||
detail: `${imageGeneration
|
||||
? 'OpenAI Images Generations'
|
||||
: this.options.protocol === 'anthropic-messages'
|
||||
? 'Anthropic Messages'
|
||||
: 'OpenAI Chat Completions'
|
||||
} 兼容模型接口 · ${this.options.baseUrl}`,
|
||||
capability: imageGeneration ? 'image-generation' : 'chat'
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection(): Promise<AgentRuntimeStatus> {
|
||||
if (!this.options.apiKey) {
|
||||
if (!this.isConfigured()) {
|
||||
return this.getStatus()
|
||||
}
|
||||
const response = await this.fetcher(
|
||||
createAnthropicMessagesUrl(this.options.baseUrl),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'anthropic-version': '2023-06-01',
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': this.options.apiKey
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.options.model,
|
||||
max_tokens: 1,
|
||||
stream: false,
|
||||
messages: [{ role: 'user', content: 'Reply OK.' }]
|
||||
})
|
||||
if (this.options.protocol === 'openai-images-generations') {
|
||||
return {
|
||||
...(await this.getStatus()),
|
||||
detail: `已识别图像生成配置,发送提示词时执行实际生成验证 · ${this.options.baseUrl}`
|
||||
}
|
||||
)
|
||||
}
|
||||
const response = await this.fetcher(this.getEndpoint(), {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify({
|
||||
model: this.options.model,
|
||||
max_tokens: 1,
|
||||
stream: false,
|
||||
messages: [{ role: 'user', content: 'Reply OK.' }]
|
||||
})
|
||||
})
|
||||
if (!response.ok) {
|
||||
let detail: string | undefined
|
||||
try {
|
||||
@@ -168,16 +526,19 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
id: 'model',
|
||||
label: this.options.model,
|
||||
available: true,
|
||||
supportsToolExecution: this.supportsToolExecution,
|
||||
detail: `已验证模型接口连接 · ${this.options.baseUrl}`
|
||||
}
|
||||
}
|
||||
|
||||
private getMessages(request: AgentExecutionRequest): ApiMessage[] {
|
||||
private getAnthropicMessages(
|
||||
request: AgentExecutionRequest
|
||||
): AnthropicApiMessage[] {
|
||||
const history =
|
||||
request.history && request.history.length > 0
|
||||
? request.history
|
||||
: this.conversations.get(request.conversationId) ?? []
|
||||
const content: ApiMessage['content'] =
|
||||
const content: AnthropicApiMessage['content'] =
|
||||
request.images && request.images.length > 0
|
||||
? [
|
||||
...request.images.map((image) => ({
|
||||
@@ -203,6 +564,36 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
]
|
||||
}
|
||||
|
||||
private getOpenAIMessages(
|
||||
request: AgentExecutionRequest,
|
||||
system: string
|
||||
): Array<Record<string, unknown>> {
|
||||
const history =
|
||||
request.history && request.history.length > 0
|
||||
? request.history
|
||||
: this.conversations.get(request.conversationId) ?? []
|
||||
const userContent =
|
||||
request.images && request.images.length > 0
|
||||
? [
|
||||
{
|
||||
type: 'text',
|
||||
text: request.prompt
|
||||
},
|
||||
...request.images.map((image) => ({
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: `data:${image.mediaType};base64,${image.data}`
|
||||
}
|
||||
}))
|
||||
]
|
||||
: request.prompt
|
||||
return [
|
||||
{ role: 'system', content: system },
|
||||
...history.slice(-20),
|
||||
{ role: 'user', content: userContent }
|
||||
]
|
||||
}
|
||||
|
||||
private saveConversation(
|
||||
conversationId: string,
|
||||
messages: ConversationMessage[]
|
||||
@@ -227,13 +618,110 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
private async *runImageGeneration(
|
||||
request: AgentExecutionRequest,
|
||||
signal: AbortSignal
|
||||
): AsyncGenerator<RuntimeEvent, void, void> {
|
||||
if (request.images?.length) {
|
||||
throw new Error('当前图像生成接口暂不支持参考图或图片编辑')
|
||||
}
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'status',
|
||||
message: `${this.options.model} 正在生成图片`
|
||||
}
|
||||
const imageRequest = {
|
||||
model: this.options.model,
|
||||
prompt: request.prompt.slice(0, 100_000),
|
||||
n: 1,
|
||||
response_format: 'b64_json'
|
||||
}
|
||||
const response = await this.fetcher(this.getEndpoint(), {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(imageRequest),
|
||||
signal
|
||||
})
|
||||
const responseText = await readBoundedText(
|
||||
response,
|
||||
response.ok ? maxImageResponseBytes : 128 * 1024
|
||||
)
|
||||
if (!response.ok) {
|
||||
let errorPayload: unknown
|
||||
try {
|
||||
errorPayload = responseText.trim()
|
||||
? JSON.parse(responseText)
|
||||
: undefined
|
||||
} catch {
|
||||
errorPayload = undefined
|
||||
}
|
||||
const requestId = [
|
||||
response.headers.get('x-request-id'),
|
||||
response.headers.get('cf-ray')
|
||||
].find(
|
||||
(candidate) =>
|
||||
candidate &&
|
||||
candidate.length <= 128 &&
|
||||
/^[\w.-]+$/u.test(candidate)
|
||||
)
|
||||
const providerMessage = getErrorMessage(errorPayload)
|
||||
const publicMessage =
|
||||
response.status === 502 &&
|
||||
providerMessage?.includes('模型接口请求失败')
|
||||
? '上游图像服务暂时不可用,请稍后重试或联系服务商'
|
||||
: providerMessage
|
||||
? redactSensitiveText(providerMessage).slice(0, 1_000)
|
||||
: '图像生成请求失败'
|
||||
throw new Error(
|
||||
`${publicMessage}(HTTP ${response.status}${
|
||||
requestId ? `,请求 ID ${requestId}` : ''
|
||||
})`
|
||||
)
|
||||
}
|
||||
let payload: unknown
|
||||
try {
|
||||
payload = JSON.parse(responseText)
|
||||
} catch {
|
||||
throw new Error('图像生成接口返回了无效 JSON')
|
||||
}
|
||||
const image = parseGeneratedImage(payload)
|
||||
const usage = {
|
||||
reported: false
|
||||
} satisfies ModelUsageAccumulator
|
||||
applyUsageUpdate(usage, getUsageUpdate(payload, 'openai'))
|
||||
const usageEvent = createUsageEvent(
|
||||
request.requestId,
|
||||
'openai',
|
||||
this.options.model,
|
||||
usage
|
||||
)
|
||||
if (usageEvent) {
|
||||
yield usageEvent
|
||||
}
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'generated-image',
|
||||
mimeType: image.mimeType,
|
||||
data: image.data,
|
||||
title: request.prompt.split(/\r?\n/u, 1)[0]!.slice(0, 120)
|
||||
}
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'done'
|
||||
}
|
||||
}
|
||||
|
||||
async *run(
|
||||
request: AgentExecutionRequest,
|
||||
signal: AbortSignal
|
||||
): AsyncGenerator<AgentEvent, void, void> {
|
||||
if (!this.options.apiKey) {
|
||||
): AsyncGenerator<RuntimeEvent, void, void> {
|
||||
if (!this.isConfigured()) {
|
||||
throw new Error('请先在设置中配置模型接口 API Key')
|
||||
}
|
||||
if (this.options.protocol === 'openai-images-generations') {
|
||||
yield* this.runImageGeneration(request, signal)
|
||||
return
|
||||
}
|
||||
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
@@ -241,31 +729,40 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
message: `${this.options.model} 正在思考`
|
||||
}
|
||||
|
||||
const messages = this.getMessages(request)
|
||||
const response = await this.fetcher(
|
||||
createAnthropicMessagesUrl(this.options.baseUrl),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'anthropic-version': '2023-06-01',
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': this.options.apiKey
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.options.model,
|
||||
max_tokens: 4096,
|
||||
stream: true,
|
||||
system: [
|
||||
'You are GoodBuddy, a secure desktop assistant. Answer clearly in the language used by the user. Never claim to have used desktop tools unless a tool result was provided.',
|
||||
this.options.skillInstructions
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n'),
|
||||
messages
|
||||
}),
|
||||
signal
|
||||
}
|
||||
)
|
||||
const system = [
|
||||
'You are GoodBuddy, a secure desktop assistant. Answer clearly in the language used by the user. Never claim to have used desktop tools unless a tool result was provided.',
|
||||
this.options.skillInstructions
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
const anthropic = this.options.protocol === 'anthropic-messages'
|
||||
const messages = anthropic
|
||||
? this.getAnthropicMessages(request)
|
||||
: this.getOpenAIMessages(request, system)
|
||||
const response = await this.fetcher(this.getEndpoint(), {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(
|
||||
anthropic
|
||||
? {
|
||||
model: this.options.model,
|
||||
max_tokens: 4096,
|
||||
stream: true,
|
||||
system,
|
||||
messages
|
||||
}
|
||||
: {
|
||||
model: this.options.model,
|
||||
max_tokens: 4096,
|
||||
stream: true,
|
||||
stream_options: {
|
||||
include_usage: true
|
||||
},
|
||||
messages
|
||||
}
|
||||
),
|
||||
signal
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let detail: string | undefined
|
||||
@@ -289,6 +786,9 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
let answer = ''
|
||||
let receivedStop = false
|
||||
let streamEnded = false
|
||||
const usage = {
|
||||
reported: false
|
||||
} satisfies ModelUsageAccumulator
|
||||
|
||||
try {
|
||||
while (!receivedStop) {
|
||||
@@ -311,7 +811,10 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
}
|
||||
|
||||
for (const block of blocks) {
|
||||
const parsed = parseStreamBlock(block)
|
||||
const parsed = parseStreamBlock(block, this.options.protocol)
|
||||
if (parsed.usage) {
|
||||
applyUsageUpdate(usage, parsed.usage)
|
||||
}
|
||||
const { delta } = parsed
|
||||
if (delta) {
|
||||
answer += delta
|
||||
@@ -354,6 +857,15 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
{ role: 'assistant', content: answer }
|
||||
])
|
||||
|
||||
const usageEvent = createUsageEvent(
|
||||
request.requestId,
|
||||
anthropic ? 'anthropic' : 'openai',
|
||||
this.options.model,
|
||||
usage
|
||||
)
|
||||
if (usageEvent) {
|
||||
yield usageEvent
|
||||
}
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'done'
|
||||
@@ -363,4 +875,9 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
async dispose(): Promise<void> {
|
||||
this.conversations.clear()
|
||||
}
|
||||
|
||||
releaseConversation(conversationId: string): Promise<void> {
|
||||
this.conversations.delete(conversationId)
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user