| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import type { Action, ActionType } from '@/lib/types/action'; |
| import { SLIDE_ONLY_ACTIONS } from '@/lib/types/action'; |
| import { nanoid } from 'nanoid'; |
| import { parse as parsePartialJson, Allow } from 'partial-json'; |
| import { jsonrepair } from 'jsonrepair'; |
| import { createLogger } from '@/lib/logger'; |
| const log = createLogger('ActionParser'); |
|
|
| |
| |
| |
| function stripCodeFences(text: string): string { |
| |
| return text.replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?\s*```\s*$/i, ''); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function parseActionsFromStructuredOutput( |
| response: string, |
| sceneType?: string, |
| allowedActions?: string[], |
| ): Action[] { |
| |
| const cleaned = stripCodeFences(response.trim()); |
|
|
| |
| const startIdx = cleaned.indexOf('['); |
| const endIdx = cleaned.lastIndexOf(']'); |
|
|
| if (startIdx === -1) { |
| log.warn('No JSON array found in response'); |
| return []; |
| } |
|
|
| const jsonStr = endIdx > startIdx ? cleaned.slice(startIdx, endIdx + 1) : cleaned.slice(startIdx); |
|
|
| |
| let items: unknown[]; |
| try { |
| items = JSON.parse(jsonStr); |
| } catch { |
| |
| try { |
| items = JSON.parse(jsonrepair(jsonStr)); |
| log.info('Recovered malformed JSON via jsonrepair'); |
| } catch { |
| try { |
| items = parsePartialJson( |
| jsonStr, |
| Allow.ARR | Allow.OBJ | Allow.STR | Allow.NUM | Allow.BOOL | Allow.NULL, |
| ); |
| } catch (e) { |
| log.warn('Failed to parse JSON array:', (e as Error).message); |
| return []; |
| } |
| } |
| } |
|
|
| if (!Array.isArray(items)) { |
| log.warn('Parsed result is not an array'); |
| return []; |
| } |
|
|
| |
| const actions: Action[] = []; |
|
|
| for (const item of items) { |
| if (!item || typeof item !== 'object' || !('type' in item)) continue; |
| const typedItem = item as Record<string, unknown>; |
|
|
| if (typedItem.type === 'text') { |
| const text = ((typedItem.content as string) || '').trim(); |
| if (text) { |
| actions.push({ |
| id: `action_${nanoid(8)}`, |
| type: 'speech', |
| text, |
| }); |
| } |
| } else if (typedItem.type === 'action') { |
| try { |
| |
| const actionName = typedItem.name || typedItem.tool_name; |
| const actionParams = (typedItem.params || typedItem.parameters || {}) as Record< |
| string, |
| unknown |
| >; |
| actions.push({ |
| id: (typedItem.action_id || typedItem.tool_id || `action_${nanoid(8)}`) as string, |
| type: actionName as Action['type'], |
| ...actionParams, |
| } as Action); |
| } catch (_e) { |
| log.warn('Invalid action item, skipping:', JSON.stringify(typedItem).slice(0, 100)); |
| } |
| } |
| } |
|
|
| |
| const discussionIdx = actions.findIndex((a) => a.type === 'discussion'); |
| if (discussionIdx !== -1 && discussionIdx < actions.length - 1) { |
| actions.splice(discussionIdx + 1); |
| } |
|
|
| |
| let result = actions; |
| if (sceneType && sceneType !== 'slide') { |
| const before = result.length; |
| result = result.filter((a) => !SLIDE_ONLY_ACTIONS.includes(a.type as ActionType)); |
| if (result.length < before) { |
| log.info(`Stripped ${before - result.length} slide-only action(s) from ${sceneType} scene`); |
| } |
| } |
|
|
| |
| |
| |
| if (allowedActions && allowedActions.length > 0) { |
| const before = result.length; |
| result = result.filter((a) => a.type === 'speech' || allowedActions.includes(a.type)); |
| if (result.length < before) { |
| log.info( |
| `Stripped ${before - result.length} disallowed action(s) by allowedActions whitelist`, |
| ); |
| } |
| } |
|
|
| return result; |
| } |
|
|