263 lines
7.0 KiB
TypeScript
263 lines
7.0 KiB
TypeScript
import { FASTAPI_BASE_URL, authHeaders, readError, type ApiEnvelope } from './session'
|
||
|
||
export type LearningAssistantSession = {
|
||
assistant_session_id: number | string
|
||
title?: string
|
||
}
|
||
|
||
type WorkflowRunResponse = {
|
||
workflow_run_id?: string
|
||
task_id?: string
|
||
data?: {
|
||
status?: string
|
||
outputs?: unknown
|
||
error?: string
|
||
[key: string]: unknown
|
||
}
|
||
[key: string]: unknown
|
||
}
|
||
|
||
type ChatStreamPayload = {
|
||
question: string
|
||
top_k?: number
|
||
score_threshold?: number
|
||
}
|
||
|
||
type StreamCallbacks = {
|
||
onDelta: (delta: string) => void
|
||
onDone?: (meta: Record<string, unknown>) => void
|
||
}
|
||
|
||
const CLINICAL_BOOK_WORKFLOW_URL = import.meta.env.DEV
|
||
? '/dify/v1/workflows/run'
|
||
: 'http://8.160.178.88:8088/v1/workflows/run'
|
||
const CLINICAL_BOOK_WORKFLOW_API_KEY = 'app-9BapxGXgF2qQi9HHkq4R95Y7'
|
||
|
||
export async function createLearningAssistantSession(title?: string) {
|
||
const body = title?.trim()
|
||
? {
|
||
title: title.trim().slice(0, 100)
|
||
}
|
||
: {}
|
||
|
||
const response = await fetch(`${FASTAPI_BASE_URL}/learning-assistant/sessions`, {
|
||
method: 'POST',
|
||
headers: authHeaders(),
|
||
body: JSON.stringify(body)
|
||
})
|
||
|
||
if (!response.ok) {
|
||
throw new Error(await readError(response))
|
||
}
|
||
|
||
const result = (await response.json()) as ApiEnvelope<Record<string, unknown>>
|
||
if (result.code !== 'OK' || !result.data) {
|
||
throw new Error(result.message || '新建会话失败')
|
||
}
|
||
|
||
const assistantSessionId = readAssistantSessionId(result.data)
|
||
if (!assistantSessionId) {
|
||
throw new Error('新建会话返回缺少 assistant_session_id')
|
||
}
|
||
|
||
return {
|
||
...result.data,
|
||
assistant_session_id: assistantSessionId
|
||
} as LearningAssistantSession
|
||
}
|
||
|
||
export async function runClinicalThinkingBooksWorkflow(input: string, signal?: AbortSignal) {
|
||
const response = await fetch(CLINICAL_BOOK_WORKFLOW_URL, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Accept: 'application/json',
|
||
Authorization: `Bearer ${CLINICAL_BOOK_WORKFLOW_API_KEY}`
|
||
},
|
||
body: JSON.stringify({
|
||
inputs: {
|
||
input
|
||
},
|
||
response_mode: 'blocking',
|
||
user: readWorkflowUserId()
|
||
}),
|
||
signal
|
||
})
|
||
|
||
if (!response.ok) {
|
||
throw new Error(await readWorkflowError(response))
|
||
}
|
||
|
||
const result = (await response.json()) as WorkflowRunResponse
|
||
if (result.data?.status && result.data.status !== 'succeeded') {
|
||
throw new Error(result.data.error || '临床思维训练书籍生成失败')
|
||
}
|
||
|
||
const output = readWorkflowOutput(result)
|
||
if (!output) {
|
||
throw new Error('临床思维训练书籍内容为空,请稍后重试')
|
||
}
|
||
|
||
return output
|
||
}
|
||
|
||
export async function streamLearningAssistantChat(
|
||
assistantSessionId: number | string,
|
||
payload: ChatStreamPayload,
|
||
callbacks: StreamCallbacks,
|
||
signal?: AbortSignal
|
||
) {
|
||
const response = await fetch(`${FASTAPI_BASE_URL}/learning-assistant/sessions/${assistantSessionId}/chat/stream`, {
|
||
method: 'POST',
|
||
headers: authHeaders('text/event-stream'),
|
||
body: JSON.stringify(payload),
|
||
signal
|
||
})
|
||
|
||
if (!response.ok || !response.body) {
|
||
throw new Error(await readError(response))
|
||
}
|
||
|
||
const reader = response.body.getReader()
|
||
const decoder = new TextDecoder()
|
||
let buffer = ''
|
||
|
||
while (true) {
|
||
const { value, done } = await reader.read()
|
||
if (done) break
|
||
|
||
buffer += decoder.decode(value, { stream: true })
|
||
const blocks = buffer.split('\n\n')
|
||
buffer = blocks.pop() || ''
|
||
|
||
for (const block of blocks) {
|
||
handleStreamBlock(block, callbacks)
|
||
}
|
||
}
|
||
|
||
if (buffer.trim()) {
|
||
handleStreamBlock(buffer, callbacks)
|
||
}
|
||
}
|
||
|
||
function readAssistantSessionId(data: Record<string, unknown>) {
|
||
const value = data.assistant_session_id || data.session_id || data.id
|
||
if (typeof value === 'number' || typeof value === 'string') return value
|
||
return ''
|
||
}
|
||
|
||
function readWorkflowUserId() {
|
||
const storageKey = 'clinical-thinking-workflow-user-id'
|
||
const stored = uni.getStorageSync(storageKey)
|
||
if (typeof stored === 'string' && stored.trim()) return stored
|
||
|
||
const generated = `clinical-thinking-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||
uni.setStorageSync(storageKey, generated)
|
||
return generated
|
||
}
|
||
|
||
async function readWorkflowError(response: Response) {
|
||
const text = await response.text().catch(() => '')
|
||
if (!text) return `临床思维训练书籍请求失败(${response.status})`
|
||
try {
|
||
const payload = JSON.parse(text) as Record<string, unknown>
|
||
const message = payload.message || payload.detail || payload.error
|
||
if (typeof message === 'string' && message.trim()) return message
|
||
} catch {}
|
||
return text
|
||
}
|
||
|
||
function readWorkflowOutput(result: WorkflowRunResponse) {
|
||
const data = result.data
|
||
if (data && typeof data === 'object') {
|
||
const output = readOutputText(data.outputs)
|
||
|| readOutputText(data.output)
|
||
|| readOutputText(data.answer)
|
||
|| readOutputText(data.text)
|
||
|| readOutputText(data.result)
|
||
if (output) return output
|
||
}
|
||
|
||
return readOutputText(result.output)
|
||
|| readOutputText(result.answer)
|
||
|| readOutputText(result.text)
|
||
|| readOutputText(result.result)
|
||
}
|
||
|
||
function readOutputText(value: unknown): string {
|
||
if (typeof value === 'string') return value.trim()
|
||
if (Array.isArray(value)) {
|
||
return value.map(readOutputText).filter(Boolean).join('\n')
|
||
}
|
||
if (!value || typeof value !== 'object') return ''
|
||
|
||
const payload = value as Record<string, unknown>
|
||
const preferredKeys = ['answer', 'text', 'result', 'output', 'content', 'markdown', 'data']
|
||
for (const key of preferredKeys) {
|
||
const text = readOutputText(payload[key])
|
||
if (text) return text
|
||
}
|
||
|
||
const entries = Object.entries(payload)
|
||
.map(([key, entry]) => {
|
||
const text = readOutputText(entry)
|
||
return text ? `${key}:${text}` : ''
|
||
})
|
||
.filter(Boolean)
|
||
|
||
return entries.join('\n')
|
||
}
|
||
|
||
function handleStreamBlock(block: string, callbacks: StreamCallbacks) {
|
||
const event = block.match(/^event:\s*(.+)$/m)?.[1] || ''
|
||
const rawLines = block
|
||
.split('\n')
|
||
.filter(line => line.startsWith('data:'))
|
||
.map(line => line.replace(/^data:\s?/, ''))
|
||
|
||
if (rawLines.length === 0) return
|
||
|
||
const rawData = rawLines.join('\n')
|
||
if (rawData === '[DONE]') {
|
||
callbacks.onDone?.({})
|
||
return
|
||
}
|
||
|
||
let data: unknown = rawData
|
||
try {
|
||
data = JSON.parse(rawData)
|
||
} catch {}
|
||
|
||
if (event === 'error') {
|
||
const message = typeof data === 'object' && data
|
||
? (data as Record<string, unknown>).message
|
||
: data
|
||
throw new Error(typeof message === 'string' ? message : 'AI 学习助手回复失败')
|
||
}
|
||
|
||
const delta = readDelta(data)
|
||
if (delta) callbacks.onDelta(delta)
|
||
|
||
if (event === 'done' || event === 'message_done') {
|
||
callbacks.onDone?.(typeof data === 'object' && data ? data as Record<string, unknown> : {})
|
||
}
|
||
}
|
||
|
||
function readDelta(data: unknown) {
|
||
if (typeof data === 'string') return data
|
||
if (!data || typeof data !== 'object') return ''
|
||
|
||
const payload = data as Record<string, unknown>
|
||
const delta = payload.delta || payload.content || payload.answer || payload.text
|
||
if (typeof delta === 'string') return delta
|
||
|
||
const nestedData = payload.data
|
||
if (nestedData && typeof nestedData === 'object') {
|
||
const nested = nestedData as Record<string, unknown>
|
||
const nestedDelta = nested.delta || nested.content || nested.answer || nested.text
|
||
if (typeof nestedDelta === 'string') return nestedDelta
|
||
}
|
||
|
||
return ''
|
||
}
|